Plugin Directory

Changeset 3748247


Ignore:
Timestamp:
10/15/2026 11:02:03 AM (10 days ago)
Author:
Kaderoishi
Message:

Update trunk to 1.1.0

The plugin is now FormBridge for Zoho, with the admin screens rebuilt around
it: a four-step feed editor, a Dashboard, the navigation in a header bar,
uploaded files reaching Zoho, and the field mapping, Logs, Campaigns and
Connections work. Log entries are now kept until deleted, with automatic
deletion opt-in, where before they went after thirty days.

Feeds, connections and settings carry over; no reconnection is needed.

Location:
techwave-form-sync-for-zoho/trunk
Files:
45 added
56 edited

Legend:

Unmodified
Added
Removed
  • techwave-form-sync-for-zoho/trunk/assets/css/admin.css

    r3649280 r3748247  
    11/**
    2  * Techwave Form Sync for Zoho — admin UI design system.
     2 * FormBridge for Zoho — admin UI design system.
    33 *
    44 * Everything is scoped to `.form2zoho` (the wrapper every screen prints) so
    … …  
    159159        --f2z-label-w: 200px;
    160160        --f2z-measure: 760px;
    161 
     161        /* One field's column, in the two-column grid a group of them lays out in.
     162           A single control that would otherwise stretch the width of a card sits
     163           on this too, so a lone input never runs to 1100px while the pair beside
     164           it is half that. */
     165        --f2z-field-col: 560px;
     166
     167        /* And the narrower one, for a step that asks a single question down the
     168           middle of the card. The width of a form field is a claim about how long
     169           its answer is: a feed title and a form name are short, and a 560px box
     170           for either is an oversized target that also drags the eye across more
     171           screen than the reading needs. Nothing is centred on both measures at
     172           once, so the two never meet. */
     173        --f2z-field-col-sm: 430px;
     174}
     175
     176/* The plugin's own base, on the plugin's own wrapper.
     177 *
     178 * These five used to sit in the :root block above, next to the tokens. :root
     179 * is <html>, so `font-size: 13px` there did not just set a base for this
     180 * plugin — it redefined what 1rem means for the whole document, and all of
     181 * wp-admin's own CSS is written in rem. Every rem measurement core makes on
     182 * these screens came out at 13/16ths of its intended size, including the
     183 * checkbox mark (21px drawn as 17px) and core's 25px touch-size checkbox
     184 * below 783px (drawn as 20px, missing the tap target it exists for).
     185 * `color`, `line-height` and `max-width` leaked onto <html> the same way.
     186 *
     187 * Every screen this stylesheet loads on prints a .form2zoho wrapper — the
     188 * feeds screen, the settings screen and the deactivation modal on
     189 * plugins.php — so nothing loses the base by moving it here.
     190 *
     191 * Each component still declares its own size; this only catches what would
     192 * otherwise be inherited. */
     193.form2zoho {
    162194        max-width: 100%;
    163         margin-bottom: var(--f2z-space-8);
    164195        color: var(--f2z-text);
    165         /* The plugin's own base size, so nothing inside inherits wp-admin's 14px
    166            in one place and 13px in another. Every component still declares its
    167            own size; this only catches what would otherwise be inherited. */
    168196        font-size: var(--f2z-fs-base);
    169197        line-height: var(--f2z-lh);
     198}
     199
     200/* Not on .form2zoho itself: the deactivation modal's wrapper is a
     201   .form2zoho too, and it sits on plugins.php holding nothing but a
     202   position:fixed overlay. Giving that 32px of bottom margin would push
     203   the plugins list down for a dialog that is not even open. */
     204.form2zoho.wrap {
     205        margin-bottom: var(--f2z-space-8);
    170206}
    171207
    … …  
    446482}
    447483
     484/* A control that asked for the full width keeps it inside a row too.
     485 *
     486 * The 420px cap above is for a control standing on its own, where it is what
     487 * stops one select stretching across a whole card. A control carrying
     488 * .form2zoho-control--full is not standing on its own — it is one field in a
     489 * column of them, and it has to end where the others end. Left capped, the
     490 * Form select stopped 140px short of the Feed title box directly above it and
     491 * its reload button floated in the gap; the account picker did the same beside
     492 * the app picker. The cell's own measure, set below, is what limits it now. */
     493.form2zoho-fieldset .form2zoho-control-row > select.form2zoho-control--full,
     494.form2zoho-fieldset .form2zoho-control-row > input.form2zoho-control--full,
     495.form2zoho-grid .form2zoho-control-row > select.form2zoho-control--full,
     496.form2zoho-grid .form2zoho-control-row > input.form2zoho-control--full {
     497        max-width: none;
     498}
     499
    448500/* A multi-select in a control row keeps its list-box height; it is the only
    449501   control here that is taller than one line, so the row aligns to its top. */
    … …  
    633685}
    634686
     687/* Centre core's tick in the box, and pin it to the same unit the box uses.
     688 *
     689 * Core draws the mark as an SVG floated into the box, and sizes it in rem
     690 * (1.3125rem on a -0.25rem left margin) against a box it also sizes in rem
     691 * (1rem). Both scale together, so core's own checkbox is centred whatever
     692 * 1rem happens to be. The rule above pins the box at 16px instead, which
     693 * breaks that pairing: the mark keeps scaling with the root font size while
     694 * the box no longer does, and the tick drifts off centre by an amount that
     695 * depends on a value this file does not control.
     696 *
     697 * So the mark is pinned in px too. 21px is what 1.3125rem is meant to be,
     698 * and the margins are core's -3px/-4px with half a pixel taken off the left:
     699 * the tick inside the 20x20 artwork spans x 5.78-16.17, so its own centre is
     700 * ~1 unit right of the artwork's, which is the offset core's -4px leaves
     701 * behind. Vertically core is already within 0.03px.
     702 *
     703 * Below 783px the box goes back to core's rem touch size, so the responsive
     704 * section hands the mark back with it rather than leaving px against rem. */
     705.form2zoho input[type="checkbox"]:checked::before {
     706        width: 21px;
     707        height: 21px;
     708        margin: -3px 0 0 -4.5px;
     709}
     710
    635711.form2zoho input[type="checkbox"]:hover:not(:disabled),
    636712.form2zoho input[type="radio"]:hover:not(:disabled) {
    … …  
    844920}
    845921
    846 .form2zoho-copy-text.is-copied {
     922/* Copied: a tick in place of the copy icon, on green, for a moment.
     923   The :hover selector matters. The pointer is still over the button straight
     924   after the click, and the shared hover rule further down comes later at the
     925   same specificity, so without it the button turned blue at once and the
     926   confirmation never showed.
     927   Two classes deep on the hidden tick, because `.form2zoho svg` near the top of
     928   this file sets every icon to display:block and outranks a single class. */
     929.form2zoho-copy-text .form2zoho-copy-text__done {
     930        display: none;
     931}
     932
     933.form2zoho-copy-text.is-copied .form2zoho-copy-text__icon {
     934        display: none;
     935}
     936
     937.form2zoho-copy-text.is-copied .form2zoho-copy-text__done {
     938        display: block;
     939}
     940
     941.form2zoho-copy-text.is-copied,
     942.form2zoho-copy-text.is-copied:hover {
    847943        background: var(--f2z-ok-bg);
    848944        border-color: var(--f2z-ok-border);
    … …  
    11211217.form2zoho .form2zoho-remove-row,
    11221218.form2zoho .button-link.form2zoho-remove-row {
     1219        padding: var(--f2z-space-1) var(--f2z-space-2);
     1220        border-radius: var(--f2z-radius-sm);
     1221        color: var(--f2z-muted);
     1222        font-weight: var(--f2z-fw-medium);
     1223        transition: background-color var(--f2z-transition), color var(--f2z-transition);
     1224}
     1225
     1226/* Grey until it is aimed at. A row of red "Remove" links reads as a table
     1227   full of errors, and the destructive one of the five controls in a mapping
     1228   row should not be the one the eye lands on first. It turns red on hover and
     1229   on focus, which is when the colour is actually saying something. */
     1230.form2zoho .form2zoho-remove-row:hover,
     1231.form2zoho .form2zoho-remove-row:focus {
     1232        background: var(--f2z-bad-bg);
    11231233        color: var(--f2z-bad);
     1234}
     1235
     1236.form2zoho .form2zoho-remove-row[hidden] {
     1237        display: none;
     1238}
     1239
     1240/* In place of Remove on a row a required Zoho field depends on. The same size
     1241   and colour as the link it replaces, so the column stays lined up, but with
     1242   no hover: there is nothing here to press. */
     1243.f2z-map-required {
     1244        display: inline-flex;
     1245        align-items: center;
     1246        gap: var(--f2z-space-1);
     1247        padding: var(--f2z-space-1) var(--f2z-space-2);
     1248        color: var(--f2z-muted);
     1249        font-size: var(--f2z-fs-base);
    11241250        font-weight: var(--f2z-fw-medium);
    1125 }
    1126 
    1127 .form2zoho .form2zoho-remove-row:hover {
    1128         color: var(--f2z-bad);
     1251        white-space: nowrap;
     1252        cursor: default;
    11291253}
    11301254
    … …  
    12181342}
    12191343
     1344/* --- "Fetching" ---------------------------------------------------------
     1345 *
     1346 * Every list in the editor is filled from Zoho or from the site, and a list
     1347 * that is loading looks exactly like a list that is stale unless it says so.
     1348 *
     1349 * Two ways to say it, because there are two kinds of control: the ones with a
     1350 * reload button beside them spin that button (the rule above), and the ones
     1351 * without get this. Drawn in CSS rather than as an image because a background
     1352 * image cannot be rotated — an animated SVG does not animate when it is used
     1353 * as a background — and an element that can carry a border can. */
     1354.form2zoho-spinner {
     1355        display: inline-block;
     1356        flex-shrink: 0;
     1357        width: 16px;
     1358        height: 16px;
     1359        border: 2px solid var(--f2z-border);
     1360        border-top-color: var(--f2z-brand);
     1361        border-radius: 50%;
     1362        animation: form2zoho-spin .7s linear infinite;
     1363}
     1364
     1365/* The control itself while it waits. The caret goes, because a caret says
     1366   "open me and choose" about a list that is not there yet. */
     1367.form2zoho select.is-loading {
     1368        background-image: none;
     1369        color: var(--f2z-placeholder);
     1370        cursor: progress;
     1371}
     1372
     1373/* An action waiting on the server rather than a list: the feeds-list switch,
     1374   the clone menu item. Dimmed and un-clickable for the round trip, so the
     1375   control visibly registered the press instead of appearing to ignore it. */
     1376.form2zoho .is-busy,
     1377.form2zoho-switch.is-busy {
     1378        opacity: .55;
     1379        cursor: progress;
     1380}
     1381
     1382/* --- Loading card ------------------------------------------------------
     1383 *
     1384 * The wait for the Zoho field list, which every row of the mapping table
     1385 * depends on. Built from the dialog's parts (surface, brand top edge, card
     1386 * radius, overlay shadow, entrance) so it reads as the product pausing, not as
     1387 * the page breaking. It names what it is fetching, and says so when Zoho is
     1388 * slow, so a wait of several seconds never looks frozen.
     1389 *
     1390 * admin.js moves it to <body>, so position:fixed covers the screen whatever
     1391 * the editor's own layout does. Over the admin bar (99999), under the nav
     1392 * progress bar (100001). */
     1393.form2zoho-page-loader {
     1394        position: fixed;
     1395        inset: 0;
     1396        z-index: 100000;
     1397        display: flex;
     1398        align-items: center;
     1399        justify-content: center;
     1400        padding: var(--f2z-space-4);
     1401        /* Lighter than a dialog's backdrop: a pause, not a question. */
     1402        background: rgba(20, 26, 33, .28);
     1403        -webkit-backdrop-filter: blur(2px);
     1404        backdrop-filter: blur(2px);
     1405        cursor: progress;
     1406        animation: form2zoho-fade-in .18s ease-out both;
     1407}
     1408
     1409/* Its own [hidden] rule. On <body> it is outside .form2zoho, whose [hidden]
     1410   rule no longer reaches it, and display:flex above beats the browser's. */
     1411.form2zoho-page-loader[hidden] {
     1412        display: none !important;
     1413}
     1414
     1415.form2zoho-loader-card {
     1416        display: flex;
     1417        flex-direction: column;
     1418        align-items: center;
     1419        box-sizing: border-box;
     1420        width: 340px;
     1421        max-width: 100%;
     1422        padding: var(--f2z-space-8) var(--f2z-space-6) var(--f2z-space-6);
     1423        background: var(--f2z-surface);
     1424        border-top: 3px solid var(--f2z-brand);
     1425        border-radius: var(--f2z-radius-card);
     1426        box-shadow: var(--f2z-shadow-overlay);
     1427        text-align: center;
     1428        animation: form2zoho-modal-in .22s cubic-bezier(.2, .7, .3, 1) both;
     1429}
     1430
     1431/* The mark sits still in a tinted disc and the ring turns around it, so the
     1432   motion reads as "working" rather than as a logo spinning for its own sake. */
     1433.form2zoho-loader-card__badge {
     1434        position: relative;
     1435        display: flex;
     1436        align-items: center;
     1437        justify-content: center;
     1438        width: 64px;
     1439        height: 64px;
     1440        margin-bottom: var(--f2z-space-5);
     1441        background: var(--f2z-brand-tint);
     1442        border-radius: 50%;
     1443}
     1444
     1445/* An arc that fades into its own tail: a conic gradient cut to a 3px ring. */
     1446.form2zoho-loader-card__ring {
     1447        position: absolute;
     1448        inset: -6px;
     1449        border-radius: 50%;
     1450        background: conic-gradient(from 0deg, rgba(33, 106, 174, 0) 0 35%, var(--f2z-brand) 100%);
     1451        -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
     1452        mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
     1453        animation: form2zoho-spin .9s linear infinite;
     1454}
     1455
     1456.form2zoho-loader-card__mark {
     1457        display: block;
     1458        width: 34px;
     1459        height: 34px;
     1460}
     1461
     1462/* Margins set outright: on <body> these paragraphs get wp-admin's own. */
     1463.form2zoho-loader-card__title {
     1464        margin: 0;
     1465        color: var(--f2z-heading);
     1466        font-size: var(--f2z-fs-lg);
     1467        font-weight: var(--f2z-fw-semibold);
     1468        line-height: 1.4;
     1469}
     1470
     1471.form2zoho-loader-card__text {
     1472        margin: var(--f2z-space-1) 0 0;
     1473        color: var(--f2z-muted);
     1474        font-size: var(--f2z-fs-base);
     1475        line-height: 1.5;
     1476}
     1477
     1478.form2zoho-loader-card__slow {
     1479        margin: var(--f2z-space-4) 0 0;
     1480        padding: var(--f2z-space-2) var(--f2z-space-3);
     1481        background: var(--f2z-surface-subtle);
     1482        border-radius: var(--f2z-radius-control);
     1483        color: var(--f2z-muted);
     1484        font-size: var(--f2z-fs-sm);
     1485        line-height: 1.5;
     1486        animation: form2zoho-fade-in .25s ease-out both;
     1487}
     1488
    12201489/* Destructive icon buttons. */
    12211490.form2zoho-bulk-delete {
    … …  
    12801549}
    12811550
    1282 /* The "Back / Next" and "Back / Save" row at the foot of each editor tab. */
    1283 .form2zoho-tab-footer {
    1284         display: flex;
    1285         align-items: center;
    1286         justify-content: space-between;
    1287         gap: var(--f2z-space-3);
    1288         margin-top: var(--f2z-space-6);
    1289         padding-top: var(--f2z-space-4);
    1290         border-top: 1px solid var(--f2z-border);
    1291 }
    1292 
    1293 .form2zoho-tab-footer .button {
    1294         min-width: 104px;
    1295 }
    1296 
    12971551/* The Logs toolbar is the Feeds list's `.form2zoho-listbar` — same two-group
    12981552   layout, same spacing — so it only needs its own bottom margin. It used to
    12991553   be a separate flex rule with its own gap. */
     1554/* The Logs toolbar sits directly under the tab bar's rule, and a row of
     1555 * bordered buttons landing there reads as attached to it — the button's own
     1556 * border and the tab bar's border become one line to the eye.
     1557 *
     1558 * The space is padding rather than margin on purpose. Margins collapse against
     1559 * the tab bar's own margin-bottom, so the two would fight and the larger alone
     1560 * would win; padding is added to it and always applies. That also makes this
     1561 * the one place to change if the row needs more air, instead of a number on
     1562 * the tab bar that every other panel silently inherits. */
    13001563.form2zoho-logs-toolbar {
     1564        padding-top: var(--f2z-space-5);
    13011565        margin-bottom: var(--f2z-space-4);
    13021566}
    … …  
    13641628 * used to be declared through four parallel `.form2zoho-page-* .form2zoho-hero`
    13651629 * selector lists repeated across five rules; the class is unique to those
    1366  * screens, so one selector does the same job. */
     1630 * screens, so one selector does the same job.
     1631 *
     1632 * It was a dark purple gradient band with the lockup sitting on it as a white
     1633 * pill, and the page's card pulled up to overlap its lower edge. Three
     1634 * decisions that only held each other up: the pill existed because the band
     1635 * was dark, and the overlap existed to give the band something to be behind.
     1636 * None of it belonged to wp-admin, which is a light grey document surface,
     1637 * and a dark slab across the top of it reads as a marketing banner rather
     1638 * than as a tool.
     1639 *
     1640 * What replaces it is a plain white bar with a hairline under it — the same
     1641 * device wp-admin uses everywhere to say "this is the top of the screen" —
     1642 * with the mark and the name at the left and the version at the right. */
    13671643.form2zoho-hero {
    13681644        display: flex;
    13691645        align-items: center;
    13701646        justify-content: space-between;
    1371         gap: var(--f2z-space-3);
    1372         margin: -20px -20px 0; /* cancels #wpbody-content's padding, full-bleed. */
    1373         padding: var(--f2z-space-8) var(--f2z-space-5) 46px;
    1374         background: linear-gradient(160deg, #1c0f33, #150a26);
     1647        gap: var(--f2z-space-4);
     1648        /* Full-bleed: cancels #wpbody-content's 20px padding so the bar runs the
     1649           width of the screen and its hairline reads as an edge, not a rule
     1650           floating inside the content. */
     1651        margin: -20px -20px 0;
     1652        padding: var(--f2z-space-5);
     1653        background: var(--f2z-surface);
     1654        border-bottom: 1px solid var(--f2z-border);
     1655}
     1656
     1657/* The version, on the far side of the bar. Quiet on purpose: it is reference
     1658   information, wanted about once — when reporting a problem — and it must not
     1659   compete with the product's name at the other end. */
     1660.form2zoho-hero-version {
     1661        flex-shrink: 0;
     1662        color: var(--f2z-muted);
     1663        font-size: var(--f2z-fs-sm);
     1664        font-variant-numeric: tabular-nums;
    13751665}
    13761666
    … …  
    13781668        display: inline-flex;
    13791669        align-items: center;
    1380         gap: var(--f2z-space-2);
    1381         padding: 6px var(--f2z-space-4) 6px 6px;
    1382         background: var(--f2z-surface);
    1383         /* Card radius, not a pill: the badge holds a square-ish icon tile, and a
    1384            999px outer corner against that tile's 6px one read as two unrelated
    1385            shapes stacked together. --f2z-radius-card is the 12px tier every other
    1386            container on these screens already uses. */
    1387         border-radius: var(--f2z-radius-card);
    1388         box-shadow: 0 2px 6px rgba(0, 0, 0, .25);
     1670        gap: var(--f2z-space-3);
     1671        min-width: 0;
     1672        /* No plate, no shadow, no radius. All three were there to lift the lockup
     1673           off the dark band; on a white bar a white pill is an invisible box that
     1674           only casts a shadow. */
    13891675        color: var(--f2z-brand-dark);
    1390         font-size: var(--f2z-fs-md);
     1676        font-size: var(--f2z-fs-xl);
     1677        line-height: 1;
     1678}
     1679
     1680/* The mark itself, with no plate behind it. It inherits the lockup's colour
     1681   through currentColor, and `display: block` keeps it off the text baseline
     1682   — left inline it reserves descender space underneath and rides high
     1683   against the name. */
     1684.form2zoho-hero-logo-mark {
     1685        display: block;
     1686        flex-shrink: 0;
     1687}
     1688
     1689.form2zoho-hero-logo-name strong {
    13911690        font-weight: var(--f2z-fw-bold);
    13921691}
    13931692
    1394 .form2zoho-hero-logo-icon {
    1395         display: inline-flex;
    1396         align-items: center;
    1397         justify-content: center;
    1398         flex-shrink: 0;
    1399         width: var(--f2z-control-h-sm);
    1400         height: var(--f2z-control-h-sm);
    1401         background: linear-gradient(135deg, var(--f2z-brand), var(--f2z-brand-dark));
    1402         border-radius: var(--f2z-radius-control);
    1403         color: #fff;
    1404         font-size: 10px;
    1405         font-weight: var(--f2z-fw-bold);
    1406         letter-spacing: .3px;
     1693/* The name's quieter half. Zoho is somebody else's trademark, so it says
     1694   what the plugin connects to without dressing up as part of the product's
     1695   own name. */
     1696.form2zoho-hero-logo-sub {
     1697        color: var(--f2z-muted);
     1698        font-weight: var(--f2z-fw-medium);
    14071699}
    14081700
    … …  
    14141706.form2zoho-settings-pop {
    14151707        position: relative;
    1416         margin: 0;
     1708        /* The gap under the masthead. Unconditional, unlike the negative pull it
     1709           replaced, so an admin notice appearing between the two cannot change
     1710           it. */
     1711        margin: var(--f2z-space-5) 0 0;
    14171712        padding: var(--f2z-space-6);
    14181713        background: var(--f2z-surface);
    1419         border-radius: var(--f2z-radius-overlay);
    1420         box-shadow: var(--f2z-shadow-overlay);
    1421 }
    1422 
    1423 /* The card is pulled up into the hero's bottom padding for the "popped"
    1424  * overlap. That is only correct while the card really is the next thing
    1425  * after the header. WordPress relocates every admin notice to directly
    1426  * after .wp-header-end (wp-admin/js/common.js: insertAfter( $headerEnd )),
    1427  * which lands it between the hero and the card on the feeds screens — an
    1428  * unconditional -32px then covers the notice's last 32px instead of the
    1429  * hero, hiding whatever it was trying to say. Applying the pull by
    1430  * adjacency keeps the design when there is no notice and lets a notice
    1431  * push the card down when there is one.
     1714        /* A hairline and a shallow shadow, not the overlay shadow these carried
     1715           while they were pulled up over a dark band. That shadow is 50px of
     1716           blur: against wp-admin's light grey it stopped reading as depth and
     1717           started reading as a grey haze around the card, which is also why the
     1718           radius comes down a tier — 16px belongs to things that float over the
     1719           page, and this sits on it. */
     1720        border: 1px solid var(--f2z-border);
     1721        border-radius: var(--f2z-radius-card);
     1722        box-shadow: var(--f2z-shadow-sm);
     1723}
     1724
     1725/* The cards used to be pulled up 32px to overlap the dark band, and only
     1726 * when they were still adjacent to the header — WordPress moves every admin
     1727 * notice to directly after .wp-header-end (wp-admin/js/common.js:
     1728 * insertAfter( $headerEnd )), and an unconditional pull covered the notice
     1729 * instead of the header, hiding whatever it was trying to say.
    14321730 *
    1433  * The settings screen prints its .wp-header-end inside the card, so its
    1434  * notices land within the card and it stays adjacent to the hero either
    1435  * way — hence the different selector for it. */
    1436 .wp-header-end + .form2zoho-card-pop,
    1437 .wp-header-end + .form2zoho-editor-pop,
    1438 .form2zoho-hero + .form2zoho-settings-pop {
    1439         margin-top: -32px;
    1440 }
    1441 
    1442 /* Give a relocated notice room, or it sits flush against the dark hero
    1443  * above it and the card below. Scoped to direct children of .wrap so the
    1444  * settings screen's in-card notices keep their own spacing. */
     1731 * There is no band to overlap now, so the pull is gone and with it that whole
     1732 * hazard: the cards carry an ordinary top margin, which is right whether a
     1733 * notice lands above them or not. The margin is declared with the cards
     1734 * themselves, in the rule above.
     1735 *
     1736 * Give a relocated notice room of its own, so it is not flush against the
     1737 * header above it and the card below. Scoped to direct children of .wrap so
     1738 * the settings screen's in-card notices keep their own spacing. */
    14451739.wrap.form2zoho > .wp-header-end ~ .notice,
    14461740.wrap.form2zoho > .wp-header-end ~ div.updated,
    … …  
    14491743}
    14501744
    1451 .form2zoho-editor-pop {
    1452         max-width: 900px;
    1453         margin-left: auto;
    1454         margin-right: auto;
    1455         padding: var(--f2z-space-8);
    1456 }
    1457 
    1458 .form2zoho-editor-wrap h1 {
    1459         margin: 0 0 var(--f2z-space-5);
    1460         font-size: var(--f2z-fs-2xl);
    1461         font-weight: var(--f2z-fw-bold);
    1462         text-align: center;
    1463 }
     1745/* The editor is the same panel as the feeds list, at the same width. It used
     1746   to be a 900px column centred in the page, which made moving between the
     1747   list and the editor look like moving between two products. The measure it
     1748   was protecting is still protected, just closer in: --f2z-control-max-w caps
     1749   every input at 420px, so a wide panel widens the panel and not the fields.
     1750   It no longer prints an <h1>: each step heads itself, and "Add feed" over
     1751   "Please select a trigger" was two titles for one screen. */
    14641752
    14651753/* --- Screen header row (title + primary action) ------------------------ */
    … …  
    15691857}
    15701858
    1571 .form2zoho-dedupe-extra {
    1572         margin-top: var(--f2z-space-2);
    1573 }
    1574 
    1575 .form2zoho-dedupe-grid {
    1576         display: flex;
    1577         flex-wrap: wrap;
    1578         gap: var(--f2z-space-2) var(--f2z-space-6);
    1579 }
    1580 
    15811859.form2zoho-apps-grid {
    15821860        display: grid;
    … …  
    15861864}
    15871865
    1588 /* "All applications" is the master toggle that disables the rest, so it owns
    1589    a full row with a rule under it. It has to stay in the same container as
    1590    the per-app boxes — admin.js finds them together via closest('td, p'). */
     1866/* "All applications" is the select-all over the rest, so it owns a full row
     1867   with a rule under it. It has to stay in the same container as the per-app
     1868   boxes — admin.js pairs them by closest('.form2zoho-apps-grid'). */
    15911869.form2zoho-app-check-all {
    15921870        grid-column: 1 / -1;
    … …  
    16001878}
    16011879
    1602 .form2zoho-mapping-section h2 {
    1603         margin-top: 0;
    1604 }
    1605 
    16061880/* ==========================================================================
    16071881   7. NAVIGATION
    … …  
    16131887   selector, rather than the settings bar carrying its own near-copy of the
    16141888   same six declarations. */
    1615 .form2zoho-tabs,
    16161889/* Element-qualified and three classes deep on purpose. Core declares this at
    16171890   `.wrap h2.nav-tab-wrapper` (0,2,1) — see the comment in its own source,
    … …  
    16251898        flex-wrap: wrap;
    16261899        gap: var(--f2z-space-1);
     1900        /* The gap under the tab bar.
     1901         *
     1902         * Deliberately modest, because it is a *margin* and a margin here is
     1903         * fragile: it collapses against whatever the tab panel puts first, so the
     1904         * real distance depends on that element rather than on this number. The
     1905         * panels that need more room take it as padding of their own, which
     1906         * cannot collapse — see .form2zoho-logs-toolbar. */
    16271907        margin: 0 0 var(--f2z-space-6);
    16281908        padding: 0;
    … …  
    16301910}
    16311911
    1632 .form2zoho-tab-btn,
    16331912.form2zoho .form2zoho-settings-tabs .nav-tab {
    16341913        appearance: none;
    … …  
    16471926}
    16481927
    1649 .form2zoho-tab-btn:hover,
     1928/* "Back to feeds", at the far end of the tab bar. A link, not a tab: it
     1929   leaves this screen rather than switching panels, so it takes the back
     1930   link's quieter look instead of a tab's. */
     1931.form2zoho .form2zoho-settings-tabs .form2zoho-tabs-back {
     1932        display: inline-flex;
     1933        align-items: center;
     1934        align-self: center;
     1935        gap: var(--f2z-space-2);
     1936        margin-left: auto;
     1937        padding: var(--f2z-space-2) var(--f2z-space-3);
     1938        border-radius: var(--f2z-radius-control);
     1939        color: var(--f2z-muted);
     1940        font-size: var(--f2z-fs-base);
     1941        font-weight: var(--f2z-fw-semibold);
     1942        line-height: var(--f2z-lh);
     1943        text-decoration: none;
     1944        transition: background var(--f2z-transition), color var(--f2z-transition);
     1945}
     1946
     1947.form2zoho .form2zoho-settings-tabs .form2zoho-tabs-back:hover,
     1948.form2zoho .form2zoho-settings-tabs .form2zoho-tabs-back:focus {
     1949        background: var(--f2z-brand-tint);
     1950        color: var(--f2z-brand-dark);
     1951        box-shadow: none;
     1952}
     1953
     1954.form2zoho .form2zoho-settings-tabs .form2zoho-tabs-back:focus-visible {
     1955        box-shadow: var(--f2z-ring);
     1956        outline: none;
     1957}
     1958
    16501959.form2zoho .form2zoho-settings-tabs .nav-tab:hover,
    16511960.form2zoho .form2zoho-settings-tabs .nav-tab:focus {
    … …  
    16551964}
    16561965
    1657 .form2zoho-tab-btn.is-active,
    16581966.form2zoho .form2zoho-settings-tabs .nav-tab-active,
    16591967.form2zoho .form2zoho-settings-tabs .nav-tab-active:hover,
    … …  
    16641972}
    16651973
    1666 .form2zoho-tab-panel {
     1974/* --- The feed editor: a four-step wizard --------------------------------
     1975 *
     1976 * Four screens shown one at a time. Each is only answerable once the one
     1977 * before it has been — the trigger decides which forms exist, the form decides
     1978 * which fields exist, the Zoho module decides what those fields can be mapped
     1979 * to — so the sequence is the design, and the rail below is a map of it.
     1980 *
     1981 * Every step is built from the same three parts, in the same order, whatever
     1982 * it is asking for:
     1983 *
     1984 *   .form2zoho-step-head    what this step wants, in one line and one sentence
     1985 *   .form2zoho-step-body    the answering — a grid, a fieldset, or groups
     1986 *   .form2zoho-step-footer  Back, and the one action that moves forward
     1987 *
     1988 * That is what makes four unlike screens read as one flow. Before this, step
     1989 * three was a WordPress two-column form-table and step two a centred column,
     1990 * so moving between them looked like moving between two products. */
     1991
     1992.form2zoho-step {
    16671993        display: none;
    1668         padding-top: var(--f2z-space-2);
    1669 }
    1670 
    1671 .form2zoho-tab-panel.is-active {
     1994        /* A floor, so the panel does not collapse around whichever step happens to
     1995           be the shortest. The one that says a trigger's plugin is not running is
     1996           a card and a sentence, and without this the whole editor shrank to a
     1997           band barely taller than the card — which reads as an error page rather
     1998           than as one step of four. */
     1999        min-height: 420px;
     2000}
     2001
     2002/* goToStep() focuses the panel so the step change is announced. It is not in
     2003   the tab order and nobody reached it by pressing Tab, so it draws no ring:
     2004   the step swapping and the rail marker moving are the visible indicator, and
     2005   a 460px-tall outline around the whole panel is not one. */
     2006.form2zoho-step:focus {
     2007        outline: none;
     2008}
     2009
     2010.form2zoho-step.is-active {
     2011        display: flex;
     2012        /* Column, so the footer can be pushed to the bottom of a short step
     2013           instead of floating just under a two-line body. */
     2014        flex-direction: column;
     2015        animation: form2zoho-step-in 220ms ease both;
     2016}
     2017
     2018/* The body takes the slack; the footer never floats mid-panel. */
     2019.form2zoho-step-body {
     2020        flex: 1 1 auto;
     2021}
     2022
     2023/* --- The rail ----------------------------------------------------------
     2024 *
     2025 * Not a progress bar. There is no filling track and nothing measures how
     2026 * "done" the form is; each step is a marker with its own name, which is the
     2027 * part a bar cannot give — "where am I, what did I already answer, what is
     2028 * still coming, and how do I get back to one of them".
     2029 *
     2030 * Markers above, names below. The connecting line then runs between the
     2031 * circles at their own height, with nothing to collide with, which is why the
     2032 * text is centred under them rather than beside them. */
     2033.form2zoho-stepper {
     2034        /* Bleeds to the card's edges (the card pads by --f2z-space-6) so the rule
     2035           under the rail reaches both sides, the way a header does. */
     2036        margin: 0 calc(-1 * var(--f2z-space-6)) var(--f2z-space-6);
     2037        padding: 0 var(--f2z-space-6) var(--f2z-space-5);
     2038        border-bottom: 1px solid var(--f2z-border-subtle);
     2039}
     2040
     2041.form2zoho-stepper__list {
     2042        display: grid;
     2043        /* Equal columns, so the connectors are equal lengths and the rail reads as
     2044           a scale rather than as four items of assorted importance. */
     2045        grid-auto-flow: column;
     2046        grid-auto-columns: 1fr;
     2047        margin: 0;
     2048        padding: 0;
     2049        list-style: none;
     2050}
     2051
     2052.form2zoho-stepper__item {
     2053        position: relative;
     2054        margin: 0;
     2055}
     2056
     2057/* The line to the next marker. Drawn from this item's centre to the next
     2058   one's, so it starts and stops exactly at the circles' edges however wide
     2059   the column is. */
     2060.form2zoho-stepper__item:not(:last-child)::after {
     2061        content: '';
     2062        position: absolute;
     2063        top: 21px;
     2064        left: calc(50% + 22px);
     2065        right: calc(-50% + 22px);
     2066        height: 2px;
     2067        background: var(--f2z-border);
     2068        transition: background-color var(--f2z-transition);
     2069}
     2070
     2071/* Behind the marker, never over it. */
     2072.form2zoho-stepper__item.is-done::after {
     2073        background: var(--f2z-brand);
     2074}
     2075
     2076.form2zoho-stepper__btn {
     2077        position: relative;
     2078        z-index: 1;
     2079        display: flex;
     2080        flex-direction: column;
     2081        align-items: center;
     2082        gap: var(--f2z-space-2);
     2083        width: 100%;
     2084        padding: var(--f2z-space-2) var(--f2z-space-2) 0;
     2085        background: none;
     2086        border: 0;
     2087        color: inherit;
     2088        font: inherit;
     2089        text-align: center;
     2090        cursor: pointer;
     2091}
     2092
     2093/* A step not yet reached. Disabled, not merely faded: the rail must not be a
     2094   way around the order the Continue buttons enforce, and `disabled` is what
     2095   says so to the keyboard and to a screen reader as well as to the eye. */
     2096.form2zoho-stepper__btn:disabled {
     2097        cursor: default;
     2098}
     2099
     2100.form2zoho-stepper__marker {
     2101        display: inline-flex;
     2102        align-items: center;
     2103        justify-content: center;
     2104        width: 30px;
     2105        height: 30px;
     2106        background: var(--f2z-surface);
     2107        border: 2px solid var(--f2z-border);
     2108        border-radius: var(--f2z-radius-pill);
     2109        color: var(--f2z-muted);
     2110        font-size: var(--f2z-fs-sm);
     2111        font-weight: var(--f2z-fw-bold);
     2112        line-height: 1;
     2113        transition: background-color var(--f2z-transition), border-color var(--f2z-transition), color var(--f2z-transition);
     2114}
     2115
     2116.form2zoho-stepper__item.is-current .form2zoho-stepper__marker {
     2117        background: var(--f2z-brand);
     2118        border-color: var(--f2z-brand);
     2119        color: #fff;
     2120        box-shadow: 0 0 0 4px var(--f2z-brand-tint);
     2121}
     2122
     2123/* A step already answered shows a tick rather than its number: the number
     2124   said where it sat in the queue, which stops being the useful fact once the
     2125   question has been answered. font-size:0 hides the digit without removing it
     2126   — the marker is aria-hidden either way, and the step's own name carries
     2127   "Step 2 of 4" for assistive technology. */
     2128.form2zoho-stepper__item.is-done .form2zoho-stepper__marker {
     2129        background: var(--f2z-brand);
     2130        border-color: var(--f2z-brand);
     2131        font-size: 0;
     2132        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
     2133        background-repeat: no-repeat;
     2134        background-position: center;
     2135        background-size: 15px 15px;
     2136}
     2137
     2138/* A saved feed's trigger: answered, and fixed. A lock instead of the tick, so
     2139   the one done step that will not open says why before anyone tries it. */
     2140.form2zoho-stepper__item.is-locked .form2zoho-stepper__marker {
     2141        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='10' rx='2'/%3E%3Cpath d='M8 11V7a4 4 0 0 1 8 0v4'/%3E%3C/svg%3E");
     2142        background-size: 14px 14px;
     2143}
     2144
     2145.form2zoho-stepper__text {
    16722146        display: block;
     2147        min-width: 0;
     2148}
     2149
     2150.form2zoho-stepper__title {
     2151        display: block;
     2152        color: var(--f2z-muted);
     2153        font-size: var(--f2z-fs-base);
     2154        font-weight: var(--f2z-fw-semibold);
     2155        line-height: var(--f2z-lh-tight);
     2156        transition: color var(--f2z-transition);
     2157}
     2158
     2159.form2zoho-stepper__item.is-current .form2zoho-stepper__title {
     2160        color: var(--f2z-brand);
     2161}
     2162
     2163.form2zoho-stepper__item.is-done .form2zoho-stepper__title {
     2164        color: var(--f2z-heading);
     2165}
     2166
     2167.form2zoho-stepper__hint {
     2168        display: block;
     2169        margin-top: 2px;
     2170        color: var(--f2z-muted);
     2171        font-size: var(--f2z-fs-xs);
     2172        line-height: var(--f2z-lh-tight);
     2173}
     2174
     2175/* Hover only where pressing does something. */
     2176.form2zoho-stepper__btn:not(:disabled):hover .form2zoho-stepper__title {
     2177        color: var(--f2z-brand-dark);
     2178}
     2179
     2180.form2zoho-stepper__btn:not(:disabled):hover .form2zoho-stepper__marker {
     2181        border-color: var(--f2z-brand);
     2182}
     2183
     2184.form2zoho-stepper__btn:focus-visible {
     2185        border-radius: var(--f2z-radius-control);
     2186        outline: 2px solid var(--f2z-brand);
     2187        outline-offset: 2px;
     2188}
     2189
     2190/* --- Each step's own heading ------------------------------------------- */
     2191
     2192.form2zoho-step-head {
     2193        margin-bottom: var(--f2z-space-6);
     2194}
     2195
     2196.form2zoho-step-head h2 {
     2197        margin: 0 0 var(--f2z-space-1);
     2198        color: var(--f2z-heading);
     2199        font-size: var(--f2z-fs-xl);
     2200        font-weight: var(--f2z-fw-semibold);
     2201        letter-spacing: -.01em;
     2202        line-height: var(--f2z-lh-tight);
     2203}
     2204
     2205.form2zoho-step-head .description {
     2206        margin: 0;
     2207        max-width: var(--f2z-measure);
     2208}
     2209
     2210/* The way out to the documentation for this step. Muted until it is wanted:
     2211   it answers a question only some people have, and on a screen whose whole
     2212   job is to get one choice made it must not read as a second thing to do. */
     2213.form2zoho a.form2zoho-step-docs {
     2214        display: inline-flex;
     2215        align-items: center;
     2216        gap: var(--f2z-space-1);
     2217        margin-top: var(--f2z-space-2);
     2218        color: var(--f2z-muted);
     2219        font-size: var(--f2z-fs-base);
     2220        text-decoration: none;
     2221}
     2222
     2223.form2zoho a.form2zoho-step-docs:hover,
     2224.form2zoho a.form2zoho-step-docs:focus {
     2225        color: var(--f2z-brand);
     2226        text-decoration: underline;
     2227}
     2228
     2229.form2zoho-step-docs svg {
     2230        flex: 0 0 auto;
     2231        opacity: .8;
     2232}
     2233
     2234/* The recording, beside the page that covers the same ground.
     2235 *
     2236 * Two links on one line, so the second needs a gap. In the dialog footer the
     2237 * first carries margin-left:auto to push the pair to the right-hand end of
     2238 * the bar; overriding it on the second is what keeps them together there
     2239 * rather than splitting them across it. */
     2240.form2zoho a.form2zoho-step-docs + a.form2zoho-step-docs,
     2241.form2zoho-modal-docs + .form2zoho-modal-docs {
     2242        margin-left: var(--f2z-space-4);
     2243}
     2244
     2245/* In a zero state the link is alone under the button, in a centred column,
     2246   and wants the same air the button above it has. */
     2247.form2zoho .form2zoho-emptystate .form2zoho-video-link {
     2248        margin-top: var(--f2z-space-4);
     2249}
     2250
     2251/* Steps one and two are single questions, so they are centred: a heading, one
     2252   thing to do, and nothing in the margins to look at. Steps three and four are
     2253   forms with many fields and stay left-aligned. */
     2254.form2zoho-step-head--center {
     2255        text-align: center;
     2256}
     2257
     2258.form2zoho-step-head--center .description {
     2259        margin-left: auto;
     2260        margin-right: auto;
     2261}
     2262
     2263/* --- The action bar ----------------------------------------------------
     2264 *
     2265 * Sticky, and bled to the card's edges so it reads as a bar rather than as a
     2266 * pair of buttons that happen to be last. The mapping step is a table, a
     2267 * conditional-logic panel and a test tool tall — its Save button used to sit
     2268 * two screens below the fold, which is a long way to scroll to find out that
     2269 * a required field on step three was never filled in. */
     2270.form2zoho-step-footer {
     2271        position: sticky;
     2272        bottom: 0;
     2273        z-index: 3;
     2274        display: flex;
     2275        align-items: center;
     2276        justify-content: space-between;
     2277        gap: var(--f2z-space-3);
     2278        /* The negative margin matches the card's padding exactly (--f2z-space-6),
     2279           and the bottom one cancels it so the bar sits on the card's own edge. */
     2280        margin: var(--f2z-space-6) calc(-1 * var(--f2z-space-6)) calc(-1 * var(--f2z-space-6));
     2281        padding: var(--f2z-space-4) var(--f2z-space-6);
     2282        background: var(--f2z-surface);
     2283        border-top: 1px solid var(--f2z-border);
     2284        border-radius: 0 0 var(--f2z-radius-card) var(--f2z-radius-card);
     2285}
     2286
     2287.form2zoho-step-footer .button {
     2288        min-width: 112px;
     2289}
     2290
     2291/* Step one on a saved feed has no Back — the grid *is* the first question —
     2292   so an empty span holds the Continue button over on the right. */
     2293.form2zoho-step-footer__spacer {
     2294        flex: 1 1 auto;
     2295}
     2296
     2297/* --- Step one: finding a trigger among twenty-seven -------------------- */
     2298
     2299.form2zoho-trigger-filter {
     2300        position: relative;
     2301        max-width: 420px;
     2302        margin: 0 auto var(--f2z-space-6);
     2303}
     2304
     2305.form2zoho-trigger-filter-icon {
     2306        position: absolute;
     2307        top: 50%;
     2308        left: var(--f2z-space-3);
     2309        color: var(--f2z-placeholder);
     2310        transform: translateY(-50%);
     2311        pointer-events: none;
     2312}
     2313
     2314.form2zoho input[type="search"].form2zoho-trigger-search {
     2315        width: 100%;
     2316        /* Room for the icon on the left; the right side keeps the browser's own
     2317           clear button clear of the text. */
     2318        padding-left: calc(var(--f2z-space-3) + 16px + var(--f2z-space-2));
     2319}
     2320
     2321/* Centred, because the row is centred and a left-aligned grid under a centred
     2322   heading and a centred search box reads as a mistake. */
     2323.form2zoho-step[data-step="trigger"] .form2zoho-source-grid {
     2324        justify-content: center;
     2325        margin-bottom: var(--f2z-space-2);
     2326}
     2327
     2328/* The filter hides tiles; it must not leave gaps in the grid, so display
     2329   rather than visibility. */
     2330.form2zoho-source-tile.is-filtered-out {
     2331        display: none;
     2332}
     2333
     2334.form2zoho-trigger-none {
     2335        margin: var(--f2z-space-6) 0 0;
     2336        color: var(--f2z-muted);
     2337        text-align: center;
     2338}
     2339
     2340/* --- Step two: the trigger just chosen --------------------------------- *
     2341 *
     2342 * A summary row, not a second copy of the tile. It used to be a 132px square
     2343 * standing alone in the middle of a full-width card, which gave the answer to
     2344 * a question already asked more visual weight than the two fields still to be
     2345 * filled in. Laid on its side it says the same thing in a band, directly above
     2346 * the fields it governs, and leaves the emphasis where the work is. */
     2347.form2zoho-chosen {
     2348        /* width:100% is not redundant. This is a flex item of .form2zoho-step
     2349           (a column flex container), and `margin-left/right: auto` on a flex item
     2350           overrides the default stretch — so without a stated width the card
     2351           shrank to fit its own text and sat 290px wide above the column of
     2352           fields, with both edges out of line. */
     2353        width: 100%;
     2354        max-width: var(--f2z-field-col-sm);
     2355        margin: 0 auto var(--f2z-space-5);
     2356}
     2357
     2358.form2zoho-chosen-card {
     2359        display: flex;
     2360        align-items: center;
     2361        gap: var(--f2z-space-3);
     2362        padding: var(--f2z-space-3);
     2363        background: var(--f2z-brand-tint);
     2364        border: 1px solid var(--f2z-brand-tint-strong);
     2365        border-radius: var(--f2z-radius-card);
     2366}
     2367
     2368.form2zoho-chosen-icon {
     2369        display: inline-flex;
     2370        flex: 0 0 auto;
     2371        align-items: center;
     2372        justify-content: center;
     2373        width: 44px;
     2374        height: 44px;
     2375        background: var(--f2z-surface);
     2376        border: 1px solid var(--f2z-border);
     2377        border-radius: var(--f2z-radius-control);
     2378}
     2379
     2380.form2zoho-chosen-icon img {
     2381        width: 30px;
     2382        height: 30px;
     2383        object-fit: contain;
     2384}
     2385
     2386.form2zoho-chosen-text {
     2387        display: flex;
     2388        flex: 1 1 auto;
     2389        flex-direction: column;
     2390        min-width: 0;
     2391}
     2392
     2393.form2zoho-chosen-eyebrow {
     2394        color: var(--f2z-muted);
     2395        font-size: var(--f2z-fs-xs);
     2396        font-weight: var(--f2z-fw-semibold);
     2397        letter-spacing: .04em;
     2398        line-height: 1.4;
     2399        text-transform: uppercase;
     2400}
     2401
     2402.form2zoho-chosen-label {
     2403        overflow: hidden;
     2404        color: var(--f2z-heading);
     2405        font-size: var(--f2z-fs-md);
     2406        font-weight: var(--f2z-fw-semibold);
     2407        line-height: var(--f2z-lh-tight);
     2408        text-overflow: ellipsis;
     2409        white-space: nowrap;
     2410}
     2411
     2412/* "Change", with the word visible rather than a bare ✕. A cross on a card
     2413   means "remove this" at least as often as it means "pick another", and there
     2414   is nothing here to remove. */
     2415.form2zoho-chosen-clear {
     2416        display: inline-flex;
     2417        flex: 0 0 auto;
     2418        align-items: center;
     2419        gap: var(--f2z-space-2);
     2420        min-height: var(--f2z-control-h-sm);
     2421        padding: 0 var(--f2z-space-3);
     2422        background: var(--f2z-surface);
     2423        border: 1px solid var(--f2z-border);
     2424        border-radius: var(--f2z-radius-control);
     2425        color: var(--f2z-brand);
     2426        font-size: var(--f2z-fs-sm);
     2427        font-weight: var(--f2z-fw-semibold);
     2428        cursor: pointer;
     2429        transition: background-color var(--f2z-transition), border-color var(--f2z-transition);
     2430}
     2431
     2432.form2zoho-chosen-clear:hover,
     2433.form2zoho-chosen-clear:focus {
     2434        background: var(--f2z-surface-hover);
     2435        border-color: var(--f2z-brand);
     2436        color: var(--f2z-brand-dark);
     2437}
     2438
     2439/* A saved feed's trigger: stated, not offered. It sits where Change would,
     2440   with no border, fill or pointer, so it does not read as something to press. */
     2441.form2zoho-chosen-lock {
     2442        display: inline-flex;
     2443        flex: 0 0 auto;
     2444        align-items: center;
     2445        gap: var(--f2z-space-1);
     2446        min-height: var(--f2z-control-h-sm);
     2447        padding: 0 var(--f2z-space-2);
     2448        color: var(--f2z-muted);
     2449        font-size: var(--f2z-fs-sm);
     2450        font-weight: var(--f2z-fw-semibold);
     2451}
     2452
     2453.form2zoho-chosen-note {
     2454        margin: var(--f2z-space-2) 0 0;
     2455        color: var(--f2z-muted);
     2456        font-size: var(--f2z-fs-sm);
     2457        line-height: 1.5;
     2458        text-align: center;
     2459}
     2460
     2461/* --- A stacked column of fields ---------------------------------------- *
     2462 *
     2463 * Not WordPress's two-column form-table: two fields under one summary card do
     2464 * not need a label column, and a label a third of the screen from its field
     2465 * only gets worse as the panel gets wider. */
     2466.form2zoho-fieldset {
     2467        max-width: var(--f2z-field-col-sm);
     2468        margin: 0 auto;
     2469}
     2470
     2471.form2zoho-field + .form2zoho-field {
     2472        margin-top: var(--f2z-space-5);
     2473}
     2474
     2475.form2zoho-field > label {
     2476        display: block;
     2477        margin-bottom: var(--f2z-space-2);
     2478        color: var(--f2z-heading);
     2479        font-size: var(--f2z-fs-base);
     2480        font-weight: var(--f2z-fw-semibold);
     2481}
     2482
     2483.form2zoho-field .description {
     2484        margin: var(--f2z-space-2) 0 0;
     2485}
     2486
     2487/* "Connect a new account", in the destination group's header.
     2488 *
     2489 * A link rather than a button because it goes somewhere — the Connections
     2490 * screen — and quiet, because it answers a question most people will not be
     2491 * asking: the account they want is usually already in the list. */
     2492.form2zoho a.form2zoho-add-connection {
     2493        display: inline-flex;
     2494        flex: 0 0 auto;
     2495        align-items: center;
     2496        gap: var(--f2z-space-1);
     2497        color: var(--f2z-brand);
     2498        font-size: var(--f2z-fs-base);
     2499        font-weight: var(--f2z-fw-semibold);
     2500        text-decoration: none;
     2501        white-space: nowrap;
     2502}
     2503
     2504.form2zoho a.form2zoho-add-connection:hover,
     2505.form2zoho a.form2zoho-add-connection:focus {
     2506        color: var(--f2z-brand-dark);
     2507        text-decoration: underline;
     2508}
     2509
     2510.form2zoho-add-connection svg {
     2511        flex: 0 0 auto;
     2512}
     2513
     2514/* The "leaves this page" arrow. Quieter than the words, because it is a
     2515   qualifier on them rather than a second thing to read — and it is what makes
     2516   the new tab visible rather than only announced. */
     2517.form2zoho-add-connection .form2zoho-extlink {
     2518        margin-left: 1px;
     2519        opacity: .75;
     2520}
     2521
     2522/* Required, said in the label rather than only discovered on Continue. The
     2523   asterisk is aria-hidden — the control's own `required` is what assistive
     2524   technology reads, and "Feed title star" is not a field name. */
     2525.form2zoho-req {
     2526        color: var(--f2z-bad);
     2527        font-weight: var(--f2z-fw-bold);
     2528}
     2529
     2530/* The browser's own validation message, left under the field.
     2531 *
     2532 * The native bubble says the right thing in the right place and then vanishes
     2533 * on the next click — on a stepped form that can be before the person has
     2534 * looked away from the button they pressed. The text is validationMessage
     2535 * verbatim, so it stays the browser's wording in the browser's language. */
     2536.form2zoho-field__error {
     2537        margin: var(--f2z-space-2) 0 0;
     2538        color: var(--f2z-bad);
     2539        font-size: var(--f2z-fs-sm);
     2540        font-weight: var(--f2z-fw-medium);
     2541        line-height: var(--f2z-lh);
     2542}
     2543
     2544/* Colour is not the only carrier: the message above says what is wrong in
     2545   words, and the control keeps `aria-invalid` for anything not reading either. */
     2546.form2zoho .is-invalid,
     2547.form2zoho .is-invalid:focus {
     2548        border-color: var(--f2z-bad);
     2549        box-shadow: var(--f2z-ring-bad);
     2550}
     2551
     2552/* Said under the summary card when the chosen trigger's plugin is not
     2553   running, in place of the fields. In the warning colour rather than the muted
     2554   one: it is the reason nothing else is on screen, not a footnote. */
     2555.form2zoho-trigger-unavailable {
     2556        /* Same flex-item rule as .form2zoho-chosen above. */
     2557        width: 100%;
     2558        max-width: var(--f2z-field-col-sm);
     2559        margin: 0 auto;
     2560        padding: var(--f2z-space-4);
     2561        background: var(--f2z-warn-bg);
     2562        border: 1px solid var(--f2z-warn-border);
     2563        border-radius: var(--f2z-radius-card);
     2564        color: var(--f2z-warn);
     2565        font-size: var(--f2z-fs-md);
     2566        text-align: center;
     2567}
     2568
     2569/* --- Steps three and four: grouped fields ------------------------------ *
     2570 *
     2571 * A group is one question with its own heading — "Zoho destination",
     2572 * "Duplicate handling", "Field mapping" — rather than a run of rows in one
     2573 * table. The two-column form-table it replaces gave a single select and a
     2574 * five-control duplicate-matching block exactly the same weight, so there was
     2575 * nothing on screen to say that the second one was optional and the first was
     2576 * the whole point of the step. */
     2577.form2zoho-group {
     2578        background: var(--f2z-surface);
     2579        border: 1px solid var(--f2z-border);
     2580        border-radius: var(--f2z-radius-card);
     2581}
     2582
     2583.form2zoho-group + .form2zoho-group {
     2584        margin-top: var(--f2z-space-4);
     2585}
     2586
     2587/* A row, so a group can carry one action opposite its name. The action is
     2588   deliberately up here rather than beside the field it serves: inside the grid
     2589   it made its own cell half as tall again as its neighbour. */
     2590.form2zoho-group__head {
     2591        display: flex;
     2592        flex-wrap: wrap;
     2593        align-items: center;
     2594        justify-content: space-between;
     2595        gap: var(--f2z-space-2) var(--f2z-space-4);
     2596        padding: var(--f2z-space-3) var(--f2z-space-5);
     2597        background: var(--f2z-surface-subtle);
     2598        border-bottom: 1px solid var(--f2z-border-subtle);
     2599        border-radius: var(--f2z-radius-card) var(--f2z-radius-card) 0 0;
     2600}
     2601
     2602/* The heading and its sentence are one item of that row, not two. */
     2603.form2zoho-group__title {
     2604        min-width: 0;
     2605}
     2606
     2607.form2zoho-group__head h3 {
     2608        margin: 0;
     2609        color: var(--f2z-heading);
     2610        font-size: var(--f2z-fs-md);
     2611        font-weight: var(--f2z-fw-semibold);
     2612        line-height: var(--f2z-lh-tight);
     2613}
     2614
     2615.form2zoho-group__head .description {
     2616        margin: var(--f2z-space-1) 0 0;
     2617}
     2618
     2619.form2zoho-group__body {
     2620        padding: var(--f2z-space-5);
     2621}
     2622
     2623.form2zoho-group__action {
     2624        margin: var(--f2z-space-4) 0 0;
     2625}
     2626
     2627/* The table already sits inside a group card, so its own border, radius and
     2628   shadow drew a second card 20px inside the first. It keeps its horizontal
     2629   scrolling — that is the part that matters on a narrow screen — and bleeds
     2630   to the group's edges, where a table belongs. */
     2631.form2zoho-group__body > .form2zoho-table-scroll {
     2632        margin: 0 calc(-1 * var(--f2z-space-5));
     2633        /* Definite, not auto.
     2634         *
     2635         * The base rule caps this box at max-width:100% — 100% of the group's
     2636         * *content* box — so with the negative margins alone it moved left without
     2637         * widening, overhanging one side and falling 40px short of the other.
     2638         * Lifting the cap fixed that and broke something worse: with width:auto
     2639         * and no cap the box shrank to fit the table's own min-width, so on a
     2640         * phone the table stopped scrolling inside its box and pushed the page
     2641         * sideways instead. A stated width is both: exactly the group's width plus
     2642         * its two paddings, and a box that overflows rather than grows. */
     2643        width: calc(100% + 2 * var(--f2z-space-5));
     2644        max-width: none;
     2645        border-right: 0;
     2646        border-left: 0;
     2647        border-radius: 0;
     2648        box-shadow: none;
     2649}
     2650
     2651/* Two columns of fields, one row per pair. --f2z-control-max-w caps each
     2652   control at 420px on its own, so the pair fills a wide card without either
     2653   input growing to a width no one can scan. */
     2654.form2zoho-grid {
     2655        display: grid;
     2656        grid-template-columns: repeat(2, minmax(0, 1fr));
     2657        gap: var(--f2z-space-5) var(--f2z-space-6);
     2658}
     2659
     2660/* Fields whose value is long, or whose help text is: the module picker and
     2661   the topic list both read badly in half a card. */
     2662.form2zoho-field--wide {
     2663        grid-column: 1 / -1;
     2664}
     2665
     2666/* One measure for every field in a group, whatever shape its cell is.
     2667 *
     2668 * A two-column cell is about 560px on a wide card, so that is the number, and
     2669 * it is the same one the editor's centred fieldset uses. Without it the two
     2670 * kinds of cell disagreed twice over: a field spanning the full row would have
     2671 * grown to 1150px, and on a narrow card — where the grid is one column — every
     2672 * field would have. Capping the cell rather than the control means the label,
     2673 * the control and the help text under it all stop at the same place. */
     2674/* Three classes deep on purpose: .form2zoho input.form2zoho-control--full
     2675   lifts the cap for controls that asked for the full width, and it would
     2676   otherwise beat this. A control that asked to fill its cell still has to stop
     2677   where its neighbour does — the app picker was left 1px wider than the
     2678   account picker beside it, which is exactly the kind of near-miss that reads
     2679   as a wobble rather than as a difference. */
     2680.form2zoho .form2zoho-grid > .form2zoho-field > * {
     2681        max-width: var(--f2z-field-col);
     2682}
     2683
     2684/* A cell spanning both columns needs the column's measure, not the cell's.
     2685 *
     2686 * --f2z-field-col is what a two-column cell is worth on a wide card, which is
     2687 * the right cap for a cell that IS one column. A .form2zoho-field--wide cell
     2688 * is the whole row, so that number stops being a description of anything: the
     2689 * module picker held 560px while the account picker beside it shrank with its
     2690 * column, and the two rows ended 6px apart at 1440px, 86px at 1280px and 176px
     2691 * at 1100px — the narrower the card, the further the module picker's reload
     2692 * button hung past the one above it.
     2693 *
     2694 * Half the row, less the column gap, is what one column actually measures at
     2695 * any card width. min() keeps the old cap for cards wide enough that a column
     2696 * exceeds it, so nothing on a large screen changes. The span itself still
     2697 * means something: nothing shares the row with these fields, which is what the
     2698 * topic list needs when it grows tall. */
     2699.form2zoho .form2zoho-grid > .form2zoho-field--wide > * {
     2700        max-width: min( var(--f2z-field-col), calc( ( 100% - var(--f2z-space-6) ) / 2 ) );
     2701}
     2702
     2703/* A field inside a grid is already spaced by the grid's own gap. */
     2704.form2zoho-grid > .form2zoho-field + .form2zoho-field {
     2705        margin-top: 0;
     2706}
     2707
     2708/* --- Checkboxes that lead a group -------------------------------------- */
     2709
     2710.form2zoho-check {
     2711        display: flex;
     2712        align-items: flex-start;
     2713        gap: var(--f2z-space-2);
     2714        color: var(--f2z-text);
     2715        font-size: var(--f2z-fs-base);
     2716        line-height: var(--f2z-lh);
     2717        cursor: pointer;
     2718}
     2719
     2720.form2zoho-check input[type="checkbox"] {
     2721        flex: 0 0 auto;
     2722        margin: 1px 0 0;
     2723}
     2724
     2725.form2zoho-check--lead {
     2726        color: var(--f2z-heading);
     2727        font-size: var(--f2z-fs-md);
     2728        font-weight: var(--f2z-fw-semibold);
     2729}
     2730
     2731/* What the switch above controls, indented under it and dimmed while it is
     2732   off. Dimmed only — every control stays live and still saves, because the
     2733   values are worth keeping between one visit and the next. This is the same
     2734   treatment the conditional-logic panel gives its rules. */
     2735.form2zoho-dedupe-body {
     2736        margin-top: var(--f2z-space-4);
     2737        padding-left: calc(var(--f2z-space-4) + var(--f2z-space-2));
     2738        transition: opacity var(--f2z-transition);
     2739}
     2740
     2741.form2zoho-group__body:has(input[name="dedupe_enabled"]:not(:checked)) .form2zoho-dedupe-body {
     2742        opacity: .6;
     2743}
     2744
     2745.form2zoho-dedupe-grid {
     2746        display: flex;
     2747        flex-wrap: wrap;
     2748        gap: var(--f2z-space-3) var(--f2z-space-6);
     2749        margin: var(--f2z-space-3) 0 var(--f2z-space-5);
     2750}
     2751
     2752/* The unique-field boxes and their note keep to the step's reading width
     2753   rather than running the whole width of the card. */
     2754.form2zoho-dedupe-body .form2zoho-field,
     2755.form2zoho-dedupe-none {
     2756        max-width: var(--f2z-field-col);
     2757}
     2758
     2759.form2zoho-dedupe-body .form2zoho-field {
     2760        max-width: var(--f2z-field-col);
    16732761}
    16742762
    … …  
    16772765        align-items: center;
    16782766        gap: var(--f2z-space-2);
    1679         margin: 0 0 var(--f2z-space-3);
     2767        margin: 0 0 var(--f2z-space-4);
    16802768        color: var(--f2z-muted);
    16812769        font-size: var(--f2z-fs-base);
    … …  
    17942882
    17952883/* Fixed width so a long "Created At" value cannot squeeze the toggle onto a
    1796    second line. */
    1797 .form2zoho-integrations-table th[data-col="status"],
    1798 .form2zoho-integrations-table td[data-col="status"] {
     2884   second line. Centred, heading included: `.form2zoho .widefat thead th`
     2885   left-aligns every heading at (0,2,2), which beat the plain column rule and
     2886   left STATUS sitting to the left of the switches under it. */
     2887.form2zoho .form2zoho-integrations-table th[data-col="status"],
     2888.form2zoho .form2zoho-integrations-table td[data-col="status"] {
    17992889        width: 90px;
    18002890        min-width: 90px;
    … …  
    18032893}
    18042894
    1805 .form2zoho-actions-col {
     2895.form2zoho-actions-col,
     2896.form2zoho .form2zoho-integrations-table th.form2zoho-actions-col {
    18062897        width: 72px;
    18072898        text-align: center;
    … …  
    18392930.form2zoho-map-table td {
    18402931        vertical-align: middle;
     2932}
     2933
     2934/* A static value its Zoho field will not accept: the same red as an unfinished
     2935   row, on the box that needs changing, with the reason under it. */
     2936.form2zoho .form2zoho-map-table .f2z-map-static.is-invalid {
     2937        border-color: var(--f2z-bad);
     2938        box-shadow: var(--f2z-ring-bad);
     2939}
     2940
     2941.f2z-map-static-error {
     2942        margin: var(--f2z-space-1) 0 0;
     2943        color: var(--f2z-bad);
     2944        font-size: var(--f2z-fs-sm);
     2945        line-height: 1.4;
     2946        white-space: normal;
     2947}
     2948
     2949/* "Combine fields": the fields the template uses, above the picker that adds
     2950   them. The brand tint of the "info" pill, so they read as picked values
     2951   rather than as statuses. */
     2952.f2z-map-tokens {
     2953        display: flex;
     2954        flex-wrap: wrap;
     2955        gap: var(--f2z-space-1);
     2956        margin-bottom: var(--f2z-space-2);
     2957}
     2958
     2959.f2z-map-token {
     2960        display: inline-flex;
     2961        align-items: center;
     2962        gap: 2px;
     2963        max-width: 100%;
     2964        padding: 1px 2px 1px var(--f2z-space-2);
     2965        background: var(--f2z-brand-tint);
     2966        border: 1px solid var(--f2z-brand-tint-strong);
     2967        border-radius: var(--f2z-radius-pill);
     2968        color: var(--f2z-brand-dark);
     2969        font-size: var(--f2z-fs-sm);
     2970        font-weight: var(--f2z-fw-semibold);
     2971        line-height: var(--f2z-lh);
     2972}
     2973
     2974.f2z-map-token__label {
     2975        min-width: 0;
     2976        overflow: hidden;
     2977        text-overflow: ellipsis;
     2978        white-space: nowrap;
     2979}
     2980
     2981/* Two classes, to get past the `.form2zoho button` reset. */
     2982.form2zoho .f2z-map-token__remove {
     2983        display: inline-flex;
     2984        flex: 0 0 auto;
     2985        align-items: center;
     2986        justify-content: center;
     2987        width: 18px;
     2988        height: 18px;
     2989        margin: 0;
     2990        padding: 0;
     2991        background: transparent;
     2992        border: 0;
     2993        border-radius: 50%;
     2994        color: inherit;
     2995        line-height: 0;
     2996        cursor: pointer;
     2997}
     2998
     2999/* Block, so no baseline gap pushes the cross off the circle's centre. */
     3000.form2zoho .f2z-map-token__remove svg {
     3001        display: block;
     3002        flex: 0 0 auto;
     3003}
     3004
     3005.form2zoho .f2z-map-token__remove:hover,
     3006.form2zoho .f2z-map-token__remove:focus-visible {
     3007        background: var(--f2z-brand);
     3008        color: #fff;
    18413009}
    18423010
    … …  
    19043072        gap: var(--f2z-space-3);
    19053073        min-width: 0;
     3074}
     3075
     3076/* Room for the name and data centre, so a wide "Authorized for" cell cannot
     3077   squeeze a short name like "Developer" onto two lines. */
     3078.form2zoho-conn-table td:first-child {
     3079        min-width: 190px;
    19063080}
    19073081
    … …  
    19423116}
    19433117
    1944 /* fit-content, then centre inside it: the column shrinks to the pill row —
    1945    its widest child — so the block still sits at the cell's left edge under the
    1946    "Authorized for" heading, while "Edit access" centres under the pills rather
    1947    than hanging off their left corner. Centring without fit-content would move
    1948    the pills themselves to the middle of the column, away from the heading. */
     3118/* fit-content, so the block is as wide as its pill row and "Edit access"
     3119   centres under the pills; auto margins then put that block in the middle of
     3120   the column, under its centred "Authorized for" heading. */
    19493121.form2zoho-authorized-cell {
    19503122        display: flex;
    … …  
    19543126        width: fit-content;
    19553127        max-width: 100%;
    1956 }
    1957 
     3128        margin: 0 auto;
     3129}
     3130
     3131/* Centred, so pills that wrap onto a second line stay in the middle too. */
    19583132.form2zoho-authorized-pills {
    19593133        display: flex;
    19603134        flex-wrap: wrap;
     3135        justify-content: center;
    19613136        gap: var(--f2z-space-1);
    19623137}
    19633138
     3139/* The folded-away apps join the same row of pills when opened. */
     3140.form2zoho-authorized-more-list {
     3141        display: contents;
     3142}
     3143
    19643144.form2zoho-conn-status-cell {
    19653145        display: flex;
    19663146        align-items: center;
     3147        justify-content: center;
    19673148        gap: var(--f2z-space-1);
    19683149}
    … …  
    19733154
    19743155.form2zoho-conn-error-note {
    1975         margin: 2px 0 0;
     3156        margin: 2px auto 0;
    19763157        max-width: 260px;
    19773158}
    … …  
    19813162        text-align: right;
    19823163        white-space: nowrap;
     3164}
     3165
     3166/* Authorized for and Status: heading and contents centred in their column. */
     3167.form2zoho .form2zoho-conn-table thead th.form2zoho-col-center,
     3168.form2zoho .form2zoho-conn-table td.form2zoho-col-center {
     3169        text-align: center;
    19833170}
    19843171
    … …  
    20443231}
    20453232
     3233/* --- Paging the log ------------------------------------------------------
     3234 *
     3235 * The same bar as the Feeds list, driven differently: the Logs screen is a tab
     3236 * and fetches its rows rather than reloading, so the controls are buttons and
     3237 * the table dims while the next page is on its way. */
     3238.form2zoho-logs-pagination {
     3239        margin-top: var(--f2z-space-4);
     3240}
     3241
     3242/* Dimmed, not emptied. Blanking the table for the length of a request makes
     3243   the page jump to nothing and back; leaving the old rows in place, greyed and
     3244   inert, keeps the height and says plainly that they are on their way out. */
     3245.form2zoho-logs tbody.is-loading {
     3246        opacity: .45;
     3247        pointer-events: none;
     3248        transition: opacity var(--f2z-transition);
     3249}
     3250
    20463251/* ==========================================================================
    20473252   9. FEEDBACK
    … …  
    21073312        color: var(--f2z-brand-dark);
    21083313        font-weight: var(--f2z-fw-semibold);
     3314}
     3315
     3316/* "+N more": a pill that reads as a control — brand-tinted, with a pointer
     3317   and a focus ring — so it is not mistaken for one more app name. */
     3318.form2zoho-conn .form2zoho-authorized-pills .form2zoho-authorized-more {
     3319        background: var(--f2z-brand-tint);
     3320        border-color: var(--f2z-brand-tint-strong);
     3321        color: var(--f2z-brand-dark);
     3322        font-family: inherit;
     3323        font-weight: var(--f2z-fw-semibold);
     3324        line-height: inherit;
     3325        cursor: pointer;
     3326}
     3327
     3328.form2zoho-conn .form2zoho-authorized-pills .form2zoho-authorized-more:hover {
     3329        background: var(--f2z-brand-tint-strong);
     3330}
     3331
     3332.form2zoho-conn .form2zoho-authorized-pills .form2zoho-authorized-more:focus-visible {
     3333        outline: 2px solid var(--f2z-brand);
     3334        outline-offset: 1px;
    21093335}
    21103336
    … …  
    25313757}
    25323758
     3759/* The documentation link at the far end of a dialog's footer. Pushed there by
     3760   margin rather than by justify-content, so the buttons keep their own
     3761   grouping at the near end and the link is plainly not one of them. */
     3762.form2zoho-modal-docs {
     3763        display: inline-flex;
     3764        align-items: center;
     3765        gap: var(--f2z-space-1);
     3766        margin-left: auto;
     3767        color: var(--f2z-muted);
     3768        font-size: var(--f2z-fs-base);
     3769        text-decoration: none;
     3770}
     3771
     3772.form2zoho-modal-docs:hover,
     3773.form2zoho-modal-docs:focus {
     3774        color: var(--f2z-brand);
     3775        text-decoration: underline;
     3776}
     3777
    25333778/* `.form-table` already carries table-layout:fixed (see section 6); the
    25343779   dialog only needs its label column not to collapse. */
    … …  
    26243869}
    26253870
     3871/* --- The Pro dialog ----------------------------------------------------
     3872 *
     3873 * What a locked source tile opens. Same overlay, same close button, same
     3874 * Escape and focus handling as the other four dialogs — the difference is
     3875 * that this one has a job the others do not: it has to answer "what is this
     3876 * greyed-out tile?" convincingly enough to be worth the click, without
     3877 * feeling like an advert that ambushed someone.
     3878 *
     3879 * So the head is the answer, not the pitch: the logo of the tile that was
     3880 * actually pressed, and one sentence about what that one source does. The
     3881 * general case — everything else Pro adds — comes after, where someone who
     3882 * is now interested will read it and someone who is not can close the box.
     3883 *
     3884 * Three bands in a flex column, the middle one scrolling, which is the shape
     3885 * .form2zoho-modal-fields and the timeline dialog already use. It matters
     3886 * here for the same reason: the action row has to stay reachable on a 700px
     3887 * laptop viewport. */
     3888.form2zoho-modal-pro {
     3889        display: flex;
     3890        flex-direction: column;
     3891        width: 580px;
     3892        padding: 0;
     3893        overflow: hidden;
     3894}
     3895
     3896/* --- Head --- */
     3897
     3898.form2zoho-pro-head {
     3899        position: relative;
     3900        flex: 0 0 auto;
     3901        padding: var(--f2z-space-8) var(--f2z-space-6) var(--f2z-space-6);
     3902        background: linear-gradient(168deg, var(--f2z-brand-tint) 0%, var(--f2z-surface) 72%);
     3903        border-bottom: 1px solid var(--f2z-border-subtle);
     3904        text-align: center;
     3905}
     3906
     3907/* A soft brand glow behind the mark, so the logo sits on something rather
     3908   than floating on flat tint. Decorative and unreachable — it is a
     3909   pseudo-element on a container, so it costs no markup and no alt text. */
     3910.form2zoho-pro-head::before {
     3911        content: '';
     3912        position: absolute;
     3913        top: -60px;
     3914        left: 50%;
     3915        z-index: 0;
     3916        width: 260px;
     3917        height: 200px;
     3918        background: radial-gradient(closest-side, rgba(33, 106, 174, .18), rgba(33, 106, 174, 0));
     3919        transform: translateX(-50%);
     3920        pointer-events: none;
     3921}
     3922
     3923/* The clicked tile's logo, at the size a product mark wants to be read at.
     3924   Same white chip and border as the picker's own icons — the dialog should
     3925   look like the tile grew, not like a different design. */
     3926.form2zoho-pro-mark {
     3927        position: relative;
     3928        z-index: 1;
     3929        display: inline-flex;
     3930        align-items: center;
     3931        justify-content: center;
     3932        width: 76px;
     3933        height: 76px;
     3934        margin-bottom: var(--f2z-space-4);
     3935        background: var(--f2z-surface);
     3936        border: 1px solid var(--f2z-border);
     3937        border-radius: var(--f2z-radius-overlay);
     3938        box-shadow: var(--f2z-shadow-lg);
     3939}
     3940
     3941.form2zoho-pro-mark-img {
     3942        width: 48px;
     3943        height: 48px;
     3944        object-fit: contain;
     3945}
     3946
     3947/* The same badge component the locked tile carries, pushed just outside the
     3948   mark's corner. Keeping the class means the two can never drift apart in
     3949   colour or shape — which would read as two different "Pro"s. */
     3950.form2zoho-pro-mark .form2zoho-source-badge {
     3951        top: -8px;
     3952        right: -14px;
     3953        box-shadow: 0 0 0 2px var(--f2z-surface);
     3954}
     3955
     3956/* (0,2,1), which beats the (0,1,1) `.form2zoho h2.form2zoho-modal-title`
     3957   rule in section 10. That one sets a 32px right gutter to clear the close
     3958   button and a bottom margin sized for a left-aligned head; this head is
     3959   centred and its own subline supplies the spacing. */
     3960.form2zoho .form2zoho-modal-pro h2.form2zoho-pro-title {
     3961        position: relative;
     3962        z-index: 1;
     3963        margin: 0 0 var(--f2z-space-2);
     3964        font-size: var(--f2z-fs-xl);
     3965        font-weight: var(--f2z-fw-bold);
     3966        letter-spacing: -.01em;
     3967}
     3968
     3969/* Capped by characters rather than pixels: the sentence is the one thing in
     3970   the dialog that is actually read rather than scanned, and a 65-character
     3971   line is where reading stops being comfortable. */
     3972.form2zoho-pro-blurb {
     3973        position: relative;
     3974        z-index: 1;
     3975        max-width: 46ch;
     3976        margin: 0 auto;
     3977        color: var(--f2z-muted);
     3978        font-size: var(--f2z-fs-md);
     3979        line-height: var(--f2z-lh-loose);
     3980}
     3981
     3982/* --- Body --- */
     3983
     3984.form2zoho-modal-pro .form2zoho-modal-scroll {
     3985        padding-top: var(--f2z-space-6);
     3986        padding-bottom: var(--f2z-space-5);
     3987}
     3988
     3989/* Two columns on a 580px dialog gives each benefit about 250px — enough for
     3990   a two-line title and three lines of text without either column becoming a
     3991   ladder of single words. */
     3992.form2zoho-pro-benefits {
     3993        display: grid;
     3994        grid-template-columns: 1fr 1fr;
     3995        gap: var(--f2z-space-5) var(--f2z-space-6);
     3996        margin: 0;
     3997        padding: 0;
     3998        list-style: none;
     3999}
     4000
     4001/* The tick is a background image on the item, not a list marker: markers
     4002   cannot be positioned against the first line of a two-line title, and this
     4003   one has to sit on the title's optical centre rather than the item's. */
     4004.form2zoho-pro-benefit {
     4005        margin: 0;
     4006        padding-left: 26px;
     4007        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23216aae' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
     4008        background-repeat: no-repeat;
     4009        background-position: 0 3px;
     4010        background-size: 15px 15px;
     4011}
     4012
     4013.form2zoho-pro-benefit strong {
     4014        display: block;
     4015        margin-bottom: 2px;
     4016        color: var(--f2z-heading);
     4017        font-size: var(--f2z-fs-base);
     4018        font-weight: var(--f2z-fw-semibold);
     4019        line-height: var(--f2z-lh-tight);
     4020}
     4021
     4022.form2zoho-pro-benefit span {
     4023        display: block;
     4024        color: var(--f2z-muted);
     4025        font-size: var(--f2z-fs-sm);
     4026        line-height: var(--f2z-lh);
     4027}
     4028
     4029/* Ten names in a sentence is a list nobody finishes. Ten logos is one
     4030   glance, and they are already on disk for the picker. */
     4031.form2zoho-pro-strip {
     4032        margin-top: var(--f2z-space-6);
     4033        padding: var(--f2z-space-4);
     4034        background: var(--f2z-surface-subtle);
     4035        border: 1px solid var(--f2z-border-subtle);
     4036        border-radius: var(--f2z-radius-card);
     4037        text-align: center;
     4038}
     4039
     4040.form2zoho-pro-strip-label {
     4041        display: block;
     4042        margin-bottom: var(--f2z-space-3);
     4043        color: var(--f2z-muted);
     4044        font-size: var(--f2z-fs-xs);
     4045        font-weight: var(--f2z-fw-semibold);
     4046        letter-spacing: .06em;
     4047        text-transform: uppercase;
     4048}
     4049
     4050.form2zoho-pro-strip-logos {
     4051        display: flex;
     4052        flex-wrap: wrap;
     4053        gap: var(--f2z-space-2);
     4054        justify-content: center;
     4055}
     4056
     4057/* Chips rather than bare logos: ten marks on ten different backgrounds, at
     4058   28px, turn into visual noise without a shared frame around them. */
     4059.form2zoho-pro-strip-logo {
     4060        width: 34px;
     4061        height: 34px;
     4062        padding: 4px;
     4063        background: var(--f2z-surface);
     4064        border: 1px solid var(--f2z-border);
     4065        border-radius: var(--f2z-radius-control);
     4066        box-shadow: var(--f2z-shadow-sm);
     4067        object-fit: contain;
     4068}
     4069
     4070/* --- Foot --- */
     4071
     4072.form2zoho-pro-foot {
     4073        display: flex;
     4074        flex: 0 0 auto;
     4075        align-items: center;
     4076        justify-content: center;
     4077        gap: var(--f2z-space-4);
     4078        padding: var(--f2z-space-4) var(--f2z-space-6);
     4079        background: var(--f2z-surface-subtle);
     4080        border-top: 1px solid var(--f2z-border);
     4081}
     4082
     4083/* WordPress's own .button-hero is 46px tall with a 14px font. Left alone it
     4084   towers over the "Maybe later" link beside it; these bring it back to a
     4085   height that belongs in a 580px dialog. */
     4086.form2zoho-pro-foot .form2zoho-pro-cta {
     4087        height: auto;
     4088        min-height: 40px;
     4089        padding: 0 var(--f2z-space-6);
     4090        font-size: var(--f2z-fs-md);
     4091        line-height: 38px;
     4092}
     4093
     4094/* Deliberately quiet. A second button styled like a real one turns a dialog
     4095   into a decision; this is a way out, and it should look like one. */
     4096.form2zoho-pro-foot .form2zoho-pro-dismiss {
     4097        color: var(--f2z-muted);
     4098        font-size: var(--f2z-fs-base);
     4099        text-decoration: none;
     4100}
     4101
     4102.form2zoho-pro-foot .form2zoho-pro-dismiss:hover,
     4103.form2zoho-pro-foot .form2zoho-pro-dismiss:focus {
     4104        color: var(--f2z-text);
     4105        text-decoration: underline;
     4106}
     4107
     4108/* A locked tile does something now, so it stops looking inert. It still
     4109   reads as unavailable — muted logo, muted label, Pro badge — but the
     4110   pointer, the hover lift and the press feedback are back, because the
     4111   promise they make ("this button works") is one it keeps. */
     4112.form2zoho-source-tile.is-locked {
     4113        cursor: pointer;
     4114}
     4115
     4116.form2zoho-source-tile.is-locked:hover {
     4117        background: var(--f2z-brand-tint);
     4118        box-shadow: var(--f2z-shadow-sm);
     4119}
     4120
     4121.form2zoho-source-tile.is-locked:hover .form2zoho-source-icon,
     4122.form2zoho-source-tile.is-locked:hover .form2zoho-source-label {
     4123        opacity: 1;
     4124}
     4125
     4126.form2zoho-source-tile.is-locked:active {
     4127        transform: scale(.98);
     4128}
     4129
    26264130/* ==========================================================================
    26274131   11. COMPONENTS
    … …  
    26394143}
    26404144
     4145/* The two groups inside a listbar.
     4146 *
     4147 * Same gap as the listbar itself. They used to sit at 8px inside a container
     4148 * spacing its own children at 12px, so the buttons read as one welded block
     4149 * while the block itself had room around it — the tighter spacing inside the
     4150 * looser one is what made them look stuck together rather than adjacent. */
    26414151.form2zoho-toolbar-left,
    26424152.form2zoho-toolbar-right {
    … …  
    26444154        align-items: center;
    26454155        flex-wrap: wrap;
    2646         gap: var(--f2z-space-2);
     4156        gap: var(--f2z-space-3);
    26474157        min-width: 0;
     4158}
     4159
     4160/* "Select failed" helps you *make* a selection; the two beside it act on one.
     4161 * A little more air after it separates the two kinds without a divider rule,
     4162 * which at this size would be more furniture than the row can carry. */
     4163#form2zoho-logs-select-failed {
     4164        margin-right: var(--f2z-space-1);
    26484165}
    26494166
    … …  
    27474264        border-radius: 50%;
    27484265        box-shadow: 0 0 0 2px var(--f2z-surface);
     4266}
     4267
     4268/* --- A source the Pro add-on provides ---------------------------------- *
     4269 *
     4270 * Drawn rather than hidden. Leaving it out answers "no" to "does this work
     4271 * with WooCommerce?", when the answer is "yes, with the add-on" — and a
     4272 * customer who cannot see that a thing exists cannot go and get it.
     4273 *
     4274 * The tile keeps its shape and its coloured mark so the grid still reads as
     4275 * one row of equals; what changes is that it stops inviting a click. The
     4276 * `disabled` attribute on the button is what actually prevents one and takes
     4277 * it out of the tab order — these rules only make that visible. */
     4278.form2zoho-source-tile.is-locked {
     4279        background: var(--f2z-surface-subtle);
     4280        cursor: default;
     4281}
     4282
     4283/* Hover and press feedback both suppressed: a tile that lifts under the
     4284   pointer is promising something it will not do. */
     4285.form2zoho-source-tile.is-locked:hover {
     4286        background: var(--f2z-surface-subtle);
     4287        box-shadow: none;
     4288}
     4289
     4290.form2zoho-source-tile.is-locked:active {
     4291        transform: none;
     4292}
     4293
     4294/* Muted, not invisible. Enough to read as unavailable at a glance, still
     4295   legible enough to tell which product it is — which is the entire point of
     4296   showing it. */
     4297.form2zoho-source-tile.is-locked .form2zoho-source-icon,
     4298.form2zoho-source-tile.is-locked .form2zoho-source-label {
     4299        opacity: .55;
     4300}
     4301
     4302/* The badge sits where the selected tile's check badge sits. They can never
     4303   collide: a locked tile cannot be the active one. */
     4304.form2zoho-source-badge {
     4305        position: absolute;
     4306        top: var(--f2z-space-2);
     4307        right: var(--f2z-space-2);
     4308        z-index: 2;
     4309        padding: 2px 7px;
     4310        background: var(--f2z-brand);
     4311        border-radius: var(--f2z-radius-pill);
     4312        color: #fff;
     4313        font-size: var(--f2z-fs-xs);
     4314        font-weight: var(--f2z-fw-bold);
     4315        line-height: 1.5;
     4316        letter-spacing: .02em;
     4317        text-transform: uppercase;
    27494318}
    27504319
    … …  
    30344603}
    30354604
     4605/* The Details disclosure on a timeline entry. .form2zoho-detail-wrap and
     4606   .form2zoho-json are the log table's own, reused as-is so the JSON reads
     4607   identically in both places; only the spacing around the button is new. */
     4608.form2zoho-timeline-details {
     4609        margin-top: var(--f2z-space-2);
     4610}
     4611
     4612.form2zoho-timeline-details .form2zoho-detail-wrap {
     4613        margin-top: var(--f2z-space-2);
     4614}
     4615
    30364616/* Size comes from `.button.button-small` on the same element; only its
    30374617   position in the timeline entry is this rule's business. */
    … …  
    33494929                min-width: 560px;
    33504930        }
     4931
     4932        /* The rail and the action bar bleed to the card's edges by negating its
     4933           padding exactly. The padding just changed, so these have to change with
     4934           it or the bar hangs 8px over both sides and the page scrolls sideways. */
     4935        .form2zoho-stepper {
     4936                margin: 0 calc(-1 * var(--f2z-space-4)) var(--f2z-space-5);
     4937                padding: 0 var(--f2z-space-4) var(--f2z-space-4);
     4938        }
     4939
     4940        .form2zoho-step-footer {
     4941                margin: var(--f2z-space-5) calc(-1 * var(--f2z-space-4)) calc(-1 * var(--f2z-space-4));
     4942                padding: var(--f2z-space-3) var(--f2z-space-4);
     4943        }
     4944
     4945        /* Two columns of fields need about 760px of card to stay readable. Below
     4946           that they stack — one question per line, which is what the fieldset on
     4947           step two does at every width. */
     4948        .form2zoho-grid {
     4949                grid-template-columns: minmax(0, 1fr);
     4950                gap: var(--f2z-space-5);
     4951        }
     4952
     4953        /* One column, so a spanning cell is the same cell as any other and takes
     4954           the ordinary measure back. Halving it here would make the module picker
     4955           half the width of the account picker directly above it. */
     4956        .form2zoho .form2zoho-grid > .form2zoho-field--wide > * {
     4957                max-width: var(--f2z-field-col);
     4958        }
     4959
     4960        /* The names stay; the one-line explanations go. Four hints across a
     4961           narrow card wrap to three lines each and the rail becomes taller than
     4962           the step it introduces. */
     4963        .form2zoho-stepper__hint {
     4964                display: none;
     4965        }
     4966}
     4967
     4968/* Height, not width. On a laptop viewport under about 760px the Pro dialog's
     4969   scrolling middle band is left with roughly 190px, which cuts the benefit
     4970   list off mid-sentence — a dialog that fits looks like one that broke.
     4971   The head is where the space is: the mark and its padding are worth 130px
     4972   between them, and shrinking those costs far less than losing half the
     4973   content behind a scrollbar. */
     4974@media (max-height: 760px) {
     4975        .form2zoho-modal-pro {
     4976                max-height: 92vh;
     4977        }
     4978
     4979        .form2zoho-pro-head {
     4980                padding: var(--f2z-space-5) var(--f2z-space-6) var(--f2z-space-4);
     4981        }
     4982
     4983        .form2zoho-pro-mark {
     4984                width: 60px;
     4985                height: 60px;
     4986                margin-bottom: var(--f2z-space-3);
     4987        }
     4988
     4989        .form2zoho-pro-mark-img {
     4990                width: 38px;
     4991                height: 38px;
     4992        }
     4993
     4994        .form2zoho .form2zoho-modal-pro h2.form2zoho-pro-title {
     4995                font-size: var(--f2z-fs-lg);
     4996        }
     4997
     4998        .form2zoho-pro-blurb {
     4999                font-size: var(--f2z-fs-base);
     5000        }
     5001
     5002        .form2zoho-pro-benefits {
     5003                gap: var(--f2z-space-4) var(--f2z-space-6);
     5004        }
    33515005}
    33525006
    33535007@media (max-width: 782px) {
     5008        .form2zoho-group__head,
     5009        .form2zoho-group__body {
     5010                padding: var(--f2z-space-4);
     5011        }
     5012
     5013        /* Follows the padding it negates — see the rule it overrides. */
     5014        .form2zoho-group__body > .form2zoho-table-scroll {
     5015                margin: 0 calc(-1 * var(--f2z-space-4));
     5016                width: calc(100% + 2 * var(--f2z-space-4));
     5017        }
     5018
     5019        /* Numbers only. "Destination" over a 70px column breaks mid-word, and the
     5020           markers plus the current step's own heading already say where you are. */
     5021        .form2zoho-stepper__text {
     5022                display: none;
     5023        }
     5024
     5025        .form2zoho-stepper__item.is-current .form2zoho-stepper__text {
     5026                display: block;
     5027        }
     5028
     5029        /* With one name shown and three hidden, the rail no longer needs equal
     5030           columns — the current step gets the room its name needs. */
     5031        .form2zoho-stepper__list {
     5032                grid-auto-columns: auto;
     5033                justify-content: center;
     5034                gap: var(--f2z-space-5);
     5035        }
     5036
     5037        /* The connectors were drawn between the centres of equal columns. Those
     5038           are gone, so the line is drawn to the gap instead. */
     5039        .form2zoho-stepper__item:not(:last-child)::after {
     5040                left: calc(100% + 2px);
     5041                right: calc(-1 * var(--f2z-space-5) + 2px);
     5042        }
     5043        /* The dialog fills a phone, so the two benefit columns become one rather
     5044           than two 130px ladders, and the foot stacks with the primary action
     5045           full width and on top. */
     5046        .form2zoho-modal-pro {
     5047                width: 100%;
     5048        }
     5049
     5050        .form2zoho-pro-benefits {
     5051                grid-template-columns: 1fr;
     5052        }
     5053
     5054        .form2zoho-pro-foot {
     5055                flex-direction: column-reverse;
     5056                gap: var(--f2z-space-2);
     5057        }
     5058
     5059        .form2zoho-pro-foot .form2zoho-pro-cta {
     5060                width: 100%;
     5061                text-align: center;
     5062        }
     5063
     5064        /* The bleed cancels wp-admin's own insets, and wp-admin changes them here:
     5065           #wpcontent's left padding drops from 20px to 10px and .wrap's right
     5066           margin from 20px to 12px. The desktop -20px/-20px therefore over-pulled
     5067           by 10px on the left and 8px on the right, which is the whole of the
     5068           sideways scroll this page had on a phone. */
    33545069        .form2zoho-hero {
    3355                 padding: var(--f2z-space-5) var(--f2z-space-4) 42px;
     5070                margin: -10px -12px 0 -10px;
     5071                padding: var(--f2z-space-4);
    33565072        }
    33575073
    … …  
    34205136        /* The tab bar scrolls sideways rather than wrapping to two rows, which
    34215137           would push the panel below the fold. */
    3422         .form2zoho-tabs,
    34235138        .form2zoho .form2zoho-settings-tabs {
    34245139                flex-wrap: nowrap;
    … …  
    34435158                padding-left: var(--f2z-space-4);
    34445159                padding-right: var(--f2z-space-4);
     5160        }
     5161
     5162        /* The footer wraps and its buttons go full width at this size, so an auto
     5163           margin would strand the documentation link alone on a right-aligned
     5164           line. Let it sit where it falls. */
     5165        .form2zoho-modal-docs {
     5166                margin-left: 0;
    34455167        }
    34465168
    … …  
    34805202        }
    34815203
     5204        /* The box is core's rem touch size again here, so the mark goes back to
     5205           core's rem geometry with it. It has to be said explicitly: the px rule
     5206           above outranks core's own (0,3,2 against 0,2,2) and would otherwise
     5207           leave a 21px mark on a 25px box. */
     5208        .form2zoho input[type="checkbox"]:checked::before {
     5209                width: 1.875rem;
     5210                height: 1.875rem;
     5211                margin: -0.1875rem -0.3125rem;
     5212        }
     5213
    34825214        /* …except the one inside a switch, which is a transparent overlay rather
    34835215           than a visible box: it has to keep covering its own 40x22 track. Sizing
    … …  
    34925224
    34935225@media (max-width: 600px) {
    3494         .form2zoho-hero {
    3495                 flex-direction: column;
    3496                 align-items: flex-start;
    3497                 gap: var(--f2z-space-2);
     5226        /* The version drops out rather than taking a line of its own. Both items
     5227           need about 310px before padding and this is a 320px viewport; stacking
     5228           them spent a whole row of a phone screen on a number wanted once, when
     5229           reporting a problem — and the Plugins screen still shows it. The hero
     5230           stays a row, so the lockup keeps the bar to itself. */
     5231        .form2zoho-hero-version {
     5232                display: none;
     5233        }
     5234
     5235        /* The lockup is sized for the desktop hero. At this width the name would
     5236           run to about 270px before its padding, which does not fit a 320px
     5237           viewport once the hero's own inset is taken off. The mark's width and
     5238           height are attributes on the <svg>, and a rule beats an attribute. */
     5239        .form2zoho-hero-logo {
     5240                font-size: var(--f2z-fs-lg);
     5241        }
     5242
     5243        .form2zoho-hero-logo-mark {
     5244                width: 28px;
     5245                height: 28px;
    34985246        }
    34995247
    … …  
    35055253        }
    35065254
    3507         .form2zoho-editor-wrap h1 {
    3508                 font-size: var(--f2z-fs-xl);
    3509         }
    3510 
    35115255        /* Tap targets get the full width; the pair still reads as one group. */
    3512         .form2zoho-tab-footer {
     5256        .form2zoho-step-footer {
    35135257                flex-wrap: wrap;
    3514         }
    3515 
    3516         .form2zoho-tab-footer .button {
     5258                margin: var(--f2z-space-5) calc(-1 * var(--f2z-space-3)) calc(-1 * var(--f2z-space-3));
     5259                padding: var(--f2z-space-3);
     5260        }
     5261
     5262        .form2zoho-step-footer .button {
    35175263                flex: 1 1 auto;
    35185264                min-width: 0;
     5265        }
     5266
     5267        .form2zoho-stepper {
     5268                margin: 0 calc(-1 * var(--f2z-space-3)) var(--f2z-space-4);
     5269                padding: 0 var(--f2z-space-3) var(--f2z-space-4);
     5270        }
     5271
     5272        /* The summary card's Change button drops under the name rather than
     5273           squeezing it to three characters and an ellipsis. */
     5274        .form2zoho-chosen-card {
     5275                flex-wrap: wrap;
     5276        }
     5277
     5278        .form2zoho-chosen-clear {
     5279                flex: 1 1 100%;
     5280                justify-content: center;
    35195281        }
    35205282
    … …  
    35985360   14. MOTION & CONTRAST PREFERENCES
    35995361   ========================================================================== */
     5362
     5363/* One step of the feed editor arriving. Short and small on purpose: it says
     5364   "this is a different screen" without making anyone wait to read it. */
     5365@keyframes form2zoho-step-in {
     5366        from {
     5367                opacity: 0;
     5368                transform: translateY(6px);
     5369        }
     5370
     5371        to {
     5372                opacity: 1;
     5373                transform: none;
     5374        }
     5375}
    36005376
    36015377@keyframes form2zoho-spin {
    … …  
    36905466                border: 1px solid CanvasText;
    36915467        }
    3692 }
     5468
     5469        /* In forced colours every background is repainted, so "current" and
     5470           "done" stop being told apart by the brand fill — and the tick is a
     5471           background-image, which is dropped outright. Weight and a border carry
     5472           it instead: the current marker gets a thick ring, a finished one keeps
     5473           its number rather than showing a tick that is not there. */
     5474        .form2zoho-stepper__item.is-current .form2zoho-stepper__marker {
     5475                border-width: 3px;
     5476                box-shadow: none;
     5477        }
     5478
     5479        .form2zoho-stepper__item.is-done .form2zoho-stepper__marker {
     5480                font-size: var(--f2z-fs-sm);
     5481                background-image: none;
     5482        }
     5483
     5484        .form2zoho-stepper__btn:disabled .form2zoho-stepper__marker,
     5485        .form2zoho-stepper__btn:disabled .form2zoho-stepper__title {
     5486                color: GrayText;
     5487        }
     5488}
     5489
     5490/* ==========================================================================
     5491   22. SEARCHABLE FIELD PICKERS (mapping table)
     5492   ==========================================================================
     5493   The <select> is kept and only hidden from sight: it still carries the name,
     5494   still holds the value, and is still what every handler in admin.js reads.
     5495   Hiding it with `display:none` would take it out of the accessibility tree
     5496   for the screen readers that do use it, so it is clipped instead — the same
     5497   trick .screen-reader-text uses — and the text input above carries the
     5498   combobox role. */
     5499
     5500.f2z-combo {
     5501        position: relative;
     5502}
     5503
     5504.f2z-combo__native {
     5505        position: absolute;
     5506        width: 1px;
     5507        height: 1px;
     5508        padding: 0;
     5509        margin: -1px;
     5510        overflow: hidden;
     5511        clip: rect(0 0 0 0);
     5512        white-space: nowrap;
     5513        border: 0;
     5514}
     5515
     5516.f2z-combo__input {
     5517        width: 100%;
     5518}
     5519
     5520/* The caret every select in the plugin draws (4.2), at the same offset, so a
     5521   searchable picker reads as a dropdown and not as a box to type a sentence
     5522   into. It points up while the list is open, and is paler on a box that
     5523   cannot be opened. Three classes, to outrank the text-input rules and their
     5524   `background` shorthand. */
     5525.form2zoho .f2z-combo .f2z-combo__input {
     5526        padding-right: 30px;
     5527        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='16' height='16'%3E%3Cpath d='M6 8l4 4 4-4' fill='none' stroke='%235b6470' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
     5528        background-repeat: no-repeat;
     5529        background-position: right 9px center;
     5530        text-overflow: ellipsis;
     5531        cursor: pointer;
     5532}
     5533
     5534.form2zoho .f2z-combo.is-open .f2z-combo__input {
     5535        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='16' height='16'%3E%3Cpath d='M6 12l4-4 4 4' fill='none' stroke='%235b6470' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
     5536        cursor: text;
     5537}
     5538
     5539.form2zoho .f2z-combo .f2z-combo__input:disabled {
     5540        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='16' height='16'%3E%3Cpath d='M6 8l4 4 4-4' fill='none' stroke='%23b4bac2' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
     5541        cursor: default;
     5542}
     5543
     5544/* Fixed, not absolute, and placed by admin.js from the input's own rectangle.
     5545   The mapping table sits in .form2zoho-table-scroll, which sets overflow-x
     5546   for narrow screens — and a box that scrolls on one axis clips on both, so
     5547   an absolutely positioned list was cut off at the table's edge with a
     5548   scrollbar of its own. Fixed positioning leaves that clipping box entirely.
     5549   There is no transformed ancestor between here and the viewport to spoil it;
     5550   the only transforms in this file are on toasts and modals.
     5551
     5552   Positioned in script rather than in CSS because a fixed element has nothing
     5553   to anchor to: see comboPosition(), which also flips it above the input when
     5554   the row is near the bottom of the window. */
     5555.f2z-combo__list {
     5556        position: fixed;
     5557        z-index: 100;
     5558        /* The width and max-height are set in script from the input's rectangle,
     5559           so they have to mean the same thing here as they do there — border-box,
     5560           or the padding and border below push the list wider than the field and
     5561           past the bottom of the window. */
     5562        box-sizing: border-box;
     5563        max-height: 240px;
     5564        overflow-y: auto;
     5565        margin: 0;
     5566        padding: var(--f2z-space-1);
     5567        list-style: none;
     5568        background: var(--f2z-surface);
     5569        border: 1px solid var(--f2z-border);
     5570        border-radius: var(--f2z-radius-control);
     5571        box-shadow: var(--f2z-shadow-lg);
     5572}
     5573
     5574.f2z-combo__opt {
     5575        margin: 0;
     5576        padding: var(--f2z-space-2) var(--f2z-space-3);
     5577        border-radius: var(--f2z-radius-control);
     5578        cursor: pointer;
     5579        font-size: var(--f2z-fs-sm);
     5580        line-height: 1.4;
     5581}
     5582
     5583.f2z-combo__opt:hover,
     5584.f2z-combo__opt.is-active {
     5585        background: var(--f2z-surface-subtle);
     5586}
     5587
     5588/* Keyboard focus has to be visible on its own, because the focus ring stays on
     5589   the input while the highlight moves down the list. */
     5590.f2z-combo__opt.is-active {
     5591        box-shadow: inset 2px 0 0 var(--f2z-brand);
     5592}
     5593
     5594.f2z-combo__empty {
     5595        margin: 0;
     5596        padding: var(--f2z-space-2) var(--f2z-space-3);
     5597        color: var(--f2z-muted);
     5598        font-size: var(--f2z-fs-sm);
     5599}
     5600
     5601/* The unique-field boxes under Duplicate handling. Its own label rather than a
     5602   <label for>, because the group has no single control to point at. */
     5603.form2zoho-dedupe-unique__label {
     5604        display: block;
     5605        margin-bottom: var(--f2z-space-2);
     5606        color: var(--f2z-text);
     5607        font-weight: var(--f2z-fw-semibold);
     5608        font-size: var(--f2z-fs-sm);
     5609}
     5610
     5611/* ==========================================================================
     5612   TOP BAR NAVIGATION & DASHBOARD
     5613   The screens are listed in the masthead (HeaderNav) instead of the sidebar
     5614   submenu, and the Dashboard screen (DashboardPage) sits under it.
     5615   ========================================================================== */
     5616
     5617.form2zoho-topbar {
     5618        justify-content: flex-start;
     5619        flex-wrap: wrap;
     5620        /* Room above and below the links, and the same inset on both sides as
     5621           the card under the bar, so the logo lines up with the card's edge. */
     5622        padding: var(--f2z-space-4) var(--f2z-space-6);
     5623        column-gap: var(--f2z-space-5);
     5624        /* Up by .wrap's own 10px top margin and no more. The hero's -20px pulled
     5625           the bar 10px under the admin toolbar, cutting off its top padding. */
     5626        margin-top: -10px;
     5627}
     5628
     5629.form2zoho-topbar .form2zoho-topnav {
     5630        margin-left: 0;
     5631}
     5632
     5633/* The links sit at the right-hand end of the bar. An auto margin rather than
     5634   justify-content: when the links outgrow the bar it falls to zero, so the
     5635   first ones stay reachable by scrolling instead of being cut off. */
     5636.form2zoho-topbar .form2zoho-topnav > :first-child {
     5637        margin-left: auto;
     5638}
     5639
     5640.form2zoho-topbar .form2zoho-hero-logo {
     5641        font-size: var(--f2z-fs-lg);
     5642}
     5643
     5644.form2zoho-topbar .form2zoho-hero-logo-mark {
     5645        width: 30px;
     5646        height: 30px;
     5647}
     5648
     5649.form2zoho-topnav {
     5650        display: flex;
     5651        flex: 1 1 0;
     5652        align-items: center;
     5653        gap: var(--f2z-space-1);
     5654        min-width: 0;
     5655        margin-left: var(--f2z-space-4);
     5656        overflow-x: auto;
     5657        scrollbar-width: none;
     5658}
     5659
     5660.form2zoho-topnav::-webkit-scrollbar {
     5661        display: none;
     5662}
     5663
     5664.form2zoho-topnav-link {
     5665        display: inline-flex;
     5666        align-items: center;
     5667        gap: 6px;
     5668        padding: 6px var(--f2z-space-3);
     5669        border-radius: var(--f2z-radius-control);
     5670        color: var(--f2z-muted);
     5671        font-size: var(--f2z-fs-base);
     5672        font-weight: var(--f2z-fw-medium);
     5673        text-decoration: none;
     5674        white-space: nowrap;
     5675}
     5676
     5677.form2zoho-topnav-link:hover,
     5678.form2zoho-topnav-link:focus {
     5679        background: var(--f2z-surface-subtle);
     5680        color: var(--f2z-text);
     5681}
     5682
     5683.form2zoho-topnav-link:focus-visible {
     5684        outline: 2px solid var(--f2z-brand);
     5685        outline-offset: 1px;
     5686        box-shadow: none;
     5687}
     5688
     5689.form2zoho-topnav-link.is-active {
     5690        background: var(--f2z-surface-head);
     5691        color: var(--f2z-heading);
     5692        font-weight: var(--f2z-fw-semibold);
     5693}
     5694
     5695.form2zoho-topbar-actions {
     5696        display: flex;
     5697        align-items: center;
     5698        gap: var(--f2z-space-3);
     5699        margin-left: auto;
     5700}
     5701
     5702.form2zoho-topbar-status {
     5703        display: inline-flex;
     5704        align-items: center;
     5705        gap: 6px;
     5706        padding: 4px 10px;
     5707        border: 1px solid var(--f2z-ok-border);
     5708        border-radius: var(--f2z-radius-pill);
     5709        background: var(--f2z-ok-bg);
     5710        color: var(--f2z-ok);
     5711        font-size: var(--f2z-fs-sm);
     5712        font-weight: var(--f2z-fw-medium);
     5713        text-decoration: none;
     5714        white-space: nowrap;
     5715}
     5716
     5717.form2zoho-topbar-status.is-off {
     5718        border-color: var(--f2z-warn-border);
     5719        background: var(--f2z-warn-bg);
     5720        color: var(--f2z-warn);
     5721}
     5722
     5723.form2zoho-topbar-status:hover,
     5724.form2zoho-topbar-status:focus {
     5725        color: inherit;
     5726        filter: brightness(.97);
     5727}
     5728
     5729.form2zoho-topbar-dot,
     5730.form2zoho-dash-dot {
     5731        flex-shrink: 0;
     5732        width: 8px;
     5733        height: 8px;
     5734        border-radius: 50%;
     5735        background: currentColor;
     5736}
     5737
     5738.wrap.form2zoho .form2zoho-topbar-new {
     5739        display: inline-flex;
     5740        align-items: center;
     5741        gap: 6px;
     5742        white-space: nowrap;
     5743}
     5744
     5745/* --- Dashboard ---------------------------------------------------------
     5746 * Four stat cards, then the period's chart beside a health summary, then the
     5747 * lists. Soft cards on the page's grey: a hairline border and a faint shadow
     5748 * rather than a drawn box, so the numbers carry the screen.
     5749 *
     5750 * The four hues (blue, purple, green, red) name the four cards and nothing
     5751 * else; on the chart, blue is what was sent and red what failed, the same
     5752 * meaning red has everywhere in the plugin. */
     5753.form2zoho-dash {
     5754        --f2z-dash-blue: #2f6fed;
     5755        --f2z-dash-blue-bg: #eaf1ff;
     5756        --f2z-dash-purple: #7c3aed;
     5757        --f2z-dash-purple-bg: #f3ebff;
     5758        --f2z-dash-green: #0e9f6e;
     5759        --f2z-dash-green-bg: #e3f8ef;
     5760        --f2z-dash-red: #e02424;
     5761        --f2z-dash-red-bg: #fdecec;
     5762        --f2z-dash-amber: #b7791f;
     5763        --f2z-dash-amber-bg: #fdf3dc;
     5764        max-width: 1240px;
     5765        margin: var(--f2z-space-6) auto 0;
     5766        color: var(--f2z-text);
     5767}
     5768
     5769.form2zoho-dash-card {
     5770        padding: var(--f2z-space-5);
     5771        border: 1px solid var(--f2z-border-subtle);
     5772        border-radius: 14px;
     5773        background: var(--f2z-surface);
     5774        box-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 4px 14px rgba(16, 24, 40, .04);
     5775}
     5776
     5777/* --- Stat cards --- */
     5778.form2zoho-dash-stats {
     5779        display: grid;
     5780        grid-template-columns: repeat(4, minmax(0, 1fr));
     5781        gap: var(--f2z-space-4);
     5782        margin-bottom: var(--f2z-space-4);
     5783}
     5784
     5785.form2zoho-dash-stat {
     5786        display: flex;
     5787        align-items: center;
     5788        gap: var(--f2z-space-4);
     5789        min-width: 0;
     5790}
     5791
     5792.form2zoho-dash-stat.is-blue,
     5793.form2zoho-dash-tile.is-blue {
     5794        --f2z-dash-hue: var(--f2z-dash-blue);
     5795        --f2z-dash-hue-bg: var(--f2z-dash-blue-bg);
     5796}
     5797
     5798.form2zoho-dash-stat.is-purple {
     5799        --f2z-dash-hue: var(--f2z-dash-purple);
     5800        --f2z-dash-hue-bg: var(--f2z-dash-purple-bg);
     5801}
     5802
     5803.form2zoho-dash-stat.is-green,
     5804.form2zoho-dash-tile.is-green {
     5805        --f2z-dash-hue: var(--f2z-dash-green);
     5806        --f2z-dash-hue-bg: var(--f2z-dash-green-bg);
     5807}
     5808
     5809.form2zoho-dash-stat.is-red {
     5810        --f2z-dash-hue: var(--f2z-dash-red);
     5811        --f2z-dash-hue-bg: var(--f2z-dash-red-bg);
     5812}
     5813
     5814.form2zoho-dash-tile.is-amber {
     5815        --f2z-dash-hue: var(--f2z-dash-amber);
     5816        --f2z-dash-hue-bg: var(--f2z-dash-amber-bg);
     5817}
     5818
     5819.form2zoho-dash-stat-icon {
     5820        display: inline-flex;
     5821        flex: 0 0 52px;
     5822        align-items: center;
     5823        justify-content: center;
     5824        width: 52px;
     5825        height: 52px;
     5826        border-radius: 12px;
     5827        background: var(--f2z-dash-hue-bg);
     5828        color: var(--f2z-dash-hue);
     5829}
     5830
     5831.form2zoho-dash-stat-body {
     5832        display: flex;
     5833        flex: 1 1 auto;
     5834        flex-direction: column;
     5835        min-width: 0;
     5836}
     5837
     5838.form2zoho-dash-stat-label {
     5839        overflow: hidden;
     5840        color: var(--f2z-muted);
     5841        font-size: var(--f2z-fs-xs);
     5842        font-weight: var(--f2z-fw-semibold);
     5843        letter-spacing: .04em;
     5844        text-overflow: ellipsis;
     5845        text-transform: uppercase;
     5846        white-space: nowrap;
     5847}
     5848
     5849.form2zoho-dash-stat-value {
     5850        margin: 2px 0 4px;
     5851        color: var(--f2z-heading);
     5852        font-size: 28px;
     5853        font-weight: var(--f2z-fw-bold);
     5854        line-height: 1.15;
     5855        font-variant-numeric: tabular-nums;
     5856}
     5857
     5858/* The success rate and the failures wear their card's colour, as the
     5859   reference does: they are the two numbers read as a verdict. */
     5860.form2zoho-dash-stat.is-green .form2zoho-dash-stat-value,
     5861.form2zoho-dash-stat.is-red .form2zoho-dash-stat-value {
     5862        color: var(--f2z-dash-hue);
     5863}
     5864
     5865.form2zoho-dash-stat-note {
     5866        display: flex;
     5867        flex-wrap: wrap;
     5868        align-items: center;
     5869        gap: 6px;
     5870        color: var(--f2z-muted);
     5871        font-size: var(--f2z-fs-xs);
     5872}
     5873
     5874.form2zoho-dash-delta {
     5875        display: inline-flex;
     5876        align-items: center;
     5877        gap: 3px;
     5878        padding: 1px 8px;
     5879        border-radius: var(--f2z-radius-pill);
     5880        background: var(--f2z-disabled-bg);
     5881        color: var(--f2z-muted);
     5882        font-weight: var(--f2z-fw-semibold);
     5883        white-space: nowrap;
     5884        font-variant-numeric: tabular-nums;
     5885}
     5886
     5887.form2zoho-dash-delta.is-good {
     5888        background: var(--f2z-dash-green-bg);
     5889        color: #057a55;
     5890}
     5891
     5892.form2zoho-dash-delta.is-bad {
     5893        background: var(--f2z-dash-red-bg);
     5894        color: #c81e1e;
     5895}
     5896
     5897.form2zoho-dash a.form2zoho-dash-stat-go {
     5898        display: inline-flex;
     5899        flex: 0 0 32px;
     5900        align-items: center;
     5901        justify-content: center;
     5902        width: 32px;
     5903        height: 32px;
     5904        border-radius: 50%;
     5905        background: var(--f2z-dash-hue-bg);
     5906        color: var(--f2z-dash-hue);
     5907        text-decoration: none;
     5908        transition: background-color var(--f2z-transition), color var(--f2z-transition);
     5909}
     5910
     5911.form2zoho-dash a.form2zoho-dash-stat-go:hover,
     5912.form2zoho-dash a.form2zoho-dash-stat-go:focus {
     5913        background: var(--f2z-dash-hue);
     5914        color: #fff;
     5915}
     5916
     5917/* --- The two columns under the cards --- */
     5918.form2zoho-dash-grid {
     5919        display: grid;
     5920        grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
     5921        gap: var(--f2z-space-4);
     5922        align-items: start;
     5923}
     5924
     5925.form2zoho-dash-main,
     5926.form2zoho-dash-side {
     5927        display: grid;
     5928        gap: var(--f2z-space-4);
     5929        min-width: 0;
     5930}
     5931
     5932.form2zoho-dash-card-head {
     5933        display: flex;
     5934        flex-wrap: wrap;
     5935        align-items: center;
     5936        justify-content: space-between;
     5937        gap: var(--f2z-space-3);
     5938        margin-bottom: var(--f2z-space-4);
     5939}
     5940
     5941.wrap.form2zoho .form2zoho-dash-card-head h2 {
     5942        display: flex;
     5943        align-items: center;
     5944        gap: var(--f2z-space-2);
     5945        margin: 0;
     5946        padding: 0;
     5947        color: var(--f2z-heading);
     5948        font-size: var(--f2z-fs-lg);
     5949        font-weight: var(--f2z-fw-bold);
     5950}
     5951
     5952.form2zoho-dash-card-head h2 svg {
     5953        flex: 0 0 auto;
     5954        color: var(--f2z-dash-blue);
     5955}
     5956
     5957.form2zoho-dash-card-head p {
     5958        margin: 2px 0 0;
     5959        color: var(--f2z-muted);
     5960        font-size: var(--f2z-fs-sm);
     5961}
     5962
     5963/* --- Performance Overview --- */
     5964.form2zoho-dash-range {
     5965        display: inline-flex;
     5966        flex-wrap: wrap;
     5967        gap: 6px;
     5968}
     5969
     5970.form2zoho-dash .form2zoho-dash-range a {
     5971        padding: 6px 12px;
     5972        border: 1px solid transparent;
     5973        border-radius: 8px;
     5974        background: var(--f2z-surface-head);
     5975        color: var(--f2z-muted);
     5976        font-size: var(--f2z-fs-sm);
     5977        font-weight: var(--f2z-fw-medium);
     5978        text-decoration: none;
     5979        white-space: nowrap;
     5980}
     5981
     5982.form2zoho-dash .form2zoho-dash-range a:hover,
     5983.form2zoho-dash .form2zoho-dash-range a:focus {
     5984        color: var(--f2z-heading);
     5985}
     5986
     5987.form2zoho-dash .form2zoho-dash-range a.is-active {
     5988        border-color: #c9dbff;
     5989        background: var(--f2z-dash-blue-bg);
     5990        color: #1d4fd8;
     5991        font-weight: var(--f2z-fw-semibold);
     5992}
     5993
     5994.form2zoho-dash-legend {
     5995        display: flex;
     5996        justify-content: flex-end;
     5997        gap: var(--f2z-space-4);
     5998        margin-bottom: var(--f2z-space-3);
     5999        color: var(--f2z-muted);
     6000        font-size: var(--f2z-fs-xs);
     6001}
     6002
     6003.form2zoho-dash-legend span::before {
     6004        content: "";
     6005        display: inline-block;
     6006        width: 8px;
     6007        height: 8px;
     6008        margin-right: 6px;
     6009        border-radius: 50%;
     6010        background: var(--f2z-dash-blue);
     6011}
     6012
     6013.form2zoho-dash-legend .is-failed::before {
     6014        background: var(--f2z-dash-red);
     6015}
     6016
     6017/* The plot: a y-axis column, then the lines. The lines are one SVG stretched
     6018   to the plot; the gridlines behind it and the hover columns over it are
     6019   HTML, so the labels stay upright at any width. */
     6020.form2zoho-dash-chart {
     6021        display: flex;
     6022        gap: var(--f2z-space-3);
     6023        height: 240px;
     6024}
     6025
     6026.form2zoho-dash-yaxis {
     6027        display: flex;
     6028        flex-direction: column;
     6029        justify-content: space-between;
     6030        min-width: 24px;
     6031        margin: -7px 0;
     6032        color: var(--f2z-muted);
     6033        font-size: var(--f2z-fs-xs);
     6034        line-height: 14px;
     6035        text-align: right;
     6036        font-variant-numeric: tabular-nums;
     6037}
     6038
     6039.form2zoho-dash-yaxis span {
     6040        min-height: 14px;
     6041}
     6042
     6043.form2zoho-dash-plot {
     6044        position: relative;
     6045        flex: 1;
     6046        min-width: 0;
     6047        border-bottom: 1px solid var(--f2z-border);
     6048        background: repeating-linear-gradient(to bottom, var(--f2z-border-subtle) 0, var(--f2z-border-subtle) 1px, transparent 1px, transparent 25%);
     6049}
     6050
     6051.form2zoho-dash-lines {
     6052        position: absolute;
     6053        inset: 0;
     6054        width: 100%;
     6055        height: 100%;
     6056        overflow: visible;
     6057        color: var(--f2z-dash-blue);
     6058}
     6059
     6060.form2zoho-dash-lines path {
     6061        fill: none;
     6062        stroke-width: 2;
     6063        stroke-linecap: round;
     6064        stroke-linejoin: round;
     6065        vector-effect: non-scaling-stroke;
     6066}
     6067
     6068.form2zoho-dash-lines .is-area {
     6069        fill: url(#f2z-dash-fill);
     6070        stroke: none;
     6071}
     6072
     6073.form2zoho-dash-lines .is-sent {
     6074        stroke: var(--f2z-dash-blue);
     6075}
     6076
     6077.form2zoho-dash-lines .is-failed {
     6078        stroke: var(--f2z-dash-red);
     6079}
     6080
     6081.form2zoho-dash-cols {
     6082        position: absolute;
     6083        inset: 0;
     6084        display: flex;
     6085}
     6086
     6087.form2zoho-dash-col {
     6088        position: relative;
     6089        flex: 1;
     6090        min-width: 0;
     6091        height: 100%;
     6092        outline: none;
     6093}
     6094
     6095/* The guide down the hovered day. */
     6096.form2zoho-dash-col::before {
     6097        content: "";
     6098        position: absolute;
     6099        top: 0;
     6100        bottom: 0;
     6101        left: 50%;
     6102        width: 1px;
     6103        background: var(--f2z-control-border);
     6104        opacity: 0;
     6105}
     6106
     6107.form2zoho-dash-point {
     6108        position: absolute;
     6109        left: 50%;
     6110        width: 10px;
     6111        height: 10px;
     6112        margin: 0 0 -5px -5px;
     6113        border: 2px solid var(--f2z-surface);
     6114        border-radius: 50%;
     6115        background: var(--f2z-dash-blue);
     6116        opacity: 0;
     6117}
     6118
     6119.form2zoho-dash-point.is-failed {
     6120        background: var(--f2z-dash-red);
     6121}
     6122
     6123.form2zoho-dash-col:hover::before,
     6124.form2zoho-dash-col:focus-visible::before,
     6125.form2zoho-dash-col:hover .form2zoho-dash-point,
     6126.form2zoho-dash-col:focus-visible .form2zoho-dash-point {
     6127        opacity: 1;
     6128}
     6129
     6130.form2zoho-dash-col:hover::after,
     6131.form2zoho-dash-col:focus-visible::after {
     6132        content: attr(data-tip);
     6133        position: absolute;
     6134        top: 6px;
     6135        left: 50%;
     6136        z-index: 2;
     6137        padding: 5px 9px;
     6138        border-radius: 6px;
     6139        background: var(--f2z-heading);
     6140        color: #fff;
     6141        font-size: var(--f2z-fs-xs);
     6142        white-space: nowrap;
     6143        transform: translateX(-50%);
     6144        pointer-events: none;
     6145}
     6146
     6147/* Near an edge the tip opens inwards, so it is never cut off by the card. */
     6148.form2zoho-dash-col:nth-child(-n+4):hover::after,
     6149.form2zoho-dash-col:nth-child(-n+4):focus-visible::after {
     6150        left: 0;
     6151        transform: none;
     6152}
     6153
     6154.form2zoho-dash-col:nth-last-child(-n+4):hover::after,
     6155.form2zoho-dash-col:nth-last-child(-n+4):focus-visible::after {
     6156        right: 0;
     6157        left: auto;
     6158        transform: none;
     6159}
     6160
     6161/* One slot per day, so a date sits under its own point. */
     6162.form2zoho-dash-xaxis {
     6163        display: flex;
     6164        margin: var(--f2z-space-2) 0 0 36px;
     6165        color: var(--f2z-muted);
     6166        font-size: var(--f2z-fs-xs);
     6167}
     6168
     6169.form2zoho-dash-xaxis span {
     6170        flex: 1;
     6171        min-width: 0;
     6172        overflow: visible;
     6173        text-align: center;
     6174        white-space: nowrap;
     6175}
     6176
     6177.form2zoho-dash-chart-note {
     6178        margin: var(--f2z-space-3) 0 0;
     6179        color: var(--f2z-muted);
     6180        font-size: var(--f2z-fs-xs);
     6181}
     6182
     6183/* --- Sync Health --- */
     6184.form2zoho-dash-health.is-good {
     6185        --f2z-dash-ring: var(--f2z-dash-green);
     6186        --f2z-dash-ring-bg: var(--f2z-dash-green-bg);
     6187        --f2z-dash-ring-ink: #057a55;
     6188}
     6189
     6190.form2zoho-dash-health.is-ok {
     6191        --f2z-dash-ring: var(--f2z-dash-blue);
     6192        --f2z-dash-ring-bg: var(--f2z-dash-blue-bg);
     6193        --f2z-dash-ring-ink: #1d4fd8;
     6194}
     6195
     6196.form2zoho-dash-health.is-warn {
     6197        --f2z-dash-ring: #d69e2e;
     6198        --f2z-dash-ring-bg: var(--f2z-dash-amber-bg);
     6199        --f2z-dash-ring-ink: #8a5000;
     6200}
     6201
     6202.form2zoho-dash-health.is-bad {
     6203        --f2z-dash-ring: var(--f2z-dash-red);
     6204        --f2z-dash-ring-bg: var(--f2z-dash-red-bg);
     6205        --f2z-dash-ring-ink: #c81e1e;
     6206}
     6207
     6208.form2zoho-dash-health.is-none {
     6209        --f2z-dash-ring: var(--f2z-border);
     6210        --f2z-dash-ring-bg: var(--f2z-surface-head);
     6211        --f2z-dash-ring-ink: var(--f2z-muted);
     6212}
     6213
     6214.form2zoho-dash-ring {
     6215        position: relative;
     6216        width: 132px;
     6217        height: 132px;
     6218        margin: 0 auto var(--f2z-space-4);
     6219}
     6220
     6221.form2zoho-dash-ring circle {
     6222        fill: none;
     6223        stroke-width: 12;
     6224}
     6225
     6226.form2zoho-dash-ring .is-track {
     6227        stroke: var(--f2z-border-subtle);
     6228}
     6229
     6230.form2zoho-dash-ring .is-value {
     6231        stroke: var(--f2z-dash-ring);
     6232        stroke-linecap: round;
     6233}
     6234
     6235.form2zoho-dash-ring-text {
     6236        position: absolute;
     6237        inset: 0;
     6238        display: flex;
     6239        flex-direction: column;
     6240        align-items: center;
     6241        justify-content: center;
     6242        text-align: center;
     6243}
     6244
     6245.form2zoho-dash-ring-text strong {
     6246        color: var(--f2z-heading);
     6247        font-size: 30px;
     6248        font-weight: var(--f2z-fw-bold);
     6249        line-height: 1.1;
     6250        font-variant-numeric: tabular-nums;
     6251}
     6252
     6253.form2zoho-dash-ring-text span {
     6254        max-width: 84px;
     6255        color: var(--f2z-dash-ring-ink);
     6256        font-size: 10px;
     6257        font-weight: var(--f2z-fw-bold);
     6258        letter-spacing: .04em;
     6259        line-height: 1.2;
     6260        text-transform: uppercase;
     6261}
     6262
     6263.form2zoho-dash-tiles {
     6264        display: grid;
     6265        grid-template-columns: repeat(3, minmax(0, 1fr));
     6266        gap: var(--f2z-space-2);
     6267        margin-bottom: var(--f2z-space-4);
     6268}
     6269
     6270.form2zoho-dash-tile {
     6271        display: flex;
     6272        flex-direction: column;
     6273        gap: 6px;
     6274        min-width: 0;
     6275        padding: var(--f2z-space-3);
     6276        border: 1px solid var(--f2z-border-subtle);
     6277        border-radius: 10px;
     6278        background: var(--f2z-surface-subtle);
     6279}
     6280
     6281.form2zoho-dash-tile-icon {
     6282        display: inline-flex;
     6283        align-items: center;
     6284        justify-content: center;
     6285        width: 28px;
     6286        height: 28px;
     6287        border-radius: 8px;
     6288        background: var(--f2z-dash-hue-bg);
     6289        color: var(--f2z-dash-hue);
     6290}
     6291
     6292.form2zoho-dash-tile > span:last-child {
     6293        display: flex;
     6294        flex-direction: column;
     6295        min-width: 0;
     6296}
     6297
     6298.form2zoho-dash-tile strong {
     6299        color: var(--f2z-heading);
     6300        font-size: var(--f2z-fs-lg);
     6301        font-weight: var(--f2z-fw-bold);
     6302        line-height: 1.2;
     6303        font-variant-numeric: tabular-nums;
     6304}
     6305
     6306.form2zoho-dash-tile strong + span {
     6307        color: var(--f2z-muted);
     6308        font-size: var(--f2z-fs-xs);
     6309        line-height: 1.3;
     6310}
     6311
     6312.form2zoho-dash-health-note {
     6313        display: flex;
     6314        align-items: flex-start;
     6315        gap: var(--f2z-space-2);
     6316        margin: 0;
     6317        padding: var(--f2z-space-3);
     6318        border-radius: 10px;
     6319        background: var(--f2z-dash-ring-bg);
     6320        color: var(--f2z-dash-ring-ink);
     6321        font-size: var(--f2z-fs-sm);
     6322        font-weight: var(--f2z-fw-semibold);
     6323        line-height: 1.4;
     6324}
     6325
     6326/* With failures to look at, the line is a warning whatever the score. */
     6327.form2zoho-dash-health-note.is-warn {
     6328        background: var(--f2z-dash-amber-bg);
     6329        color: #8a5000;
     6330}
     6331
     6332.form2zoho-dash-health-note svg {
     6333        flex: 0 0 auto;
     6334        margin-top: 1px;
     6335}
     6336
     6337.form2zoho-dash-more {
     6338        display: inline-flex;
     6339        align-items: center;
     6340        gap: 2px;
     6341        color: var(--f2z-heading);
     6342        font-size: var(--f2z-fs-sm);
     6343        font-weight: var(--f2z-fw-medium);
     6344        text-decoration: none;
     6345        white-space: nowrap;
     6346}
     6347
     6348.form2zoho-dash-icon-link {
     6349        display: inline-flex;
     6350        flex-shrink: 0;
     6351        padding: 2px;
     6352        border-radius: var(--f2z-radius-sm);
     6353        color: var(--f2z-muted);
     6354}
     6355
     6356.form2zoho-dash-icon-link:hover,
     6357.form2zoho-dash-icon-link:focus {
     6358        color: var(--f2z-brand);
     6359}
     6360
     6361.form2zoho-dash-empty {
     6362        margin: 0;
     6363        color: var(--f2z-muted);
     6364        font-size: var(--f2z-fs-sm);
     6365}
     6366
     6367.form2zoho-dash ul {
     6368        margin: 0;
     6369        padding: 0;
     6370        list-style: none;
     6371}
     6372
     6373.form2zoho-dash-feeds {
     6374        margin: 0 calc(-1 * var(--f2z-space-5)) calc(-1 * var(--f2z-space-5)) !important;
     6375        border-top: 1px solid var(--f2z-border-subtle);
     6376}
     6377
     6378.form2zoho-dash-feeds li {
     6379        display: flex;
     6380        align-items: center;
     6381        gap: var(--f2z-space-3);
     6382        margin: 0;
     6383        padding: var(--f2z-space-3) var(--f2z-space-5);
     6384        border-bottom: 1px solid var(--f2z-border-subtle);
     6385}
     6386
     6387.form2zoho-dash-feeds li:last-child {
     6388        border-bottom: 0;
     6389}
     6390
     6391.form2zoho-dash-feed-icon {
     6392        display: inline-flex;
     6393        flex-shrink: 0;
     6394        align-items: center;
     6395        justify-content: center;
     6396        width: 32px;
     6397        height: 32px;
     6398        border-radius: var(--f2z-radius-control);
     6399        background: var(--f2z-brand-tint);
     6400        color: #fff;
     6401        font-size: var(--f2z-fs-xs);
     6402        font-weight: var(--f2z-fw-bold);
     6403}
     6404
     6405.form2zoho-dash-feed-main,
     6406.form2zoho-dash-recent-main,
     6407.form2zoho-dash-conns li > span:last-child {
     6408        display: flex;
     6409        flex: 1;
     6410        flex-direction: column;
     6411        min-width: 0;
     6412}
     6413
     6414.form2zoho-dash-feed-main a {
     6415        overflow: hidden;
     6416        color: var(--f2z-heading);
     6417        font-weight: var(--f2z-fw-semibold);
     6418        text-decoration: none;
     6419        text-overflow: ellipsis;
     6420        white-space: nowrap;
     6421}
     6422
     6423.form2zoho-dash-feed-main a:hover {
     6424        color: var(--f2z-brand);
     6425}
     6426
     6427.form2zoho-dash-feed-main > span,
     6428.form2zoho-dash-feed-runs > span,
     6429.form2zoho-dash-recent-main > span:last-child,
     6430.form2zoho-dash-conns li > span:last-child > span {
     6431        overflow: hidden;
     6432        color: var(--f2z-muted);
     6433        font-size: var(--f2z-fs-xs);
     6434        text-overflow: ellipsis;
     6435        white-space: nowrap;
     6436}
     6437
     6438.form2zoho-dash-feed-runs {
     6439        display: flex;
     6440        flex-direction: column;
     6441        align-items: flex-end;
     6442        flex-shrink: 0;
     6443        font-size: var(--f2z-fs-sm);
     6444        font-variant-numeric: tabular-nums;
     6445}
     6446
     6447.form2zoho-dash-pill {
     6448        flex-shrink: 0;
     6449        padding: 2px 8px;
     6450        border-radius: var(--f2z-radius-pill);
     6451        font-size: var(--f2z-fs-xs);
     6452        font-weight: var(--f2z-fw-medium);
     6453}
     6454
     6455.form2zoho-dash-pill.is-on {
     6456        background: var(--f2z-ok-bg);
     6457        color: var(--f2z-ok);
     6458}
     6459
     6460.form2zoho-dash-pill.is-off {
     6461        background: var(--f2z-disabled-bg);
     6462        color: var(--f2z-disabled-text);
     6463}
     6464
     6465.form2zoho-dash-conns li {
     6466        display: flex;
     6467        align-items: center;
     6468        gap: var(--f2z-space-3);
     6469        margin: 0 0 var(--f2z-space-2);
     6470        padding: var(--f2z-space-2) var(--f2z-space-3);
     6471        border: 1px solid var(--f2z-border);
     6472        border-radius: var(--f2z-radius-control);
     6473}
     6474
     6475.form2zoho-dash-conns li:last-child,
     6476.form2zoho-dash-recent li:last-child {
     6477        margin-bottom: 0;
     6478}
     6479
     6480.form2zoho-dash-recent li {
     6481        display: flex;
     6482        align-items: flex-start;
     6483        gap: var(--f2z-space-3);
     6484        margin: 0 0 var(--f2z-space-3);
     6485}
     6486
     6487.form2zoho-dash-recent .form2zoho-dash-dot {
     6488        margin-top: 5px;
     6489}
     6490
     6491.form2zoho-dash-dot.is-ok {
     6492        color: var(--f2z-ok);
     6493}
     6494
     6495.form2zoho-dash-dot.is-off {
     6496        color: var(--f2z-warn);
     6497}
     6498
     6499.form2zoho-dash-dot.is-bad {
     6500        color: var(--f2z-bad);
     6501}
     6502
     6503.form2zoho-dash-version {
     6504        margin: var(--f2z-space-5) 0 0;
     6505        color: var(--f2z-muted);
     6506        font-size: var(--f2z-fs-xs);
     6507}
     6508
     6509@media (max-width: 1100px) {
     6510        .form2zoho-topnav {
     6511                order: 3;
     6512                flex: 1 1 100%;
     6513                margin: var(--f2z-space-2) 0 0;
     6514        }
     6515
     6516        .form2zoho-topbar-actions {
     6517                order: 2;
     6518        }
     6519}
     6520
     6521@media (max-width: 992px) {
     6522        .form2zoho-dash-stats {
     6523                grid-template-columns: repeat(2, minmax(0, 1fr));
     6524        }
     6525
     6526        .form2zoho-dash-grid {
     6527                grid-template-columns: minmax(0, 1fr);
     6528        }
     6529}
     6530
     6531@media (max-width: 600px) {
     6532        .form2zoho-topbar-status {
     6533                display: none;
     6534        }
     6535
     6536        .form2zoho-dash-stats {
     6537                grid-template-columns: minmax(0, 1fr);
     6538        }
     6539
     6540        .form2zoho-dash-card {
     6541                padding: var(--f2z-space-4);
     6542        }
     6543
     6544        .form2zoho-dash-chart {
     6545                height: 200px;
     6546        }
     6547
     6548        .form2zoho-dash-feeds {
     6549                margin: 0 calc(-1 * var(--f2z-space-4)) calc(-1 * var(--f2z-space-4)) !important;
     6550        }
     6551
     6552        .form2zoho-dash-feeds li {
     6553                flex-wrap: wrap;
     6554                padding: var(--f2z-space-3) var(--f2z-space-4);
     6555        }
     6556}
     6557
     6558/* --- Feed editor: "Zoho destination" card ------------------------------
     6559 * Docs, YouTube and Connect account share the card header, split by thin
     6560 * rules; the pickers below sit on one row (Account, Zoho app, Module, and any
     6561 * field an add-on adds such as Layout) and wrap when the card narrows. */
     6562.form2zoho-dest-links {
     6563        display: flex;
     6564        flex-wrap: wrap;
     6565        align-items: center;
     6566        gap: var(--f2z-space-2) 0;
     6567}
     6568
     6569.form2zoho-dest-links > a:not([hidden]) {
     6570        display: inline-flex;
     6571        align-items: center;
     6572        gap: var(--f2z-space-1);
     6573        margin: 0;
     6574        padding: 0 var(--f2z-space-3);
     6575        border-left: 1px solid var(--f2z-border);
     6576        line-height: 1.2;
     6577}
     6578
     6579.form2zoho .form2zoho-dest-links > a.form2zoho-step-docs {
     6580        margin: 0;
     6581        color: var(--f2z-text);
     6582        font-size: var(--f2z-fs-base);
     6583}
     6584
     6585.form2zoho-dest-links > a:not([hidden]):first-child,
     6586.form2zoho-dest-links > a[hidden] + a:not([hidden]) {
     6587        padding-left: 0;
     6588        border-left: 0;
     6589}
     6590
     6591.form2zoho-dest-links > a:last-child {
     6592        padding-right: 0;
     6593}
     6594
     6595.form2zoho-youtube-link svg {
     6596        opacity: 1;
     6597}
     6598
     6599.form2zoho-grid--dest {
     6600        grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
     6601        gap: var(--f2z-space-4);
     6602}
     6603
     6604.form2zoho-grid--dest > .form2zoho-field--wide {
     6605        grid-column: auto;
     6606}
     6607
     6608.form2zoho .form2zoho-grid--dest > .form2zoho-field > * {
     6609        max-width: none;
     6610}
     6611
     6612/* --- Feed list: the name and the trigger open the feed ---------------- */
     6613.form2zoho a.form2zoho-row-open {
     6614        color: inherit;
     6615        text-decoration: none;
     6616}
     6617
     6618.form2zoho a.form2zoho-row-open:hover strong,
     6619.form2zoho a.form2zoho-row-open:focus strong,
     6620.form2zoho a.form2zoho-row-open:hover .form2zoho-trigger-chip,
     6621.form2zoho a.form2zoho-row-open:focus .form2zoho-trigger-chip {
     6622        color: var(--f2z-brand);
     6623        text-decoration: underline;
     6624}
     6625
     6626/* --- Docs & Support tab ------------------------------------------------ */
     6627.form2zoho-support {
     6628        display: grid;
     6629        gap: var(--f2z-space-4);
     6630        margin-top: var(--f2z-space-5);
     6631}
     6632
     6633.form2zoho-support-cards {
     6634        display: grid;
     6635        grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
     6636        gap: var(--f2z-space-4);
     6637}
     6638
     6639.form2zoho-support-card {
     6640        display: flex;
     6641        flex-direction: column;
     6642        align-items: flex-start;
     6643        gap: var(--f2z-space-2);
     6644        padding: var(--f2z-space-5);
     6645        border: 1px solid var(--f2z-border);
     6646        border-radius: var(--f2z-radius-card);
     6647        background: var(--f2z-surface);
     6648}
     6649
     6650.wrap.form2zoho .form2zoho-support-card h3,
     6651.wrap.form2zoho .form2zoho-support-heading {
     6652        margin: 0;
     6653        color: var(--f2z-heading);
     6654        font-size: var(--f2z-fs-lg);
     6655        font-weight: var(--f2z-fw-semibold);
     6656}
     6657
     6658/* The icon tile and the title on one line, the tile a little ahead of it. */
     6659.wrap.form2zoho .form2zoho-support-card h3 {
     6660        display: flex;
     6661        align-items: center;
     6662        gap: var(--f2z-space-3);
     6663        margin-bottom: var(--f2z-space-1);
     6664}
     6665
     6666.form2zoho-support-card p {
     6667        flex: 1;
     6668        margin: 0 0 var(--f2z-space-2);
     6669        color: var(--f2z-muted);
     6670        font-size: var(--f2z-fs-base);
     6671        line-height: var(--f2z-lh);
     6672}
     6673
     6674.form2zoho a.form2zoho-support-link {
     6675        display: inline-flex;
     6676        align-items: center;
     6677        gap: var(--f2z-space-1);
     6678        color: var(--f2z-brand);
     6679        font-weight: var(--f2z-fw-semibold);
     6680        text-decoration: none;
     6681}
     6682
     6683.form2zoho a.form2zoho-support-link:hover,
     6684.form2zoho a.form2zoho-support-link:focus {
     6685        text-decoration: underline;
     6686}
     6687
     6688.wrap.form2zoho .form2zoho-support-heading {
     6689        margin-top: var(--f2z-space-3);
     6690        font-size: var(--f2z-fs-md);
     6691}
     6692
     6693.form2zoho ul.form2zoho-support-list {
     6694        margin: 0;
     6695        padding: 0;
     6696        border: 1px solid var(--f2z-border);
     6697        border-radius: var(--f2z-radius-card);
     6698        background: var(--f2z-surface);
     6699        list-style: none;
     6700}
     6701
     6702.form2zoho-support-list li {
     6703        margin: 0;
     6704        border-top: 1px solid var(--f2z-border-subtle);
     6705}
     6706
     6707.form2zoho-support-list li:first-child {
     6708        border-top: 0;
     6709}
     6710
     6711.form2zoho-support-list a {
     6712        display: flex;
     6713        align-items: center;
     6714        gap: var(--f2z-space-3);
     6715        padding: var(--f2z-space-3) var(--f2z-space-5);
     6716        color: inherit;
     6717        text-decoration: none;
     6718}
     6719
     6720.form2zoho-support-list a:hover,
     6721.form2zoho-support-list a:focus {
     6722        background: var(--f2z-surface-hover);
     6723}
     6724
     6725.form2zoho-support-icon {
     6726        display: inline-flex;
     6727        flex: 0 0 36px;
     6728        align-items: center;
     6729        justify-content: center;
     6730        width: 36px;
     6731        height: 36px;
     6732        border-radius: var(--f2z-radius-control);
     6733        background: var(--f2z-brand-tint);
     6734        color: var(--f2z-brand);
     6735}
     6736
     6737.form2zoho-support-icon.is-video {
     6738        background: #fdecec;
     6739}
     6740
     6741.form2zoho-support-item {
     6742        display: flex;
     6743        flex: 1;
     6744        flex-direction: column;
     6745        min-width: 0;
     6746}
     6747
     6748.form2zoho-support-item strong {
     6749        color: var(--f2z-heading);
     6750        font-weight: var(--f2z-fw-semibold);
     6751}
     6752
     6753.form2zoho-support-item span {
     6754        color: var(--f2z-muted);
     6755        font-size: var(--f2z-fs-sm);
     6756}
     6757
     6758.form2zoho-support-list .form2zoho-support-ext {
     6759        flex-shrink: 0;
     6760        color: var(--f2z-muted);
     6761}
     6762
     6763/* The top bar's own inset on small screens, where wp-admin's gutter is 10px. */
     6764@media (max-width: 782px) {
     6765        .form2zoho-topbar {
     6766                padding: var(--f2z-space-3) var(--f2z-space-4);
     6767                column-gap: var(--f2z-space-3);
     6768        }
     6769}
     6770
     6771/* --- Feed editor: "Zoho destination" card, compact pickers --------------
     6772 * The heading carries a small brand badge; each list that has a reload
     6773 * button holds it inside its own box, between the value and the arrow, so
     6774 * the four pickers read as four equal controls on one row. */
     6775.form2zoho-group__head h3 {
     6776        display: inline-flex;
     6777        align-items: center;
     6778        gap: var(--f2z-space-2);
     6779}
     6780
     6781.form2zoho-group__icon {
     6782        display: inline-flex;
     6783        align-items: center;
     6784        justify-content: center;
     6785        flex: 0 0 auto;
     6786        width: 28px;
     6787        height: 28px;
     6788        background: var(--f2z-brand);
     6789        border-radius: var(--f2z-radius-control);
     6790        color: #fff;
     6791}
     6792
     6793/* The leaves-the-site arrow after a guide or video link, wherever one is:
     6794   quieter than the icon that says what the link is. */
     6795.form2zoho-dest-links .form2zoho-extlink,
     6796.form2zoho-step-docs .form2zoho-extlink,
     6797.form2zoho-modal-docs .form2zoho-extlink {
     6798        flex: 0 0 auto;
     6799        opacity: .75;
     6800}
     6801
     6802.form2zoho-modal-docs svg {
     6803        flex: 0 0 auto;
     6804}
     6805
     6806.form2zoho-grid--dest .form2zoho-control-row:has(> .form2zoho-reload-btn) {
     6807        position: relative;
     6808}
     6809
     6810.form2zoho .form2zoho-grid--dest .form2zoho-control-row:has(> .form2zoho-reload-btn) > select {
     6811        flex: 1 1 100%;
     6812        padding-right: 72px;
     6813        background-position: right 10px top 55%;
     6814}
     6815
     6816.form2zoho .form2zoho-grid--dest .form2zoho-control-row > .form2zoho-reload-btn {
     6817        position: absolute;
     6818        top: 50%;
     6819        right: 30px;
     6820        width: 34px;
     6821        height: 22px;
     6822        margin: 0;
     6823        border-width: 0 1px;
     6824        border-color: var(--f2z-border);
     6825        border-radius: 0;
     6826        background: transparent;
     6827        transform: translateY(-50%);
     6828}
     6829
     6830.form2zoho .form2zoho-grid--dest .form2zoho-control-row > .form2zoho-reload-btn:hover:not(:disabled) {
     6831        border-color: var(--f2z-border);
     6832        background: var(--f2z-brand-tint);
     6833}
     6834
     6835/* A short explanation behind an "i", for a field whose help would otherwise
     6836   make its cell taller than the others on the row. */
     6837.form2zoho-info {
     6838        display: inline-flex;
     6839        align-items: center;
     6840        margin-left: var(--f2z-space-1);
     6841        color: var(--f2z-muted);
     6842        vertical-align: middle;
     6843        cursor: help;
     6844}
     6845
     6846.form2zoho-info:hover,
     6847.form2zoho-info:focus {
     6848        color: var(--f2z-brand);
     6849}
  • techwave-form-sync-for-zoho/trunk/assets/js/admin-nav.js

    r3649280 r3748247  
    11/**
    2  * Techwave Form Sync for Zoho — in-plugin AJAX navigation.
     2 * FormBridge for Zoho — in-plugin AJAX navigation.
    33 *
    44 * Every link inside the plugin (pagination, feed search/filter, tabs,
    … …  
    120120                        return false;
    121121                }
    122                 var page = ( a.search.match( /[?&]page=([^&]*)/ ) || [] )[ 1 ] || '';
    123                 return 0 === decodeURIComponent( page ).indexOf( 'form2zoho' );
     122                var page = decodeURIComponent( ( a.search.match( /[?&]page=([^&]*)/ ) || [] )[ 1 ] || '' );
     123                // These two exactly. A "formbridge" prefix would also take in the Pro
     124                // add-on's licence screen, which is not built to be swapped in.
     125                return 'formbridge' === page || 'formbridge-feeds' === page || 'formbridge-dashboard' === page;
    124126        }
    125127
    … …  
    291293        }
    292294
     295        /**
     296         * The classes wp-admin uses to say which menu item you are on.
     297         *
     298         * Only these are copied. The rest of a menu item's classes are structural
     299         * (`menu-top`, `wp-has-submenu`) or runtime state WordPress's own script
     300         * owns (`opensub`, added on hover), and replacing those wholesale would
     301         * hand the menu a stale copy of something it is in the middle of using.
     302         */
     303        var MENU_STATE_CLASSES = [
     304                'current',
     305                'wp-has-current-submenu',
     306                'wp-not-current-submenu',
     307                'wp-menu-open'
     308        ];
     309
     310        /**
     311         * Move the sidebar's "you are here" to match the page just swapped in.
     312         *
     313         * A swap replaces `.wrap.form2zoho` and nothing else, so the sidebar kept
     314         * pointing at whichever screen the person came *from* — click Connections
     315         * from Forms and the page changed while the menu still said Forms.
     316         *
     317         * Copied from the fetched document rather than worked out here: the
     318         * server already decided which item is current, and it knows about things
     319         * this could only guess at — a submenu item whose URL carries extra query
     320         * args, a screen registered by the add-on. Matched by position, because
     321         * both documents are the same menu rendered for the same user moments
     322         * apart; if that is somehow not true the counts differ and this leaves
     323         * the menu alone rather than scrambling it.
     324         *
     325         * @param {Document} doc The parsed response for the page being shown.
     326         */
     327        function syncAdminMenu( doc ) {
     328                var fresh = doc.getElementById( 'adminmenu' );
     329                var live = document.getElementById( 'adminmenu' );
     330
     331                if ( ! fresh || ! live ) {
     332                        return;
     333                }
     334
     335                var freshItems = fresh.querySelectorAll( 'li' );
     336                var liveItems = live.querySelectorAll( 'li' );
     337
     338                if ( ! freshItems.length || freshItems.length !== liveItems.length ) {
     339                        return;
     340                }
     341
     342                for ( var i = 0; i < liveItems.length; i++ ) {
     343                        copyMenuState( freshItems[ i ], liveItems[ i ] );
     344
     345                        var freshLink = freshItems[ i ].querySelector( ':scope > a' );
     346                        var liveLink = liveItems[ i ].querySelector( ':scope > a' );
     347
     348                        if ( freshLink && liveLink ) {
     349                                copyMenuState( freshLink, liveLink );
     350
     351                                // Not a class, and the one part of this a screen reader uses.
     352                                if ( freshLink.hasAttribute( 'aria-current' ) ) {
     353                                        liveLink.setAttribute( 'aria-current', freshLink.getAttribute( 'aria-current' ) );
     354                                } else {
     355                                        liveLink.removeAttribute( 'aria-current' );
     356                                }
     357                        }
     358                }
     359        }
     360
     361        /**
     362         * Give `to` the same state classes `from` has, and no others.
     363         *
     364         * @param {Element} from Element in the freshly fetched document.
     365         * @param {Element} to   The live element to bring into line.
     366         */
     367        function copyMenuState( from, to ) {
     368                MENU_STATE_CLASSES.forEach( function ( name ) {
     369                        to.classList.toggle( name, from.classList.contains( name ) );
     370                } );
     371        }
     372
     373        /**
     374         * Does the fetched page load a script or stylesheet this one has not?
     375         *
     376         * Only the wrap is swapped and only admin.js is re-run, so a screen whose
     377         * own files are not on this page yet (the Pro add-on's feed editor
     378         * scripts, reached from Dashboard or Connections) would arrive without
     379         * them: its panels stay hidden and nothing on them responds. Such a
     380         * screen is loaded normally instead. Compared by path, so a changed
     381         * ?ver= is left to syncAssets().
     382         *
     383         * @param {Document} doc Freshly fetched, parsed document.
     384         * @return {boolean}
     385         */
     386        function needsAssets( doc ) {
     387                var have = {};
     388                Array.prototype.forEach.call(
     389                        document.querySelectorAll( 'script[src], link[rel="stylesheet"][href]' ),
     390                        function ( el ) {
     391                                have[ parseUrl( el.src || el.href ).pathname ] = true;
     392                        }
     393                );
     394
     395                return Array.prototype.some.call(
     396                        doc.querySelectorAll( 'script[src], link[rel="stylesheet"][href]' ),
     397                        function ( el ) {
     398                                return ! have[ parseUrl( el.getAttribute( 'src' ) || el.getAttribute( 'href' ) ).pathname ];
     399                        }
     400                );
     401        }
     402
    293403        function swap( url, pushHistory ) {
    294404                // Don't show the spinner for fast responses — only if the request
    … …  
    301411                                var doc = new window.DOMParser().parseFromString( html, 'text/html' );
    302412                                var newWrap = doc.querySelector( '.wrap.form2zoho' );
    303                                 if ( ! newWrap ) {
     413                                if ( ! newWrap || needsAssets( doc ) ) {
    304414                                        window.location.href = url;
    305415                                        return;
    … …  
    309419                                }
    310420                                document.querySelector( '.wrap.form2zoho' ).outerHTML = newWrap.outerHTML;
     421                                syncAdminMenu( doc );
    311422                                playEnter( '.wrap.form2zoho' );
    312423                                if ( pushHistory ) {
    … …  
    336447                a.href = url;
    337448                var page = decodeURIComponent( ( a.search.match( /[?&]page=([^&]*)/ ) || [] )[ 1 ] || '' );
    338                 return 'form2zoho' === page;
    339         }
    340 
    341         /**
    342          * Is this a Connections/Logs/Settings/Version tab link (page=form2zoho,
    343          * exactly — not the feed-builder pages, which use page=form2zoho-feeds)?
     449                return 'formbridge' === page;
     450        }
     451
     452        /**
     453         * Is this a Connections/Logs/Settings/Version tab link (page=formbridge,
     454         * exactly — not the feed-builder pages, which use page=formbridge-feeds)?
    344455         * These four share one PHP class and only ever swap one fragment, so
    345456         * they get the fast AJAX path instead of a full page fetch.
    … …  
    378489                $body.html( data.html );
    379490                playEnter( $body );
     491                markNav( url );
     492                markSidebar( url );
    380493                if ( data.title ) {
    381494                        document.title = data.title;
    … …  
    387500                syncAssets( data );
    388501                reboot();
     502        }
     503
     504        /**
     505         * Move the masthead's "you are here" to the tab just swapped in. A tab
     506         * swap replaces only #form2zoho-tab-body, so the masthead above it keeps
     507         * whatever it said before.
     508         *
     509         * @param {string} url Page URL of the tab now showing.
     510         */
     511        function markNav( url ) {
     512                var tab = decodeURIComponent( ( parseUrl( url ).search.match( /[?&]tab=([^&]*)/ ) || [] )[ 1 ] || 'connections' );
     513                $( '.form2zoho-topnav-link' ).each( function () {
     514                        var on = this.getAttribute( 'data-nav' ) === tab;
     515                        this.classList.toggle( 'is-active', on );
     516                        if ( on ) {
     517                                this.setAttribute( 'aria-current', 'page' );
     518                        } else {
     519                                this.removeAttribute( 'aria-current' );
     520                        }
     521                } );
     522        }
     523
     524        /**
     525         * Move the sidebar's "you are here" with a tab swap.
     526         *
     527         * Logs, Connections, Settings and Docs & Support are sidebar entries for
     528         * tabs of one screen, and a tab swap replaces only the tab's body — so
     529         * the sidebar kept the entry the person came from in bold. The entry
     530         * whose link names this tab is the current one; a tab with no entry of
     531         * its own belongs to the entry that names no tab, Connections.
     532         *
     533         * @param {string} url Page URL of the tab now showing.
     534         */
     535        function markSidebar( url ) {
     536                var arg = function ( search, name ) {
     537                        var m = String( search || '' ).match( new RegExp( '[?&]' + name + '=([^&#]*)' ) );
     538                        return m ? decodeURIComponent( m[ 1 ] ) : '';
     539                };
     540                var page = arg( parseUrl( url ).search, 'page' );
     541                var tab = arg( parseUrl( url ).search, 'tab' );
     542                var links = [].filter.call( document.querySelectorAll( '#adminmenu .wp-submenu a' ), function ( a ) {
     543                        return page && arg( a.search, 'page' ) === page;
     544                } );
     545                var hit = links.filter( function ( a ) {
     546                        return tab && arg( a.search, 'tab' ) === tab;
     547                } )[ 0 ] || links.filter( function ( a ) {
     548                        return ! arg( a.search, 'tab' );
     549                } )[ 0 ];
     550
     551                if ( ! hit ) {
     552                        return;
     553                }
     554
     555                links.forEach( function ( a ) {
     556                        var on = a === hit;
     557
     558                        a.classList.toggle( 'current', on );
     559                        if ( a.parentNode ) {
     560                                a.parentNode.classList.toggle( 'current', on );
     561                        }
     562                        if ( on ) {
     563                                a.setAttribute( 'aria-current', 'page' );
     564                        } else {
     565                                a.removeAttribute( 'aria-current' );
     566                        }
     567                } );
    389568        }
    390569
  • techwave-form-sync-for-zoho/trunk/assets/js/admin.js

    r3649280 r3748247  
    11/**
    2  * Techwave Form Sync for Zoho — admin helper.
     2 * FormBridge for Zoho — admin helper.
    33 *
    44 * Drives the "Modules & fields" discovery panel on the Connections tab. It
    55 * never saves anything; it just lets an admin look up the exact Zoho API names
    6  * to use in the form-to-Zoho mapping under Techwave Form Sync for Zoho \u2192 Forms.
     6 * to use in the form-to-Zoho mapping under FormBridge for Zoho \u2192 Forms.
    77 */
    88( function ( $ ) {
    … …  
    2121        // Clearing our own namespace first stops handlers from stacking up.
    2222        $( document ).off( '.f2zNav' );
     23
     24        // The field-list spinner lives on <body> once used (see fieldsLoading()),
     25        // so a swap to another screen would leave the old one behind. The swapped-in
     26        // markup brings its own.
     27        $( 'body > #f2z_fields_loading' ).remove();
    2328
    2429        /**
    … …  
    173178         */
    174179        function status( $panel, text ) {
     180                clearStatus( $panel );
     181                $panel.append( $( '<p class="form2zoho-status description"></p>' ).text( text ) );
     182        }
     183
     184        function clearStatus( $panel ) {
    175185                $panel.find( '.form2zoho-status' ).remove();
    176                 $panel.append( $( '<p class="form2zoho-status description"></p>' ).text( text ) );
    177186        }
    178187
    … …  
    197206        $( document ).on( 'click.f2zNav', '.form2zoho-close', function () {
    198207                $( '#form2zoho-browse-panel' ).attr( 'hidden', 'hidden' ).empty();
     208        } );
     209
     210        // Switching app clears what the previous one left behind.
     211        //
     212        // The organization picker is the one that matters. Zoho Books and Desk are
     213        // the only apps with organizations, so choosing Books builds a picker and
     214        // a "Select an organization" line under the panel — and switching to Zoho
     215        // CRM used to leave both sitting there, asking for something CRM does not
     216        // have and making the panel look like it was still waiting on an answer.
     217        $( document ).on( 'change.f2zNav', '.form2zoho-sel-int', function () {
     218                var $panel = $( '#form2zoho-browse-panel' );
     219
     220                $panel.find( '.form2zoho-org-slot' ).empty();
     221                $panel.find( '.form2zoho-modules-out' ).empty();
     222                clearStatus( $panel );
    199223        } );
    200224
    … …  
    228252
    229253                                var data = res.data || {};
     254
     255                                // Whatever is on screen describes the last app asked about.
     256                                // Cleared here as well as on the app dropdown, because the
     257                                // answer to "does this one need an organization?" only arrives
     258                                // with the response.
     259                                clearStatus( $panel );
     260
     261                                if ( ! data.needs_organization ) {
     262                                        $ctrl.find( '.form2zoho-org-slot' ).empty();
     263                                }
    230264
    231265                                // Render organization picker if needed and not already chosen.
    … …  
    303337                ( items || [] ).forEach( function ( it ) {
    304338                        var o = new Option( it.label, it.value );
     339                        // Zoho's own name for the field's type, carried so the row can
     340                        // save it. Only the Zoho-field select ever receives items that
     341                        // have one; for every other select this is simply absent.
     342                        if ( it.type ) {
     343                                o.setAttribute( 'data-type', it.type );
     344                        }
     345                        // A form field that takes an upload. Only the Form-field select
     346                        // receives these.
     347                        if ( it.file ) {
     348                                o.setAttribute( 'data-file', '1' );
     349                        }
    305350                        if ( String( it.value ) === String( current ) ) {
    306351                                o.selected = true;
    … …  
    327372                var needsOrg = cfg.needsOrg || {};
    328373                var deptApps = cfg.deptApps || [];
     374                var noConnApps = cfg.noConnApps || [];
    329375                var state = { forms: [], zohoFields: [] };
    330376
    … …  
    337383                var $module = $( '#f2z_module' );
    338384
     385                /**
     386                 * Whether the chosen destination is reached through a Zoho account.
     387                 *
     388                 * Everything the free plugin ships is. An add-on may add a destination
     389                 * that is simply a URL, and for that one the account picker is not an
     390                 * unanswered question — it is the wrong question.
     391                 */
     392                function needsConnection() {
     393                        return noConnApps.indexOf( $intgr.val() ) === -1;
     394                }
     395
     396                /**
     397                 * Hide the account picker for a destination that has no account.
     398                 *
     399                 * `required` comes off with it. A hidden control that still blocks the
     400                 * form is the worst of both: Continue refuses, and the browser cannot
     401                 * show you the field it is refusing over.
     402                 */
     403                function toggleConnection() {
     404                        var on = needsConnection();
     405
     406                        $( '#f2z_conn_row' ).toggle( on );
     407                        $conn.prop( 'required', on );
     408                        // Updating a matching Zoho record means nothing to a URL, and the
     409                        // destination never reads the setting, so the box goes with the
     410                        // account picker and is cleared.
     411                        $( '#f2z_dedupe_row' ).toggle( on );
     412                        if ( ! on ) {
     413                                $( 'input[name="dedupe_enabled"]' ).prop( 'checked', false ).trigger( 'change' );
     414                        }
     415
     416                        if ( ! on ) {
     417                                // Cleared rather than left behind, so a feed saved against a
     418                                // URL destination does not keep pointing at a Zoho account it
     419                                // never used — which the feeds list would then show as its
     420                                // destination.
     421                                $conn.val( '' );
     422
     423                                // With no account left to narrow it by, every app comes back.
     424                                // Otherwise switching back from such a destination would only
     425                                // offer the previous account's apps, with the account picker
     426                                // that could widen the list hidden until one was chosen.
     427                                filterApps( true );
     428                        }
     429                }
     430
    339431                // Show the org row only for org-scoped apps (Books / Desk / Bigin).
    340432                function toggleOrg() {
    … …  
    352444                var connectionApps = cfg.connectionApps || {};
    353445                var appLabels = cfg.appLabels || {};
     446
     447                // Every app the page rendered, before anything is filtered out.
     448                var allApps = $intgr.find( 'option' ).map( function () {
     449                        return { value: this.value, label: this.text };
     450                } ).get();
     451
     452                /**
     453                 * Whether an app may be picked with this account.
     454                 *
     455                 * An account authorised for "all" allows every Zoho app, and a
     456                 * destination that needs no account is allowed with any. No account
     457                 * chosen allows everything: the list is not narrowed until there is
     458                 * an account to narrow it by.
     459                 */
     460                function appAllowed( app, connId ) {
     461                        if ( ! connId || noConnApps.indexOf( app ) > -1 ) {
     462                                return true;
     463                        }
     464
     465                        var apps = connectionApps[ connId ] || [ 'all' ];
     466
     467                        return apps.indexOf( 'all' ) > -1 || apps.indexOf( app ) > -1;
     468                }
     469
     470                /**
     471                 * Offer only the apps the chosen account is authorised for.
     472                 *
     473                 * Picking an app the account cannot reach used to be possible, and
     474                 * only produced a warning here and a scope error from Zoho on the first
     475                 * real submission.
     476                 *
     477                 * @param {boolean} keepCurrent Keep an app that is not allowed, labelled
     478                 *     as such, instead of switching away from it — a saved feed being
     479                 *     opened must not be repointed without anybody choosing to.
     480                 * @return {boolean} Whether the chosen app changed.
     481                 */
     482                function filterApps( keepCurrent ) {
     483                        var connId  = $conn.val() || '';
     484                        var current = $intgr.val() || '';
     485                        var allowed = allApps.filter( function ( app ) {
     486                                return appAllowed( app.value, connId );
     487                        } );
     488                        var kept = allowed.some( function ( app ) {
     489                                return app.value === current;
     490                        } );
     491
     492                        $intgr.empty();
     493                        allowed.forEach( function ( app ) {
     494                                $intgr.append( new Option( app.label, app.value ) );
     495                        } );
     496
     497                        if ( current && ! kept && keepCurrent ) {
     498                                var saved = allApps.filter( function ( app ) {
     499                                        return app.value === current;
     500                                } )[ 0 ];
     501
     502                                $intgr.append( new Option( ( i18n.appNotPermitted || '%s' ).replace( '%s', saved ? saved.label : current ), current ) );
     503                                kept = true;
     504                        }
     505
     506                        if ( kept ) {
     507                                $intgr.val( current );
     508                                return false;
     509                        }
     510
     511                        // A Zoho app before a destination that needs no account: an account
     512                        // was just chosen, and that is what it was chosen for.
     513                        var first = allowed.filter( function ( app ) {
     514                                return noConnApps.indexOf( app.value ) === -1;
     515                        } )[ 0 ] || allowed[ 0 ];
     516
     517                        $intgr.val( first ? first.value : '' );
     518
     519                        return true;
     520                }
    354521
    355522                function checkAppAuthorization() {
    … …  
    381548                                );
    382549                        }
     550                }
     551
     552                /**
     553                 * Re-read the saved connections, without reloading the page.
     554                 *
     555                 * The account picker is the one list in this editor that is not filled
     556                 * from Zoho — it is filled from this site, and the way it grows is
     557                 * that someone goes to the Connections screen and authorizes an
     558                 * account. That used to mean abandoning a half-built feed, so the
     559                 * editor now links there in a new tab and this brings the result back.
     560                 *
     561                 * @return {jqXHR|Object} The request, so a caller can chain .always().
     562                 */
     563                function loadConnections() {
     564                        var $notice = $( '#f2z_conn_notice' );
     565
     566                        if ( ! $conn.length ) {
     567                                return $.Deferred().resolve().promise();
     568                        }
     569
     570                        busy( $conn, true );
     571
     572                        return post( 'form2zoho_list_connections' )
     573                                .always( function () {
     574                                        busy( $conn, false );
     575                                } )
     576                                .done( function ( res ) {
     577                                        if ( ! res || ! res.success ) {
     578                                                $notice.text( ( res && res.data && res.data.message ) || i18n.error ).prop( 'hidden', false );
     579                                                return;
     580                                        }
     581
     582                                        $notice.prop( 'hidden', true ).text( '' );
     583
     584                                        var rows = res.data.connections || [];
     585                                        var before = $conn.val();
     586                                        // Read back rather than translated again: PHP already
     587                                        // wrote this placeholder, and a second copy of the string
     588                                        // is a second thing for a translator to keep in step.
     589                                        var placeholder = $conn.find( 'option[value=""]' ).first().text();
     590                                        var byId = {};
     591
     592                                        rows.forEach( function ( row ) {
     593                                                byId[ row.id ] = row;
     594                                                // The app-authorization warning reads this map, which
     595                                                // is localized once at page load — so without this an
     596                                                // account connected since then would be the one the
     597                                                // warning could say nothing about.
     598                                                connectionApps[ row.id ] = row.apps || [ 'all' ];
     599                                        } );
     600
     601                                        buildOptions(
     602                                                $conn,
     603                                                rows.map( function ( row ) {
     604                                                        return { value: row.id, label: row.label };
     605                                                } ),
     606                                                placeholder
     607                                        );
     608
     609                                        // Not read by anything here, but the server writes it on
     610                                        // the options it renders and the rebuilt ones should not
     611                                        // be the odd pair out.
     612                                        $conn.find( 'option' ).each( function () {
     613                                                if ( byId[ this.value ] ) {
     614                                                        this.setAttribute( 'data-dc', byId[ this.value ].dc );
     615                                                }
     616                                        } );
     617
     618                                        if ( $conn.val() === before ) {
     619                                                // Same account still chosen, but it may have been
     620                                                // re-authorised for different apps in the other tab,
     621                                                // so the app list is narrowed again from the map just
     622                                                // refreshed.
     623                                                if ( before && filterApps( false ) ) {
     624                                                        $intgr.trigger( 'change' );
     625                                                        return;
     626                                                }
     627
     628                                                checkAppAuthorization();
     629                                                return;
     630                                        }
     631
     632                                        // The chosen account went away while this tab was open.
     633                                        // Every list below it — modules, fields — belongs to an
     634                                        // account that no longer exists, so this is a real change
     635                                        // and the change handler is what clears them.
     636                                        $conn.trigger( 'change' );
     637                                } )
     638                                .fail( function () {
     639                                        $notice.text( i18n.error ).prop( 'hidden', false );
     640                                } );
    383641                }
    384642
    … …  
    545803                }
    546804
    547                 function loadTopics() {
     805                /**
     806                 * Say that a list is being fetched, and stop saying it.
     807                 *
     808                 * Every list in the editor is filled from Zoho or from the site, and
     809                 * a list that is loading looks exactly like a list that is stale
     810                 * unless it says so. There are two ways to say it because there are
     811                 * two kinds of control: the ones with a reload button beside them
     812                 * spin that button, and the ones without have a spinner of their own
     813                 * in the markup. Both are toggled from here so no caller has to know
     814                 * which kind it is holding.
     815                 *
     816                 * @param {jQuery}  $control The select being filled.
     817                 * @param {boolean} on       Whether the fetch is running.
     818                 */
     819                function busy( $control, on ) {
     820                        if ( ! $control || ! $control.length ) {
     821                                return;
     822                        }
     823
     824                        var $row = $control.closest( '.form2zoho-control-row' );
     825
     826                        $control.toggleClass( 'is-loading', !! on );
     827                        $row.find( '.form2zoho-reload-btn, .form2zoho-reload-forms' ).toggleClass( 'is-spinning', !! on );
     828                        $row.find( '.form2zoho-spinner' ).prop( 'hidden', ! on );
     829                }
     830
     831                // `fresh` is the reload button: the server keeps each list for a few
     832                // minutes, and that button must really ask Zoho.
     833                function loadTopics( fresh ) {
    548834                        if ( ! isCampaignsApp() || ! $conn.val() ) {
    549835                                return $.Deferred().resolve().promise();
    550836                        }
    551837                        var ctx = contextKey();
     838                        busy( $( '#f2z_topics' ), true );
    552839                        return post( 'form2zoho_browse_topics', {
    553840                                connection_id: $conn.val(),
    554                                 integration: $intgr.val()
     841                                integration: $intgr.val(),
     842                                fresh: fresh ? 1 : 0
     843                        } ).always( function () {
     844                                busy( $( '#f2z_topics' ), false );
    555845                        } ).done( function ( res ) {
    556846                                if ( ctx !== contextKey() ) {
    … …  
    571861                }
    572862
    573                 function loadTags() {
     863                function loadTags( fresh ) {
    574864                        // Tags exist only in Zoho Campaigns — Marketing Automation has
    575865                        // no tags feature, so never call the tags endpoint for it.
    … …  
    578868                        }
    579869                        var ctx = contextKey();
     870                        busy( $( '#f2z_tag' ), true );
    580871                        return post( 'form2zoho_browse_tags', {
    581872                                connection_id: $conn.val(),
    582                                 integration: $intgr.val()
     873                                integration: $intgr.val(),
     874                                fresh: fresh ? 1 : 0
     875                        } ).always( function () {
     876                                busy( $( '#f2z_tag' ), false );
    583877                        } ).done( function ( res ) {
    584878                                if ( ctx !== contextKey() ) {
    … …  
    607901                                return;
    608902                        }
     903                        busy( $dept, true );
    609904                        post( 'form2zoho_browse_departments', {
    610905                                connection_id: conn,
    611906                                integration: integration,
    612907                                org_id: org
     908                        } ).always( function () {
     909                                busy( $dept, false );
    613910                        } ).done( function ( res ) {
    614911                                if ( res && res.success ) {
    … …  
    630927                function fillFormFieldSelects() {
    631928                        var items = currentFormFields().map( function ( fl ) {
    632                                 return { value: fl.id, label: fl.label };
     929                                return { value: fl.id, label: fl.label, file: !! fl.file };
    633930                        } );
    634931                        $( '.f2z-map-form' ).each( function () {
    635932                                buildOptions( $( this ), items, i18n.selectField );
    636933                        } );
     934                        enhanceMappingSelects();
     935                        // The options carry the labels, so chips drawn before they arrived
     936                        // swap their field ids for names now.
     937                        renderAllTemplateTokens();
     938                }
     939
     940                /**
     941                 * Give every field Zoho insists on a row of its own.
     942                 *
     943                 * Zoho refuses the whole record when a mandatory field is missing, and
     944                 * the only sign of it beforehand was an asterisk in a dropdown of
     945                 * sixty. Somebody mapping a Contacts feed had to notice Last Name was
     946                 * starred, add a row, and find it again — and the cost of not
     947                 * noticing was every submission failing.
     948                 *
     949                 * Rows are only ever added, never removed or repointed: a row the
     950                 * person is already using for that field counts as done, whether it
     951                 * came from a saved feed or from the last thirty seconds. A blank row
     952                 * is filled before a new one is appended, so the common case — a fresh
     953                 * feed, one empty row waiting — does not leave a stray blank behind.
     954                 *
     955                 * The select's options have not been built yet when this runs, which
     956                 * is why the choice goes into data-selected: buildOptions() reads it
     957                 * as the value to re-select, and the caller fills the selects next.
     958                 */
     959                /**
     960                 * A row with nothing in it that this can safely take over.
     961                 *
     962                 * Not rowIsUntouched(): that reads the selects' values, and a row
     963                 * restored from a saved feed has none yet — its field is waiting in
     964                 * data-selected until the options arrive. Reusing one of those would
     965                 * quietly repoint a mapping somebody had already made.
     966                 */
     967                function rowIsSpare( $row ) {
     968                        function picked( sel ) {
     969                                var $el = $row.find( sel );
     970                                return $el.val() || $el.attr( 'data-selected' ) || '';
     971                        }
     972
     973                        return '' === picked( '.f2z-map-zoho' )
     974                                && '' === picked( '.f2z-map-form' )
     975                                && '' === ( $row.find( '.f2z-map-static' ).val() || '' ).trim();
     976                }
     977
     978                function addRequiredRows() {
     979                        var required = state.zohoFields.filter( function ( f ) {
     980                                return f.required;
     981                        } );
     982
     983                        var $body = $( '.form2zoho-map-rows' );
     984                        var tpl   = $( '#form2zoho-row-template' ).html();
     985
     986                        if ( ! required.length || ! $body.length || ! tpl ) {
     987                                return;
     988                        }
     989
     990                        var taken = {};
     991                        $body.find( '.f2z-map-zoho' ).each( function () {
     992                                var v = $( this ).val() || $( this ).attr( 'data-selected' ) || '';
     993                                if ( v ) {
     994                                        taken[ v ] = true;
     995                                }
     996                        } );
     997
     998                        required.forEach( function ( f ) {
     999                                if ( taken[ f.value ] ) {
     1000                                        return;
     1001                                }
     1002
     1003                                var $row = $body.find( '.form2zoho-map-row' ).filter( function () {
     1004                                        return rowIsSpare( $( this ) );
     1005                                } ).first();
     1006
     1007                                if ( ! $row.length ) {
     1008                                        $row = $( tpl ).appendTo( $body );
     1009                                        syncRowSource( $row );
     1010                                }
     1011
     1012                                $row.find( '.f2z-map-zoho' ).val( f.value ).attr( 'data-selected', f.value );
     1013                                taken[ f.value ] = true;
     1014                        } );
     1015                }
     1016
     1017                /**
     1018                 * Lock the row that carries each field Zoho insists on.
     1019                 *
     1020                 * addRequiredRows() makes sure the row exists; this keeps it there. Its
     1021                 * Zoho field cannot be repointed and the row cannot be removed, because
     1022                 * either takes away the one mapping every record needs, and the first
     1023                 * sign would be Zoho refusing each submission. Source and value stay
     1024                 * editable: which answer fills the field is still the person's call.
     1025                 *
     1026                 * Only the first row per field is locked, so a second row aimed at the
     1027                 * same field can still be changed or removed. Runs whenever the field
     1028                 * list is refilled, which is also what frees every row when the app or
     1029                 * module changes and the list is emptied.
     1030                 */
     1031                function syncRequiredRows() {
     1032                        var required = {};
     1033                        var locked   = {};
     1034
     1035                        state.zohoFields.forEach( function ( f ) {
     1036                                if ( f.required ) {
     1037                                        required[ f.value ] = true;
     1038                                }
     1039                        } );
     1040
     1041                        $( '.form2zoho-map-rows .form2zoho-map-row' ).each( function () {
     1042                                var $row = $( this );
     1043                                var v    = $row.find( '.f2z-map-zoho' ).val() || '';
     1044                                var lock = '' !== v && !! required[ v ] && ! locked[ v ];
     1045
     1046                                if ( lock ) {
     1047                                        locked[ v ] = true;
     1048                                }
     1049
     1050                                setRowRequired( $row, lock );
     1051                        } );
     1052                }
     1053
     1054                /**
     1055                 * Lock or free one mapping row. See syncRequiredRows().
     1056                 *
     1057                 * The Zoho field select is disabled and its hidden twin posts the value
     1058                 * instead: a disabled control is not submitted, and collect_mapping()
     1059                 * reads map_zoho[] by index, so every row must still send exactly one.
     1060                 */
     1061                function setRowRequired( $row, lock ) {
     1062                        var $sel    = $row.find( '.f2z-map-zoho' );
     1063                        var $remove = $row.find( '.form2zoho-remove-row' );
     1064
     1065                        $row.toggleClass( 'is-required', lock );
     1066                        $sel.prop( 'disabled', lock );
     1067                        $row.find( '.f2z-map-zoho-slot' ).prop( 'disabled', ! lock ).val( lock ? $sel.val() : '' );
     1068                        comboSyncDisabled( $sel );
     1069
     1070                        $remove.prop( 'hidden', lock );
     1071                        $row.find( '.f2z-map-required' ).remove();
     1072
     1073                        if ( lock ) {
     1074                                $( '<span class="f2z-map-required"></span>' )
     1075                                        .attr( 'title', i18n.requiredRowHint || '' )
     1076                                        .append( '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>' )
     1077                                        .append( $( '<span></span>' ).text( i18n.requiredRow || '' ) )
     1078                                        .append( $( '<span class="screen-reader-text"></span>' ).text( i18n.requiredRowHint || '' ) )
     1079                                        .insertAfter( $remove );
     1080                        }
     1081                }
     1082
     1083                /**
     1084                 * Offer the module's unique fields as duplicate-match boxes.
     1085                 *
     1086                 * Zoho only lets an upsert match on fields it enforces uniqueness for,
     1087                 * and the module's field list already says which ones they are. With
     1088                 * none ticked, or none to tick, CRM and Bigin still upsert and Zoho
     1089                 * applies its own duplicate check, which #f2z_dedupe_none explains.
     1090                 */
     1091                var DEDUPE_FIELD_APPS = [ 'crm', 'bigin' ];
     1092
     1093                function fillUniqueDedupeFields() {
     1094                        var $wrap   = $( '#f2z_dedupe_unique' );
     1095                        var $list   = $( '#f2z_dedupe_unique_list' );
     1096                        var $none   = $( '#f2z_dedupe_none' );
     1097                        var $loaded = $( '#f2z_dedupe_loaded' );
     1098
     1099                        if ( ! $wrap.length ) {
     1100                                return;
     1101                        }
     1102
     1103                        // What to tick: whatever is ticked now and, until a field list has
     1104                        // loaded once, what the feed was saved with.
     1105                        var current = $list.find( 'input:checked' ).map( function () {
     1106                                return this.value;
     1107                        } ).get();
     1108
     1109                        if ( '1' !== $loaded.val() ) {
     1110                                ( $( '#f2z_dedupe_saved' ).val() || '' ).split( ',' ).forEach( function ( name ) {
     1111                                        name = name.trim();
     1112                                        if ( name && current.indexOf( name ) === -1 ) {
     1113                                                current.push( name );
     1114                                        }
     1115                                } );
     1116                        }
     1117
     1118                        var unique = state.zohoFields.filter( function ( f ) {
     1119                                return f.unique;
     1120                        } );
     1121
     1122                        $list.empty();
     1123
     1124                        unique.forEach( function ( f ) {
     1125                                var $label = $( '<label class="form2zoho-dedupe-field"></label>' );
     1126                                var $box   = $( '<input type="checkbox" name="dedupe_unique[]" />' ).val( f.value );
     1127
     1128                                $box.prop( 'checked', current.indexOf( f.value ) > -1 );
     1129
     1130                                // .text(), so a field label from Zoho is never treated as
     1131                                // markup on the way in.
     1132                                $label.append( $box ).append( $( '<span></span>' ).text( f.label ) );
     1133                                $list.append( $label );
     1134                        } );
     1135
     1136                        $wrap.prop( 'hidden', ! unique.length );
     1137
     1138                        // Only once the list has really arrived, and only for an app whose
     1139                        // delivery would use these fields. Before that, "none" is a guess.
     1140                        $none.prop( 'hidden', ! ( state.fieldsLoaded && ! unique.length && DEDUPE_FIELD_APPS.indexOf( $intgr.val() ) > -1 ) );
     1141
     1142                        // From here on the boxes are the answer, and saving uses them.
     1143                        if ( state.fieldsLoaded ) {
     1144                                $loaded.val( '1' );
     1145                        }
    6371146                }
    6381147
    6391148                // Refill every "Zoho field" select from the loaded module fields.
    6401149                function fillZohoFieldSelects() {
     1150                        fillUniqueDedupeFields();
     1151
     1152                        // Before the options are built, so a row added for a mandatory
     1153                        // field is filled in the same pass as every other row.
     1154                        addRequiredRows();
     1155
    6411156                        var items = state.zohoFields.map( function ( f ) {
    642                                 return { value: f.value, label: f.label + ( f.required ? ' *' : '' ) };
     1157                                return { value: f.value, label: f.label + ( f.required ? ' *' : '' ), type: f.type };
    6431158                        } );
    6441159                        $( '.f2z-map-zoho' ).each( function () {
    6451160                                buildOptions( $( this ), items, i18n.selectZoho );
     1161                                // The list has only just arrived, so a row restored from a
     1162                                // saved feed learns its field's type here rather than on a
     1163                                // change nobody is going to make.
     1164                                syncRowZohoType( $( this ).closest( '.form2zoho-map-row' ) );
    6461165                        } );
     1166
     1167                        // After the options are built, so each row's field is really
     1168                        // selected, and before fillFormFieldSelects() wraps any new row's
     1169                        // select: the searchable box copies the disabled state it finds.
     1170                        syncRequiredRows();
     1171
     1172                        // A row appended above has an empty "Form field" select until the
     1173                        // form's own list is put into it. That call ends with
     1174                        // enhanceMappingSelects(), which is what picks up any new row.
     1175                        fillFormFieldSelects();
     1176                }
     1177
     1178                /**
     1179                 * The wording for the chosen trigger: a form plugin's feed listens
     1180                 * to a form, every other trigger's to an event on the site.
     1181                 *
     1182                 * @param {string} formKey  i18n key of the form wording.
     1183                 * @param {string} eventKey i18n key of the event wording.
     1184                 * @return {string}
     1185                 */
     1186                function formWord( formKey, eventKey ) {
     1187                        return i18n[ isEventSource( $formType.val() ) ? eventKey : formKey ] || i18n[ formKey ];
    6471188                }
    6481189
    6491190                // Load the site's forms from the currently chosen form plugin.
    650                 function loadForms( fresh ) {
    651                         return post( 'form2zoho_list_forms', { fresh: fresh ? 1 : 0, form_type: $formType.val() || 'elementor' } ).done( function ( res ) {
     1191                function loadForms( fresh, force ) {
     1192                        var type = $formType.val();
     1193
     1194                        // A new feed starts with no trigger chosen, so there is no list of
     1195                        // forms to ask for. Falling back to a default here would fetch one
     1196                        // plugin's forms and offer them under a trigger nobody picked.
     1197                        if ( ! type ) {
     1198                                buildOptions( $form, [], formWord( 'selectForm', 'selectEvent' ) );
     1199                                setTriggerAvailable( true );
     1200                                return $.Deferred().resolve().promise();
     1201                        }
     1202
     1203                        // The tile already knows whether its plugin is running, and a
     1204                        // plugin that is not running has no forms to list. Skipping the
     1205                        // request is most of the wait: admin-ajax boots WordPress and
     1206                        // every active plugin before reaching our handler, which on a
     1207                        // site with a couple of dozen of them is seconds regardless of
     1208                        // what is being asked.
     1209                        //
     1210                        // `force` is the reload button beside the Form select, and that must
     1211                        // always really ask: data-available is this page's view of the
     1212                        // world, and the plugin may have been switched on in another tab
     1213                        // since. `fresh` is not the same thing — it only means "skip the
     1214                        // server's cache", and the trigger-change path sets it too.
     1215                        if ( ! force && ! triggerIsAvailable() ) {
     1216                                buildOptions( $form, [], formWord( 'selectForm', 'selectEvent' ) );
     1217                                setTriggerAvailable( false, unavailableMessage() );
     1218                                return $.Deferred().resolve().promise();
     1219                        }
     1220
     1221                        // The forms list itself still has to be fetched, and that is the
     1222                        // same slow admin-ajax boot, so the wait is shown the same way
     1223                        // every other list in the editor shows it.
     1224                        busy( $form, true );
     1225
     1226                        return post( 'form2zoho_list_forms', { fresh: fresh ? 1 : 0, form_type: type } ).always( function () {
     1227                                busy( $form, false );
     1228                        } ).done( function ( res ) {
    6521229                                if ( res && res.success ) {
    6531230                                        state.forms = res.data.forms || [];
    … …  
    6551232                                                $form,
    6561233                                                state.forms.map( function ( f ) {
    657                                                         return { value: f.name, label: f.name };
     1234                                                        // A source may give its entry a readable label; the
     1235                                                        // value stays the name feeds are matched on.
     1236                                                        return { value: f.name, label: f.label || f.name };
    6581237                                                } ),
    659                                                 i18n.selectForm
     1238                                                formWord( 'selectForm', 'selectEvent' )
    6601239                                        );
    6611240                                        fillFormFieldSelects();
    … …  
    6671246                                        } );
    6681247
     1248                                        // The chosen trigger's plugin is not running. Step two has
     1249                                        // nothing left to ask — there is no form list to pick from
     1250                                        // — so it says so and stops, rather than showing two fields
     1251                                        // that cannot be completed. The ✕ on the tile above is the
     1252                                        // way out, which is why the footer goes with the rest.
     1253                                        setTriggerAvailable( false !== res.data.available, res.data.message );
     1254
    6691255                                        if ( false === res.data.available ) {
    6701256                                                $help.text( res.data.message || i18n.cf7Unavailable ).addClass( 'form2zoho-form-help-warn' );
    6711257                                        } else if ( ! stillExists ) {
    672                                                 $help.text( i18n.formStale ).addClass( 'form2zoho-form-help-warn' );
     1258                                                $help.text( formWord( 'formStale', 'eventStale' ) ).addClass( 'form2zoho-form-help-warn' );
    6731259                                        } else {
    674                                                 $help.text( i18n.formHelp ).removeClass( 'form2zoho-form-help-warn' );
     1260                                                $help.text( formWord( 'formHelp', 'eventHelp' ) ).removeClass( 'form2zoho-form-help-warn' );
    6751261                                        }
     1262
     1263                                        // The list arrives long after the page does, and until it
     1264                                        // lands the Form select is empty and therefore invalid.
     1265                                        // Whatever is watching step two's Next needs telling.
     1266                                        $( document ).trigger( 'f2z:formsLoaded' );
    6761267                                }
    6771268                        } );
    6781269                }
    6791270
     1271                // An organization and department picked for one app or account mean
     1272                // nothing to the next. Books and Desk organization ids differ, so a
     1273                // Books one left selected after switching to Desk showed as "not
     1274                // found" and was sent to Desk, which refused the departments lookup.
     1275                function clearOrganization() {
     1276                        $org.val( '' );
     1277                        buildOptions( $org, [], i18n.selectOrg );
     1278                        $dept.val( '' );
     1279                        buildOptions( $dept, [], i18n.selectDept );
     1280                }
     1281
     1282                // Apps whose module list is the same whatever organization is chosen.
     1283                var orgFreeModuleApps = [ 'desk' ];
     1284
    6801285                // Load modules (and organizations) for the chosen app + account.
    681                 function loadModules() {
     1286                //
     1287                // `forOrg` is a new organization being picked: its list is already on
     1288                // the page, so it neither spins nor is fetched again. `fresh` is the
     1289                // reload button, which skips the server's cached custom modules.
     1290                function loadModules( forOrg, fresh ) {
    6821291                        var integration = $intgr.val();
    6831292                        var conn = $conn.val();
    684                         if ( ! integration || ! conn ) {
    685                                 return;
     1293                        if ( ! integration || ( needsConnection() && ! conn ) ) {
     1294                                return $.Deferred().resolve().promise();
    6861295                        }
    6871296                        toggleOrg();
    6881297                        toggleDept();
    6891298                        var ctx = contextKey();
    690                         post( 'form2zoho_browse_modules', {
     1299                        var orgId = $org.val() || '';
     1300                        busy( $module, true );
     1301                        if ( ! forOrg ) {
     1302                                busy( $org, true );
     1303                        }
     1304                        return post( 'form2zoho_browse_modules', {
    6911305                                connection_id: conn,
    6921306                                integration: integration,
    693                                 org_id: $org.val() || ''
     1307                                org_id: orgId,
     1308                                have_organizations: forOrg ? 1 : 0,
     1309                                fresh: fresh ? 1 : 0
     1310                        } ).always( function () {
     1311                                busy( $module, false );
     1312                                if ( ! forOrg ) {
     1313                                        busy( $org, false );
     1314                                }
    6941315                        } ).done( function ( res ) {
    695                                 if ( ctx !== contextKey() ) {
     1316                                // A second organization picked before this one answered.
     1317                                if ( ctx !== contextKey() || orgId !== ( $org.val() || '' ) ) {
    6961318                                        return;
    6971319                                }
    … …  
    7141336
    7151337                                loadDepartments();
    716                                 loadFields();
     1338                                loadFields( fresh );
    7171339                        } );
     1340                }
     1341
     1342                // The loading card over the page while Zoho's field list is on its way.
     1343                // Moved to <body>, so nothing in the editor's own layout can clip or
     1344                // offset it. Shown only once the wait outlasts a moment, so a quick
     1345                // answer does not flash, and counted, so a second load started before
     1346                // the first returns does not take it down early.
     1347                var fieldLoads     = 0;
     1348                var fieldLoadTimer = null;
     1349                var fieldSlowTimer = null;
     1350
     1351                // "Fetching Leads fields from Zoho CRM…", from what is selected now.
     1352                // Empty when there is nothing to name, and the card keeps its default.
     1353                function loadingFieldsText() {
     1354                        var app    = $.trim( $intgr.find( 'option:selected' ).text() );
     1355                        var module = $module.val() ? $.trim( $module.find( 'option:selected' ).text() ) : '';
     1356
     1357                        if ( app && module && ! isCampaignsApp() && i18n.loadingFieldsOf ) {
     1358                                return i18n.loadingFieldsOf.replace( '%1$s', module ).replace( '%2$s', app );
     1359                        }
     1360
     1361                        return app && i18n.loadingFieldsFrom ? i18n.loadingFieldsFrom.replace( '%s', app ) : '';
     1362                }
     1363
     1364                function fieldsLoading( on ) {
     1365                        var $loader = $( '#f2z_fields_loading' );
     1366                        var $slow   = $loader.find( '.form2zoho-loader-card__slow' );
     1367
     1368                        if ( ! $loader.length ) {
     1369                                return;
     1370                        }
     1371                        if ( ! $loader.parent().is( 'body' ) ) {
     1372                                $loader.appendTo( document.body );
     1373                        }
     1374
     1375                        fieldLoads = Math.max( 0, fieldLoads + ( on ? 1 : -1 ) );
     1376                        clearTimeout( fieldLoadTimer );
     1377                        clearTimeout( fieldSlowTimer );
     1378
     1379                        if ( ! fieldLoads ) {
     1380                                $loader.prop( 'hidden', true );
     1381                                $slow.prop( 'hidden', true );
     1382                                return;
     1383                        }
     1384
     1385                        var text = loadingFieldsText();
     1386                        if ( text ) {
     1387                                $loader.find( '.form2zoho-loader-card__text' ).text( text );
     1388                        }
     1389
     1390                        fieldLoadTimer = setTimeout( function () {
     1391                                $loader.prop( 'hidden', false );
     1392                        }, 200 );
     1393
     1394                        // Several seconds is Zoho being slow, not the page being stuck, and
     1395                        // without a word a long wait reads as the second.
     1396                        fieldSlowTimer = setTimeout( function () {
     1397                                $slow.prop( 'hidden', false );
     1398                        }, 6000 );
    7181399                }
    7191400
    … …  
    7221403                // mailing list), so for those two apps this only needs the
    7231404                // account, not a chosen module.
    724                 function loadFields() {
     1405                //
     1406                // Several things lead here at once: choosing an app asks for the
     1407                // fields directly and again once its modules arrive, and opening a
     1408                // saved feed asks before and after its module list. A list already
     1409                // loaded, or on its way, for the same app, account, organization and
     1410                // module is not asked for twice. Campaigns/Marketing Automation fields
     1411                // do not depend on the list, so picking a list does not reload them.
     1412                var fieldsKey = '';
     1413
     1414                function loadFields( fresh ) {
    7251415                        var integration = $intgr.val();
    7261416                        var conn = $conn.val();
    7271417                        var module = $module.val();
    7281418                        var needsModule = ! isCampaignsApp();
    729                         if ( ! integration || ! conn || ( needsModule && ! module ) ) {
     1419                        if ( needsConnection() ) {
     1420                                syncPassthrough( false );
     1421                        }
     1422                        if ( ! integration || ( needsConnection() && ! conn ) || ( needsModule && ! module ) ) {
    7301423                                return;
    7311424                        }
     1425                        var key = [ integration, conn, $org.val() || '', needsModule ? module : '' ].join( '|' );
     1426                        if ( ! fresh && key === fieldsKey ) {
     1427                                return;
     1428                        }
     1429                        fieldsKey = key;
    7321430                        var ctx = contextKey();
     1431                        // A URL destination answers from this site, not from Zoho, so the
     1432                        // "Loading Zoho fields" card would only say something untrue.
     1433                        var overlay = needsConnection();
     1434                        if ( overlay ) {
     1435                                fieldsLoading( true );
     1436                        }
    7331437                        post( 'form2zoho_browse_fields', {
    7341438                                connection_id: conn,
    7351439                                integration: integration,
    7361440                                org_id: $org.val() || '',
    737                                 module: module
     1441                                module: module,
     1442                                fresh: fresh ? 1 : 0
     1443                        } ).always( function () {
     1444                                if ( overlay ) {
     1445                                        fieldsLoading( false );
     1446                                }
    7381447                        } ).done( function ( res ) {
     1448                                if ( key !== fieldsKey ) {
     1449                                        // A different module or organization was picked meanwhile.
     1450                                        return;
     1451                                }
    7391452                                if ( ctx !== contextKey() ) {
    7401453                                        return;
    … …  
    7421455                                if ( res && res.success ) {
    7431456                                        state.zohoFields = res.data.fields || [];
     1457                                        state.fieldsLoaded = true;
    7441458                                        fillZohoFieldSelects();
    7451459                                        $( '#f2z_fields_notice' ).prop( 'hidden', true );
     1460                                        syncPassthrough( ! needsConnection() && ! state.zohoFields.length );
    7461461                                } else {
    747                                         $( '#f2z_fields_notice' ).text( ( res && res.data && res.data.message ) || i18n.error ).prop( 'hidden', false );
     1462                                        fieldsKey = '';
     1463                                        showFieldsError( ( res && res.data && res.data.message ) || i18n.error );
    7481464                                }
    7491465                        } ).fail( function () {
     1466                                if ( key !== fieldsKey ) {
     1467                                        return;
     1468                                }
     1469                                fieldsKey = '';
    7501470                                if ( ctx !== contextKey() ) {
    7511471                                        return;
    7521472                                }
    753                                 $( '#f2z_fields_notice' ).text( i18n.error ).prop( 'hidden', false );
     1473                                showFieldsError( i18n.error );
    7541474                        } );
    7551475                }
     1476
     1477                // A URL destination with no field names of its own sends the whole
     1478                // submission (the Pro webhook: no mapping rows means everything),
     1479                // so the mapping table is replaced by a line saying so. A feed that
     1480                // already has rows keeps them on show, so nothing saved is hidden.
     1481                function syncPassthrough( on ) {
     1482                        if ( on ) {
     1483                                on = ! $( '.form2zoho-map-rows tr' ).filter( function () {
     1484                                        var $r = $( this );
     1485                                        return !! ( $r.find( '.f2z-map-zoho' ).val() || $r.find( '.f2z-map-form' ).val() || $.trim( $r.find( '.f2z-map-static' ).val() || '' ) );
     1486                                } ).length;
     1487                        }
     1488                        $( '#f2z_passthrough_notice' ).prop( 'hidden', ! on );
     1489                        $( '.form2zoho-mapping-section .form2zoho-table-scroll, .form2zoho-mapping-section .form2zoho-add-row' ).toggle( ! on );
     1490                }
     1491
     1492                // A field list that did not load says why, and offers to ask again.
     1493                // The usual cause is Zoho being briefly out of reach, and the only way
     1494                // to retry used to be picking the module a second time.
     1495                function showFieldsError( message ) {
     1496                        $( '#f2z_fields_notice' )
     1497                                .empty()
     1498                                .append( document.createTextNode( message + ' ' ) )
     1499                                .append( $( '<button type="button" class="button-link f2z-fields-retry"></button>' ).text( i18n.tryAgain || 'Try again' ) )
     1500                                .prop( 'hidden', false );
     1501                }
     1502
     1503                $( document ).on( 'click.f2zNav', '.f2z-fields-retry', function () {
     1504                        $( '#f2z_fields_notice' ).prop( 'hidden', true );
     1505                        loadFields();
     1506                } );
    7561507
    7571508                // Every mapping row names a field of one specific module, inside one
    … …  
    7721523                        // switching app on a brand-new feed shouldn't raise a warning
    7731524                        // about mapping the person never made.
     1525                        //
     1526                        // A Zoho field on its own does not count, because addRequiredRows()
     1527                        // puts one there unasked: pick CRM Leads and the first row fills
     1528                        // with Last_Name before anybody has touched it. Changing module
     1529                        // then cleared that row, announced a mapping had been lost, and
     1530                        // the very next field load put Last_Name straight back — a warning
     1531                        // about nothing, on the one screen where a warning should mean
     1532                        // "your work is gone". The row it sits on is marked is-required by
     1533                        // syncRequiredRows(), which is what tells the two apart: a person
     1534                        // who really mapped that field chose a form field or typed a
     1535                        // static value beside it, and either of those still counts.
    7741536                        var hadMapping = $rows.filter( function () {
    775                                 var $row = $( this );
    776                                 return ( $row.find( '.f2z-map-zoho' ).val() || '' ) !== ''
    777                                         || ( $row.find( '.f2z-map-form' ).val() || '' ) !== ''
    778                                         || ( $row.find( '.f2z-map-static' ).val() || '' ) !== '';
     1537                                var $row   = $( this );
     1538                                var form   = ( $row.find( '.f2z-map-form' ).val() || '' ) !== '';
     1539                                var stat   = ( $row.find( '.f2z-map-static' ).val() || '' ) !== '';
     1540                                var zoho   = ( $row.find( '.f2z-map-zoho' ).val() || '' ) !== '';
     1541
     1542                                return form || stat || ( zoho && ! $row.hasClass( 'is-required' ) );
    7791543                        } ).length > 0;
    7801544
     1545                        // Any dropdown open in the table is closed before rows go, for the
     1546                        // same reason as the Remove button.
     1547                        comboCloseWithin( $rows );
    7811548                        $rows.slice( 1 ).remove();
    7821549                        var $first = $rows.first().removeClass( 'is-incomplete' );
    … …  
    7851552                        // refill put the old field straight back in.
    7861553                        $first.find( '.f2z-map-zoho, .f2z-map-form' ).val( '' ).removeAttr( 'data-selected' );
     1554                        // Unlocked with it: the row no longer carries the required field it
     1555                        // was locked for, and left locked it showed "Required" beside an
     1556                        // empty Zoho field nobody could pick.
     1557                        setRowRequired( $first, false );
    7871558                        $first.find( '.f2z-map-static' ).val( '' );
    7881559                        $first.find( '.f2z-map-source' ).val( 'field' );
     1560                        syncRowSource( $first );
     1561                        // The search boxes show what their select holds, so they have to
     1562                        // be told the select was just emptied.
     1563                        enhanceMappingSelects();
    7891564
    7901565                        $( '#f2z-mapping-alert' ).prop( 'hidden', true );
    … …  
    8191594                // Events.
    8201595                $form.on( 'change', fillFormFieldSelects );
     1596
     1597                // A row added with "+ Add mapping row" is filled from the lists already
     1598                // on the page. It used to fire change on the form and module selects to
     1599                // get there, which re-ran everything a real change does: the Zoho field
     1600                // list was fetched again, and with the add-on the endpoint list and the
     1601                // action hook status too, on every press. Only a list that has actually
     1602                // arrived is used, so rows are never refilled from an empty one, which
     1603                // would mark every saved choice as not found.
     1604                $( document ).on( 'f2z:mappingRowAdded.f2zNav', function () {
     1605                        if ( currentFormFields().length ) {
     1606                                fillFormFieldSelects();
     1607                        }
     1608                        if ( state.fieldsLoaded ) {
     1609                                fillZohoFieldSelects();
     1610                        }
     1611                } );
    8211612                $intgr.on( 'change', function () {
    8221613                        // A module/topic/tag id from a *different* Zoho app is
    … …  
    8341625                        // from.
    8351626                        $module.val( '' );
     1627                        // The old app's module name must not follow the feed to the new
     1628                        // app, or a CRM "Leads" feed goes on showing Books' "Invoices".
     1629                        $( '#f2z_module_label_value' ).val( '' );
    8361630                        // The module select is being emptied here, so its remembered
    8371631                        // value has to follow — otherwise picking the new app's first
    … …  
    8431637                        $( '#f2z_topics' ).val( [] ).trigger( 'f2z:optionsUpdated' );
    8441638                        $( '#f2z_tag' ).val( [] ).trigger( 'f2z:optionsUpdated' );
     1639                        clearOrganization();
    8451640                        state.zohoFields = [];
     1641                        state.fieldsLoaded = false;
     1642                        fieldsKey = '';
    8461643                        fillZohoFieldSelects();
     1644                        // Before loadModules(), which asks whether this destination needs
     1645                        // an account before deciding it has enough to go on.
     1646                        toggleConnection();
    8471647                        loadModules();
    8481648                        checkAppAuthorization();
    … …  
    8561656                                resetMapping();
    8571657                        }
     1658                        // Organizations belong to an account as well.
     1659                        clearOrganization();
     1660                        // A different account may not reach the app that was chosen. When
     1661                        // the app has to change, its own change handler reloads everything
     1662                        // below it, so doing it here as well would load it all twice.
     1663                        if ( filterApps( false ) ) {
     1664                                $intgr.trigger( 'change' );
     1665                                return;
     1666                        }
    8581667                        loadModules();
    8591668                        checkAppAuthorization();
    … …  
    8661675                loadTopics();
    8671676                loadTags();
     1677                // The module list, not just the fields: an organisation's custom
     1678                // modules belong to it, so a list loaded before one was chosen has
     1679                // only the built-in resources in it. loadModules() goes on to load
     1680                // the departments and the fields itself.
    8681681                $org.on( 'change', function () {
    869                         loadDepartments();
    870                         loadFields();
     1682                        // Desk's modules are the same three for every organization, so
     1683                        // only what does depend on it is asked for again.
     1684                        if ( orgFreeModuleApps.indexOf( $intgr.val() ) > -1 && $module.find( 'option' ).length > 1 ) {
     1685                                loadDepartments();
     1686                                loadFields();
     1687                                return;
     1688                        }
     1689                        loadModules( true );
    8711690                } );
    8721691                // Keep the hidden module_label input in sync with the selected
    … …  
    8751694                // Automation. Synced on change and again on save, in case options
    8761695                // were rebuilt after the last change event.
    877                 function syncModuleLabel() {
     1696                function syncModuleLabel( picked ) {
    8781697                        var val  = $module.val() || '';
    8791698                        var text = $.trim( $module.find( 'option:selected' ).text() );
    … …  
    8851704                        // older feed, the pre-filled option's text is the raw value
    8861705                        // itself — keep whatever label is already saved in that case.
    887                         if ( text && text !== val ) {
     1706                        // A module picked from the loaded list is always a real label,
     1707                        // even when it reads the same as its value (CRM's "Leads").
     1708                        if ( text && ( text !== val || true === picked ) ) {
    8881709                                $( '#f2z_module_label_value' ).val( text );
    8891710                        }
    8901711                }
    8911712                $module.on( 'change', function () {
    892                         if ( targetChanged( 'module', $module ) ) {
     1713                        var reset = targetChanged( 'module', $module );
     1714                        var asked = fieldsKey;
     1715
     1716                        if ( reset ) {
    8931717                                resetMapping();
    8941718                        }
    895                         syncModuleLabel();
     1719                        syncModuleLabel( true );
    8961720                        loadFields();
     1721
     1722                        // Campaigns and Marketing Automation fields belong to the account,
     1723                        // not the list, so picking a list asks for nothing new — and
     1724                        // nothing then gave the required fields their rows back after the
     1725                        // reset above. Contact Email only returned once "+ Add mapping
     1726                        // row" happened to refill the table. When no new list is on its
     1727                        // way, the one already here is put back now.
     1728                        if ( reset && asked === fieldsKey && state.fieldsLoaded ) {
     1729                                fillZohoFieldSelects();
     1730                        }
    8971731                } );
    8981732                $module.closest( 'form' ).on( 'submit', syncModuleLabel );
    … …  
    9001734                        e.preventDefault();
    9011735                        var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true );
    902                         loadForms( true ).always( function () {
     1736                        // The one caller that really wants the server asked, whatever the
     1737                        // tile on the page believes.
     1738                        loadForms( true, true ).always( function () {
     1739                                $btn.removeClass( 'is-spinning' ).prop( 'disabled', false );
     1740                        } );
     1741                } );
     1742                $( document ).on( 'click.f2zNav', '.form2zoho-reload-connections', function ( e ) {
     1743                        e.preventDefault();
     1744                        var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true );
     1745                        loadConnections().always( function () {
    9031746                                $btn.removeClass( 'is-spinning' ).prop( 'disabled', false );
    9041747                        } );
    … …  
    9071750                        e.preventDefault();
    9081751                        var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true );
    909                         loadModules();
    910                         // loadModules() doesn't return its own promise, so just give the
    911                         // spinner a beat to be visible instead of chaining .always().
    912                         setTimeout( function () {
     1752                        loadModules( false, true ).always( function () {
    9131753                                $btn.removeClass( 'is-spinning' ).prop( 'disabled', false );
    914                         }, 500 );
     1754                        } );
     1755                } );
     1756                // The organization list comes back with the modules, so reloading it
     1757                // is the module reload; busy() spins this button while it runs.
     1758                $( document ).on( 'click.f2zNav', '.form2zoho-reload-orgs', function ( e ) {
     1759                        e.preventDefault();
     1760                        var $btn = $( this ).prop( 'disabled', true );
     1761                        loadModules( false, true ).always( function () {
     1762                                $btn.prop( 'disabled', false );
     1763                        } );
     1764                } );
     1765                $( document ).on( 'click.f2zNav', '.form2zoho-reload-depts', function ( e ) {
     1766                        e.preventDefault();
     1767                        loadDepartments();
    9151768                } );
    9161769                $( document ).on( 'click.f2zNav', '.form2zoho-reload-topics', function ( e ) {
    9171770                        e.preventDefault();
    9181771                        var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true );
    919                         loadTopics().always( function () {
     1772                        loadTopics( true ).always( function () {
    9201773                                $btn.removeClass( 'is-spinning' ).prop( 'disabled', false );
    9211774                        } );
    … …  
    9241777                        e.preventDefault();
    9251778                        var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true );
    926                         loadTags().always( function () {
     1779                        loadTags( true ).always( function () {
    9271780                                $btn.removeClass( 'is-spinning' ).prop( 'disabled', false );
    9281781                        } );
    … …  
    9421795                // need to know the picker is tiles rather than a plain dropdown.
    9431796                $( document ).on( 'click.f2zNav', '.form2zoho-source-tile', function () {
     1797                        // A locked tile advertises a source the Pro add-on provides. It
     1798                        // is a real, focusable button — it opens the Pro dialog — so this
     1799                        // handler has to be the thing that stops it selecting anything.
     1800                        // Falling through would set a form_type the server then rejects,
     1801                        // leaving the picker showing a selection the saved feed does not
     1802                        // have.
     1803                        if ( $( this ).hasClass( 'is-locked' ) ) {
     1804                                openProModal( $( this ) );
     1805                                return;
     1806                        }
     1807
     1808                        // A saved feed's trigger cannot be changed. Its grid is never on
     1809                        // screen, so this only stops a click that arrives some other way.
     1810                        if ( triggerLocked() ) {
     1811                                return;
     1812                        }
     1813
    9441814                        var key = $( this ).data( 'source' );
    9451815                        $( '.form2zoho-source-tile' ).removeClass( 'is-active' ).attr( 'aria-checked', 'false' );
    9461816                        $( this ).addClass( 'is-active' ).attr( 'aria-checked', 'true' );
    9471817                        $formType.val( key ).trigger( 'change' );
     1818
     1819                        // Picking a trigger is the whole of step one, so answering it
     1820                        // carries on rather than leaving the person to find the button —
     1821                        // including when they have come back to change it, which is the
     1822                        // same question being answered again. Guarded on the step so the
     1823                        // picker can be printed somewhere else one day without silently
     1824                        // moving people off that screen.
     1825                        syncChosenTrigger();
     1826                        syncAppDocs();
     1827
     1828                        // Before anything is requested. The tile carries the answer, so
     1829                        // step two is already right by the time it comes into view rather
     1830                        // than showing two fields and correcting itself seconds later.
     1831                        setTriggerAvailable( triggerIsAvailable(), unavailableMessage() );
     1832
     1833                        if ( 'trigger' === $( this ).closest( '.form2zoho-step' ).data( 'step' ) ) {
     1834                                goToStep( 'details' );
     1835                        }
    9481836                } );
    9491837
    … …  
    9681856
    9691857                // Initial load.
     1858                renderAllTemplateTokens();
     1859                filterApps( true );
     1860                toggleConnection();
    9701861                toggleOrg();
    9711862                toggleDept();
    9721863                toggleCampaignsUi();
    9731864                loadForms( false );
    974                 if ( feed.connection_id && feed.integration ) {
     1865                // A destination with no account has nothing to wait for, so its module
     1866                // list is fetched on the strength of the integration alone.
     1867                if ( feed.integration && ( feed.connection_id || ! needsConnection() ) ) {
    9751868                        loadModules();
     1869                        // The saved module is already in its select, so its fields need
     1870                        // not wait for the module list. loadModules() asks again when it
     1871                        // is done, and loadFields() skips that as already on its way.
     1872                        loadFields();
    9761873                        // loadModules() only chains loadFields() (via its own done
    9771874                        // callback) — Topics/Tags were never being fetched on the
    … …  
    9871884        }
    9881885
    989         // Add a blank mapping row, then fill its dropdowns from current state.
     1886        /* ---- Searchable field pickers ----
     1887         *
     1888         * A CRM Contacts module has sixty-one fields and a Books custom module can
     1889         * have more, all in one <select>. A native dropdown only type-aheads on
     1890         * the start of a label, so finding "Secondary Email" meant scrolling past
     1891         * everything alphabetically before it.
     1892         *
     1893         * The <select> is not replaced. It stays in the DOM, keeps its name, and
     1894         * remains the only thing that is read on save and by every other handler
     1895         * here — buildOptions() still refills it, syncRowZohoType() still reads
     1896         * its selected option. What follows is a text input drawn on top of it,
     1897         * which filters a list and then writes the chosen value back through
     1898         * .val().trigger('change'). With scripting off, or if any of this throws,
     1899         * the plain select is still there and still works.
     1900         */
     1901
     1902        var comboSeq  = 0;
     1903
     1904        /**
     1905         * The one open list, and where it belongs when it closes.
     1906         *
     1907         * Only one can be open — focusing another input blurs the first, which
     1908         * closes it — so a single reference is enough, and it is what lets the
     1909         * list live somewhere other than inside its own combo while it is open.
     1910         */
     1911        var comboOpen = null;
     1912
     1913        /**
     1914         * A combo's list, wherever it currently is.
     1915         *
     1916         * Not $combo.find(): while the list is open it is a child of <body>, not
     1917         * of the combo. See comboFilter() for why it has to be.
     1918         */
     1919        function comboList( $combo ) {
     1920                // A plain property rather than jQuery data: removing a mapping row runs
     1921                // jQuery's cleanData over everything inside it, which wiped the old
     1922                // data entry. An open list then could not be found to close, and was
     1923                // left floating on <body> after its row had gone.
     1924                var el = $combo.get( 0 );
     1925
     1926                return el && el.f2zList ? $( el.f2zList ) : $combo.find( '.f2z-combo__list' );
     1927        }
     1928
     1929        /**
     1930         * Close the open list if its combo is inside something about to be removed.
     1931         *
     1932         * Called before a mapping row is taken away. Waiting for the input's blur
     1933         * is not enough: that close is deliberately delayed, the row is gone by
     1934         * the time it runs, and with keyboard focus still in the box there is no
     1935         * blur at all.
     1936         *
     1937         * @param {jQuery} $container What is about to be removed.
     1938         */
     1939        function comboCloseWithin( $container ) {
     1940                if ( ! comboOpen ) {
     1941                        return;
     1942                }
     1943
     1944                var open = comboOpen.get( 0 );
     1945
     1946                $container.each( function () {
     1947                        if ( this === open || $.contains( this, open ) ) {
     1948                                comboClose( comboOpen );
     1949                                return false;
     1950                        }
     1951                } );
     1952        }
     1953
     1954        function comboOptions( $select ) {
     1955                return $select.find( 'option' ).map( function () {
     1956                        return { value: this.value, label: this.text, type: this.getAttribute( 'data-type' ) || '' };
     1957                } ).get();
     1958        }
     1959
     1960        // Zoho's names for the field types that hold an uploaded file.
     1961        var FILE_TYPES = [ 'fileupload', 'imageupload' ];
     1962
     1963        function isFileType( type ) {
     1964                return FILE_TYPES.indexOf( type ) > -1;
     1965        }
     1966
     1967        /** The row sends a form field, and that field is an upload. */
     1968        function rowSendsFile( $row ) {
     1969                return 'field' === $row.find( '.f2z-map-source' ).val()
     1970                        && '1' === $row.find( '.f2z-map-form option:selected' ).attr( 'data-file' );
     1971        }
     1972
     1973        /** The module's field list has somewhere a file can go. */
     1974        function hasFileFields( $select ) {
     1975                return $select.find( 'option' ).filter( function () {
     1976                        return isFileType( this.getAttribute( 'data-type' ) );
     1977                } ).length > 0;
     1978        }
     1979
     1980        /**
     1981         * Only offer file fields to a row whose form field is an upload.
     1982         *
     1983         * Skipped when the module has no file field at all (Books, Desk, or a CRM
     1984         * module without one): the upload's file name can still go to a text
     1985         * field, and an empty list would take that away with nothing in its place.
     1986         */
     1987        function rowWantsFileFields( $row ) {
     1988                return rowSendsFile( $row ) && hasFileFields( $row.find( '.f2z-map-zoho' ) );
     1989        }
     1990
     1991        /** The label to show when the box is not being typed in. */
     1992        function comboLabel( $select ) {
     1993                var $picked = $select.find( 'option:selected' );
     1994
     1995                // The placeholder option is a prompt, not a choice — showing it as
     1996                // text would make an empty box look filled in.
     1997                return $picked.length && '' !== $picked.val() ? $picked.text() : '';
     1998        }
     1999
     2000        /**
     2001         * Carry the select's disabled state onto the box that stands in for it.
     2002         *
     2003         * syncRowSource() disables the Form field select on a "Static value" row,
     2004         * but that select is hidden inside the combobox. Without this the box a
     2005         * person actually sees stayed live: it looked enabled, opened, and let a
     2006         * form field be picked into a row that would never send it.
     2007         */
     2008        function comboSyncDisabled( $select ) {
     2009                var $combo = $select.closest( '.f2z-combo' );
     2010                var off    = !! $select.prop( 'disabled' );
     2011
     2012                if ( ! $combo.length ) {
     2013                        return;
     2014                }
     2015                if ( off && $combo.hasClass( 'is-open' ) ) {
     2016                        comboClose( $combo );
     2017                }
     2018                $combo.toggleClass( 'is-disabled', off ).find( '.f2z-combo__input' ).prop( 'disabled', off );
     2019        }
     2020
     2021        /**
     2022         * Put the open list under (or over) its input.
     2023         *
     2024         * The list is position:fixed and lives on <body> while open, so its
     2025         * coordinates are read off the input every time it opens and again
     2026         * whenever anything scrolls underneath it.
     2027         *
     2028         * It flips above the input when there is more room there. A mapping table
     2029         * grows downwards and the newest row is the one being filled in, so the
     2030         * row that most needs this is always the one nearest the bottom.
     2031         */
     2032        function comboPosition( $combo ) {
     2033                var $list = comboList( $combo );
     2034                var input = $combo.find( '.f2z-combo__input' ).get( 0 );
     2035
     2036                if ( ! input || $list.prop( 'hidden' ) ) {
     2037                        return;
     2038                }
     2039
     2040                var r      = input.getBoundingClientRect();
     2041                var gap    = 2;
     2042                var margin = 8;
     2043                var below  = window.innerHeight - r.bottom - margin;
     2044                var above  = r.top - margin;
     2045                var up     = below < 160 && above > below;
     2046                var room   = Math.max( 120, Math.min( 240, up ? above : below ) );
     2047
     2048                $list.css( {
     2049                        left: Math.round( r.left ) + 'px',
     2050                        width: Math.round( r.width ) + 'px',
     2051                        'max-height': Math.round( room ) + 'px',
     2052                        top: up ? 'auto' : Math.round( r.bottom + gap ) + 'px',
     2053                        bottom: up ? Math.round( window.innerHeight - r.top + gap ) + 'px' : 'auto'
     2054                } );
     2055        }
     2056
     2057        // Anything that moves the page moves the input the list is pinned to.
     2058        // Capture phase, because the mapping table's own scroll box does not
     2059        // bubble a scroll event.
     2060        function comboReposition() {
     2061                if ( ! comboOpen ) {
     2062                        return;
     2063                }
     2064
     2065                // Its input has left the page by some route that did not close it
     2066                // first. There is nothing to pin the list to, so it goes.
     2067                if ( ! document.body.contains( comboOpen.get( 0 ) ) ) {
     2068                        comboClose( comboOpen );
     2069                        return;
     2070                }
     2071
     2072                comboPosition( comboOpen );
     2073        }
     2074
     2075        window.addEventListener( 'scroll', comboReposition, true );
     2076        window.addEventListener( 'resize', comboReposition );
     2077
     2078        function comboClose( $combo ) {
     2079                var $list = comboList( $combo );
     2080
     2081                $combo.removeClass( 'is-open' );
     2082
     2083                // Home again, so the list is never orphaned on <body> if the row it
     2084                // belongs to is removed while it happens to be open.
     2085                $list.prop( 'hidden', true ).empty().removeAttr( 'style' ).appendTo( $combo );
     2086
     2087                $combo.find( '.f2z-combo__input' ).attr( 'aria-expanded', 'false' ).removeAttr( 'aria-activedescendant' );
     2088
     2089                if ( comboOpen && comboOpen.get( 0 ) === $combo.get( 0 ) ) {
     2090                        comboOpen = null;
     2091                }
     2092        }
     2093
     2094        /**
     2095         * Draw the options matching what has been typed.
     2096         *
     2097         * Matching is a plain case-insensitive substring over the label, which is
     2098         * the whole point: "email" has to find "Secondary Email", and that is
     2099         * exactly what the native control could not do.
     2100         *
     2101         * The list is moved onto <body> to be shown. Two ancestors make that
     2102         * necessary and neither can be worked around from here: the mapping table
     2103         * sits in .form2zoho-table-scroll, and a box that scrolls on one axis
     2104         * clips on both — so a list drawn inside it is cut off at the table's
     2105         * edge. Escaping that with position:fixed then runs into the step panel's
     2106         * entry animation, which puts a transform on the section and so becomes
     2107         * the containing block for anything fixed inside it, leaving the list
     2108         * pinned to the panel rather than to the viewport. Out on <body> there is
     2109         * nothing above it to clip it or to anchor it.
     2110         */
     2111        function comboFilter( $combo, query ) {
     2112                var $select = $combo.find( '.f2z-combo__native' );
     2113                var $list   = comboList( $combo );
     2114                var needle  = ( query || '' ).trim().toLowerCase();
     2115                var id      = $list.attr( 'id' );
     2116
     2117                // A Zoho field another row already sends to is not offered here. One
     2118                // field holds one value, so a second row aimed at it cannot also
     2119                // arrive — it used to be selectable, and then quietly disappeared on
     2120                // save. The row's own current field stays in the list, or changing
     2121                // your mind about it would mean losing it first.
     2122                var taken    = {};
     2123                var fileOnly = false;
     2124                if ( $select.hasClass( 'f2z-map-zoho' ) ) {
     2125                        var mine = $select.val();
     2126                        $( '.form2zoho-map-rows .f2z-map-zoho' ).not( $select ).each( function () {
     2127                                var v = $( this ).val() || $( this ).attr( 'data-selected' ) || '';
     2128                                if ( v && v !== mine ) {
     2129                                        taken[ v ] = true;
     2130                                }
     2131                        } );
     2132
     2133                        fileOnly = rowWantsFileFields( $select.closest( '.form2zoho-map-row' ) );
     2134                }
     2135
     2136                var matches = comboOptions( $select ).filter( function ( o ) {
     2137                        // The placeholder is never offered as a match; clearing the box is
     2138                        // what unsets the field.
     2139                        return '' !== o.value
     2140                                && ! taken[ o.value ]
     2141                                && ( ! fileOnly || isFileType( o.type ) )
     2142                                && ( '' === needle || o.label.toLowerCase().indexOf( needle ) > -1 );
     2143                } );
     2144
     2145                $list.empty();
     2146
     2147                if ( ! matches.length ) {
     2148                        $list.append( $( '<li class="f2z-combo__empty"></li>' ).text( i18n.comboNoMatch || 'No field matches' ) );
     2149                } else {
     2150                        var current = $select.val();
     2151                        matches.forEach( function ( o, i ) {
     2152                                var $li = $( '<li class="f2z-combo__opt" role="option"></li>' )
     2153                                        .attr( 'id', id + '-o' + i )
     2154                                        .attr( 'data-value', o.value )
     2155                                        .attr( 'aria-selected', o.value === current ? 'true' : 'false' )
     2156                                        .text( o.label );
     2157                                if ( o.value === current ) {
     2158                                        $li.addClass( 'is-active' );
     2159                                }
     2160                                $list.append( $li );
     2161                        } );
     2162                }
     2163
     2164                if ( comboOpen && comboOpen.get( 0 ) !== $combo.get( 0 ) ) {
     2165                        comboClose( comboOpen );
     2166                }
     2167
     2168                $list.prop( 'hidden', false ).appendTo( document.body );
     2169                $combo.addClass( 'is-open' );
     2170                $combo.find( '.f2z-combo__input' ).attr( 'aria-expanded', 'true' );
     2171                comboOpen = $combo;
     2172                comboPosition( $combo );
     2173        }
     2174
     2175        function comboPick( $combo, value ) {
     2176                var $select = $combo.find( '.f2z-combo__native' );
     2177
     2178                // .trigger('change') on purpose: every other handler in this file
     2179                // listens to the select, not to this box, so the change has to arrive
     2180                // the way it always did.
     2181                $select.val( value ).trigger( 'change' );
     2182                $combo.find( '.f2z-combo__input' ).val( comboLabel( $select ) );
     2183                comboClose( $combo );
     2184        }
     2185
     2186        function comboMove( $combo, step ) {
     2187                var $opts = comboList( $combo ).find( '.f2z-combo__opt' );
     2188
     2189                if ( ! $opts.length ) {
     2190                        return;
     2191                }
     2192
     2193                var at   = $opts.filter( '.is-active' ).index();
     2194                var next = at + step;
     2195
     2196                if ( next < 0 ) {
     2197                        next = $opts.length - 1;
     2198                } else if ( next >= $opts.length ) {
     2199                        next = 0;
     2200                }
     2201
     2202                $opts.removeClass( 'is-active' ).attr( 'aria-selected', 'false' );
     2203
     2204                var $to = $opts.eq( next ).addClass( 'is-active' ).attr( 'aria-selected', 'true' );
     2205                $combo.find( '.f2z-combo__input' ).attr( 'aria-activedescendant', $to.attr( 'id' ) );
     2206
     2207                var el = $to.get( 0 );
     2208                if ( el && el.scrollIntoView ) {
     2209                        el.scrollIntoView( { block: 'nearest' } );
     2210                }
     2211        }
     2212
     2213        /** Wrap one select, or refresh the box if it is already wrapped. */
     2214        function enhanceSelect( select ) {
     2215                var $select = $( select );
     2216                var $combo  = $select.closest( '.f2z-combo' );
     2217
     2218                if ( $combo.length ) {
     2219                        // Already wrapped. buildOptions() has just refilled the select, so
     2220                        // only the visible label can be out of date.
     2221                        $combo.find( '.f2z-combo__input' ).val( comboLabel( $select ) );
     2222                        comboSyncDisabled( $select );
     2223                        return;
     2224                }
     2225
     2226                var id = 'f2z-combo-' + ( ++comboSeq );
     2227
     2228                $combo = $( '<div class="f2z-combo"></div>' );
     2229                $select.addClass( 'f2z-combo__native' ).before( $combo );
     2230                $combo.append( $select );
     2231
     2232                $( '<input type="text" class="f2z-combo__input" role="combobox" autocomplete="off" spellcheck="false" />' )
     2233                        .attr( 'aria-expanded', 'false' )
     2234                        .attr( 'aria-controls', id )
     2235                        .attr( 'aria-autocomplete', 'list' )
     2236                        .attr( 'placeholder', $select.find( 'option' ).first().text() || i18n.comboSearch || '' )
     2237                        .val( comboLabel( $select ) )
     2238                        .prependTo( $combo );
     2239
     2240                var $list = $( '<ul class="f2z-combo__list" role="listbox" hidden></ul>' ).attr( 'id', id ).appendTo( $combo );
     2241
     2242                // Remembered, because once it is open the list is no longer a
     2243                // descendant of this element. See comboList() for why a property.
     2244                $combo.get( 0 ).f2zList = $list.get( 0 );
     2245
     2246                comboSyncDisabled( $select );
     2247        }
     2248
     2249        function enhanceMappingSelects() {
     2250                $( '.form2zoho-map-rows .f2z-map-zoho, .form2zoho-map-rows .f2z-map-form' ).each( function () {
     2251                        enhanceSelect( this );
     2252                } );
     2253        }
     2254
     2255        $( document ).on( 'focus.f2zNav click.f2zNav', '.f2z-combo__input', function () {
     2256                // A box standing in for a disabled select, such as the Zoho field on a
     2257                // row a required field depends on, never opens. A pointer cannot reach
     2258                // it, but a focus sent by script still arrives here.
     2259                if ( this.disabled ) {
     2260                        return;
     2261                }
     2262
     2263                var $combo = $( this ).closest( '.f2z-combo' );
     2264
     2265                if ( ! $combo.hasClass( 'is-open' ) ) {
     2266                        // Opening shows everything, not the matches for the label already
     2267                        // sitting in the box — that would offer one option and look broken.
     2268                        comboFilter( $combo, '' );
     2269                }
     2270        } );
     2271
     2272        $( document ).on( 'input.f2zNav', '.f2z-combo__input', function () {
     2273                comboFilter( $( this ).closest( '.f2z-combo' ), $( this ).val() );
     2274        } );
     2275
     2276        $( document ).on( 'keydown.f2zNav', '.f2z-combo__input', function ( e ) {
     2277                var $combo = $( this ).closest( '.f2z-combo' );
     2278
     2279                if ( 'ArrowDown' === e.key || 'ArrowUp' === e.key ) {
     2280                        e.preventDefault();
     2281                        if ( ! $combo.hasClass( 'is-open' ) ) {
     2282                                comboFilter( $combo, '' );
     2283                        }
     2284                        comboMove( $combo, 'ArrowDown' === e.key ? 1 : -1 );
     2285                        return;
     2286                }
     2287
     2288                if ( 'Enter' === e.key && $combo.hasClass( 'is-open' ) ) {
     2289                        var $active = comboList( $combo ).find( '.f2z-combo__opt.is-active' );
     2290                        if ( $active.length ) {
     2291                                // Only when the list is open and something is highlighted, so
     2292                                // Enter still submits the form the rest of the time.
     2293                                e.preventDefault();
     2294                                comboPick( $combo, $active.attr( 'data-value' ) );
     2295                        }
     2296                        return;
     2297                }
     2298
     2299                if ( 'Escape' === e.key && $combo.hasClass( 'is-open' ) ) {
     2300                        e.preventDefault();
     2301                        var $sel = $combo.find( '.f2z-combo__native' );
     2302                        comboClose( $combo );
     2303                        $( this ).val( comboLabel( $sel ) );
     2304                }
     2305        } );
     2306
     2307        // mousedown, not click: the input's blur fires first and would close the
     2308        // list out from under the pointer. The list is on <body> by now, so the
     2309        // combo it belongs to is the open one rather than an ancestor.
     2310        $( document ).on( 'mousedown.f2zNav', '.f2z-combo__opt', function ( e ) {
     2311                e.preventDefault();
     2312
     2313                if ( comboOpen ) {
     2314                        comboPick( comboOpen, $( this ).attr( 'data-value' ) );
     2315                }
     2316        } );
     2317
     2318        $( document ).on( 'blur.f2zNav', '.f2z-combo__input', function () {
     2319                var $combo = $( this ).closest( '.f2z-combo' );
     2320                var $input = $( this );
     2321
     2322                // Whatever was half-typed is discarded and the real selection shown
     2323                // again: the box reports the select's state, it does not hold its own.
     2324                setTimeout( function () {
     2325                        comboClose( $combo );
     2326                        $input.val( comboLabel( $combo.find( '.f2z-combo__native' ) ) );
     2327                }, 120 );
     2328        } );
     2329
     2330        /**
     2331         * Copy the chosen Zoho field's type into the row's hidden input.
     2332         *
     2333         * The type is what tells delivery whether it is filling a checkbox or a
     2334         * multi-select rather than a text field, and it has to travel with the
     2335         * saved feed: asking Zoho for the field list again on every submission
     2336         * would put an API call in front of every visitor.
     2337         *
     2338         * Left alone when the option carries no type. A field list that never
     2339         * reported one (an app whose fields are curated rather than fetched, or a
     2340         * row rebuilt from a feed saved before this existed) should keep whatever
     2341         * the server rendered instead of being blanked by a refill.
     2342         */
     2343        function syncRowZohoType( $row ) {
     2344                var $sel = $row.find( '.f2z-map-zoho' );
     2345                var type = $sel.find( 'option:selected' ).attr( 'data-type' );
     2346
     2347                if ( 'undefined' !== typeof type ) {
     2348                        $row.find( '.f2z-map-type' ).val( type );
     2349                }
     2350        }
     2351
     2352        $( document ).on( 'change.f2zNav', '.f2z-map-zoho', function () {
     2353                syncRowZohoType( $( this ).closest( '.form2zoho-map-row' ) );
     2354        } );
     2355
     2356        // Picking an upload as the form field lets go of a Zoho field that is not
     2357        // a file field, so the box never shows a choice its own list no longer
     2358        // offers. Only on a change somebody makes: a saved feed being filled back
     2359        // in does not fire this, and is left exactly as it was saved.
     2360        $( document ).on( 'change.f2zNav', '.form2zoho-map-rows .f2z-map-form', function () {
     2361                var $row  = $( this ).closest( '.form2zoho-map-row' );
     2362                var $zoho = $row.find( '.f2z-map-zoho' );
     2363
     2364                // Never on a row a required field depends on: its Zoho field is locked,
     2365                // and blanking it would leave that field unmapped behind a lock.
     2366                if ( ! $zoho.val() || $row.hasClass( 'is-required' ) || ! rowWantsFileFields( $row ) ) {
     2367                        return;
     2368                }
     2369
     2370                if ( ! isFileType( $zoho.find( 'option:selected' ).attr( 'data-type' ) ) ) {
     2371                        $zoho.val( '' ).trigger( 'change' );
     2372                        $row.find( '.f2z-map-type' ).val( '' );
     2373                        enhanceSelect( $zoho.get( 0 ) );
     2374                }
     2375        } );
     2376
     2377        // Add a blank mapping row, then fill its dropdowns from the lists already
     2378        // on the page (see the f2z:mappingRowAdded listener in the editor). Nothing
     2379        // is fetched: neither the form nor the module has changed.
    9902380        $( document ).on( 'click.f2zNav', '.form2zoho-add-row', function () {
    9912381                var tpl = $( '#form2zoho-row-template' ).html();
    … …  
    9932383                        return;
    9942384                }
    995                 $( tpl ).appendTo( '.form2zoho-map-rows' );
     2385                syncRowSource( $( tpl ).appendTo( '.form2zoho-map-rows' ) );
     2386                enhanceMappingSelects();
    9962387                if ( $editor.length ) {
    997                         // Repopulate selects (existing selections are preserved).
    998                         $( '#f2z_form' ).trigger( 'change' );
    999                         $( '#f2z_module' ).trigger( 'change' );
     2388                        $( document ).trigger( 'f2z:mappingRowAdded' );
    10002389                }
    10012390        } );
    … …  
    10032392        // Remove a mapping row (keep at least one).
    10042393        $( document ).on( 'click.f2zNav', '.form2zoho-remove-row', function () {
     2394                // A row a required Zoho field depends on shows no Remove; this stops a
     2395                // click that arrives some other way. See syncRequiredRows().
     2396                if ( $( this ).closest( '.form2zoho-map-row' ).hasClass( 'is-required' ) ) {
     2397                        return;
     2398                }
     2399
    10052400                var $rows = $( '.form2zoho-map-rows .form2zoho-map-row' );
    10062401                if ( $rows.length > 1 ) {
    1007                         $( this ).closest( '.form2zoho-map-row' ).remove();
     2402                        var $gone = $( this ).closest( '.form2zoho-map-row' );
     2403
     2404                        // A dropdown open on this row is closed first, or its list stays
     2405                        // on the page with no row under it.
     2406                        comboCloseWithin( $gone );
     2407                        $gone.remove();
    10082408                } else {
    1009                         $( this ).closest( '.form2zoho-map-row' ).find( 'input, select' ).val( '' );
     2409                        // The last row is emptied rather than removed. The Source select
     2410                        // is put back to "Form field" explicitly: .val('') on a select
     2411                        // with no empty option leaves it unchanged, so without this the
     2412                        // cleared row could keep a Source of "Static value" while its
     2413                        // static box had just been wiped.
     2414                        var $row = $( this ).closest( '.form2zoho-map-row' );
     2415
     2416                        // .f2z-combo__input is excluded for the same reason .f2z-map-source
     2417                        // is: it is not a mapping value, and enhanceMappingSelects() below
     2418                        // re-reads it from the select that was just cleared.
     2419                        $row.find( 'input, select' ).not( '.f2z-map-source, .f2z-combo__input' ).val( '' );
     2420                        $row.find( '.f2z-map-source' ).val( 'field' );
     2421                        syncRowSource( $row );
     2422                        enhanceMappingSelects();
    10102423                }
    10112424        } );
    … …  
    10162429        // contradictory sources at once, so clear it when the source flips.
    10172430        $( document ).on( 'change.f2zNav', '.f2z-map-source', function () {
    1018                 if ( 'static' !== $( this ).val() ) {
     2431                var $row = $( this ).closest( '.form2zoho-map-row' );
     2432
     2433                if ( 'field' !== $( this ).val() ) {
     2434                        // data-selected is buildOptions' fallback for an empty select, so
     2435                        // it has to go too — otherwise the next refill puts the field
     2436                        // right back. True of "Combine fields" as well: there the select
     2437                        // is an insert button, and a value left sitting in it would read
     2438                        // as the row's source.
     2439                        $row.find( '.f2z-map-form' ).val( '' ).removeAttr( 'data-selected' );
     2440                        enhanceMappingSelects();
     2441                }
     2442
     2443                syncRowSource( $row );
     2444        } );
     2445
     2446        /* ---- "Combine fields": the Form field select inserts a placeholder ----
     2447         *
     2448         * The template is written in field *ids* and the picker lists *labels*, so
     2449         * without this somebody would have to know that "Your email" is
     2450         * `your-email` before they could write anything. Choosing a field appends
     2451         * its placeholder and puts the select back to its prompt, so it reads as
     2452         * an insert button rather than as a selection that did not stick.
     2453         */
     2454        $( document ).on( 'change.f2zNav', '.f2z-map-form', function () {
     2455                var $sel = $( this );
     2456                var $row = $sel.closest( '.form2zoho-map-row' );
     2457
     2458                if ( 'template' !== $row.find( '.f2z-map-source' ).val() ) {
    10192459                        return;
    10202460                }
    1021                 // data-selected is buildOptions' fallback for an empty select, so it
    1022                 // has to go too — otherwise the next refill puts the field right back.
    1023                 $( this ).closest( '.form2zoho-map-row' )
    1024                         .find( '.f2z-map-form' ).val( '' ).removeAttr( 'data-selected' );
     2461
     2462                var field = $sel.val() || '';
     2463
     2464                if ( '' === field ) {
     2465                        return;
     2466                }
     2467
     2468                var $box = $row.find( '.f2z-map-static' );
     2469                var text = $box.val() || '';
     2470
     2471                // A space between placeholders is what the common case — a first and
     2472                // last name — wants, and it is easier to delete than to add.
     2473                $box.val( text + ( '' === text || / $/.test( text ) ? '' : ' ' ) + '{' + field + '}' );
     2474                renderTemplateTokens( $row );
     2475
     2476                $sel.val( '' ).removeAttr( 'data-selected' );
     2477                enhanceMappingSelects();
     2478        } );
     2479
     2480        /*
     2481         * The fields a template uses, shown above the picker as chips that can be
     2482         * removed.
     2483         *
     2484         * The picker goes back to its prompt after every insert, so on its own it
     2485         * never showed what had been added. The only record was the text box, and
     2486         * that names field ids, not the labels the picker lists. The chips are
     2487         * read back out of that text whenever it changes, so typing in the box and
     2488         * pressing a chip's remove button can never disagree.
     2489         */
     2490
     2491        // The rule FieldMapper::render() applies on delivery: "{{" and "}}" are
     2492        // literal braces, anything else in braces names a field. A new expression
     2493        // each time, so no caller inherits another's lastIndex.
     2494        function templateToken() {
     2495                return /\{\{|\}\}|\{([^{}]+)\}/g;
     2496        }
     2497
     2498        function templateFields( text ) {
     2499                var ids = [];
     2500
     2501                ( text || '' ).replace( templateToken(), function ( m, name ) {
     2502                        name = ( name || '' ).trim();
     2503                        if ( name && ids.indexOf( name ) === -1 ) {
     2504                                ids.push( name );
     2505                        }
     2506                        return m;
     2507                } );
     2508
     2509                return ids;
     2510        }
     2511
     2512        function renderTemplateTokens( $row ) {
     2513                var $wrap = $row.find( '.f2z-map-tokens' );
     2514
     2515                if ( ! $wrap.length ) {
     2516                        return;
     2517                }
     2518
     2519                var $sel = $row.find( '.f2z-map-form' );
     2520                var ids  = 'template' === $row.find( '.f2z-map-source' ).val() ? templateFields( $row.find( '.f2z-map-static' ).val() ) : [];
     2521
     2522                $wrap.empty();
     2523
     2524                ids.forEach( function ( id ) {
     2525                        // The label the picker shows once the form's fields have loaded;
     2526                        // the id before that, or for a field the form no longer has.
     2527                        var $opt  = $sel.find( 'option' ).filter( function () {
     2528                                return this.value === id;
     2529                        } ).first();
     2530                        var label = $opt.length && $opt.text() ? $opt.text() : id;
     2531
     2532                        $wrap.append(
     2533                                $( '<span class="f2z-map-token"></span>' ).attr( 'title', '{' + id + '}' ).append(
     2534                                        $( '<span class="f2z-map-token__label"></span>' ).text( label ),
     2535                                        // A drawn cross, not the &times; character: that glyph sits
     2536                                        // below the middle of its line box in most fonts, so it
     2537                                        // never centred in the round hover background.
     2538                                        $( '<button type="button" class="f2z-map-token__remove"><svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M2 2l6 6M8 2l-6 6"/></svg></button>' )
     2539                                                .attr( 'data-field', id )
     2540                                                .attr( 'aria-label', ( i18n.removeTemplateField || 'Remove %s' ).replace( '%s', label ) )
     2541                                )
     2542                        );
     2543                } );
     2544
     2545                $wrap.prop( 'hidden', ! ids.length );
     2546        }
     2547
     2548        function renderAllTemplateTokens() {
     2549                $( '.form2zoho-map-rows .form2zoho-map-row' ).each( function () {
     2550                        renderTemplateTokens( $( this ) );
     2551                } );
     2552        }
     2553
     2554        $( document ).on( 'input.f2zNav', '.form2zoho-map-rows .f2z-map-static', function () {
     2555                renderTemplateTokens( $( this ).closest( '.form2zoho-map-row' ) );
     2556        } );
     2557
     2558        // Take every placeholder for that field out of the template, with the one
     2559        // space that separated it from its neighbour, so "{first} {last}" loses
     2560        // "{last}" without leaving a trailing space behind.
     2561        $( document ).on( 'click.f2zNav', '.f2z-map-token__remove', function () {
     2562                var $row = $( this ).closest( '.form2zoho-map-row' );
     2563                var id   = $( this ).attr( 'data-field' );
     2564                var $box = $row.find( '.f2z-map-static' );
     2565                var text = $box.val() || '';
     2566                var kept = '';
     2567                var from = 0;
     2568
     2569                text.replace( templateToken(), function ( m, name, offset ) {
     2570                        if ( ! name || name.trim() !== id ) {
     2571                                return m;
     2572                        }
     2573
     2574                        var start = offset;
     2575                        var end   = offset + m.length;
     2576
     2577                        if ( ' ' === text.charAt( end ) && ( 0 === start || ' ' === text.charAt( start - 1 ) ) ) {
     2578                                end++;
     2579                        } else if ( ' ' === text.charAt( start - 1 ) ) {
     2580                                start--;
     2581                        }
     2582
     2583                        kept += text.slice( from, Math.max( from, start ) );
     2584                        from  = end;
     2585                        return m;
     2586                } );
     2587
     2588                kept += text.slice( from );
     2589
     2590                // trigger('input'): the chips redraw from the text, and a row marked
     2591                // unfinished clears the moment it is fine again, as it does for typing.
     2592                $box.val( kept ).trigger( 'input' ).trigger( 'focus' );
     2593        } );
     2594
     2595        // Toggle a timeline entry's JSON detail. Its own handler rather than
     2596        // .form2zoho-log-toggle, which reaches its panel with .next() on a table
     2597        // row — the timeline is a list, and the detail is a child of the entry.
     2598        $( document ).on( 'click.f2zNav', '.form2zoho-timeline-toggle', function () {
     2599                var $btn  = $( this );
     2600                var $wrap = $btn.siblings( '.form2zoho-detail-wrap' );
     2601                var show  = $wrap.prop( 'hidden' );
     2602
     2603                $wrap.prop( 'hidden', ! show );
     2604                $btn.attr( 'aria-expanded', show ? 'true' : 'false' );
    10252605        } );
    10262606
    … …  
    11072687                $( document ).on( 'change.f2zNav', '.form2zoho-log-check', refreshToolbar );
    11082688
     2689                // Paging replaces the tbody wholesale, so every checkbox this toolbar
     2690                // was counting is gone with it. No DOM event fires for that, so the
     2691                // pager says so rather than this guessing.
     2692                $( document ).on( 'f2z:logsPageChanged.f2zNav', refreshToolbar );
     2693
    11092694                $selectFailedBtn.on( 'click', function () {
    11102695                        var turnOn = ! allFailedSelected();
    … …  
    11302715                                                        setLogsEmptyState( true );
    11312716                                                }
     2717                                                // The page count changed and this page has holes in it.
     2718                                                // The live-log block owns the paging, so it is told
     2719                                                // rather than reached into.
     2720                                                $( document ).trigger( 'f2z:logsDeleted' );
    11322721                                        } else {
    11332722                                                showToast( ( res && res.data && res.data.message ) || i18n.logsDeleteFailed, true );
    … …  
    12062795        var $logsTable = $( '#form2zoho-logs-table' );
    12072796        if ( $logsTable.length ) {
    1208                 var pillClass = { success: 'form2zoho-pill-ok', failed: 'form2zoho-pill-bad', error: 'form2zoho-pill-bad', retry: 'form2zoho-pill-warn', suppressed: 'form2zoho-pill-warn' };
     2797                var pillClass = { success: 'form2zoho-pill-ok', failed: 'form2zoho-pill-bad', error: 'form2zoho-pill-bad', retry: 'form2zoho-pill-warn', suppressed: 'form2zoho-pill-warn', skipped: 'form2zoho-pill-warn' };
    12092798                // Translated, via shared_i18n(). These were English literals, so a
    12102799                // translated site showed the polled rows in English right underneath
    … …  
    12162805                        retry: i18n.statusRetry,
    12172806                        info: i18n.statusInfo,
    1218                         suppressed: i18n.statusSuppressed
     2807                        suppressed: i18n.statusSuppressed,
     2808                        skipped: i18n.statusSkipped
    12192809                };
    12202810
    … …  
    12282818                        html += '<td><span class="form2zoho-pill ' + cls + '">' + esc( label ) + '</span></td>';
    12292819                        html += '<td>' + esc( appLabel ) + '</td>';
    1230                         html += '<td>' + ( row.module ? '<code>' + esc( row.module ) + '</code>' : '\u2014' ) + '</td>';
    1231                         html += '<td>' + ( row.form_id ? esc( row.form_id ) : '\u2014' ) + '</td>';
    1232                         html += '<td>' + ( row.record_id ? '<code>' + esc( row.record_id ) + '</code>' : '\u2014' ) + '</td>';
     2820                        html += '<td>' + ( row.module ? '<code>' + esc( row.module ) + '</code>' : '-' ) + '</td>';
     2821                        html += '<td>' + ( row.form_id ? esc( row.form_id ) : '-' ) + '</td>';
     2822                        html += '<td>' + ( row.record_id ? '<code>' + esc( row.record_id ) + '</code>' : '-' ) + '</td>';
    12332823                        html += '<td>' + esc( row.message ) + '</td>';
    12342824                        html += '<td><button type="button" class="button-link form2zoho-log-toggle">' + esc( i18n.details ) + '</button></td>';
    … …  
    12422832                }
    12432833
    1244                 var pollTimer = null;
     2834                /* ---- Paging ----
     2835                 *
     2836                 * The log is read a page at a time from the database. The bar carries
     2837                 * the state, because the server is the only thing that knows how many
     2838                 * pages there are — a number that changes under the screen every time
     2839                 * a form is submitted.
     2840                 */
     2841
     2842                var $paging = $( '#form2zoho-logs-pagination' );
     2843
     2844                function pagingState() {
     2845                        return {
     2846                                paged: parseInt( $paging.attr( 'data-paged' ), 10 ) || 1,
     2847                                pages: parseInt( $paging.attr( 'data-pages' ), 10 ) || 1,
     2848                                perPage: parseInt( $paging.attr( 'data-per-page' ), 10 ) || 20,
     2849                                total: parseInt( $paging.attr( 'data-total' ), 10 ) || 0
     2850                        };
     2851                }
     2852
     2853                /** Whether the newest rows belong on the page being looked at. */
     2854                function onFirstPage() {
     2855                        return ! $paging.length || pagingState().paged <= 1;
     2856                }
     2857
     2858                /**
     2859                 * Fetch one page of the log and put it on screen.
     2860                 *
     2861                 * @param {number} paged   1-based page to show.
     2862                 * @param {number} perPage Rows per page.
     2863                 * @return {jqXHR|Object} The request.
     2864                 */
     2865                function loadLogsPage( paged, perPage ) {
     2866                        if ( ! $paging.length ) {
     2867                                return $.Deferred().resolve().promise();
     2868                        }
     2869
     2870                        var $tbody = $( '#form2zoho-logs-tbody' );
     2871
     2872                        $paging.addClass( 'is-busy' ).find( 'button, select' ).prop( 'disabled', true );
     2873                        $tbody.addClass( 'is-loading' );
     2874
     2875                        return post( 'form2zoho_logs_page', { log_paged: paged, log_per_page: perPage } )
     2876                                .always( function () {
     2877                                        $paging.removeClass( 'is-busy' );
     2878                                        $tbody.removeClass( 'is-loading' );
     2879                                } )
     2880                                .done( function ( res ) {
     2881                                        if ( ! res || ! res.success ) {
     2882                                                showToast( ( res && res.data && res.data.message ) || i18n.error, true );
     2883                                                syncPagingControls();
     2884                                                return;
     2885                                        }
     2886
     2887                                        var d = res.data;
     2888
     2889                                        $tbody.html( d.html );
     2890                                        $paging.attr( {
     2891                                                'data-paged': d.paged,
     2892                                                'data-pages': d.pages,
     2893                                                'data-per-page': d.perPage,
     2894                                                'data-total': d.total
     2895                                        } );
     2896                                        $( '#form2zoho-logs-pageinfo' ).text( d.pageInfo );
     2897                                        $( '#form2zoho-logs-perpage' ).val( String( d.perPage ) );
     2898
     2899                                        // The poll counts from the newest row in the table, not
     2900                                        // from the newest one on screen — see Logger::newest_id().
     2901                                        $logsTable.attr( 'data-after-id', d.newestId );
     2902
     2903                                        setLogsEmptyState( 0 === d.total );
     2904                                        $paging.prop( 'hidden', 0 === d.total );
     2905                                        syncPagingControls();
     2906
     2907                                        // Every checkbox on the old page went with it, so the
     2908                                        // bulk-action bar has to stop claiming a selection.
     2909                                        $( document ).trigger( 'f2z:logsPageChanged' );
     2910                                } )
     2911                                .fail( function () {
     2912                                        showToast( i18n.error, true );
     2913                                        syncPagingControls();
     2914                                } );
     2915                }
     2916
     2917                /** Prev/Next are off at the ends of the list. */
     2918                function syncPagingControls() {
     2919                        if ( ! $paging.length ) {
     2920                                return;
     2921                        }
     2922                        var st = pagingState();
     2923                        $paging.find( 'select' ).prop( 'disabled', false );
     2924                        $paging.find( '.form2zoho-logs-prev' ).prop( 'disabled', st.paged <= 1 );
     2925                        $paging.find( '.form2zoho-logs-next' ).prop( 'disabled', st.paged >= st.pages );
     2926                }
     2927
     2928                $( document ).on( 'click.f2zNav', '.form2zoho-logs-prev', function () {
     2929                        var st = pagingState();
     2930                        if ( st.paged > 1 ) {
     2931                                loadLogsPage( st.paged - 1, st.perPage );
     2932                        }
     2933                } );
     2934
     2935                $( document ).on( 'click.f2zNav', '.form2zoho-logs-next', function () {
     2936                        var st = pagingState();
     2937                        if ( st.paged < st.pages ) {
     2938                                loadLogsPage( st.paged + 1, st.perPage );
     2939                        }
     2940                } );
     2941
     2942                // Back to page one: the row that was third on a page of twenty is not
     2943                // the third row of a page of a hundred, so staying put would land
     2944                // somewhere nobody chose.
     2945                $( document ).on( 'change.f2zNav', '#form2zoho-logs-perpage', function () {
     2946                        loadLogsPage( 1, parseInt( $( this ).val(), 10 ) || 20 );
     2947                } );
     2948
     2949                // Deleting rows changes how many pages there are, so the page is
     2950                // re-read rather than patched. It also refills the gap the deleted
     2951                // rows left, instead of leaving a page of twelve.
     2952                $( document ).on( 'f2z:logsDeleted.f2zNav', function () {
     2953                        var st = pagingState();
     2954                        loadLogsPage( st.paged, st.perPage );
     2955                } );
    12452956
    12462957                function pollLogs() {
    12472958                        if ( ! $( '#form2zoho-live-switch' ).is( ':checked' ) ) {
    1248                                 return;
     2959                                return $.Deferred().resolve().promise();
    12492960                        }
    12502961                        var after = parseInt( $logsTable.attr( 'data-after-id' ), 10 ) || 0;
    1251                         post( 'form2zoho_recent_logs', { after_id: after } ).done( function ( res ) {
     2962                        return post( 'form2zoho_recent_logs', { after_id: after } ).done( function ( res ) {
    12522963                                if ( ! res || ! res.success || ! res.data.rows || ! res.data.rows.length ) {
    12532964                                        return;
    … …  
    12552966                                var $tbody = $( '#form2zoho-logs-tbody' );
    12562967                                var maxId = after;
     2968                                var arrived = res.data.rows.length;
     2969
     2970                                // The cursor moves whichever page is on screen. Not moving it
     2971                                // would make every later poll re-fetch the same rows, and
     2972                                // coming back to page one would then show them twice.
    12572973                                res.data.rows.forEach( function ( row ) {
    1258                                         $tbody.prepend( logRowHtml( row ) );
    12592974                                        maxId = Math.max( maxId, row.id );
    12602975                                } );
    12612976                                $logsTable.attr( 'data-after-id', maxId );
     2977
     2978                                if ( $paging.length ) {
     2979                                        // The log grew, so the page count may have too.
     2980                                        var st = pagingState();
     2981                                        $paging.attr( 'data-total', st.total + arrived );
     2982                                        $paging.attr( 'data-pages', Math.max( 1, Math.ceil( ( st.total + arrived ) / st.perPage ) ) );
     2983                                        $( '#form2zoho-logs-pageinfo' ).text(
     2984                                                ( i18n.logsPageInfo || 'Page %1$s of %2$s · %3$s entries' )
     2985                                                        .replace( '%1$s', pagingState().paged )
     2986                                                        .replace( '%2$s', pagingState().pages )
     2987                                                        .replace( '%3$s', pagingState().total )
     2988                                        );
     2989                                        syncPagingControls();
     2990                                }
     2991
     2992                                // A new row belongs at the top of page one. On any other page
     2993                                // it would be an interruption in the middle of older rows, in
     2994                                // the wrong order, and pushing a row off the bottom that the
     2995                                // page is supposed to be showing.
     2996                                if ( ! onFirstPage() ) {
     2997                                        return;
     2998                                }
     2999
     3000                                res.data.rows.forEach( function ( row ) {
     3001                                        $tbody.prepend( logRowHtml( row ) );
     3002                                } );
     3003
     3004                                // Page one holds a page's worth. Prepending without this made
     3005                                // it grow by one row per submission until it was the whole log
     3006                                // again, which is what the paging is for.
     3007                                trimToPage( $tbody );
     3008
    12623009                                setLogsEmptyState( false );
     3010                                $paging.prop( 'hidden', false );
    12633011                                setTimeout( function () {
    12643012                                        $tbody.find( '.form2zoho-log-row-new' ).removeClass( 'form2zoho-log-row-new' );
    … …  
    12693017                }
    12703018
     3019                /**
     3020                 * Drop rows that have fallen off the end of page one.
     3021                 *
     3022                 * Each log entry is two <tr> — the row and its hidden detail — so this
     3023                 * counts entries and removes both halves of the ones past the end.
     3024                 *
     3025                 * @param {jQuery} $tbody The logs tbody.
     3026                 */
     3027                function trimToPage( $tbody ) {
     3028                        if ( ! $paging.length ) {
     3029                                return;
     3030                        }
     3031
     3032                        var perPage = pagingState().perPage;
     3033
     3034                        $tbody.find( '.form2zoho-log-row' ).slice( perPage ).each( function () {
     3035                                $( this ).next( '.form2zoho-log-detail' ).remove();
     3036                                $( this ).remove();
     3037                        } );
     3038                }
     3039
     3040                /* ---- When to poll again ----
     3041                 *
     3042                 * This was setInterval(pollLogs, 4000): a request every four seconds
     3043                 * whatever happened to the last one. On a site where admin-ajax takes
     3044                 * longer than four seconds to answer — and it can, because every
     3045                 * plugin on the site boots before our handler is reached — that is not
     3046                 * one poll every four seconds, it is a growing pile of them, each
     3047                 * booting WordPress again, each making the next one slower. The screen
     3048                 * then blames whatever the person clicked.
     3049                 *
     3050                 * So: schedule the next poll when the last one comes back, never
     3051                 * before, and space them by what the server is actually managing. A
     3052                 * server answering in 200ms keeps the four-second rhythm; one taking
     3053                 * ten seconds is asked every twenty instead of being given four
     3054                 * requests to juggle.
     3055                 */
     3056                var POLL_MIN = 4000;
     3057                var POLL_MAX = 60000;
     3058                var pollDelay = POLL_MIN;
     3059
     3060                // admin.js is re-executed after every in-plugin navigation, so more
     3061                // than one copy of this closure can be alive at once — an older one
     3062                // with a request still in flight, about to schedule its next poll.
     3063                // Bumping a counter on window and checking it is what makes only the
     3064                // newest run poll. Clearing the timer is not enough on its own: it
     3065                // cannot cancel a request that has already gone out.
     3066                var pollGen = window.form2zohoPollGen = ( window.form2zohoPollGen || 0 ) + 1;
     3067
     3068                function pollIsCurrent() {
     3069                        return pollGen === window.form2zohoPollGen &&
     3070                                null !== document.getElementById( 'form2zoho-logs-table' );
     3071                }
     3072
     3073                function schedulePoll( delay ) {
     3074                        if ( ! pollIsCurrent() ) {
     3075                                return;
     3076                        }
     3077                        window.form2zohoPollTimer = setTimeout( runPoll, delay );
     3078                }
     3079
     3080                function runPoll() {
     3081                        if ( ! pollIsCurrent() ) {
     3082                                return;
     3083                        }
     3084
     3085                        // A background tab is nobody watching. Browsers throttle the timer
     3086                        // but not the request it fires, so without this a Logs tab left
     3087                        // open all day keeps a server busy for an audience of no one.
     3088                        if ( document.hidden || ! $( '#form2zoho-live-switch' ).is( ':checked' ) ) {
     3089                                schedulePoll( POLL_MIN );
     3090                                return;
     3091                        }
     3092
     3093                        var started = Date.now();
     3094
     3095                        pollLogs().always( function () {
     3096                                // Twice what the last answer cost, floored at four seconds and
     3097                                // capped at a minute. Twice, so the server spends at most half
     3098                                // its time answering this screen.
     3099                                var took = Date.now() - started;
     3100                                pollDelay = Math.min( POLL_MAX, Math.max( POLL_MIN, took * 2 ) );
     3101                                schedulePoll( pollDelay );
     3102                        } );
     3103                }
     3104
     3105                // admin.js is re-executed after every in-plugin navigation, so a timer
     3106                // from the previous run has to be cleared or they accumulate — one
     3107                // more poller per visit to this tab.
    12713108                if ( window.form2zohoPollTimer ) {
    1272                         clearInterval( window.form2zohoPollTimer );
    1273                 }
    1274                 window.form2zohoPollTimer = setInterval( pollLogs, 4000 );
     3109                        clearTimeout( window.form2zohoPollTimer );
     3110                }
     3111                schedulePoll( POLL_MIN );
     3112
    12753113                $( '#form2zoho-live-switch' ).on( 'change', function () {
    1276                         $( '.form2zoho-live-dot' ).toggleClass( 'is-paused', ! $( this ).is( ':checked' ) );
     3114                        var on = $( this ).is( ':checked' );
     3115                        $( '.form2zoho-live-dot' ).toggleClass( 'is-paused', ! on );
     3116
     3117                        // Switching Live back on should show something now, not after
     3118                        // whatever back-off the last slow answer earned.
     3119                        if ( on ) {
     3120                                clearTimeout( window.form2zohoPollTimer );
     3121                                pollDelay = POLL_MIN;
     3122                                schedulePoll( 0 );
     3123                        }
     3124                } );
     3125
     3126                // Same when the tab is brought back to the front.
     3127                $( document ).on( 'visibilitychange.f2zNav', function () {
     3128                        if ( ! document.hidden && $( '#form2zoho-live-switch' ).is( ':checked' ) ) {
     3129                                clearTimeout( window.form2zohoPollTimer );
     3130                                schedulePoll( 0 );
     3131                        }
    12773132                } );
    12783133        } else if ( window.form2zohoPollTimer ) {
    12793134                // Navigated away from the Logs tab — stop polling in the background.
    1280                 clearInterval( window.form2zohoPollTimer );
     3135                clearTimeout( window.form2zohoPollTimer );
    12813136                window.form2zohoPollTimer = null;
    12823137        }
    … …  
    12903145         * @return {void}
    12913146         */
     3147        /**
     3148         * How long a confirmation stays on screen before it clears itself.
     3149         *
     3150         * One value for both kinds — the toast that appears in place and the
     3151         * banner that comes back after a save or a delete. They are the same thing
     3152         * from the reader's side, and they were 2600ms and 5000ms: the banner sat
     3153         * there long after the toast had gone, which is what made saving feel like
     3154         * it had left something behind.
     3155         *
     3156         * Only ever used for a confirmation. A warning or an error is the one
     3157         * thing on the page worth reading twice and is never put on a timer.
     3158         */
     3159        var NOTICE_LINGER = 2600;
     3160
    12923161        function showToast( message, isError ) {
    12933162                var $stack = $( '.form2zoho-toast-stack' );
    … …  
    13193188                                setTimeout( function () { $toast.remove(); }, 200 );
    13203189                        },
    1321                         2600
     3190                        NOTICE_LINGER
    13223191                );
    13233192        }
    … …  
    13823251        } );
    13833252
     3253        /**
     3254         * Say that a control is doing something, and stop saying it.
     3255         *
     3256         * For actions rather than lists: a switch being flipped, a feed being
     3257         * cloned. Every one of these is an admin-ajax round trip, which on a site
     3258         * with a couple of dozen plugins is seconds before the handler is even
     3259         * reached — long enough that a control which does not react reads as one
     3260         * that did not register the click.
     3261         *
     3262         * Disabled as well as marked, so the same request cannot be sent twice
     3263         * while the first is still out.
     3264         *
     3265         * @param {jQuery}  $el The control that was used.
     3266         * @param {boolean} on  Whether its request is running.
     3267         */
     3268        function actionBusy( $el, on ) {
     3269                if ( ! $el || ! $el.length ) {
     3270                        return;
     3271                }
     3272
     3273                $el.prop( 'disabled', !! on ).toggleClass( 'is-busy', !! on );
     3274                $el.closest( '.form2zoho-switch' ).toggleClass( 'is-busy', !! on );
     3275        }
     3276
    13843277        $( document ).on( 'change.f2zNav', '.form2zoho-feed-toggle', function () {
    13853278                var $cb = $( this );
    13863279                var id  = $cb.data( 'feed-id' );
     3280                actionBusy( $cb, true );
    13873281                post( 'form2zoho_toggle_feed', { feed_id: id } )
     3282                        .always( function () {
     3283                                actionBusy( $cb, false );
     3284                        } )
    13883285                        .done( function () {
    13893286                                showToast( i18n.statusChanged );
    … …  
    13953292        } );
    13963293
     3294        // Row menu: send a real test record with the saved feed, the same
     3295        // request the editor's "Send test record" button makes.
     3296        $( document ).on( 'click.f2zNav', '.form2zoho-action-test', function () {
     3297                var $item = $( this );
     3298                var id = $item.data( 'feed-id' );
     3299                askConfirm( { message: i18n.testConfirm, label: i18n.sendAction, danger: false }, function () {
     3300                        actionBusy( $item, true );
     3301                        post( 'form2zoho_test_feed', { feed_id: id } )
     3302                                .always( function () {
     3303                                        actionBusy( $item, false );
     3304                                } )
     3305                                .done( function ( res ) {
     3306                                        if ( res && res.success ) {
     3307                                                showToast( i18n.testSuccess + ': ' + res.data.message );
     3308                                        } else {
     3309                                                showToast( i18n.testFailed + ': ' + ( ( res && res.data && res.data.message ) || i18n.error ), true );
     3310                                        }
     3311                                } )
     3312                                .fail( function () {
     3313                                        showToast( i18n.testFailed + ': ' + i18n.error, true );
     3314                                } );
     3315                } );
     3316        } );
     3317
    13973318        $( document ).on( 'click.f2zNav', '.form2zoho-action-clone', function () {
    1398                 var id = $( this ).data( 'feed-id' );
    1399                 post( 'form2zoho_clone_feed', { feed_id: id } ).done( function ( res ) {
    1400                         if ( res && res.success ) {
    1401                                 window.location.reload();
    1402                         }
    1403                 } );
    1404         } );
     3319                var $item = $( this );
     3320                var id = $item.data( 'feed-id' );
     3321                actionBusy( $item, true );
     3322                post( 'form2zoho_clone_feed', { feed_id: id } )
     3323                        // Restored even though success reloads the page: a failure has to
     3324                        // leave the menu item usable again, and a reload that never comes
     3325                        // must not leave it dead.
     3326                        .always( function () {
     3327                                actionBusy( $item, false );
     3328                        } )
     3329                        .done( function ( res ) {
     3330                                if ( res && res.success ) {
     3331                                        window.location.reload();
     3332                                }
     3333                        } );
     3334        } );
     3335
     3336        /**
     3337         * Open the Pro dialog for one locked source tile.
     3338         *
     3339         * The dialog's benefit list and logo strip are the same whichever tile was
     3340         * pressed; the mark, the headline and the one-line answer are not. They
     3341         * come off the tile as data attributes rather than being assembled here,
     3342         * so every string in the dialog stays in PHP where it can be translated —
     3343         * this function only moves text it never reads.
     3344         *
     3345         * @param {jQuery} $tile The locked tile that was clicked.
     3346         */
     3347        function openProModal( $tile ) {
     3348                var $modal = $( '#form2zoho-pro-modal' );
     3349
     3350                if ( ! $modal.length ) {
     3351                        return;
     3352                }
     3353
     3354                var icon = $tile.data( 'pro-icon' ) || '';
     3355                var $img = $modal.find( '.form2zoho-pro-mark-img' );
     3356
     3357                // .attr(), not .text(): these are attribute and text-node writes on
     3358                // values that came from esc_attr() in PHP, and jQuery escapes on the
     3359                // way in. Nothing here builds HTML from them.
     3360                $modal.find( '.form2zoho-pro-title' ).text( $tile.data( 'pro-title' ) || '' );
     3361                $modal.find( '.form2zoho-pro-blurb' ).text( $tile.data( 'pro-blurb' ) || '' );
     3362
     3363                if ( icon ) {
     3364                        $img.attr( 'src', icon ).show();
     3365                } else {
     3366                        $img.removeAttr( 'src' ).hide();
     3367                }
     3368
     3369                openModal( $modal );
     3370        }
    14053371
    14063372        function openModal( $modal ) {
    … …  
    14093375                $modal.find( '.form2zoho-modal' ).attr( 'tabindex', '-1' ).trigger( 'focus' );
    14103376        }
    1411         $( document ).on( 'click.f2zNav', '.form2zoho-modal-close, .form2zoho-modal-overlay', function ( e ) {
     3377        $( document ).on( 'click.f2zNav', '.form2zoho-modal-close, .form2zoho-pro-dismiss, .form2zoho-modal-overlay', function ( e ) {
    14123378                if ( e.target === this ) {
    14133379                        closeModal( $( this ).closest( '.form2zoho-modal-overlay' ) );
    … …  
    14273393                                }
    14283394                                var d = res.data;
    1429                                 var dedupe = ( d.dedupe || [] ).join( ', ' ) || '\u2014';
     3395                                var dedupe = ( d.dedupe || [] ).join( ', ' ) || '-';
    14303396                                // Same title class as the other three dialogs — a bare <h2> picks
    14313397                                // up the generic `.form2zoho h2` margins instead, which put an
    … …  
    14353401                                html += '<tr><th scope="row">' + esc( i18n.colForm ) + '</th><td><code>' + esc( d.form_name ) + '</code></td></tr>';
    14363402                                html += '<tr><th scope="row">' + esc( i18n.colFormPlugin ) + '</th><td>' + esc( d.form_type ) + '</td></tr>';
    1437                                 html += '<tr><th scope="row">' + esc( i18n.colDestination ) + '</th><td>' + esc( d.app ) + ( d.module ? ' \u2014 ' + esc( d.module ) : '' ) + '</td></tr>';
     3403                                html += '<tr><th scope="row">' + esc( i18n.colDestination ) + '</th><td>' + esc( d.app ) + ( d.module ? ' - ' + esc( d.module ) : '' ) + '</td></tr>';
    14383404                                html += '<tr><th scope="row">' + esc( i18n.colStatus ) + '</th><td>' + ( d.enabled ? '<span class="form2zoho-pill form2zoho-pill-ok">' + esc( i18n.enabled ) + '</span>' : '<span class="form2zoho-pill form2zoho-pill-bad">' + esc( i18n.disabled ) + '</span>' ) + '</td></tr>';
    14393405                                html += '<tr><th scope="row">' + esc( i18n.colMapped ) + '</th><td>' + esc( d.mapping_count ) + '</td></tr>';
    … …  
    14523418                var $modal = $( '#form2zoho-timeline-modal' );
    14533419                var $body = $modal.find( '.form2zoho-timeline' ).html( '<p>' + esc( i18n.loading ) + '</p>' );
    1454                 $modal.find( '.form2zoho-modal-title' ).text( title + ' \u2014 ' + i18n.timeline );
     3420                $modal.find( '.form2zoho-modal-title' ).text( i18n.timeline + ': ' + title );
    14553421                openModal( $modal );
    14563422                post( 'form2zoho_feed_timeline', { feed_id: id } )
    … …  
    14713437                                        html += '<span class="form2zoho-timeline-dot"></span>';
    14723438                                        html += '<div class="form2zoho-timeline-content">';
    1473                                         html += '<div class="form2zoho-timeline-meta"><strong>' + esc( row.created_at ) + '</strong> UTC</div>';
     3439                                        html += '<div class="form2zoho-timeline-meta"><strong>' + esc( row.created_at ) + '</strong> ' + esc( res.data.timezone || '' ) + '</div>';
    14743440                                        html += '<div>' + esc( row.message ) + '</div>';
    14753441                                        // No deep link for Campaigns / Marketing Automation — Zoho
    … …  
    15023468                                                html += '</div>';
    15033469                                        }
     3470
     3471                                        // The same JSON the Logs tab hides behind its own Details
     3472                                        // button, and the same markup, so the copy button and the
     3473                                        // <pre> styling are the ones that already exist.
     3474                                        //
     3475                                        // The timeline is where somebody goes when a feed is
     3476                                        // misbehaving, and it was showing the outcome without any
     3477                                        // of the context that explains it: which field names the
     3478                                        // form actually submitted, how many attempts it took,
     3479                                        // whether the failure was worth retrying. All of that was
     3480                                        // already in the response — format_row() sends it — and
     3481                                        // reading it meant leaving for the Logs tab and finding
     3482                                        // the row again by timestamp.
     3483                                        if ( row.context ) {
     3484                                                html += '<div class="form2zoho-timeline-details">';
     3485                                                html += '<button type="button" class="button-link form2zoho-timeline-toggle" aria-expanded="false">' + esc( i18n.details ) + '</button>';
     3486                                                html += '<div class="form2zoho-detail-wrap" hidden>';
     3487                                                html += '<button type="button" class="button form2zoho-copy" title="' + esc( i18n.copyToClipboard ) + '">' +
     3488                                                        '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' +
     3489                                                        '<span>' + esc( i18n.copy ) + '</span></button>';
     3490                                                html += '<pre class="form2zoho-json">' + esc( row.context ) + '</pre>';
     3491                                                html += '</div></div>';
     3492                                        }
     3493
    15043494                                        html += '</div></li>';
    15053495                                } );
    … …  
    16733663                        return 'is-success';
    16743664                }
    1675                 if ( 'suppressed' === status ) {
     3665                if ( 'suppressed' === status || 'skipped' === status ) {
    16763666                        return 'is-warning';
    16773667                }
    … …  
    16983688                var show = $( this ).is( ':checked' );
    16993689                $( '.form2zoho-integrations-table [data-col="' + col + '"]' ).toggleClass( 'form2zoho-col-hidden', ! show );
     3690
     3691                // Remembered for next time. The table is already showing the new
     3692                // state, so nothing here waits for or reacts to the answer: a failed
     3693                // save costs the preference, not the click that was just made.
     3694                var hidden = $( '.form2zoho-colvis-input' ).not( ':checked' ).map( function () {
     3695                        return $( this ).data( 'col' );
     3696                } ).get();
     3697
     3698                // An empty list still has to be sent, or switching the last hidden
     3699                // column back on would leave the old list stored and the column would
     3700                // come back hidden on the next load.
     3701                post( 'form2zoho_save_columns', { 'hidden[]': hidden.length ? hidden : [ '' ] } );
    17003702        } );
    17013703
    … …  
    17703772                                }, 1500 );
    17713773                        } else {
    1772                                 var originalTitle = $btn.attr( 'title' );
    1773                                 $btn.addClass( 'is-copied' ).attr( 'title', i18n.copied );
    1774                                 setTimeout( function () {
    1775                                         $btn.removeClass( 'is-copied' ).attr( 'title', originalTitle );
    1776                                 }, 1500 );
     3774                                // An icon button: the stylesheet swaps its copy icon for a tick
     3775                                // while .is-copied is on. The title is remembered once, and a
     3776                                // second click restarts the timer, so a quick double click can
     3777                                // neither end the tick early nor leave the title reading "Copied".
     3778                                if ( undefined === $btn.data( 'f2zTitle' ) ) {
     3779                                        $btn.data( 'f2zTitle', $btn.attr( 'title' ) || '' );
     3780                                }
     3781                                clearTimeout( $btn.data( 'f2zCopiedTimer' ) );
     3782                                $btn.addClass( 'is-copied' ).attr( 'title', i18n.copied || $btn.data( 'f2zTitle' ) );
     3783                                $btn.data( 'f2zCopiedTimer', setTimeout( function () {
     3784                                        $btn.removeClass( 'is-copied' ).attr( 'title', $btn.data( 'f2zTitle' ) );
     3785                                }, 2000 ) );
    17773786                        }
    17783787                };
    … …  
    18413850        }
    18423851
    1843         /* ---- Feed editor: tabs ---- */
    1844 
    1845         function activateTab( tab ) {
    1846                 $( '.form2zoho-tab-btn' ).removeClass( 'is-active' );
    1847                 $( '.form2zoho-tab-btn[data-tab="' + tab + '"]' ).addClass( 'is-active' );
    1848                 $( '.form2zoho-tab-panel' ).removeClass( 'is-active' );
    1849                 $( '.form2zoho-tab-panel[data-tab-panel="' + tab + '"]' ).addClass( 'is-active' );
    1850         }
    1851 
    1852         $( document ).on( 'click.f2zNav', '.form2zoho-tab-btn', function () {
    1853                 activateTab( $( this ).data( 'tab' ) );
    1854         } );
    1855 
    1856         $( document ).on( 'click.f2zNav', '.form2zoho-tab-next', function () {
    1857                 var $panel = $( this ).closest( '.form2zoho-tab-panel' );
    1858                 var valid = true;
    1859                 $panel.find( 'input, select, textarea' ).each( function () {
    1860                         if ( this.willValidate && ! this.checkValidity() ) {
    1861                                 this.reportValidity();
    1862                                 valid = false;
     3852        /* ---- Feed editor: steps ----
     3853         *
     3854         * The editor is a four-step sequence, not a set of tabs. Each step is
     3855         * only answerable once the one before it has been — the trigger decides
     3856         * which forms exist, the form decides which fields exist, the Zoho module
     3857         * decides what those fields can map to — so the navigation enforces the
     3858         * order forwards and leaves it open backwards.
     3859         */
     3860
     3861        function stepKeys() {
     3862                return $( '#f2z-feed-editor .form2zoho-step' ).map( function () {
     3863                        return $( this ).data( 'step' );
     3864                } ).get();
     3865        }
     3866
     3867        /* How far into the sequence anyone has actually got.
     3868         *
     3869         * The rail at the top can go back to any step already answered, and to
     3870         * none that is not. Without this it would be a way around the very order
     3871         * the Continue buttons exist to enforce — the mapping step cannot be
     3872         * filled in before a module has been chosen, so it must not be reachable
     3873         * before then either. It only ever grows, so stepping back to change an
     3874         * earlier answer does not take the later steps away again. */
     3875        var furthestStep = 0;
     3876
     3877        function showStep( step ) {
     3878                var $steps = $( '#f2z-feed-editor .form2zoho-step' );
     3879                var keys = stepKeys();
     3880                var at = $.inArray( step, keys );
     3881
     3882                if ( at < 0 ) {
     3883                        return;
     3884                }
     3885
     3886                $steps.removeClass( 'is-active' );
     3887                $steps.filter( '[data-step="' + step + '"]' ).addClass( 'is-active' );
     3888
     3889                if ( at > furthestStep ) {
     3890                        furthestStep = at;
     3891                }
     3892
     3893                syncStepper( keys, at );
     3894        }
     3895
     3896        /**
     3897         * Redraw the rail: which step is here, which are behind, which are shut.
     3898         *
     3899         * `is-done` rather than only `is-current`, because "three of these four
     3900         * questions are answered" is the thing a person wants to know on arriving
     3901         * back at a half-finished editor, and a single highlight cannot say it.
     3902         *
     3903         * The disabled attribute — not a class — is what actually stops a forward
     3904         * jump, so it holds for the keyboard and for assistive technology, not
     3905         * just for the pointer.
     3906         *
     3907         * @param {Array}  keys Step names in order.
     3908         * @param {number} at   Index of the step now on screen.
     3909         */
     3910        function syncStepper( keys, at ) {
     3911                $( '#f2z-feed-editor .form2zoho-stepper__item' ).each( function ( i ) {
     3912                        var $item = $( this );
     3913
     3914                        $item.toggleClass( 'is-current', i === at )
     3915                                .toggleClass( 'is-done', i < at );
     3916
     3917                        $item.find( '.form2zoho-stepper__btn' )
     3918                                .prop( 'disabled', i > furthestStep || ( 'trigger' === keys[ i ] && triggerLocked() ) )
     3919                                .attr( 'aria-current', i === at ? 'step' : null );
     3920                } );
     3921        }
     3922
     3923        /**
     3924         * Write the browser's own validation message under the field it belongs to.
     3925         *
     3926         * The native bubble says the right thing and says it in the right place,
     3927         * but it is gone on the next click — so on a four-step form the reason a
     3928         * step will not advance can disappear before the person has looked away
     3929         * from the button they pressed. This leaves the same sentence in the page,
     3930         * under the control, until the control is fixed.
     3931         *
     3932         * The text is `validationMessage`, never a string of ours: the browser
     3933         * already knows whether the field is empty, too short or a malformed
     3934         * email, and it says so in the user's own language.
     3935         *
     3936         * @param {Element} el The control that failed.
     3937         */
     3938        function setFieldError( el ) {
     3939                var $c = $( el );
     3940                var $field = $c.closest( '.form2zoho-field' );
     3941
     3942                if ( ! $field.length ) {
     3943                        return;
     3944                }
     3945
     3946                var $msg = $field.children( '.form2zoho-field__error' );
     3947
     3948                if ( ! $msg.length ) {
     3949                        // role="alert" so it is announced when it appears. It is created
     3950                        // on demand rather than sitting empty in the markup, because an
     3951                        // empty live region that gains text is announced; one that is
     3952                        // merely unhidden is not, reliably.
     3953                        $msg = $( '<p class="form2zoho-field__error" role="alert"></p>' );
     3954                        if ( el.id ) {
     3955                                $msg.attr( 'id', el.id + '-error' );
     3956                        }
     3957                        $field.append( $msg );
     3958                }
     3959
     3960                $msg.text( el.validationMessage );
     3961                $c.addClass( 'is-invalid' ).attr( 'aria-invalid', 'true' );
     3962
     3963                if ( el.id ) {
     3964                        $c.attr( 'aria-describedby', el.id + '-error' );
     3965                }
     3966        }
     3967
     3968        /** Take the message and the invalid styling back off. */
     3969        function clearFieldError( el ) {
     3970                var $c = $( el );
     3971                var $field = $c.closest( '.form2zoho-field' );
     3972
     3973                $c.removeClass( 'is-invalid' ).removeAttr( 'aria-invalid' );
     3974
     3975                // Only ours. This runs over every valid control on a step, so an
     3976                // aria-describedby pointing at a help paragraph rather than at our
     3977                // error line must survive it.
     3978                if ( el.id && $c.attr( 'aria-describedby' ) === el.id + '-error' ) {
     3979                        $c.removeAttr( 'aria-describedby' );
     3980                }
     3981
     3982                if ( ! $field.length ) {
     3983                        return;
     3984                }
     3985
     3986                // Only the message this control put there. Two controls can share one
     3987                // field wrapper — the Module picker sits beside the hidden input that
     3988                // carries its label — and taking away every message in the wrapper
     3989                // meant the hidden input's turn through the loop below deleted the
     3990                // picker's, since a hidden input never validates and so always lands
     3991                // in the clearing branch. What was left was a red border with nothing
     3992                // beside it saying why, on the one field most likely to be empty.
     3993                $field.children( '.form2zoho-field__error' ).filter( function () {
     3994                        return ! el.id || this.id === el.id + '-error';
     3995                } ).remove();
     3996        }
     3997
     3998        /** Whether every constrained control on one step passes the browser's own validation. */
     3999        function stepIsValid( step ) {
     4000                var first = null;
     4001
     4002                $( '#f2z-feed-editor .form2zoho-step[data-step="' + step + '"]' )
     4003                        .find( 'input, select, textarea' )
     4004                        .each( function () {
     4005                                // .validity.valid, never .checkValidity(): the method fires an
     4006                                // `invalid` event on every control it rejects, and the capture
     4007                                // listener at the foot of this file answers that event by
     4008                                // showing the control's step. Asking a question would then
     4009                                // move the page — so on load, before anyone had typed a
     4010                                // character, the editor opened on step two with "Please fill
     4011                                // out this field" already under the title box. The property
     4012                                // reads the same state and fires nothing.
     4013                                if ( this.willValidate && ! this.validity.valid ) {
     4014                                        setFieldError( this );
     4015
     4016                                        // Every offender is marked, but only the first is
     4017                                        // reported: the browser shows one bubble at a time, and
     4018                                        // pointing it at the last field on the step would send
     4019                                        // someone to the bottom of it to fix the top.
     4020                                        if ( ! first ) {
     4021                                                first = this;
     4022                                        }
     4023                                } else {
     4024                                        clearFieldError( this );
     4025                                }
     4026                        } );
     4027
     4028                if ( first ) {
     4029                        // Reported, not just counted: a step can be scrolled past the
     4030                        // failing control, and the browser's own bubble is the thing that
     4031                        // points at it.
     4032                        showStep( step );
     4033                        first.reportValidity();
     4034                        return false;
     4035                }
     4036
     4037                return true;
     4038        }
     4039
     4040        // The moment it is fixed, not the next time Continue is pressed. Delegated
     4041        // from document so it survives the client-side page swap in admin-nav.js.
     4042        $( document ).on( 'input.f2zNav change.f2zNav', '#f2z-feed-editor .is-invalid', function () {
     4043                if ( this.willValidate && this.validity.valid ) {
     4044                        clearFieldError( this );
     4045                }
     4046        } );
     4047
     4048        /*
     4049         * A mark means "you pressed Continue and this was wrong". Change the
     4050         * destination and that verdict was about a different question: which
     4051         * fields this step even asks for depends on the app, and one of them
     4052         * stops being required altogether when the destination needs no account.
     4053         * So the marks come off, and come back on the next Continue if they are
     4054         * still deserved.
     4055         *
     4056         * Without this, marking Module empty while on a destination that needs no
     4057         * account, then switching to a Zoho app, left the Module picker red on a
     4058         * step where nothing had been submitted yet — and left it red alone, with
     4059         * the equally empty Connected account beside it unmarked, because that one
     4060         * had not been required at the time.
     4061         */
     4062        $( document ).on( 'change.f2zNav', '#f2z-feed-editor #f2z_intgr', function () {
     4063                $( '#f2z-feed-editor .form2zoho-step[data-step="connection"]' )
     4064                        .find( 'input, select, textarea' )
     4065                        .each( function () {
     4066                                clearFieldError( this );
     4067                        } );
     4068        } );
     4069
     4070        /**
     4071         * Move to a step, checking everything between here and there.
     4072         *
     4073         * Backwards is always allowed — going back to change an earlier answer is
     4074         * the whole reason the progress bar is clickable. Forwards has to pass
     4075         * every step it skips over, or the last two steps could be reached with
     4076         * no form chosen and nothing to map.
     4077         */
     4078        function goToStep( step ) {
     4079                var keys = stepKeys();
     4080                var from = $.inArray( $( '#f2z-feed-editor .form2zoho-step.is-active' ).data( 'step' ), keys );
     4081                var to = $.inArray( step, keys );
     4082                var i;
     4083
     4084                // A saved feed's trigger is fixed, so its grid is not a step it has.
     4085                if ( to < 0 || ( 'trigger' === step && triggerLocked() ) ) {
     4086                        return;
     4087                }
     4088
     4089                for ( i = from; i < to; i++ ) {
     4090                        if ( ! stepIsValid( keys[ i ] ) ) {
     4091                                return;
     4092                        }
     4093                }
     4094
     4095                showStep( step );
     4096
     4097                // The whole content of the panel has just been replaced. Sighted users
     4098                // see that; a screen-reader user is still sitting on the button they
     4099                // pressed, on a step that is no longer on screen, unless focus moves.
     4100                // The panel itself takes it — it carries role="group" and is labelled
     4101                // by its own heading, so focusing it announces which step this is and
     4102                // what is in it. preventScroll because the animation below owns the
     4103                // scrolling; without it the browser jumps first and the page then
     4104                // slides, twice.
     4105                var panel = document.querySelector(
     4106                        '#f2z-feed-editor .form2zoho-step[data-step="' + step + '"]'
     4107                );
     4108                if ( panel ) {
     4109                        try {
     4110                                panel.focus( { preventScroll: true } );
     4111                        } catch ( e ) {
     4112                                panel.focus();
     4113                        }
     4114                }
     4115
     4116                // Back to the top of the editor. Each step is a screen of its own, and
     4117                // arriving halfway down one because the last one was longer is how a
     4118                // heading gets missed.
     4119                var $top = $( '#f2z-feed-editor' ).closest( '.form2zoho-editor-pop' );
     4120                if ( $top.length ) {
     4121                        $( 'html, body' ).animate( { scrollTop: $top.offset().top - 40 }, 200 );
     4122                }
     4123        }
     4124
     4125        /**
     4126         * Redraw step two's header card from whichever tile is selected.
     4127         *
     4128         * Step two never shows the grid, so this card is the only place the answer
     4129         * to step one is visible once you have left it. Copied from the tile
     4130         * rather than looked up: the tile already holds the icon and the label the
     4131         * person just clicked, and a second source for them is a second thing to
     4132         * keep in step.
     4133         */
     4134        function syncChosenTrigger() {
     4135                var $tile = $( '.form2zoho-source-tile.is-active' ).first();
     4136                var $card = $( '.form2zoho-chosen-card' );
     4137
     4138                // A locked trigger's card is drawn by PHP, including for a source
     4139                // whose tile cannot be selected, and nothing here can change it.
     4140                if ( ! $card.length || triggerLocked() ) {
     4141                        return;
     4142                }
     4143
     4144                if ( ! $tile.length ) {
     4145                        $card.attr( 'data-source', '' ).find( '.form2zoho-chosen-icon' ).empty();
     4146                        $card.find( '.form2zoho-chosen-label' ).text( '' );
     4147                        return;
     4148                }
     4149
     4150                var $img = $tile.find( '.form2zoho-source-icon img' );
     4151
     4152                $card.attr( 'data-source', $tile.data( 'source' ) );
     4153                $card.find( '.form2zoho-chosen-icon' ).html(
     4154                        $img.length ? $( '<img>' ).attr( 'src', $img.attr( 'src' ) ).attr( 'alt', '' ) : ''
     4155                );
     4156                $card.find( '.form2zoho-chosen-label' ).text( $tile.find( '.form2zoho-source-label' ).text() );
     4157
     4158                syncTriggerDocs( $tile.data( 'source' ) );
     4159                syncFormWording( $tile.data( 'source' ) );
     4160        }
     4161
     4162        /**
     4163         * Whether a trigger is something that happens on the site rather than a
     4164         * form plugin. Every source the server did not list as a form is one.
     4165         *
     4166         * @param {string} source Source key; empty while none is chosen.
     4167         * @return {boolean}
     4168         */
     4169        function isEventSource( source ) {
     4170                return !! source && -1 === $.inArray( source, Form2Zoho.formSources || [] );
     4171        }
     4172
     4173        /**
     4174         * Word step two's picker for the chosen trigger: "Form" for a form
     4175         * plugin, "Event" for anything else. The page printed it for the trigger
     4176         * it was rendered with; this keeps it right when that changes.
     4177         *
     4178         * @param {string} source The chosen source key.
     4179         */
     4180        function syncFormWording( source ) {
     4181                var t = Form2Zoho.i18n || {};
     4182                var event = isEventSource( source );
     4183                var reload = event ? t.reloadEvents : t.reloadForms;
     4184
     4185                $( '.form2zoho-form-word' ).text( ( event ? t.eventLabel : t.formLabel ) || '' );
     4186                $( '.form2zoho-details-help' ).text( ( event ? t.detailsEvent : t.detailsForm ) || '' );
     4187                $( '.form2zoho-reload-forms' ).attr( { 'aria-label': reload, title: reload } );
     4188
     4189                // Not while it is saying something is wrong.
     4190                $( '.form2zoho-form-help' ).not( '.form2zoho-form-help-warn' ).text( ( event ? t.eventHelp : t.formHelp ) || '' );
     4191        }
     4192
     4193        /**
     4194         * Point step two's guide at the chosen trigger, or hide it.
     4195         *
     4196         * The link is rendered by PHP for whichever trigger the step opened with,
     4197         * and the trigger can then be changed without a page load — so this is the
     4198         * half that keeps the two in step. A source with no page of its own, which
     4199         * is every Pro trigger, hides the link rather than leaving it pointing at
     4200         * the last one that had one.
     4201         *
     4202         * @param {string} source The chosen source key.
     4203         */
     4204        function syncTriggerDocs( source ) {
     4205                var $link = $( '.form2zoho-trigger-docs' );
     4206
     4207                if ( ! $link.length ) {
     4208                        return;
     4209                }
     4210
     4211                var docs = ( Form2Zoho.triggerDocs || {} )[ source ];
     4212
     4213                if ( ! docs || ! docs.url ) {
     4214                        $link.prop( 'hidden', true ).attr( 'href', '' );
     4215                        return;
     4216                }
     4217
     4218                $link
     4219                        .attr( 'href', docs.url )
     4220                        .attr( 'aria-label', ( Form2Zoho.i18n.docsNewTab || '%s' ).replace( '%s', docs.label ) )
     4221                        .prop( 'hidden', false )
     4222                        .find( 'span' ).text( docs.label );
     4223        }
     4224
     4225        /**
     4226         * Point the destination step's guide at the chosen Zoho app, or hide it.
     4227         *
     4228         * The twin of syncTriggerDocs(), for the other picker that changes what
     4229         * the person is being asked to do. A destination an add-on registers has
     4230         * no row unless a site filters one in, and then the link hides rather
     4231         * than pointing at whichever Zoho app was chosen before it.
     4232         */
     4233        function syncAppDocs() {
     4234                var $link = $( '.form2zoho-app-docs' );
     4235
     4236                if ( ! $link.length ) {
     4237                        return;
     4238                }
     4239
     4240                var docs = ( Form2Zoho.appDocs || {} )[ $( '#f2z_intgr' ).val() ];
     4241
     4242                if ( ! docs || ! docs.url ) {
     4243                        $link.prop( 'hidden', true ).attr( 'href', '' );
     4244                        return;
     4245                }
     4246
     4247                // The visible text stays "Docs"; the app's name is in the accessible name.
     4248                $link
     4249                        .attr( 'href', docs.url )
     4250                        .attr( 'aria-label', ( Form2Zoho.i18n.docsNewTab || '%s' ).replace( '%s', docs.label ) )
     4251                        .prop( 'hidden', false );
     4252        }
     4253
     4254        // Delegated, and bound once: the editor is swapped in and out of the page
     4255        // by admin-nav.js without this file being re-run, so a handler bound to
     4256        // the select itself would be bound to a node that was thrown away.
     4257        $( document ).on( 'change.f2zDocs', '#f2z-feed-editor #f2z_intgr', syncAppDocs );
     4258
     4259        /** Whether the editor holds a saved feed, whose trigger cannot be changed. */
     4260        function triggerLocked() {
     4261                return '1' === $( '#f2z-feed-editor' ).attr( 'data-trigger-locked' );
     4262        }
     4263
     4264        /** The selected tile, or an empty set when nothing is chosen yet. */
     4265        function activeTile() {
     4266                return $( '.form2zoho-source-tile.is-active' ).first();
     4267        }
     4268
     4269        /** Whether the chosen trigger's plugin was running when this page rendered. */
     4270        function triggerIsAvailable() {
     4271                var $tile = activeTile();
     4272
     4273                // No tile selected is not "unavailable" — it is "not asked yet", and
     4274                // the step has its own reason to be empty then.
     4275                return ! $tile.length || 1 === parseInt( $tile.attr( 'data-available' ), 10 );
     4276        }
     4277
     4278        /** "<name> is not installed or activated.", built from the tile's own label. */
     4279        function unavailableMessage() {
     4280                var label = activeTile().find( '.form2zoho-source-label' ).text();
     4281
     4282                return ( i18n.notInstalled || '%s is not installed or activated.' ).replace( '%s', label );
     4283        }
     4284
     4285        /**
     4286         * Show step two either as a form to fill in, or as "that plugin is not
     4287         * running".
     4288         *
     4289         * @param {boolean} available Whether the chosen trigger's plugin is there.
     4290         * @param {string}  message   What to say when it is not.
     4291         */
     4292        function setTriggerAvailable( available, message ) {
     4293                var $step = $( '#f2z-feed-editor .form2zoho-step[data-step="details"]' );
     4294
     4295                if ( ! $step.length ) {
     4296                        return;
     4297                }
     4298
     4299                $step.find( '.form2zoho-trigger-unavailable' )
     4300                        .text( message || '' )
     4301                        .prop( 'hidden', !! available );
     4302
     4303                // All hidden together: the heading that asks for a feed title, the
     4304                // fields that would take one, and the button that would carry someone
     4305                // past them. Leaving "Name this feed" above "that plugin is not
     4306                // running" asks for something and refuses it in the same breath.
     4307                $step.find( '.form2zoho-step-head, .form2zoho-step-body, .form2zoho-step-footer' )
     4308                        .prop( 'hidden', ! available );
     4309        }
     4310
     4311        /**
     4312         * Step two's Next is off until both of its fields are answered.
     4313         *
     4314         * The browser's own validity, not a length test: `required` is already
     4315         * declared on both controls, so the browser is the one thing that cannot
     4316         * disagree with what the form will do when it is submitted. Read as a
     4317         * property rather than through checkValidity(), which fires `invalid` —
     4318         * see the note in stepIsValid().
     4319         */
     4320        function syncDetailsNext() {
     4321                var $step = $( '#f2z-feed-editor .form2zoho-step[data-step="details"]' );
     4322                var ready = true;
     4323
     4324                if ( ! $step.length ) {
     4325                        return;
     4326                }
     4327
     4328                $step.find( 'input, select, textarea' ).each( function () {
     4329                        if ( this.willValidate && ! this.validity.valid ) {
     4330                                ready = false;
    18634331                                return false;
    18644332                        }
    18654333                } );
    1866                 if ( ! valid ) {
     4334
     4335                $step.find( '.form2zoho-step-next' ).prop( 'disabled', ! ready );
     4336        }
     4337
     4338        // Every control in the editor, not only step two's own. Changing the
     4339        // trigger clears the Form select on the spot — the form chosen for the old
     4340        // trigger does not exist under the new one — and that clearing happens
     4341        // before the replacement list has been asked for, let alone arrived. Watch
     4342        // only step two and the button stays live over an empty Form for as long
     4343        // as the request takes.
     4344        $( document ).on(
     4345                'input.f2zNav change.f2zNav',
     4346                '#f2z-feed-editor input, #f2z-feed-editor select, #f2z-feed-editor textarea',
     4347                syncDetailsNext
     4348        );
     4349
     4350        // The forms list arrives by ajax well after the page does, so the gate has
     4351        // to be re-checked when it lands, not only when someone types.
     4352        $( document ).on( 'f2z:formsLoaded.f2zNav', syncDetailsNext );
     4353
     4354        // "Change" on the summary card is the way back to the grid.
     4355        $( document ).on( 'click.f2zNav', '.form2zoho-chosen-clear', function () {
     4356                goToStep( 'trigger' );
     4357        } );
     4358
     4359        // PHP renders both of these correctly for the feed being edited; running
     4360        // them once on ready is what covers the case where it could not — a saved
     4361        // feed whose form plugin has since been deactivated, so no tile is active.
     4362        $( syncChosenTrigger );
     4363        $( syncDetailsNext );
     4364
     4365        /* Open the whole rail for a feed that already exists.
     4366         *
     4367         * furthestStep is there to stop someone reaching a question before the
     4368         * answer it depends on has been given. On a saved feed every one of them
     4369         * has been given — that is what saving it meant — so starting at step one
     4370         * with steps two to four shut would make editing an existing feed slower
     4371         * than creating a new one, four clicks to reach the mapping. */
     4372        $( function () {
     4373                var $editor = $( '#f2z-feed-editor' );
     4374
     4375                if ( ! $editor.length ) {
    18674376                        return;
    18684377                }
    1869                 activateTab( $( this ).data( 'next' ) );
    1870                 $( 'html, body' ).animate( { scrollTop: $( '.form2zoho-tabs' ).offset().top - 40 }, 200 );
    1871         } );
    1872 
    1873         $( document ).on( 'click.f2zNav', '.form2zoho-tab-back', function () {
    1874                 activateTab( $( this ).data( 'back' ) );
    1875                 $( 'html, body' ).animate( { scrollTop: $( '.form2zoho-tabs' ).offset().top - 40 }, 200 );
     4378
     4379                if ( $editor.find( 'input[name="feed_id"]' ).val() ) {
     4380                        furthestStep = stepKeys().length - 1;
     4381                }
     4382
     4383                // Draws the rail for whichever step is open — showStep() is what keeps
     4384                // it in step afterwards, and nothing has called it yet.
     4385                showStep( $editor.find( '.form2zoho-step.is-active' ).data( 'step' ) );
     4386        } );
     4387
     4388        /* ---- Feed editor: filtering the trigger grid ---- */
     4389
     4390        $( document ).on( 'input.f2zNav', '#f2z_trigger_search', function () {
     4391                var needle = $.trim( this.value ).toLowerCase();
     4392                var shown = 0;
     4393
     4394                $( '.form2zoho-source-tile' ).each( function () {
     4395                        var $tile = $( this );
     4396                        // The label is what people search by; the key catches the ones
     4397                        // whose product name and tile label differ ("wpforms" for
     4398                        // "WPForms Lite", "wcbookings" for "WooCommerce Bookings").
     4399                        var hay = ( $tile.find( '.form2zoho-source-label' ).text() + ' ' + ( $tile.data( 'source' ) || '' ) ).toLowerCase();
     4400                        var hit = '' === needle || hay.indexOf( needle ) !== -1;
     4401
     4402                        $tile.toggleClass( 'is-filtered-out', ! hit );
     4403
     4404                        if ( hit ) {
     4405                                shown++;
     4406                        }
     4407                } );
     4408
     4409                $( '.form2zoho-trigger-none' ).prop( 'hidden', 0 !== shown );
     4410        } );
     4411
     4412        // The rail. Its forward markers are disabled until their step is reached,
     4413        // so this can only ever go back — through goToStep(), the same door the
     4414        // Back buttons use.
     4415        $( document ).on( 'click.f2zNav', '.form2zoho-stepper__btn', function () {
     4416                goToStep( $( this ).data( 'goto' ) );
     4417        } );
     4418
     4419        $( document ).on( 'click.f2zNav', '.form2zoho-step-next', function () {
     4420                goToStep( $( this ).data( 'next' ) );
     4421        } );
     4422
     4423        $( document ).on( 'click.f2zNav', '.form2zoho-step-back', function () {
     4424                goToStep( $( this ).data( 'back' ) );
     4425        } );
     4426
     4427        /* Enter in a field, before the last step, means Next — never Save.
     4428         *
     4429         * Left alone, Enter submits the whole form. The browser then points at the
     4430         * first empty required field wherever it is, the invalid handler below
     4431         * opens that field's step, and a person on step two landed on step three
     4432         * with the Continue button they could not press still greyed out behind
     4433         * them. With every field filled it was worse: the feed saved half-built.
     4434         * So Enter does exactly what the step's own button would, and nothing
     4435         * when that button is disabled. */
     4436        $( document ).on( 'keydown.f2zNav', '#f2z-feed-editor', function ( e ) {
     4437                if ( 'Enter' !== e.key || e.isDefaultPrevented() || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.originalEvent && e.originalEvent.isComposing ) {
     4438                        return;
     4439                }
     4440
     4441                // A textarea takes the newline, and a button or link its own press.
     4442                if ( ! $( e.target ).is( 'input' ) || $( e.target ).is( ':button, :submit, :reset, :checkbox, :radio, :file' ) ) {
     4443                        return;
     4444                }
     4445
     4446                var $step = $( e.target ).closest( '.form2zoho-step' );
     4447                var keys = stepKeys();
     4448
     4449                // The last step has no Next; Enter there is the save it always was.
     4450                if ( ! $step.length || $.inArray( $step.data( 'step' ), keys ) === keys.length - 1 ) {
     4451                        return;
     4452                }
     4453
     4454                e.preventDefault();
     4455
     4456                var $next = $step.find( '.form2zoho-step-next' ).first();
     4457
     4458                if ( $next.length && ! $next.prop( 'disabled' ) ) {
     4459                        $next.trigger( 'click' );
     4460                } else if ( $next.length ) {
     4461                        // Say what is missing, the way pressing Continue would have.
     4462                        stepIsValid( $step.data( 'step' ) );
     4463                }
    18764464        } );
    18774465
    … …  
    18944482        }
    18954483
     4484        // Enable whichever of Form field / Static value the row's Source names, and
     4485        // disable the other, so a row cannot be left saying two contradictory
     4486        // things at once.
     4487        //
     4488        // Each control is paired with a hidden input of the same name and exactly
     4489        // one of the pair is enabled at a time. That pairing is load-bearing: a
     4490        // browser does not submit a disabled control, and collect_mapping() in
     4491        // FeedsPage.php reads map_zoho/map_source/map_form/map_static *by index*.
     4492        // Disabling a control without its twin taking over would drop one entry
     4493        // and silently pair every later row with the wrong value.
     4494        function syncRowSource( $row ) {
     4495                var src = $row.find( '.f2z-map-source' ).val();
     4496
     4497                // "Combine fields" is the row that uses both controls at once: the
     4498                // text box holds the template, and the Form field select is how a
     4499                // {placeholder} is put into it. Exactly one of each name is still
     4500                // enabled per row, which is what keeps the parallel arrays in
     4501                // collect_mapping() lined up.
     4502                var usesText = 'static' === src || 'template' === src;
     4503                var usesForm = 'static' !== src;
     4504
     4505                $row.find( '.f2z-map-form' ).prop( 'disabled', ! usesForm );
     4506                $row.find( '.f2z-map-form-slot' ).prop( 'disabled', usesForm );
     4507                $row.find( '.f2z-map-static' ).prop( 'disabled', ! usesText );
     4508                $row.find( '.f2z-map-static-slot' ).prop( 'disabled', usesText );
     4509
     4510                // The Form field select sits hidden inside a searchable box, and the
     4511                // box is what has to look and act disabled.
     4512                comboSyncDisabled( $row.find( '.f2z-map-form' ) );
     4513
     4514                // The chips belong to "Combine fields" only, and go with it.
     4515                renderTemplateTokens( $row );
     4516
     4517                var $box = $row.find( '.f2z-map-static' );
     4518                $box.attr( 'placeholder', 'template' === src ? ( i18n.templateHint || '' ) : '' );
     4519        }
     4520
    18964521        // Whether a mapping row actually carries something to send: a form field
    18974522        // picked, or a static value typed. Mirrors collect_mapping() in
    18984523        // FeedsPage.php, which drops rows that fail this on save.
    18994524        function rowIsFilled( $row ) {
    1900                 return 'static' === $row.find( '.f2z-map-source' ).val()
    1901                         ? '' !== ( $row.find( '.f2z-map-static' ).val() || '' ).trim()
    1902                         : '' !== ( $row.find( '.f2z-map-form' ).val() || '' );
     4525                var src = $row.find( '.f2z-map-source' ).val();
     4526
     4527                return 'field' === src
     4528                        ? '' !== ( $row.find( '.f2z-map-form' ).val() || '' )
     4529                        : '' !== ( $row.find( '.f2z-map-static' ).val() || '' ).trim();
    19034530        }
    19044531
    … …  
    19174544                return '' !== ( $row.find( '.f2z-map-zoho' ).val() || '' ) && rowIsFilled( $row );
    19184545        }
     4546
     4547        /*
     4548         * A static value checked against the kind of Zoho field it is aimed at.
     4549         *
     4550         * A static value is sent exactly as typed, so a Phone field given letters
     4551         * reached Zoho that way, and Zoho refused the record or stored the junk.
     4552         * The same rules run again on save in Support\StaticValue: keep the two in
     4553         * step. Only types with a shape Zoho enforces are listed; text, picklists
     4554         * and checkboxes are left alone, and "Combine fields" is not checked,
     4555         * because its {placeholders} are only filled at delivery.
     4556         */
     4557        var STATIC_KINDS = {
     4558                phone: 'phone',
     4559                email: 'email',
     4560                integer: 'integer',
     4561                bigint: 'integer',
     4562                double: 'decimal',
     4563                currency: 'decimal',
     4564                percent: 'decimal',
     4565                decimal: 'decimal',
     4566                date: 'date',
     4567                datetime: 'datetime',
     4568                website: 'url',
     4569                url: 'url'
     4570        };
     4571
     4572        function isCalendarDate( ymd ) {
     4573                var d = new Date( ymd + 'T00:00:00Z' );
     4574                return ! isNaN( d.getTime() ) && d.toISOString().slice( 0, 10 ) === ymd;
     4575        }
     4576
     4577        // The rule a value breaks for a field type, or '' when it is fine.
     4578        function staticValueProblem( ztype, value ) {
     4579                var kind = STATIC_KINDS[ String( ztype || '' ).toLowerCase() ];
     4580                var ok;
     4581
     4582                value = String( value || '' ).trim();
     4583                if ( ! kind || '' === value ) {
     4584                        return '';
     4585                }
     4586
     4587                switch ( kind ) {
     4588                        case 'phone':
     4589                                ok = /^\+?[0-9\s().\-]+$/.test( value ) && ( value.match( /[0-9]/g ) || [] ).length >= 3 && value.length <= 30;
     4590                                break;
     4591                        case 'email':
     4592                                ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( value );
     4593                                break;
     4594                        case 'integer':
     4595                                ok = /^-?[0-9]+$/.test( value );
     4596                                break;
     4597                        case 'decimal':
     4598                                ok = /^-?[0-9]+(\.[0-9]+)?$/.test( value );
     4599                                break;
     4600                        case 'date':
     4601                                ok = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test( value ) && isCalendarDate( value );
     4602                                break;
     4603                        case 'datetime':
     4604                                ok = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2})?(Z|[+\-][0-9]{2}:[0-9]{2})?$/.test( value ) && isCalendarDate( value.slice( 0, 10 ) );
     4605                                break;
     4606                        default:
     4607                                ok = /^(https?:[/][/])?[^\s/.]+(\.[^\s/.]+)+([/]\S*)?$/i.test( value );
     4608                }
     4609
     4610                return ok ? '' : kind;
     4611        }
     4612
     4613        function rowZohoLabel( $row ) {
     4614                var label = $.trim( $row.find( '.f2z-map-zoho option:selected' ).text() ).replace( /\s*\*$/, '' );
     4615                return label || $row.find( '.f2z-map-zoho' ).val() || '';
     4616        }
     4617
     4618        function rowStaticProblem( $row ) {
     4619                if ( 'static' !== $row.find( '.f2z-map-source' ).val() ) {
     4620                        return '';
     4621                }
     4622
     4623                var type = $row.find( '.f2z-map-zoho option:selected' ).attr( 'data-type' ) || $row.find( '.f2z-map-type' ).val() || '';
     4624
     4625                return staticValueProblem( type, $row.find( '.f2z-map-static' ).val() );
     4626        }
     4627
     4628        function staticValueMessage( kind, label ) {
     4629                var keys = { phone: 'staticPhone', email: 'staticEmail', integer: 'staticInteger', decimal: 'staticDecimal', date: 'staticDate', datetime: 'staticDatetime', url: 'staticUrl' };
     4630                return ( i18n[ keys[ kind ] ] || '%s' ).replace( '%s', label );
     4631        }
     4632
     4633        // Mark the box and say under it what the Zoho field wants there. Returns
     4634        // the message, or '' once the value is fine.
     4635        function showStaticProblem( $row ) {
     4636                var kind    = rowStaticProblem( $row );
     4637                var $box    = $row.find( '.f2z-map-static' );
     4638                var $msg    = $row.find( '.f2z-map-static-error' );
     4639                var message = kind ? staticValueMessage( kind, rowZohoLabel( $row ) ) : '';
     4640
     4641                $box.toggleClass( 'is-invalid', !! kind ).attr( 'aria-invalid', kind ? 'true' : null );
     4642
     4643                if ( ! kind ) {
     4644                        $msg.remove();
     4645                        return '';
     4646                }
     4647
     4648                if ( ! $msg.length ) {
     4649                        $msg = $( '<p class="f2z-map-static-error" role="alert"></p>' );
     4650                        $box.after( $msg );
     4651                }
     4652                $msg.text( message );
     4653
     4654                return message;
     4655        }
     4656
     4657        // Checked when the box is left rather than on every key, so a number is
     4658        // not scolded half-typed. Once flagged it is checked as it is typed, so the
     4659        // message goes the moment the value is right. A change of Zoho field or of
     4660        // Source changes the rule, so those check again too.
     4661        $( document ).on( 'change.f2zNav', '.form2zoho-map-rows .f2z-map-static', function () {
     4662                showStaticProblem( $( this ).closest( '.form2zoho-map-row' ) );
     4663        } );
     4664
     4665        $( document ).on( 'input.f2zNav', '.form2zoho-map-rows .f2z-map-static.is-invalid', function () {
     4666                showStaticProblem( $( this ).closest( '.form2zoho-map-row' ) );
     4667        } );
     4668
     4669        $( document ).on( 'change.f2zNav', '.form2zoho-map-rows .f2z-map-zoho, .form2zoho-map-rows .f2z-map-source', function () {
     4670                var $row = $( this ).closest( '.form2zoho-map-row' );
     4671                if ( '' !== ( $row.find( '.f2z-map-static' ).val() || '' ).trim() || $row.find( '.f2z-map-static' ).hasClass( 'is-invalid' ) ) {
     4672                        showStaticProblem( $row );
     4673                }
     4674        } );
    19194675
    19204676        // Block "Save feed" on three mapping problems, and point the person at
    … …  
    19294685        //      silent disappearance, and it is the easier mistake to make —
    19304686        //      "+ Add mapping row" leaves the new row blank by design.
     4687        //   4. A static value its Zoho field will not accept, such as letters in
     4688        //      a phone number. Zoho would refuse the record or store the junk.
    19314689        $( document ).on( 'submit.f2zNav', '#f2z-feed-editor', function ( e ) {
    19324690                var required = {};
    … …  
    19394697                var satisfied = {};
    19404698                var incomplete = [];
     4699                var invalid = [];
    19414700                var blank = 0;
    19424701                var $rows = $( '.form2zoho-map-row' );
    … …  
    19594718                        if ( rowIsFilled( $row ) ) {
    19604719                                satisfied[ zoho ] = true;
     4720                                var problem = showStaticProblem( $row );
     4721                                if ( problem ) {
     4722                                        invalid.push( problem );
     4723                                }
    19614724                                return;
    19624725                        }
    … …  
    19724735
    19734736                var $alert = $( '#f2z-mapping-alert' );
    1974                 if ( ! missing.length && ! incomplete.length && ! blank ) {
     4737                if ( ! missing.length && ! incomplete.length && ! blank && ! invalid.length ) {
    19754738                        $alert.prop( 'hidden', true );
    19764739                        return;
    … …  
    19884751                        parts.push( i18n.blankRows );
    19894752                }
     4753                if ( invalid.length ) {
     4754                        parts.push( invalid.join( ' ' ) );
     4755                }
    19904756                if ( missing.length ) {
    19914757                        parts.push( ( i18n.missingRequired ).replace( '%s', missing.join( ', ' ) ) );
    … …  
    19934759
    19944760                $alert.find( 'strong' ).text(
    1995                         ( incomplete.length || blank ) ? i18n.incompleteRowsTitle : i18n.missingRequiredTitle
     4761                        ( incomplete.length || blank ) ? i18n.incompleteRowsTitle : ( invalid.length ? i18n.staticInvalidTitle : i18n.missingRequiredTitle )
    19964762                );
    19974763                $alert.find( 'p' ).text( parts.join( ' ' ) );
    19984764                $alert.prop( 'hidden', false );
    19994765
    2000                 if ( typeof activateTab === 'function' ) {
    2001                         activateTab( 'mapping' );
    2002                 }
     4766                // The alert is inside the mapping step, so it is no use unless that
     4767                // step is the one on screen. showStep(), not goToStep(): this is a
     4768                // save that already failed on the mapping, and re-running the earlier
     4769                // steps' validation on the way there would hide that behind an
     4770                // unrelated bubble.
     4771                showStep( 'mapping' );
    20034772                $( 'html, body' ).animate( { scrollTop: $alert.offset().top - 40 }, 200 );
    20044773        } );
    … …  
    20194788        );
    20204789
    2021         // If a required field on a hidden tab fails validation, switch to that
    2022         // tab so the browser's native validation bubble points at something visible.
    2023         $( document ).on( 'invalid.f2zNav', '#f2z-feed-editor *', function () {
    2024                 var $panel = $( this ).closest( '.form2zoho-tab-panel' );
    2025                 if ( $panel.length && ! $panel.hasClass( 'is-active' ) ) {
    2026                         activateTab( $panel.data( 'tab-panel' ) );
     4790        /* A required field on a step that is not on screen still blocks the save.
     4791         * Show its step, so the browser's native bubble points at something the
     4792         * person can actually see — and leave the message under the field, which
     4793         * outlasts the bubble.
     4794         *
     4795         * Bound natively, on document, in the capture phase. `invalid` is one of
     4796         * the handful of DOM events declared non-bubbling, so a delegated jQuery
     4797         * handler never hears it however the selector is written; capture runs on
     4798         * the way down and therefore does. On document rather than on the form so
     4799         * it survives the client-side page swap in admin-nav.js, which replaces
     4800         * the whole wrap. */
     4801        // Native listeners have no namespace to clear, and admin-nav.js re-executes
     4802        // this whole file after every in-plugin navigation, so the previous one is
     4803        // removed by hand. Leaving them to stack would keep the *first* run's
     4804        // closure alive, and with it a furthestStep belonging to an editor that is
     4805        // no longer on the page.
     4806        if ( window.form2zohoInvalidHandler ) {
     4807                document.removeEventListener( 'invalid', window.form2zohoInvalidHandler, true );
     4808        }
     4809
     4810        window.form2zohoInvalidHandler = function ( e ) {
     4811                var el = e.target;
     4812
     4813                if ( ! el || ! el.closest || ! el.closest( '#f2z-feed-editor' ) ) {
     4814                        return;
     4815                }
     4816
     4817                setFieldError( el );
     4818
     4819                var step = el.closest( '.form2zoho-step' );
     4820
     4821                if ( ! step || step.classList.contains( 'is-active' ) ) {
     4822                        return;
     4823                }
     4824
     4825                // Back to an earlier step only. A field further on than the step on
     4826                // screen is one nobody has reached yet; opening its step would skip
     4827                // the checks the Next button makes on the way there.
     4828                var keys = stepKeys();
     4829                var at = $.inArray( $( '#f2z-feed-editor .form2zoho-step.is-active' ).data( 'step' ), keys );
     4830
     4831                if ( at < 0 || $.inArray( step.getAttribute( 'data-step' ), keys ) < at ) {
     4832                        showStep( step.getAttribute( 'data-step' ) );
     4833                }
     4834        };
     4835
     4836        document.addEventListener( 'invalid', window.form2zohoInvalidHandler, true );
     4837
     4838        /* ---- Connection dialog: the API console link follows the data centre ----
     4839         *
     4840         * Zoho runs a separate console per region, and a client registered in one
     4841         * does not exist in the others. Sending someone to the .com console when
     4842         * their account is on .eu shows them an empty console, which looks exactly
     4843         * like having lost their clients — so the link has to move with the
     4844         * picker. The map comes from PHP, which is where the hosts are written
     4845         * down, so this only chooses between them.
     4846         */
     4847        $( document ).on( 'change.f2zNav', '#form2zoho_data_center', function () {
     4848                var urls = ( window.Form2Zoho && Form2Zoho.consoleUrls ) || {};
     4849                var url = urls[ $( this ).val() ];
     4850
     4851                if ( url ) {
     4852                        $( '.form2zoho-console-link' ).attr( 'href', url );
    20274853                }
    20284854        } );
    … …  
    20934919        } );
    20944920
    2095         // "All applications" and the individual per-app checkboxes are mutually
    2096         // exclusive from the account's point of view (picking "all" already
    2097         // covers every app), so keep them from disagreeing with each other
    2098         // instead of letting the person submit a confusing mixed state.
     4921        /* ---- Success messages clear themselves ----
     4922         *
     4923         * "Feed saved." is the answer to a question nobody is still asking by the
     4924         * time they have read it, and it sat on the screen until it was clicked
     4925         * away — pushing the feed list down, and on a screen where saving happens
     4926         * repeatedly, stacking up as a row of stale confirmations.
     4927         *
     4928         * Only success. A warning about rows that were left out, or an error from
     4929         * Zoho, is the one thing on the page worth reading twice, and taking it
     4930         * away on a timer is how somebody saves a broken feed twice over.
     4931         *
     4932         * The timer is paused while the pointer is over the box or the focus is
     4933         * inside it, because both mean somebody is still dealing with it — the
     4934         * dismiss button is focusable, and a message that vanishes as it is being
     4935         * reached for is worse than one that stays.
     4936         */
     4937        $( function () {
     4938                $( '.form2zoho-alert-success.is-dismissible' ).each( function () {
     4939                        var $alert = $( this );
     4940                        var timer  = null;
     4941
     4942                        function go() {
     4943                                timer = window.setTimeout( function () {
     4944                                        $alert.fadeOut( 200, function () {
     4945                                                $( this ).remove();
     4946                                        } );
     4947                                }, NOTICE_LINGER );
     4948                        }
     4949
     4950                        function hold() {
     4951                                window.clearTimeout( timer );
     4952                        }
     4953
     4954                        $alert.on( 'mouseenter.f2zNav focusin.f2zNav', hold );
     4955                        $alert.on( 'mouseleave.f2zNav focusout.f2zNav', function () {
     4956                                hold();
     4957                                go();
     4958                        } );
     4959
     4960                        go();
     4961                } );
     4962        } );
     4963
     4964        // "All applications" is a select-all over the per-app boxes, and it has to
     4965        // stay honest in both directions.
     4966        //
     4967        // It matters more than a tidy tick, because "all" is not a seventh app: a
     4968        // submission carrying it is a request for every scope the plugin supports,
     4969        // so the server grants everything regardless of what else is ticked. Edit
     4970        // access renders every per-app box ticked for a connection authorized for
     4971        // "all", and unticking Zoho CRM there used to leave the master box on —
     4972        // the POST still said "all", and the connection was re-authorized for CRM
     4973        // anyway, with the person having just watched themselves remove it.
     4974        //
     4975        // So: unticking any single app clears the master box, which is what makes
     4976        // the narrowed list the thing actually submitted. Ticking every app sets
     4977        // it again, so a hand-assembled full house is stored as "all" and keeps
     4978        // covering apps this plugin has not shipped yet.
     4979        function syncAppsAll( $grid ) {
     4980                var $one = $grid.find( '.form2zoho-apps-one' );
     4981
     4982                $grid
     4983                        .find( '.form2zoho-apps-all' )
     4984                        .prop( 'checked', $one.length > 0 && $one.filter( ':checked' ).length === $one.length );
     4985        }
     4986
     4987        // On load, for every grid on the screen — the Create Connection dialog and
     4988        // one Edit access panel per connection. Those panels are hidden rather
     4989        // than absent, so their inputs are here to be read. Wrapped in ready()
     4990        // like the editor's own start-up above: this file is enqueued in the
     4991        // footer today, and a direct call would be the one thing that broke if it
     4992        // were ever moved. Re-running it after an in-plugin AJAX navigation is
     4993        // wanted, and ready() fires straight away by then.
     4994        $( function () {
     4995                $( '.form2zoho-apps-grid' ).each( function () {
     4996                        syncAppsAll( $( this ) );
     4997                } );
     4998        } );
     4999
    20995000        $( document ).on( 'change.f2zNav', '.form2zoho-apps-all', function () {
    2100                 var $row = $( this ).closest( 'td, p' );
    2101                 var checked = $( this ).is( ':checked' );
    2102                 $row.find( '.form2zoho-apps-one' ).prop( 'disabled', checked );
    2103                 if ( checked ) {
    2104                         $row.find( '.form2zoho-apps-one' ).prop( 'checked', false );
    2105                 }
    2106         } );
     5001                $( this )
     5002                        .closest( '.form2zoho-apps-grid' )
     5003                        .find( '.form2zoho-apps-one' )
     5004                        .prop( 'checked', $( this ).is( ':checked' ) );
     5005        } );
     5006
    21075007        $( document ).on( 'change.f2zNav', '.form2zoho-apps-one', function () {
    2108                 if ( $( this ).is( ':checked' ) ) {
    2109                         $( this ).closest( 'td, p' ).find( '.form2zoho-apps-all' ).prop( 'checked', false );
    2110                 }
     5008                syncAppsAll( $( this ).closest( '.form2zoho-apps-grid' ) );
    21115009        } );
    21125010
    … …  
    21335031        // always come back with no fields/tags, because the access token
    21345032        // itself was never granted that scope).
     5033        // "+N more" in a connection's "Authorized for" cell: the rest of the
     5034        // granted apps open in place, and the button then offers to fold them.
     5035        $( document ).on( 'click.f2zNav', '.form2zoho-authorized-more', function () {
     5036                var $btn = $( this );
     5037                var open = 'true' !== $btn.attr( 'aria-expanded' );
     5038
     5039                $( '#' + $btn.attr( 'aria-controls' ) ).prop( 'hidden', ! open );
     5040                $btn.attr( 'aria-expanded', open ? 'true' : 'false' ).text( open ? $btn.data( 'less' ) : $btn.data( 'more' ) );
     5041        } );
     5042
    21355043        $( document ).on( 'click.f2zNav', '.form2zoho-toggle-edit-apps', function () {
    21365044                var connId = $( this ).data( 'conn' );
    … …  
    21395047                } );
    21405048        } );
     5049
     5050        // One save per press of "Create feed" / "Save feed".
     5051        //
     5052        // Nothing stopped a second press while the first was still on its way, and
     5053        // a new feed has no id until the server stores it, so every press created
     5054        // one more feed. The button is disabled the moment a save really goes
     5055        // ahead, and FeedsPage::handle_save() files a repeated post under the same
     5056        // new id for any press that gets past this anyway.
     5057        function setSaving( form, saving ) {
     5058                var creating = '' === ( $( form ).find( 'input[name="feed_id"]' ).val() || '' );
     5059
     5060                form.f2zSaving = saving;
     5061                clearTimeout( form.f2zSavingTimer );
     5062                $( form ).attr( 'aria-busy', saving ? 'true' : null );
     5063
     5064                $( form ).find( '.form2zoho-step-footer' ).find( 'input[type="submit"], button[type="submit"]' ).each( function () {
     5065                        var $btn = $( this );
     5066                        var prop = $btn.is( 'input' ) ? 'value' : 'textContent';
     5067
     5068                        if ( saving ) {
     5069                                $btn.data( 'f2zLabel', this[ prop ] );
     5070                                this[ prop ] = ( creating ? i18n.creatingFeed : i18n.savingFeed ) || this[ prop ];
     5071                        } else if ( undefined !== $btn.data( 'f2zLabel' ) ) {
     5072                                this[ prop ] = $btn.data( 'f2zLabel' );
     5073                                $btn.removeData( 'f2zLabel' );
     5074                        }
     5075                        $btn.prop( 'disabled', saving );
     5076                } );
     5077        }
     5078
     5079        // Bound last, so it only locks once every check above has let the submit
     5080        // through — a save stopped by the mapping check keeps its button.
     5081        $( document ).on( 'submit.f2zNav', '#f2z-feed-editor', function ( e ) {
     5082                var form = this;
     5083
     5084                if ( form.f2zSaving ) {
     5085                        e.preventDefault();
     5086                        return;
     5087                }
     5088                if ( e.isDefaultPrevented() ) {
     5089                        return;
     5090                }
     5091
     5092                setSaving( form, true );
     5093
     5094                // A handler bound after this one, an add-on's, may still cancel the
     5095                // submit. Hand the button back if one did.
     5096                setTimeout( function () {
     5097                        if ( e.isDefaultPrevented() || ( e.originalEvent && e.originalEvent.defaultPrevented ) ) {
     5098                                setSaving( form, false );
     5099                        }
     5100                }, 0 );
     5101
     5102                // Stopping the page load (Esc, the browser's X) leaves this page on
     5103                // screen with nothing to re-enable the button. It comes back after a
     5104                // while; a press then is safe, the server files it under the same id.
     5105                form.f2zSavingTimer = setTimeout( function () {
     5106                        setSaving( form, false );
     5107                }, 15000 );
     5108        } );
     5109
     5110        // Back to this page from the browser's history cache, which keeps the page
     5111        // exactly as it was left, disabled button included.
     5112        $( window ).off( 'pageshow.f2zNav' ).on( 'pageshow.f2zNav', function ( e ) {
     5113                var form = document.getElementById( 'f2z-feed-editor' );
     5114                if ( e.originalEvent && e.originalEvent.persisted && form && form.f2zSaving ) {
     5115                        setSaving( form, false );
     5116                }
     5117        } );
    21415118} )( jQuery );
  • techwave-form-sync-for-zoho/trunk/includes/Admin/Ajax.php

    r3649280 r3748247  
    1818use Form2Zoho\Support\DataCenters;
    1919use Form2Zoho\Support\FormSources;
     20use Form2Zoho\Support\ErrorTranslator;
     21use Form2Zoho\Support\SiteTime;
     22use Form2Zoho\Support\PhpMail;
    2023
    2124defined( 'ABSPATH' ) || exit;
    … …  
    108111                add_action( 'wp_ajax_form2zoho_browse_tags', array( $this, 'browse_tags' ) );
    109112                add_action( 'wp_ajax_form2zoho_list_forms', array( $this, 'list_forms' ) );
     113                add_action( 'wp_ajax_form2zoho_list_connections', array( $this, 'list_connections' ) );
    110114                add_action( 'wp_ajax_form2zoho_recent_logs', array( $this, 'recent_logs' ) );
    111115                add_action( 'wp_ajax_form2zoho_feed_timeline', array( $this, 'feed_timeline' ) );
     116                add_action( 'wp_ajax_form2zoho_save_columns', array( $this, 'save_columns' ) );
    112117                add_action( 'wp_ajax_form2zoho_toggle_feed', array( $this, 'toggle_feed' ) );
    113118                add_action( 'wp_ajax_form2zoho_clone_feed', array( $this, 'clone_feed' ) );
    … …  
    128133         * correct the whole time. This answers the question on the spot.
    129134         *
    130          * A success here means WordPress handed the message to a mailer, not
    131          * that it arrived: it can still bounce or be filtered afterwards, which
    132          * no plugin can observe. The wording says as much.
     135         * Sent the same way the notifications are, through wp_mail() (see
     136         * Support\PhpMail), so the answer is true of the notifications too,
     137         * whether an SMTP plugin sends them or PHP's mail() does.
     138         *
     139         * A success here means the mailer accepted the message, not that it arrived:
     140         * it can still bounce or be filtered afterwards, which no plugin can
     141         * observe. The wording says as much.
    133142         *
    134143         * @return void
    … …  
    152161                        "\n",
    153162                        array(
    154                                 __( 'This is a test message from Techwave Form Sync for Zoho.', 'techwave-form-sync-for-zoho' ),
     163                                __( 'This is a test message from FormBridge for Zoho.', 'techwave-form-sync-for-zoho' ),
    155164                                '',
    156165                                __( 'If you are reading it, this site can send the notification emails the plugin offers under Settings → Notifications.', 'techwave-form-sync-for-zoho' ),
    … …  
    158167                );
    159168
    160                 $sent = wp_mail(
     169                $sent = PhpMail::send(
    161170                        $to,
    162171                        /* translators: %s: site name. */
    163                         sprintf( __( '[%s] Techwave Form Sync test email', 'techwave-form-sync-for-zoho' ), $site ),
     172                        sprintf( __( '[%s] FormBridge test email', 'techwave-form-sync-for-zoho' ), $site ),
    164173                        $body
    165174                );
    166175
    167                 if ( ! $sent ) {
     176                if ( is_wp_error( $sent ) ) {
    168177                        wp_send_json_error(
    169178                                array(
    170                                         'message' => __( 'This site could not send the email. WordPress refused to hand it to a mailer, which usually means no mail transport is configured — many hosts need an SMTP plugin such as WP Mail SMTP. Notification emails will not arrive until that is fixed.', 'techwave-form-sync-for-zoho' ),
     179                                        'message' => sprintf(
     180                                                /* translators: %s: the mailer's own reason, e.g. "Could not instantiate mail function." */
     181                                                __( 'This site could not send the email. Reason: %s If an SMTP plugin is installed, check its settings. If not, install one, or ask your hosting provider to enable PHP mail (sendmail). Notification emails will not arrive until that is fixed.', 'techwave-form-sync-for-zoho' ),
     182                                                $sent->get_error_message()
     183                                        ),
    171184                                )
    172185                        );
    … …  
    177190                                'message' => sprintf(
    178191                                        /* translators: %s: administration email address. */
    179                                         __( 'Test email sent to %s. Check that inbox — if it never arrives, the message left WordPress but was rejected or filtered further down the line.', 'techwave-form-sync-for-zoho' ),
     192                                        __( 'Test email sent to %s. Check that inbox. If it never arrives, the message left this server but was rejected or filtered further down the line.', 'techwave-form-sync-for-zoho' ),
    180193                                        $to
    181194                                ),
    … …  
    218231
    219232                $rows = $this->logger->for_feed( $feed_id, 100 );
    220                 wp_send_json_success( array( 'rows' => array_map( array( $this, 'format_row' ), $rows ) ) );
     233                wp_send_json_success(
     234                        array(
     235                                'rows'     => array_map( array( $this, 'format_row' ), $rows ),
     236                                // Printed beside each entry's time, which is already on the site's clock.
     237                                'timezone' => SiteTime::zone(),
     238                        )
     239                );
     240        }
     241
     242        /**
     243         * Remember which feed-list columns this person has switched off.
     244         *
     245         * Saved per user, so the screen comes back the way it was left instead of
     246         * resetting to all five on every page load — which is what made the toggle
     247         * feel like it had not worked.
     248         *
     249         * Only the hidden ones are stored. An empty list is the default and means
     250         * "show everything", so a new column added by a later version appears for
     251         * everybody rather than being invisible to anyone who had ever touched
     252         * this menu.
     253         *
     254         * @return void
     255         */
     256        public function save_columns() {
     257                if ( ! current_user_can( self::CAP ) ) {
     258                        wp_send_json_error( array( 'message' => __( 'Permission denied.', 'techwave-form-sync-for-zoho' ) ), 403 );
     259                }
     260                check_ajax_referer( self::NONCE, 'nonce' );
     261
     262                $known = array_keys( FeedsPage::columns() );
     263                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every element goes through sanitize_key() and an allowlist in the loop below.
     264                $sent = isset( $_POST['hidden'] ) ? (array) wp_unslash( $_POST['hidden'] ) : array();
     265
     266                $hidden = array();
     267                foreach ( $sent as $key ) {
     268                        $key = sanitize_key( $key );
     269
     270                        // Allowlisted against the columns that actually exist. This lands
     271                        // in user meta and comes back out as markup decisions, so an
     272                        // unknown key has no business being stored.
     273                        if ( in_array( $key, $known, true ) ) {
     274                                $hidden[] = $key;
     275                        }
     276                }
     277
     278                update_user_meta( get_current_user_id(), FeedsPage::HIDDEN_COLUMNS_META, array_values( array_unique( $hidden ) ) );
     279
     280                wp_send_json_success( array( 'hidden' => $hidden ) );
    221281        }
    222282
    … …  
    276336                $feed['title']   = $title;
    277337                $feed['enabled'] = false;
    278                 $new_id          = $this->feeds->save( $feed );
     338                // A copy is a new feed, created now. Carrying the original's date made
     339                // it read, and sort, as though it had existed all along.
     340                $feed['created_at']     = current_time( 'mysql' );
     341                $feed['created_at_gmt'] = current_time( 'mysql', true );
     342                $new_id                 = $this->feeds->save( $feed );
    279343
    280344                wp_send_json_success( array( 'id' => $new_id ) );
    … …  
    501565                if ( ! empty( $feed['dedupe_enabled'] ) && ! empty( $feed['dedupe_fields'] ) ) {
    502566                        $dupe = array_filter( array_map( 'trim', explode( ',', (string) $feed['dedupe_fields'] ) ) );
     567                } elseif ( ! empty( $feed['dedupe_enabled'] ) && in_array( $integration, array( 'crm', 'bigin' ), true ) ) {
     568                        // Nothing ticked still updates matching records there, so "-"
     569                        // would read as duplicate handling being off.
     570                        $dupe = array( __( "Zoho's own duplicate check", 'techwave-form-sync-for-zoho' ) );
    503571                }
    504572
    … …  
    559627
    560628                $status = isset( $context['status'] ) ? (string) $context['status'] : '';
    561                 if ( 'success' !== $status && 'suppressed' !== $status ) {
     629                if ( 'success' !== $status && 'suppressed' !== $status && 'skipped' !== $status ) {
    562630                        $status = 'error';
    563631                }
    … …  
    608676                // page-loaded one, with the form name, timestamp, and a readable
    609677                // app name included alongside the rest of the context.
    610                 $context_out        = array( 'time' => $row->created_at . ' UTC' ) + $context;
     678                $context_out        = array( 'time' => SiteTime::from_gmt( $row->created_at ) . ' (' . SiteTime::zone() . ')' ) + $context;
    611679                $context_out['app'] = $app_label;
    612680                if ( '' !== (string) $row->form_id ) {
    … …  
    616684                return array(
    617685                        'id'          => (int) $row->id,
    618                         'created_at'  => (string) $row->created_at,
     686                        // Stored in UTC; sent on the site's clock. The live view pages by
     687                        // id, never by this, so converting it changes only what is read.
     688                        'created_at'  => SiteTime::from_gmt( (string) $row->created_at ),
    619689                        'level'       => (string) $row->level,
    620690                        'status'      => $status,
    … …  
    626696                        'record_id'   => $record_id,
    627697                        'view_url'    => '' !== $view_url ? esc_url_raw( $view_url, array( 'http', 'https' ) ) : '',
    628                         'message'     => (string) $row->message,
     698                        'message'     => Logger::display_message( (string) $row->message ),
    629699                        'context'     => wp_json_encode( $context_out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
    630700                );
    … …  
    663733                }
    664734
    665                 $topics = $integration->get_topics( $context );
     735                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- capability + nonce verified in guard() above.
     736                $fresh = ! empty( $_POST['fresh'] );
     737
     738                $topics = $this->remembered(
     739                        $integration,
     740                        array( 'topics', $context['integration'], $context['connection_id'] ),
     741                        $fresh,
     742                        function () use ( $integration, $context ) {
     743                                return $integration->get_topics( $context );
     744                        }
     745                );
    666746                $this->bail_on_error( $topics );
    667747
    … …  
    682762                }
    683763
    684                 $tags = $integration->get_tags( $context );
     764                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- capability + nonce verified in guard() above.
     765                $fresh = ! empty( $_POST['fresh'] );
     766
     767                $tags = $this->remembered(
     768                        $integration,
     769                        array( 'tags', $context['integration'], $context['connection_id'] ),
     770                        $fresh,
     771                        function () use ( $integration, $context ) {
     772                                return $integration->get_tags( $context );
     773                        }
     774                );
    685775                $this->bail_on_error( $tags );
    686776
    687777                wp_send_json_success( array( 'tags' => $this->flatten( $tags ) ) );
     778        }
     779
     780        /**
     781         * Return the saved Zoho connections, for the feed editor's account picker.
     782         *
     783         * Exists so that connecting an account no longer costs a half-built feed.
     784         * The editor links to the Connections screen in a new tab; this is what
     785         * brings the account made there into the list here, without the page load
     786         * that would throw away everything already typed.
     787         *
     788         * `apps` rides along because the editor warns when a connection was
     789         * authorized for a different Zoho app than the one chosen. That map is
     790         * localized once at page load, so a connection made after it would
     791         * otherwise be the one connection the warning could say nothing about.
     792         *
     793         * No secrets are returned: the id, the label someone typed, the data
     794         * centre and the authorized app list — the same four facts the page
     795         * already prints into its own <select>.
     796         *
     797         * @return void
     798         */
     799        public function list_connections() {
     800                if ( ! current_user_can( self::CAP ) ) {
     801                        wp_send_json_error( array( 'message' => __( 'Permission denied.', 'techwave-form-sync-for-zoho' ) ), 403 );
     802                }
     803                check_ajax_referer( self::NONCE, 'nonce' );
     804
     805                $out = array();
     806
     807                foreach ( $this->connections->all() as $id => $conn ) {
     808                        // The same rule as the list the page renders: a draft that never
     809                        // finished authorising cannot deliver, so reloading must not bring
     810                        // it back into the account picker.
     811                        if ( ! ConnectionManager::is_authorised( $conn ) ) {
     812                                continue;
     813                        }
     814
     815                        $apps  = isset( $conn['apps'] ) && is_array( $conn['apps'] ) ? array_values( $conn['apps'] ) : array( 'all' );
     816                        $out[] = array(
     817                                'id'    => (string) $id,
     818                                'label' => isset( $conn['label'] ) ? (string) $conn['label'] : (string) $id,
     819                                'dc'    => isset( $conn['data_center'] ) ? (string) $conn['data_center'] : 'us',
     820                                'apps'  => array_map( 'strval', $apps ),
     821                        );
     822                }
     823
     824                wp_send_json_success( array( 'connections' => $out ) );
    688825        }
    689826
    … …  
    709846                                        'available' => false,
    710847                                        /* translators: %s: form plugin name, e.g. "Contact Form 7". */
    711                                         'message'   => sprintf( __( '%s is not active on this site.', 'techwave-form-sync-for-zoho' ), FormSources::label( $form_type ) ),
     848                                        'message'   => sprintf( __( '%s is not installed or activated.', 'techwave-form-sync-for-zoho' ), FormSources::label( $form_type ) ),
    712849                                )
    713850                        );
    … …  
    744881                );
    745882
    746                 if ( $integration->needs_organization() ) {
    747                         $orgs = $integration->get_organizations( $context['connection_id'] );
     883                // phpcs:disable WordPress.Security.NonceVerification.Missing -- capability + nonce verified in guard() above.
     884                // Choosing an organization reloads the modules, and the organization
     885                // list is already on the page by then. Asking Zoho for it again was a
     886                // whole extra round trip on every pick, so the editor says it has one.
     887                $have_orgs = ! empty( $_POST['have_organizations'] );
     888                // The reload button beside the module select, which must really ask.
     889                $fresh = ! empty( $_POST['fresh'] );
     890                // phpcs:enable WordPress.Security.NonceVerification.Missing
     891
     892                if ( $integration->needs_organization() && ! $have_orgs ) {
     893                        $orgs = $this->remembered(
     894                                $integration,
     895                                array( 'organizations', $context['integration'], $context['connection_id'] ),
     896                                $fresh,
     897                                function () use ( $integration, $context ) {
     898                                        return $integration->get_organizations( $context['connection_id'] );
     899                                }
     900                        );
    748901                        $this->bail_on_error( $orgs );
    749902                        $payload['organizations'] = $this->flatten( $orgs );
    750903                }
    751904
    752                 $modules = $integration->get_modules(
    753                         array(
    754                                 'connection_id' => $context['connection_id'],
    755                                 'org_id'        => $context['org_id'],
    756                         )
     905                $modules = $this->remembered(
     906                        $integration,
     907                        array( 'modules', $context['integration'], $context['connection_id'], $context['org_id'] ),
     908                        $fresh,
     909                        function () use ( $integration, $context, $fresh ) {
     910                                return $integration->get_modules(
     911                                        array(
     912                                                'connection_id' => $context['connection_id'],
     913                                                'org_id'        => $context['org_id'],
     914                                                'fresh'         => $fresh,
     915                                        )
     916                                );
     917                        }
    757918                );
    758919                $this->bail_on_error( $modules );
    759920                $payload['modules'] = $this->flatten( $modules );
    760 
    761                 // Desk-style integrations expose departments (needed for tickets).
    762                 $payload['departments'] = array();
    763                 if ( method_exists( $integration, 'get_departments' ) && '' !== $context['org_id'] ) {
    764                         $departments = $integration->get_departments( $context['connection_id'], $context['org_id'] );
    765                         if ( ! is_wp_error( $departments ) ) {
    766                                 $payload['departments'] = $this->flatten( $departments );
    767                         }
    768                 }
    769921
    770922                wp_send_json_success( $payload );
    … …  
    797949                }
    798950
    799                 $fields = $integration->get_fields(
    800                         array(
    801                                 'connection_id' => $context['connection_id'],
    802                                 'org_id'        => $context['org_id'],
    803                                 'module'        => $module,
    804                         )
     951                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- capability + nonce verified in guard() above.
     952                $fresh = ! empty( $_POST['fresh'] );
     953
     954                $fields = $this->remembered(
     955                        $integration,
     956                        array( 'fields', $context['integration'], $context['connection_id'], $context['org_id'], $no_module_needed ? '' : $module ),
     957                        $fresh,
     958                        function () use ( $integration, $context, $module, $fresh ) {
     959                                return $integration->get_fields(
     960                                        array(
     961                                                'connection_id' => $context['connection_id'],
     962                                                'org_id'        => $context['org_id'],
     963                                                'module'        => $module,
     964                                                'fresh'         => $fresh,
     965                                        )
     966                                );
     967                        }
    805968                );
    806969                $this->bail_on_error( $fields );
    807970
    808971                wp_send_json_success( array( 'fields' => $this->flatten( $fields ) ) );
     972        }
     973
     974        /**
     975         * A Zoho list for the editor, kept for ten minutes.
     976         *
     977         * Every pick in the editor used to go back to Zoho: switching a CRM feed
     978         * from Leads to Contacts and back was three field lookups, and opening a
     979         * saved feed fetched its modules and fields again each time, a second or
     980         * more apiece. The reload buttons pass $fresh, so something just created
     981         * in Zoho still shows up. Errors are never kept, and a destination that
     982         * is not a Zoho app (its lists come from this site) is always asked.
     983         *
     984         * @param mixed    $integration The integration.
     985         * @param array    $parts       What identifies the list.
     986         * @param bool     $fresh       Skip what is kept.
     987         * @param callable $load        Fetches the list.
     988         * @return mixed The list, or the WP_Error the fetch returned.
     989         */
     990        private function remembered( $integration, array $parts, $fresh, callable $load ) {
     991                if ( ! self::needs_connection( $integration ) ) {
     992                        return $load();
     993                }
     994
     995                $key = 'form2zoho_ui_' . md5( (string) wp_json_encode( $parts ) );
     996
     997                if ( ! $fresh ) {
     998                        $kept = get_transient( $key );
     999
     1000                        if ( is_array( $kept ) ) {
     1001                                return $kept;
     1002                        }
     1003                }
     1004
     1005                $list = $load();
     1006
     1007                if ( is_array( $list ) ) {
     1008                        set_transient( $key, $list, 10 * MINUTE_IN_SECONDS );
     1009                }
     1010
     1011                return $list;
     1012        }
     1013
     1014        /**
     1015         * Whether a destination is reached through a saved Zoho connection.
     1016         *
     1017         * Asked rather than assumed, and assumed true when the integration cannot
     1018         * answer: an older integration that predates the question is a Zoho one,
     1019         * and the safe reading of silence here is the stricter check.
     1020         *
     1021         * @param mixed $integration The integration, or null.
     1022         * @return bool
     1023         */
     1024        private static function needs_connection( $integration ) {
     1025                if ( null === $integration || ! method_exists( $integration, 'needs_connection' ) ) {
     1026                        return true;
     1027                }
     1028
     1029                return (bool) $integration->needs_connection();
    8091030        }
    8101031
    … …  
    8271048                $org_id        = isset( $_POST['org_id'] ) ? sanitize_text_field( wp_unslash( $_POST['org_id'] ) ) : '';
    8281049
    829                 if ( '' === $connection_id || null === $this->connections->get( $connection_id ) ) {
    830                         wp_send_json_error( array( 'message' => __( 'Unknown or disconnected account.', 'techwave-form-sync-for-zoho' ) ) );
    831                 }
    832 
    8331050                if ( '' === $integration ) {
    8341051                        wp_send_json_error( array( 'message' => __( 'No integration selected.', 'techwave-form-sync-for-zoho' ) ) );
    8351052                }
     1053
     1054                // A destination that is reached through a Zoho account must name a
     1055                // real one. A destination that is just a URL has no account to name,
     1056                // and demanding one would make its module and field lists unreachable
     1057                // — see AbstractIntegration::needs_connection().
     1058                if ( self::needs_connection( $this->registry->get( $integration ) )
     1059                        && ( '' === $connection_id || null === $this->connections->get( $connection_id ) ) ) {
     1060                        wp_send_json_error( array( 'message' => __( 'Unknown or disconnected account.', 'techwave-form-sync-for-zoho' ) ) );
     1061                }
     1062
     1063                $this->guard_app_access( $integration, $connection_id );
    8361064
    8371065                return array(
    … …  
    8431071
    8441072        /**
     1073         * Stop early when the account was never authorized for the app being asked
     1074         * about.
     1075         *
     1076         * A connection carries only the Zoho permissions it asked for at
     1077         * authorization time, so pointing a CRM-only account at Zoho Books gets an
     1078         * HTTP 401 and the message "Zoho rejected the access token." That is
     1079         * accurate and no help at all: it reads as a broken or expired connection,
     1080         * and the two things somebody then tries — reconnecting, or making a new
     1081         * connection — both rebuild the same CRM-only grant and fail again.
     1082         *
     1083         * The stored app list already knows the answer, so it is given here
     1084         * instead, before a request is spent discovering it. Named apps only: a
     1085         * connection authorized for "all" covers everything, and one whose list is
     1086         * missing is left alone for the API to judge rather than guessed about.
     1087         *
     1088         * @param string $integration_id Integration id, e.g. "books".
     1089         * @param string $connection_id  Connection id.
     1090         * @return void
     1091         */
     1092        private function guard_app_access( $integration_id, $connection_id ) {
     1093                $integration = $this->registry->get( $integration_id );
     1094
     1095                // A destination that is just a URL has no Zoho grant to check.
     1096                if ( null === $integration || ! self::needs_connection( $integration ) ) {
     1097                        return;
     1098                }
     1099
     1100                $conn = $this->connections->get( $connection_id );
     1101
     1102                if ( ! is_array( $conn ) || ! isset( $conn['apps'] ) || ! is_array( $conn['apps'] ) ) {
     1103                        return;
     1104                }
     1105
     1106                if ( in_array( 'all', $conn['apps'], true ) || in_array( $integration_id, $conn['apps'], true ) ) {
     1107                        return;
     1108                }
     1109
     1110                wp_send_json_error(
     1111                        array(
     1112                                'message' => sprintf(
     1113                                        /* translators: 1: Zoho app name, e.g. "Zoho Books". 2: the same app name. */
     1114                                        __( 'This connection is not authorized for %1$s. Use "Edit access" on the connection, tick %2$s, then press Update & Reauthorize.', 'techwave-form-sync-for-zoho' ),
     1115                                        $integration->label(),
     1116                                        $integration->label()
     1117                                ),
     1118                        )
     1119                );
     1120        }
     1121
     1122        /**
    8451123         * Convert a WP_Error result into a JSON error response and stop.
     1124         *
     1125         * A request that never reached Zoho is described in words, with the host
     1126         * it could not reach, instead of cURL's own "cURL error 28: Connection
     1127         * timed out after 10002 milliseconds".
    8461128         *
    8471129         * @param mixed $maybe_error Possible WP_Error.
    … …  
    8501132        private function bail_on_error( $maybe_error ) {
    8511133                if ( is_wp_error( $maybe_error ) ) {
    852                         wp_send_json_error( array( 'message' => $maybe_error->get_error_message() ) );
     1134                        $data    = $maybe_error->get_error_data();
     1135                        $host    = is_array( $data ) && isset( $data['host'] ) ? (string) $data['host'] : '';
     1136                        $network = ErrorTranslator::transport( $maybe_error->get_error_message(), $host, true );
     1137
     1138                        wp_send_json_error( array( 'message' => '' !== $network ? $network : $maybe_error->get_error_message() ) );
    8531139                }
    8541140        }
    … …  
    8921178
    8931179                                $required = ! empty( $value['required'] );
     1180                                $type     = isset( $value['type'] ) ? (string) $value['type'] : '';
     1181                                $unique   = ! empty( $value['unique'] );
    8941182                        } else {
    8951183                                $val      = is_int( $key ) ? $value : $key;
    8961184                                $label    = $value;
    8971185                                $required = false;
     1186                                $type     = '';
     1187                                $unique   = false;
    8981188                        }
    8991189
    … …  
    9021192                                'label'    => (string) $label,
    9031193                                'required' => (bool) $required,
     1194                                // Empty for module/organisation rows and for the apps whose
     1195                                // field lists are curated rather than fetched; the mapping
     1196                                // falls back to plain text when it is, which is what those
     1197                                // apps want anyway.
     1198                                'type'     => $type,
     1199                                'unique'   => $unique,
    9041200                        );
    9051201                }
  • techwave-form-sync-for-zoho/trunk/includes/Admin/DeactivationFeedback.php

    r3649280 r3748247  
    1313
    1414/**
    15  * Asks why, once, on the Plugins screen, when someone deactivates Techwave Form Sync for Zoho.
     15 * Asks why, once, on the Plugins screen, when someone deactivates FormBridge for Zoho.
    1616 *
    1717 * Entirely voluntary and entirely skippable. The modal is an overlay on the
    … …  
    180180
    181181                echo '<div class="form2zoho-modal-scroll">';
    182                 echo '<p class="description">' . esc_html__( 'If you have a moment, what made you deactivate Techwave Form Sync for Zoho? Answering is entirely optional — nothing is sent unless you choose to send it.', 'techwave-form-sync-for-zoho' ) . '</p>';
     182                echo '<p class="description">' . esc_html__( 'If you have a moment, what made you deactivate FormBridge for Zoho? Answering is entirely optional, and nothing is sent unless you choose to send it.', 'techwave-form-sync-for-zoho' ) . '</p>';
    183183
    184184                echo '<div class="form2zoho-deactivate-reasons" role="radiogroup" aria-label="' . esc_attr__( 'Reason for deactivating', 'techwave-form-sync-for-zoho' ) . '">';
  • techwave-form-sync-for-zoho/trunk/includes/Admin/FeedsPage.php

    r3649280 r3748247  
    1717use Form2Zoho\Logging\Logger;
    1818use Form2Zoho\Support\Assets;
     19use Form2Zoho\Support\Branding;
    1920use Form2Zoho\Support\ErrorTranslator;
     21use Form2Zoho\Support\FieldMapper;
    2022use Form2Zoho\Support\FormSources;
     23use Form2Zoho\Support\VideoGuide;
    2124use Form2Zoho\Support\Markup;
     25use Form2Zoho\Support\SiteTime;
     26use Form2Zoho\Support\StaticValue;
    2227
    2328defined( 'ABSPATH' ) || exit;
    … …  
    3439         * plugin's whole top-level menu slug. SettingsPage registers itself
    3540         * underneath it, so this one is public.
    36          */
    37         public const MENU_SLUG = 'form2zoho-feeds';
     41         *
     42         * It was `form2zoho-feeds` before the plugin took its own name, and
     43         * LegacyPageSlugs sends that address here.
     44         */
     45        public const MENU_SLUG = 'formbridge-feeds';
     46
     47        /**
     48         * Where each person's column choice is kept.
     49         *
     50         * User meta rather than an option, because this is a preference and not a
     51         * setting: two admins looking at the same feeds should not be able to
     52         * rearrange each other's screen. That is also how wp-admin stores its own
     53         * Screen Options column toggles, so it behaves the way the rest of the
     54         * dashboard already does.
     55         */
     56        public const HIDDEN_COLUMNS_META = 'form2zoho_hidden_columns';
     57
     58        /**
     59         * The feed list's optional columns, and what they are called.
     60         *
     61         * One definition, read by the menu that toggles them, the table that draws
     62         * them and the endpoint that saves the choice — so a column cannot be
     63         * toggleable but unsaveable, or saved under a key nothing renders.
     64         *
     65         * @return array<string,string>
     66         */
     67        public static function columns() {
     68                return array(
     69                        'trigger' => __( 'Feed Name', 'techwave-form-sync-for-zoho' ),
     70                        'source'  => __( 'Trigger', 'techwave-form-sync-for-zoho' ),
     71                        'action'  => __( 'Action Name', 'techwave-form-sync-for-zoho' ),
     72                        'created' => __( 'Created At', 'techwave-form-sync-for-zoho' ),
     73                        'status'  => __( 'Status', 'techwave-form-sync-for-zoho' ),
     74                );
     75        }
     76
     77        /**
     78         * The class that hides one column, when this person has hidden it.
     79         *
     80         * Rendered by the server rather than left to the script that toggles it.
     81         * Adding the class on load would mean every page draw showing the full
     82         * table for a moment and then snapping narrower, which reads as a bug even
     83         * though the end state is right.
     84         *
     85         * @param string            $key    Column key.
     86         * @param array<int,string> $hidden Columns this person has switched off.
     87         * @return string Class name, or '' when the column is shown.
     88         */
     89        private static function col_class( $key, array $hidden ) {
     90                return in_array( $key, $hidden, true ) ? 'form2zoho-col-hidden' : '';
     91        }
     92
     93        /**
     94         * The columns this person has switched off.
     95         *
     96         * Filtered against columns() on the way out, so a key left behind by an
     97         * older version cannot hide a column that no longer answers to it.
     98         *
     99         * @return array<int,string>
     100         */
     101        public static function hidden_columns() {
     102                $saved = get_user_meta( get_current_user_id(), self::HIDDEN_COLUMNS_META, true );
     103
     104                if ( ! is_array( $saved ) ) {
     105                        return array();
     106                }
     107
     108                return array_values( array_intersect( $saved, array_keys( self::columns() ) ) );
     109        }
    38110
    39111        private const SLUG = self::MENU_SLUG;
    … …  
    121193        public function add_menu() {
    122194                $this->hook_suffix = add_menu_page(
    123                         __( 'Techwave Form Sync for Zoho', 'techwave-form-sync-for-zoho' ),
     195                        __( 'FormBridge for Zoho', 'techwave-form-sync-for-zoho' ),
    124196                        // Shortened for the sidebar only. The full name is the page title
    125197                        // above; once the icon and padding are taken out, a WordPress menu
    126198                        // label has room for about fourteen characters before it wraps onto
    127                         // a second line — "Techwave Form Sync" did, and sat two lines tall
     199                        // a second line — "FormBridge for Zoho" did, and sat two lines tall
    128200                        // next to single-line neighbours like "WPForms". Every in-product
    129                         // reference to the menu ("Techwave Sync → Forms") uses this same
     201                        // reference to the menu ("FormBridge → All Feeds") uses this same
    130202                        // shortened form, so what the docs say to click matches what is on
    131203                        // screen.
    132                         __( 'Techwave Sync', 'techwave-form-sync-for-zoho' ),
     204                        self::menu_title(),
    133205                        self::CAP,
    134206                        self::SLUG,
    … …  
    143215                // WordPress auto-creates a first submenu duplicating the parent as soon
    144216                // as any submenu is added, and labels it with the parent's menu title —
    145                 // leaving a meaningless "Techwave Sync > Techwave Sync" entry in the
     217                // leaving a meaningless "FormBridge > FormBridge" entry in the
    146218                // sidebar.
    147219                // Registering it explicitly against the same slug replaces that
    … …  
    149221                add_submenu_page(
    150222                        self::SLUG,
    151                         __( 'Forms', 'techwave-form-sync-for-zoho' ),
    152                         __( 'Forms', 'techwave-form-sync-for-zoho' ),
     223                        __( 'All Feeds', 'techwave-form-sync-for-zoho' ),
     224                        __( 'All Feeds', 'techwave-form-sync-for-zoho' ),
    153225                        self::CAP,
    154226                        self::SLUG,
    … …  
    158230
    159231        /**
     232         * The sidebar label for the top-level menu.
     233         *
     234         * Filterable so an add-on can say which edition is installed — the
     235         * companion Pro plugin renames this to "FormBridge Pro" — without the two
     236         * plugins each registering a menu and the site ending up with both.
     237         *
     238         * Keep any replacement to roughly fourteen characters. Once the icon and
     239         * padding are taken out that is all a WordPress menu label has room for
     240         * before it wraps onto a second line and sits two lines tall next to
     241         * single-line neighbours like "WPForms". A longer label is not rejected
     242         * here — a filter that silently discarded what it was given would be
     243         * worse — but it will look wrong.
     244         *
     245         * @return string
     246         */
     247        private static function menu_title() {
     248                $default = __( 'FormBridge', 'techwave-form-sync-for-zoho' );
     249
     250                /**
     251                 * Filter the plugin's top-level admin menu label.
     252                 *
     253                 * @param string $title The label. Around fourteen characters fits.
     254                 */
     255                $title = apply_filters( 'form2zoho_menu_title', $default );
     256
     257                // An empty label is an invisible menu item: the entry is still there
     258                // and still clickable, but there is nothing to read or aim at. Falling
     259                // back is the only outcome that leaves the site usable.
     260                return is_string( $title ) && '' !== trim( $title ) ? $title : $default;
     261        }
     262
     263        /**
    160264         * Menu icon as an inline SVG data URI.
    161265         *
    162          * Previously 'dashicons-update' — the circular-arrows mark WordPress uses
    163          * for its own Updates screen, so in the sidebar it read as "something
    164          * needs updating" rather than as this plugin. An inline SVG also costs no
    165          * extra HTTP request.
    166          *
    167          * fill="black" is deliberate: WordPress only recolours a data-URI menu
    168          * icon to match the active admin colour scheme when the source is a flat
    169          * single colour.
     266         * The plugin's own glyph rather than a dashicon, so the menu entry reads
     267         * as this plugin. It costs no extra request either, being inline.
     268         *
     269         * fill on the <svg> and nowhere else, which is the part that matters.
     270         * WordPress hands a base64 icon to CSS as a background-image (see
     271         * menu-header.php) and then wp-admin/js/svg-painter.js decodes it on
     272         * DOMReady and rewrites every `fill="..."` to the active colour scheme's
     273         * icon colour. One fill on the wrapper is therefore the one thing it
     274         * changes, and the glyph follows the scheme on all nine of them — which
     275         * is why the value below is only what shows if that script never runs.
     276         *
     277         * What the same script does to anything else is why two hand-drawn
     278         * replacements for this icon had to be reverted: it rewrites `fill="none"`
     279         * as well, so an outline comes back filled solid, and it leaves `stroke`
     280         * alone, so a stroked shape keeps this file's colour while everything
     281         * around it follows the scheme.
    170282         *
    171283         * @return string
    172284         */
    173285        private static function menu_icon() {
    174                 $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="black">'
    175                         . '<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h4a1.5 1.5 0 0 1 0 3h-4A1.5 1.5 0 0 1 3 4.5Z"/>'
    176                         . '<path d="M3 10a1.5 1.5 0 0 1 1.5-1.5h3a1.5 1.5 0 0 1 0 3h-3A1.5 1.5 0 0 1 3 10Z"/>'
    177                         . '<path d="M3 15.5A1.5 1.5 0 0 1 4.5 14h4a1.5 1.5 0 0 1 0 3h-4A1.5 1.5 0 0 1 3 15.5Z"/>'
    178                         . '<path d="M12.4 5.3a1.2 1.2 0 0 1 1.7 0l3.6 3.85a1.2 1.2 0 0 1 0 1.7l-3.6 3.85a1.2 1.2 0 1 1-1.75-1.64L14 11.2h-2.3a1.2 1.2 0 1 1 0-2.4H14l-1.65-1.86a1.2 1.2 0 0 1 .05-1.64Z"/>'
     286                // Same fill core declares for its own menu icons, as the plain
     287                // fallback beside its rgba() value.
     288                $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' . Branding::MENU_VIEWBOX . '" fill="#a7aaad">'
     289                        . Branding::menu_mark()
    179290                        . '</svg>';
    180291
    … …  
    205316                $needs_org = array();
    206317                $dept_apps = array();
     318                // Destinations that are reached without a Zoho account. The editor
     319                // hides the account picker for these and stops treating an empty one
     320                // as a reason not to load the rest of the step.
     321                $no_conn_apps = array();
    207322                foreach ( $this->registry->all() as $id => $integration ) {
    208323                        $needs_org[ $id ] = (bool) $integration->needs_organization();
    209324                        if ( method_exists( $integration, 'get_departments' ) ) {
    210325                                $dept_apps[] = $id;
     326                        }
     327                        if ( method_exists( $integration, 'needs_connection' ) && ! $integration->needs_connection() ) {
     328                                $no_conn_apps[] = $id;
    211329                        }
    212330                }
    … …  
    230348                                'ajaxUrl'        => admin_url( 'admin-ajax.php' ),
    231349                                'nonce'          => wp_create_nonce( 'form2zoho_ajax' ),
     350                                // The triggers that are form plugins. Any other is an event,
     351                                // and step two words its picker that way.
     352                                'formSources'    => FormSources::form_keys(),
    232353                                'needsOrg'       => $needs_org,
    233354                                'deptApps'       => $dept_apps,
     355                                'noConnApps'     => $no_conn_apps,
    234356                                'connectionApps' => $conn_apps,
    235357                                'appLabels'      => $app_labels,
    236                                 'connectionsUrl' => admin_url( 'admin.php?page=form2zoho' ),
     358                                'connectionsUrl' => admin_url( 'admin.php?page=' . SettingsPage::SLUG ),
     359                                // Keyed by source, so the Details step's guide can follow a
     360                                // trigger changed without a page load.
     361                                'triggerDocs'    => self::trigger_docs_for_js(),
     362                                // The same, for the Zoho app picker on the destination step.
     363                                'appDocs'        => $this->app_docs_for_js(),
    237364                                'i18n'           => array(
    238365                                        'loading'                  => __( 'Loading…', 'techwave-form-sync-for-zoho' ),
     366                                        /* translators: %s: link text, e.g. "Contact Form 7 guide". */
     367                                        'docsNewTab'               => __( '%s (opens in a new tab)', 'techwave-form-sync-for-zoho' ),
    239368                                        'viewInZoho'               => __( 'View Record in Zoho', 'techwave-form-sync-for-zoho' ),
    240369                                        'recordId'                 => __( 'Record ID', 'techwave-form-sync-for-zoho' ),
    241370                                        'error'                    => __( 'Request failed.', 'techwave-form-sync-for-zoho' ),
    242371                                        'statusChanged'            => __( 'Status changed successfully', 'techwave-form-sync-for-zoho' ),
    243                                         'selectForm'               => __( '— Select a form —', 'techwave-form-sync-for-zoho' ),
    244                                         'selectOrg'                => __( '— Select organization —', 'techwave-form-sync-for-zoho' ),
    245                                         'selectMod'                => __( '— Select module —', 'techwave-form-sync-for-zoho' ),
    246                                         'selectDept'               => __( '— Select department —', 'techwave-form-sync-for-zoho' ),
    247                                         'selectZoho'               => __( '— Select Zoho field —', 'techwave-form-sync-for-zoho' ),
    248                                         'selectField'              => __( '— Select form field —', 'techwave-form-sync-for-zoho' ),
     372                                        'selectForm'               => __( 'Select a form', 'techwave-form-sync-for-zoho' ),
     373                                        'selectEvent'              => __( 'Select an event', 'techwave-form-sync-for-zoho' ),
     374                                        'formLabel'                => __( 'Form', 'techwave-form-sync-for-zoho' ),
     375                                        'eventLabel'               => __( 'Event', 'techwave-form-sync-for-zoho' ),
     376                                        'reloadForms'              => __( 'Reload forms', 'techwave-form-sync-for-zoho' ),
     377                                        'reloadEvents'             => __( 'Reload events', 'techwave-form-sync-for-zoho' ),
     378                                        'detailsForm'              => __( 'Give the feed a name you will recognise later, and pick the form it listens to.', 'techwave-form-sync-for-zoho' ),
     379                                        'detailsEvent'             => __( 'Give the feed a name you will recognise later, and pick the event it listens to.', 'techwave-form-sync-for-zoho' ),
     380                                        'eventHelp'                => __( 'Pick the event that sends a record to Zoho.', 'techwave-form-sync-for-zoho' ),
     381                                        'eventStale'               => __( 'This event could not be found in the current list. Please pick the event again and save, or nothing will ever match.', 'techwave-form-sync-for-zoho' ),
     382                                        'selectOrg'                => __( 'Select organization', 'techwave-form-sync-for-zoho' ),
     383                                        'selectMod'                => __( 'Select module', 'techwave-form-sync-for-zoho' ),
     384                                        'selectDept'               => __( 'Select department', 'techwave-form-sync-for-zoho' ),
     385                                        'selectZoho'               => __( 'Select Zoho field', 'techwave-form-sync-for-zoho' ),
     386                                        'selectField'              => __( 'Select form field', 'techwave-form-sync-for-zoho' ),
     387                                        // The searchable field pickers in the mapping table.
     388                                        'comboSearch'              => __( 'Type to search', 'techwave-form-sync-for-zoho' ),
     389                                        'comboNoMatch'             => __( 'No field matches', 'techwave-form-sync-for-zoho' ),
     390                                        /* translators: shown in the mapping row's text box when the row combines several form fields. */
     391                                        'templateHint'             => __( 'e.g. {first-name} {last-name}. Pick a form field to insert one', 'techwave-form-sync-for-zoho' ),
     392                                        /* translators: %d: number of matching fields. */
     393                                        'comboCount'               => __( '%d fields match', 'techwave-form-sync-for-zoho' ),
    249394                                        'mailingList'              => __( 'Mailing List', 'techwave-form-sync-for-zoho' ),
    250395                                        'mailingListHelp'          => __( 'Loaded from Zoho once the account is chosen.', 'techwave-form-sync-for-zoho' ),
    251                                         'moduleResource'           => __( 'Module / resource', 'techwave-form-sync-for-zoho' ),
     396                                        'moduleResource'           => __( 'Module', 'techwave-form-sync-for-zoho' ),
    252397                                        'moduleHelp'               => __( 'Loaded from Zoho once the app and account are chosen.', 'techwave-form-sync-for-zoho' ),
    253398                                        'noMailingListsCampaigns'  => __( 'No Mailing Lists found in Zoho Campaigns.', 'techwave-form-sync-for-zoho' ),
    … …  
    257402                                        'noMatches'                => __( 'No matches', 'techwave-form-sync-for-zoho' ),
    258403                                        /* translators: %s: the stale saved value that no longer matches anything Zoho returned. */
    259                                         'staleValueLabel'          => __( '%s (not found — please reselect)', 'techwave-form-sync-for-zoho' ),
     404                                        'staleValueLabel'          => __( '%s (not found, please reselect)', 'techwave-form-sync-for-zoho' ),
     405                                        /* translators: %s: Zoho app name. */
     406                                        'appNotPermitted'          => __( '%s (not authorised for this account)', 'techwave-form-sync-for-zoho' ),
    260407                                        'testConfirm'              => __( 'Send one real test record to Zoho now?', 'techwave-form-sync-for-zoho' ),
    261408                                        'testSuccess'              => __( 'Test successful', 'techwave-form-sync-for-zoho' ),
    … …  
    264411                                        'formStale'                => __( 'This form could not be found in the current list. Please pick the form again and save, or submissions will never match.', 'techwave-form-sync-for-zoho' ),
    265412                                        'cf7Unavailable'           => __( 'Contact Form 7 is not active on this site.', 'techwave-form-sync-for-zoho' ),
     413                                        /* translators: %s: form plugin name, e.g. "Contact Form 7". */
     414                                        'notInstalled'             => __( '%s is not installed or activated.', 'techwave-form-sync-for-zoho' ),
    266415                                        /* translators: %s: comma-separated list of required Zoho field names that are still unmapped. */
    267416                                        'missingRequired'          => __( 'These required Zoho fields are not mapped yet: %s. Map them (or set a static value) before saving.', 'techwave-form-sync-for-zoho' ),
    268417                                        'missingRequiredTitle'     => __( 'Missing required fields', 'techwave-form-sync-for-zoho' ),
    269418                                        /* translators: %s: comma-separated list of Zoho field names whose mapping row is unfinished. */
    270                                         'incompleteRows'           => __( 'These rows name a Zoho field but have nothing to send: %s. Pick a form field or type a static value, or remove the row — otherwise it will not be saved.', 'techwave-form-sync-for-zoho' ),
     419                                        'incompleteRows'           => __( 'These rows name a Zoho field but have nothing to send: %s. Pick a form field or type a static value, or remove the row. Otherwise it will not be saved.', 'techwave-form-sync-for-zoho' ),
    271420                                        'incompleteRowsTitle'      => __( 'Unfinished mapping rows', 'techwave-form-sync-for-zoho' ),
    272                                         'blankRows'                => __( 'Every mapping row needs a Zoho field. The highlighted rows have none — pick a Zoho field for each, or remove the row.', 'techwave-form-sync-for-zoho' ),
    273                                         'mappingReset'             => __( 'The field mapping was cleared because the Zoho app, account or module changed — the previous fields belong to the target you just switched away from. Please map the fields again.', 'techwave-form-sync-for-zoho' ),
     421                                        'blankRows'                => __( 'Every mapping row needs a Zoho field. The highlighted rows have none. Pick a Zoho field for each, or remove the row.', 'techwave-form-sync-for-zoho' ),
     422                                        'creatingFeed'             => __( 'Creating feed…', 'techwave-form-sync-for-zoho' ),
     423                                        'savingFeed'               => __( 'Saving feed…', 'techwave-form-sync-for-zoho' ),
     424                                        /* translators: %s: a form field's label. */
     425                                        'removeTemplateField'      => __( 'Remove %s', 'techwave-form-sync-for-zoho' ),
     426                                        'tryAgain'                 => __( 'Try again', 'techwave-form-sync-for-zoho' ),
     427                                        /* translators: 1: Zoho module name, e.g. "Leads". 2: Zoho app name, e.g. "Zoho CRM". */
     428                                        'loadingFieldsOf'          => __( 'Fetching %1$s fields from %2$s…', 'techwave-form-sync-for-zoho' ),
     429                                        /* translators: %s: Zoho app name, e.g. "Zoho Campaigns". */
     430                                        'loadingFieldsFrom'        => __( 'Fetching fields from %s…', 'techwave-form-sync-for-zoho' ),
     431                                        'staticInvalidTitle'       => __( 'Static values Zoho will not accept', 'techwave-form-sync-for-zoho' ),
     432                                        'requiredRow'              => __( 'Required', 'techwave-form-sync-for-zoho' ),
     433                                        'requiredRowHint'          => __( 'Zoho needs this field on every record, so this row cannot be removed or pointed at another field.', 'techwave-form-sync-for-zoho' ),
     434                                        /* translators: %s: Zoho field name, e.g. "Phone". */
     435                                        'staticPhone'              => __( '%s needs a phone number: digits, spaces and + - ( ) only.', 'techwave-form-sync-for-zoho' ),
     436                                        /* translators: %s: Zoho field name, e.g. "Email". */
     437                                        'staticEmail'              => __( '%s needs an email address, such as name@example.com.', 'techwave-form-sync-for-zoho' ),
     438                                        /* translators: %s: Zoho field name, e.g. "No. of Employees". */
     439                                        'staticInteger'            => __( '%s needs a whole number.', 'techwave-form-sync-for-zoho' ),
     440                                        /* translators: %s: Zoho field name, e.g. "Annual Revenue". */
     441                                        'staticDecimal'            => __( '%s needs a number, such as 12.50.', 'techwave-form-sync-for-zoho' ),
     442                                        /* translators: %s: Zoho field name, e.g. "Date of Birth". */
     443                                        'staticDate'               => __( '%s needs a date written as YYYY-MM-DD.', 'techwave-form-sync-for-zoho' ),
     444                                        /* translators: %s: Zoho field name, e.g. "Follow-up Time". */
     445                                        'staticDatetime'           => __( '%s needs a date and time written as YYYY-MM-DDTHH:MM:SS.', 'techwave-form-sync-for-zoho' ),
     446                                        /* translators: %s: Zoho field name, e.g. "Website". */
     447                                        'staticUrl'                => __( '%s needs a web address, such as https://example.com.', 'techwave-form-sync-for-zoho' ),
     448                                        'mappingReset'             => __( 'The field mapping was cleared because the Zoho app, account or module changed. The previous fields belong to the target you just switched away from. Please map the fields again.', 'techwave-form-sync-for-zoho' ),
    274449                                        /* translators: 1: currently authorized app(s), 2: app the person just picked. */
    275450                                        'appNotAuthorized'         => __( 'This account is only authorized for %1$s. You need to re-authorize it for %2$s before you can fetch this data.', 'techwave-form-sync-for-zoho' ),
    … …  
    338513                        'statusInfo'            => __( 'Info', 'techwave-form-sync-for-zoho' ),
    339514                        'statusSuppressed'      => __( 'Suppressed', 'techwave-form-sync-for-zoho' ),
     515                        'statusSkipped'         => __( 'Skipped', 'techwave-form-sync-for-zoho' ),
    340516                        'details'               => __( 'Details', 'techwave-form-sync-for-zoho' ),
    341517                        'copy'                  => __( 'Copy', 'techwave-form-sync-for-zoho' ),
    … …  
    354530                        /* translators: %d: number of log entries. */
    355531                        'logsResendMany'        => __( 'Resend %d selected entries to Zoho now?', 'techwave-form-sync-for-zoho' ),
    356                         'logsResubmittedOne'    => __( 'Entry resubmitted — check the log for the result.', 'techwave-form-sync-for-zoho' ),
     532                        'logsResubmittedOne'    => __( 'Entry resubmitted. Check the log for the result.', 'techwave-form-sync-for-zoho' ),
    357533                        /* translators: %d: number of log entries. */
    358                         'logsResubmittedMany'   => __( '%d entries resubmitted — check the log for the result.', 'techwave-form-sync-for-zoho' ),
     534                        'logsResubmittedMany'   => __( '%d entries resubmitted. Check the log for the result.', 'techwave-form-sync-for-zoho' ),
    359535                        'logsNothingToResubmit' => __( 'Nothing to resubmit.', 'techwave-form-sync-for-zoho' ),
    360536                        'logsResubmitFailed'    => __( 'Could not resubmit the selected entries.', 'techwave-form-sync-for-zoho' ),
     537                        /* translators: 1: current page number, 2: total pages, 3: total number of log entries. Kept in step with SettingsPage::logs_page_info(), which renders the same line on page load; this copy is only redrawn when live polling grows the count, so it never has to say "1 entry". */
     538                        'logsPageInfo'          => __( 'Page %1$s of %2$s · %3$s entries', 'techwave-form-sync-for-zoho' ),
    361539                );
    362540        }
    … …  
    380558
    381559                if ( 'edit' === $action || 'new' === $action ) {
    382                         // Same dark hero masthead as the feeds list ("home") screen, so
    383                         // both pages look like one product.
    384                         echo '<div class="form2zoho-hero">';
    385                         echo '<span class="form2zoho-hero-logo"><span class="form2zoho-hero-logo-icon">' . esc_html__( 'TFS', 'techwave-form-sync-for-zoho' ) . '</span>' . esc_html__( 'Techwave Form Sync for Zoho', 'techwave-form-sync-for-zoho' ) . '</span>';
    386                         echo '</div>';
     560                        // The same masthead as the feeds list ("home") screen, so both
     561                        // pages look like one product.
     562                        HeaderNav::render( 'integrations' );
    387563                        echo '<hr class="wp-header-end" />';
    388564                        $this->render_editor();
    389565                } else {
    390                         // Feeds list ("home") screen: dark hero bar with the plugin logo
    391                         // on the left, matching the requested reference design. The
    392                         // white feeds card is rendered by render_list() and overlaps
    393                         // the bottom of this bar via CSS only.
    394                         echo '<div class="form2zoho-hero">';
    395                         echo '<span class="form2zoho-hero-logo"><span class="form2zoho-hero-logo-icon">' . esc_html__( 'TFS', 'techwave-form-sync-for-zoho' ) . '</span>' . esc_html__( 'Techwave Form Sync for Zoho', 'techwave-form-sync-for-zoho' ) . '</span>';
    396                         echo '</div>';
     566                        // Feeds list ("home") screen. The card below is rendered by
     567                        // render_list(); the masthead only introduces the product.
     568                        HeaderNav::render( 'integrations' );
    397569                        echo '<hr class="wp-header-end" />';
    398570                        $this->render_list();
    … …  
    408580         */
    409581        private function render_list() {
     582                // Read once for the whole screen: the toggle menu, the header row and
     583                // every body row all draw from it.
     584                $hidden_cols = self::hidden_columns();
     585
    410586                $new_url = add_query_arg(
    411587                        array(
    … …  
    431607                $this->maybe_notice();
    432608
    433                 // Newest feeds first. Sort by created_at when present (falls back to
    434                 // reversing insertion order for legacy feeds saved before created_at
    435                 // existed), so a freshly added feed always lands at the top.
     609                // Newest feeds first, by creation time (see SiteTime::feed_timestamp()
     610                // for feeds saved before it was kept in UTC), so a freshly added feed
     611                // always lands at the top.
    436612                $feeds = $this->feeds->all();
    437613                uasort(
    438614                        $feeds,
    439615                        function ( $a, $b ) {
    440                                 $at = isset( $a['created_at'] ) ? strtotime( (string) $a['created_at'] ) : 0;
    441                                 $bt = isset( $b['created_at'] ) ? strtotime( (string) $b['created_at'] ) : 0;
     616                                $at = SiteTime::feed_timestamp( (array) $a );
     617                                $bt = SiteTime::feed_timestamp( (array) $b );
    442618                                if ( $at === $bt ) {
    443619                                        return 0;
    … …  
    459635                        echo '<span>' . esc_html__( 'Add New Feed', 'techwave-form-sync-for-zoho' ) . '</span>';
    460636                        echo '</a>';
     637                        // The whole job, start to finish. Offered here and on the
     638                        // Connections zero state and nowhere else: this is the one moment
     639                        // somebody has nothing set up and no picture of what setting it up
     640                        // involves, which is the only question a full walkthrough answers
     641                        // better than a page about one step.
     642                        VideoGuide::render( 'setup', __( 'Watch the full setup', 'techwave-form-sync-for-zoho' ) );
    461643                        echo '</div>';
    462644                        echo '</div>'; // Close .form2zoho-card-pop.
    … …  
    512694
    513695                echo '<select class="form2zoho-app-filter" data-base="' . esc_url( $base ) . '">';
    514                 echo '<option value="">' . esc_html__( 'All apps', 'techwave-form-sync-for-zoho' ) . '</option>';
     696                echo '<option value="">' . esc_html__( 'All Apps', 'techwave-form-sync-for-zoho' ) . '</option>';
    515697                foreach ( $labels as $key => $label ) {
    516698                        echo '<option value="' . esc_attr( $key ) . '" ' . selected( $app_filter, $key, false ) . '>' . esc_html( $label ) . '</option>';
    … …  
    523705                echo '</button>';
    524706                echo '<div class="form2zoho-colvis-menu" hidden>';
    525                 $columns = array(
    526                         'trigger' => __( 'Feed Name', 'techwave-form-sync-for-zoho' ),
    527                         'source'  => __( 'Trigger', 'techwave-form-sync-for-zoho' ),
    528                         'action'  => __( 'Action Name', 'techwave-form-sync-for-zoho' ),
    529                         'created' => __( 'Created At', 'techwave-form-sync-for-zoho' ),
    530                         'status'  => __( 'Status', 'techwave-form-sync-for-zoho' ),
    531                 );
    532                 foreach ( $columns as $key => $label ) {
    533                         echo '<label class="form2zoho-colvis-item"><input type="checkbox" class="form2zoho-colvis-input" data-col="' . esc_attr( $key ) . '" checked /> ' . esc_html( $label ) . '</label>';
     707                foreach ( self::columns() as $key => $label ) {
     708                        echo '<label class="form2zoho-colvis-item"><input type="checkbox" class="form2zoho-colvis-input" data-col="' . esc_attr( $key ) . '" ' . checked( ! in_array( $key, $hidden_cols, true ), true, false ) . ' /> ' . esc_html( $label ) . '</label>';
    534709                }
    535710                echo '</div>';
    … …  
    571746                echo '<thead><tr>';
    572747                echo '<th scope="col" class="form2zoho-check-col"><span class="form2zoho-check-wrap"><label class="screen-reader-text" for="form2zoho-select-all-feeds">' . esc_html__( 'Select all feeds', 'techwave-form-sync-for-zoho' ) . '</label><input type="checkbox" id="form2zoho-select-all-feeds" class="form2zoho-select-all" /></span></th>';
    573                 echo '<th scope="col" data-col="trigger">' . esc_html__( 'Feed Name', 'techwave-form-sync-for-zoho' ) . '</th>';
    574                 echo '<th scope="col" data-col="source">' . esc_html__( 'Trigger', 'techwave-form-sync-for-zoho' ) . '</th>';
    575                 echo '<th scope="col" data-col="action">' . esc_html__( 'Action Name', 'techwave-form-sync-for-zoho' ) . '</th>';
    576                 echo '<th scope="col" data-col="created">' . esc_html__( 'Created At', 'techwave-form-sync-for-zoho' ) . '</th>';
    577                 echo '<th scope="col" data-col="status">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>';
     748                echo '<th scope="col" data-col="trigger" class="' . esc_attr( self::col_class( 'trigger', $hidden_cols ) ) . '">' . esc_html__( 'Feed Name', 'techwave-form-sync-for-zoho' ) . '</th>';
     749                echo '<th scope="col" data-col="source" class="' . esc_attr( self::col_class( 'source', $hidden_cols ) ) . '">' . esc_html__( 'Trigger', 'techwave-form-sync-for-zoho' ) . '</th>';
     750                echo '<th scope="col" data-col="action" class="' . esc_attr( self::col_class( 'action', $hidden_cols ) ) . '">' . esc_html__( 'Action Name', 'techwave-form-sync-for-zoho' ) . '</th>';
     751                echo '<th scope="col" data-col="created" class="' . esc_attr( self::col_class( 'created', $hidden_cols ) ) . '">' . esc_html__( 'Created At', 'techwave-form-sync-for-zoho' ) . '</th>';
     752                echo '<th scope="col" data-col="status" class="' . esc_attr( self::col_class( 'status', $hidden_cols ) ) . '">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>';
    578753                echo '<th scope="col" class="form2zoho-actions-col">' . esc_html__( 'Actions', 'techwave-form-sync-for-zoho' ) . '</th>';
    579754                echo '</tr></thead><tbody>';
    … …  
    605780
    606781                        $app     = isset( $labels[ $feed['integration'] ] ) ? $labels[ $feed['integration'] ] : $feed['integration'];
    607                         $created = ! empty( $feed['created_at'] ) ? mysql2date( 'Y-m-d H:i:s', $feed['created_at'] ) : '—';
     782                        // On the site's clock, in the timezone from Settings > General.
     783                        $created = SiteTime::feed_created( $feed );
     784                        $created = '' !== $created ? $created : '-';
    608785                        $is_on   = ! empty( $feed['enabled'] );
    609786                        // Prefer the saved human-readable module label (e.g. the mailing
    … …  
    617794                                $module_disp = substr( $module_disp, 0, 10 ) . '…';
    618795                        }
    619                         $action_nm = trim( $app . ( '' !== $module_disp ? ' — ' . $module_disp : '' ) );
     796                        $action_nm = trim( $app . ( '' !== $module_disp ? ' - ' . $module_disp : '' ) );
    620797
    621798                        echo '<tr data-feed-id="' . esc_attr( $feed['id'] ) . '" data-integration="' . esc_attr( $feed['integration'] ) . '">';
    … …  
    625802                        $tile = FormSources::tile_for( isset( $feed['form_type'] ) ? (string) $feed['form_type'] : 'elementor' );
    626803
    627                         echo '<td data-col="trigger"><strong>' . esc_html( $feed['title'] ? $feed['title'] : __( 'Untitled feed', 'techwave-form-sync-for-zoho' ) ) . '</strong></td>';
    628                         echo '<td data-col="source"><span class="form2zoho-trigger-chip">';
     804                        // The name and the trigger both open the feed, the same page the
     805                        // row menu's Edit goes to.
     806                        echo '<td data-col="trigger" class="' . esc_attr( self::col_class( 'trigger', $hidden_cols ) ) . '"><a class="form2zoho-row-open" href="' . esc_url( $edit ) . '"><strong>' . esc_html( $feed['title'] ? $feed['title'] : __( 'Untitled feed', 'techwave-form-sync-for-zoho' ) ) . '</strong></a></td>';
     807                        echo '<td data-col="source" class="' . esc_attr( self::col_class( 'source', $hidden_cols ) ) . '"><a class="form2zoho-row-open" href="' . esc_url( $edit ) . '"><span class="form2zoho-trigger-chip">';
    629808                        echo esc_html( $tile['label'] );
    630                         echo '</span><br/><span class="form2zoho-subtle">' . esc_html( $feed['form_name'] ) . '</span></td>';
    631                         echo '<td data-col="action">' . esc_html( $action_nm ) . '</td>';
    632                         echo '<td data-col="created">' . esc_html( $created ) . '</td>';
    633 
    634                         echo '<td data-col="status">';
     809                        echo '</span><br/><span class="form2zoho-subtle">' . esc_html( $feed['form_name'] ) . '</span></a></td>';
     810                        echo '<td data-col="action" class="' . esc_attr( self::col_class( 'action', $hidden_cols ) ) . '">' . esc_html( $action_nm ) . '</td>';
     811                        echo '<td data-col="created" class="' . esc_attr( self::col_class( 'created', $hidden_cols ) ) . '">' . esc_html( $created ) . '</td>';
     812
     813                        echo '<td data-col="status" class="' . esc_attr( self::col_class( 'status', $hidden_cols ) ) . '">';
    635814                        echo '<label class="form2zoho-switch">';
    636815                        printf(
    … …  
    663842                        echo '<svg class="form2zoho-menu-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5Z"/></svg>';
    664843                        echo '<span>' . esc_html__( 'Edit', 'techwave-form-sync-for-zoho' ) . '</span></a>';
     844
     845                        // The same test as the editor's "Send test record", from the list.
     846                        echo '<button type="button" class="form2zoho-menu-item form2zoho-action-test" data-feed-id="' . esc_attr( $feed['id'] ) . '">';
     847                        echo '<svg class="form2zoho-menu-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
     848                        echo '<span>' . esc_html__( 'Send test record', 'techwave-form-sync-for-zoho' ) . '</span></button>';
    665849
    666850                        echo '<button type="button" class="form2zoho-menu-item form2zoho-action-clone" data-feed-id="' . esc_attr( $feed['id'] ) . '">';
    … …  
    770954                $feed = is_array( $feed ) ? $feed : array();
    771955
    772                 $title     = isset( $feed['title'] ) ? $feed['title'] : '';
    773                 $enabled   = isset( $feed['enabled'] ) ? (bool) $feed['enabled'] : true;
    774                 $form      = isset( $feed['form_name'] ) ? $feed['form_name'] : '';
    775                 $form_type = isset( $feed['form_type'] ) && '' !== $feed['form_type'] ? $feed['form_type'] : 'elementor';
     956                $title = isset( $feed['title'] ) ? $feed['title'] : '';
     957                $form  = isset( $feed['form_name'] ) ? $feed['form_name'] : '';
     958                // Empty for a new feed: nothing is chosen until someone chooses it, and
     959                // a tile showing a green tick before the question has been answered is
     960                // a wrong answer already given. An existing feed keeps what it stored,
     961                // and one saved before form_type existed still means Elementor — the
     962                // only source there was then. See FeedsRepository::for_form().
     963                $form_type = isset( $feed['form_type'] ) && '' !== $feed['form_type']
     964                        ? $feed['form_type']
     965                        : ( $id ? 'elementor' : '' );
    776966                $intgr     = isset( $feed['integration'] ) ? $feed['integration'] : 'crm';
    777967                $conn      = isset( $feed['connection_id'] ) ? $feed['connection_id'] : '';
    … …  
    783973                $mapping   = isset( $feed['mapping'] ) && is_array( $feed['mapping'] ) ? $feed['mapping'] : array();
    784974
     975                // A saved feed's trigger cannot be changed. The form it listens to, the
     976                // fields that form has and every mapping row were chosen for that
     977                // trigger, and none of them would mean anything under another one.
     978                $trigger_locked = ! empty( $feed );
     979
    785980                $list_url = add_query_arg( array( 'page' => self::SLUG ), admin_url( 'admin.php' ) );
    786                 echo '<div class="form2zoho-editor-pop">';
     981                echo '<div class="form2zoho-editor-pop form2zoho-wizard">';
    787982                echo '<div class="form2zoho-editor-wrap">';
    788983                echo '<a href="' . esc_url( $list_url ) . '" class="form2zoho-back-link">';
    … …  
    791986                echo '</a>';
    792987
    793                 echo '<h1>' . ( $id ? esc_html__( 'Edit feed', 'techwave-form-sync-for-zoho' ) : esc_html__( 'Add feed', 'techwave-form-sync-for-zoho' ) ) . '</h1>';
    794 
    795                 $conn_choices = $this->connections->choices();
     988                // Only accounts that finished authorising. A draft — details saved, Zoho's
     989                // consent screen never completed — cannot deliver a record, so offering
     990                // it only let a feed be pointed at an account that would fail every
     991                // submission.
     992                $conn_choices = $this->connections->authorised_choices();
    796993                if ( empty( $conn_choices ) ) {
    797994                        $this->render_alert(
    … …  
    800997                                        /* translators: %s: link to the Connections tab. */
    801998                                        __( 'No Zoho accounts connected yet. Connect one under %s first.', 'techwave-form-sync-for-zoho' ),
    802                                         '<a href="' . esc_url( admin_url( 'admin.php?page=form2zoho' ) ) . '">' . esc_html__( 'Techwave Sync → Connections', 'techwave-form-sync-for-zoho' ) . '</a>'
     999                                        '<a href="' . esc_url( admin_url( 'admin.php?page=' . SettingsPage::SLUG ) ) . '">' . esc_html__( 'FormBridge → Connections', 'techwave-form-sync-for-zoho' ) . '</a>'
    8031000                                )
     1001                        );
     1002                }
     1003
     1004                // A saved feed keeps the account it names even when that account is no
     1005                // longer authorised, so opening the feed does not silently repoint it.
     1006                // Labelled, so the reason it needs attention is on screen.
     1007                $saved_conn = '' !== (string) $conn ? $this->connections->get( (string) $conn ) : null;
     1008                if ( null !== $saved_conn && ! isset( $conn_choices[ $conn ] ) ) {
     1009                        $conn_choices[ $conn ] = sprintf(
     1010                                /* translators: %s: connected account name. */
     1011                                __( '%s (not authorised)', 'techwave-form-sync-for-zoho' ),
     1012                                isset( $saved_conn['label'] ) ? $saved_conn['label'] : $conn
    8041013                        );
    8051014                }
    … …  
    8171026                );
    8181027
    819                 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" class="form2zoho-form" id="f2z-feed-editor" data-feed="' . esc_attr( $feed_js ) . '">';
     1028                echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" class="form2zoho-form" id="f2z-feed-editor" data-feed="' . esc_attr( $feed_js ) . '"' . ( $trigger_locked ? ' data-trigger-locked="1"' : '' ) . '>';
    8201029                wp_nonce_field( 'form2zoho_save_feed' );
    8211030                echo '<input type="hidden" name="action" value="form2zoho_save_feed" />';
    8221031                echo '<input type="hidden" name="feed_id" value="' . esc_attr( $id ) . '" />';
    8231032
    824                 echo '<div class="form2zoho-tabs" role="tablist">';
    825                 echo '<button type="button" class="form2zoho-tab-btn is-active" data-tab="general">' . esc_html__( 'General', 'techwave-form-sync-for-zoho' ) . '</button>';
    826                 echo '<button type="button" class="form2zoho-tab-btn" data-tab="connection">' . esc_html__( 'Zoho Connection', 'techwave-form-sync-for-zoho' ) . '</button>';
    827                 echo '<button type="button" class="form2zoho-tab-btn" data-tab="mapping">' . esc_html__( 'Field Mapping', 'techwave-form-sync-for-zoho' ) . '</button>';
    828                 echo '</div>';
    829 
    830                 echo '<div class="form2zoho-tab-panel is-active" data-tab-panel="general">';
    831                 echo '<table class="form-table" role="presentation"><tbody>';
    832 
    833                 // Title.
    834                 echo '<tr><th><label for="f2z_title">' . esc_html__( 'Feed title', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    835                 echo '<input type="text" class="regular-text" id="f2z_title" name="title" value="' . esc_attr( $title ) . '" required />';
    836                 echo '</td></tr>';
    837 
    838                 // Enabled.
    839                 echo '<tr><th>' . esc_html__( 'Enabled', 'techwave-form-sync-for-zoho' ) . '</th><td>';
    840                 // Same switch component the feeds list uses for this very same
    841                 // property (see .form2zoho-switch in admin.css). It was a bare
    842                 // checkbox here, so the one setting a person flips in two places
    843                 // looked like two different controls. Wrapped in a <label> so the
    844                 // sentence beside it names the input — the row's "Enabled" heading
    845                 // is a <th>, which is not a label for anything.
    846                 echo '<label class="form2zoho-switch-field">';
    847                 echo '<span class="form2zoho-switch">';
     1033                // A new feed is given the id it will be stored under now, while the
     1034                // editor is drawn, so a second press of "Create feed" posts the same
     1035                // id and handle_save() files it on the feed the first press made.
     1036                if ( ! $id ) {
     1037                        echo '<input type="hidden" name="new_feed_id" value="' . esc_attr( $this->feeds->generate_id() ) . '" />';
     1038                }
     1039
     1040                $this->render_stepper( $trigger_locked );
     1041
     1042                // ---- Step 1: the trigger ----------------------------------------
     1043                //
     1044                // On its own, and first, because it decides the content of everything
     1045                // after it: the list of forms, the fields those forms have, and in the
     1046                // add-on the whole shape of the record. Asking for a feed title before
     1047                // this was asking someone to name something they had not chosen yet.
     1048                // tabindex="-1" so goToStep() can put focus on the panel. A wizard that
     1049                // swaps its whole content without moving focus leaves a screen-reader
     1050                // user reading the step they just left. The panel rather than its
     1051                // heading, because role="group" + aria-labelledby means focusing it
     1052                // announces the step's name and what is inside it — and because a
     1053                // focus ring drawn round a heading after a mouse click reads as a
     1054                // defect rather than as an indicator.
     1055                // Not the opening step for a saved feed: its trigger is locked, so the
     1056                // editor opens on step two instead and this step is never shown.
    8481057                printf(
    849                         '<input type="checkbox" name="enabled" value="1" %s />',
    850                         checked( true, $enabled, false ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- checked() returns a fixed attribute string.
     1058                        '<section class="form2zoho-step%s" data-step="trigger" tabindex="-1" role="group" aria-labelledby="f2z-step-trigger-title">',
     1059                        $trigger_locked ? '' : ' is-active'
    8511060                );
    852                 echo '<span class="form2zoho-switch__track" aria-hidden="true"><span class="form2zoho-switch__thumb"></span></span>';
    853                 echo '</span>';
    854                 echo '<span>' . esc_html__( 'Send submissions for this feed.', 'techwave-form-sync-for-zoho' ) . '</span>';
    855                 echo '</label>';
    856                 echo '</td></tr>';
    857 
    858                 // Form plugin (Elementor, Contact Form 7, WPForms, Fluent Forms, Forminator, Ninja Forms, Gravity Forms).
    859                 echo '<tr><th>' . esc_html__( 'Form plugin', 'techwave-form-sync-for-zoho' ) . '</th><td>';
    860                 echo '<div class="form2zoho-source-grid" role="radiogroup" aria-label="' . esc_attr__( 'Form plugin', 'techwave-form-sync-for-zoho' ) . '">';
     1061                echo '<div class="form2zoho-step-body">';
     1062                echo '<div class="form2zoho-step-head form2zoho-step-head--center">';
     1063                echo '<h2 id="f2z-step-trigger-title">' . esc_html__( 'Choose a trigger', 'techwave-form-sync-for-zoho' ) . '</h2>';
     1064                echo '<p class="description">' . esc_html__( 'Which form or event should send records to Zoho? Picking one moves you straight on.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1065                // Twenty-seven tiles is the one screen in this editor where someone
     1066                // can be unsure what they are even choosing between, so the guide is
     1067                // offered here rather than as a general "Help" on every step. The
     1068                // documentation is per-step; so is the link.
     1069                self::render_docs_link( 'trigger', __( 'Trigger guide', 'techwave-form-sync-for-zoho' ) );
     1070                // And the recording of the same ground. This is the step where a
     1071                // person is most likely to be looking at a name they do not recognise,
     1072                // so it is worth offering both here rather than only the page.
     1073                VideoGuide::render( 'triggers', __( 'Watch: every trigger', 'techwave-form-sync-for-zoho' ) );
     1074                echo '</div>';
     1075
     1076                // A filter, not a search: it hides tiles that do not match and touches
     1077                // nothing else. Twenty-seven tiles is more than a screen and more than
     1078                // anyone wants to read through, and the answer is nearly always a
     1079                // product whose name the person already knows.
     1080                //
     1081                // type="search" for the browser's own clear button, and a label rather
     1082                // than the placeholder alone — a placeholder is gone the moment
     1083                // anything is typed, which is exactly when a screen reader user needs
     1084                // to know what the box is.
     1085                echo '<div class="form2zoho-trigger-filter">';
     1086                echo '<label class="screen-reader-text" for="f2z_trigger_search">' . esc_html__( 'Search triggers', 'techwave-form-sync-for-zoho' ) . '</label>';
     1087                echo '<svg class="form2zoho-trigger-filter-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
     1088                echo '<input type="search" id="f2z_trigger_search" class="form2zoho-trigger-search" autocomplete="off" placeholder="' . esc_attr__( 'Search triggers…', 'techwave-form-sync-for-zoho' ) . '" />';
     1089                echo '</div>';
     1090
     1091                // Elementor, Contact Form 7, WPForms, Fluent Forms, Forminator, Ninja
     1092                // Forms, Gravity Forms — and, with the add-on, seventeen more.
     1093                echo '<div class="form2zoho-source-grid" role="radiogroup" aria-label="' . esc_attr__( 'Trigger', 'techwave-form-sync-for-zoho' ) . '">';
    8611094                foreach ( FormSources::tiles() as $tile ) {
    862                         $is_active = ( $form_type === $tile['key'] );
    863                         echo '<button type="button" class="form2zoho-source-tile' . ( $is_active ? ' is-active' : '' ) . '" data-source="' . esc_attr( $tile['key'] ) . '" role="radio" aria-checked="' . ( $is_active ? 'true' : 'false' ) . '">';
     1095                        $locked    = ! empty( $tile['locked'] );
     1096                        $is_active = ( ! $locked && $form_type === $tile['key'] );
     1097
     1098                        $classes = 'form2zoho-source-tile';
     1099                        if ( $is_active ) {
     1100                                $classes .= ' is-active';
     1101                        }
     1102                        if ( $locked ) {
     1103                                $classes .= ' is-locked';
     1104                        }
     1105
     1106                        // A locked tile is deliberately NOT `disabled`, and deliberately
     1107                        // not a radio either. It is not an option that happens to be
     1108                        // unavailable — it is a button that explains an add-on, and it
     1109                        // opens a dialog. Marking it up as a disabled radio would say
     1110                        // "this is a choice you cannot make" to a screen reader while
     1111                        // being, in fact, a working button; `aria-haspopup="dialog"` is
     1112                        // what it actually is. Nothing can be selected by clicking it:
     1113                        // the JS handler returns early on .is-locked, the hidden select
     1114                        // carries no option for it, and sanitize_form_type() rejects the
     1115                        // key even if one is posted by hand.
     1116                        if ( $locked ) {
     1117                                printf(
     1118                                        '<button type="button" class="%s" data-source="%s" data-pro-title="%s" data-pro-blurb="%s" data-pro-icon="%s" aria-haspopup="dialog">',
     1119                                        esc_attr( $classes ),
     1120                                        esc_attr( $tile['key'] ),
     1121                                        esc_attr( ProUpsell::headline( $tile['key'], $tile['label'] ) ),
     1122                                        esc_attr( ProUpsell::blurb( $tile['key'] ) ),
     1123                                        esc_url( $tile['icon'] )
     1124                                );
     1125                        } else {
     1126                                // data-available is what stops the next step waiting on a
     1127                                // round trip to learn something this page already knows.
     1128                                // admin-ajax boots the whole of WordPress and every active
     1129                                // plugin before it reaches our handler — measured at two to
     1130                                // four seconds on this install for a request that does no
     1131                                // work at all — so asking it "is this plugin running?" made
     1132                                // the answer arrive long after the screen that needed it.
     1133                                printf(
     1134                                        '<button type="button" class="%s" data-source="%s" data-available="%d" role="radio" aria-checked="%s">',
     1135                                        esc_attr( $classes ),
     1136                                        esc_attr( $tile['key'] ),
     1137                                        FormSources::is_available( $tile['key'] ) ? 1 : 0,
     1138                                        $is_active ? 'true' : 'false'
     1139                                );
     1140                        }
     1141
    8641142                        if ( ! empty( $tile['icon'] ) ) {
    8651143                                echo '<span class="form2zoho-source-icon form2zoho-source-icon-img"><img src="' . esc_url( $tile['icon'] ) . '" alt="" loading="lazy" /></span>';
    … …  
    8681146                        }
    8691147                        echo '<span class="form2zoho-source-label">' . esc_html( $tile['label'] ) . '</span>';
     1148
     1149                        if ( $locked ) {
     1150                                // Inside the button so it is read out with the label rather
     1151                                // than as a stray word after it.
     1152                                echo '<span class="form2zoho-source-badge">' . esc_html__( 'Pro', 'techwave-form-sync-for-zoho' ) . '</span>';
     1153                                // "WooCommerce Customer Pro" does not tell anyone what
     1154                                // pressing this does. This does, and only to a screen reader
     1155                                // — sighted users get the same answer from the badge plus the
     1156                                // dialog that opens.
     1157                                echo '<span class="screen-reader-text">' . esc_html__( 'Available in FormBridge Pro. Opens a dialog.', 'techwave-form-sync-for-zoho' ) . '</span>';
     1158                        }
     1159
    8701160                        echo '</button>';
    8711161                }
    8721162                echo '</div>';
    8731163                echo '<select id="f2z_form_type" name="form_type" class="form2zoho-source-select" hidden>';
     1164                // A browser selects the first option of a select that has none marked,
     1165                // so "nothing chosen" has to be an option of its own or the hidden
     1166                // field would quietly post the alphabetically first source.
     1167                if ( '' === $form_type ) {
     1168                        echo '<option value="" selected></option>';
     1169                }
    8741170                foreach ( FormSources::labels() as $key => $label ) {
     1171                        // The tiles drive this select, and a locked tile cannot be
     1172                        // clicked — but the select is what the form actually posts, so
     1173                        // leaving the option in it would let the value be set another way.
     1174                        if ( FormSources::is_locked( $key ) ) {
     1175                                continue;
     1176                        }
    8751177                        echo '<option value="' . esc_attr( $key ) . '" ' . selected( $form_type, $key, false ) . '>' . esc_html( $label ) . '</option>';
    8761178                }
    8771179                echo '</select>';
    878                 echo '<p class="description">' . esc_html__( 'Choose which form plugin this feed listens to.', 'techwave-form-sync-for-zoho' ) . '</p>';
    879                 echo '</td></tr>';
     1180
     1181                // Shown by the filter when nothing matches, so an empty grid is not
     1182                // left to be read as "there are no triggers".
     1183                echo '<p class="form2zoho-trigger-none" hidden>' . esc_html__( 'No trigger matches that search.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1184                echo '</div>';
     1185
     1186                // No Next here on purpose: choosing a tile *is* the answer to this
     1187                // step, and a button beside it would be a second way to do the same
     1188                // thing that could be pressed without doing it at all. A saved feed
     1189                // never shows this step, so it needs no way past it either.
     1190                echo '</section>';
     1191
     1192                // ---- Step 2: what to call it, and which form ---------------------
     1193                //
     1194                // Opens with the trigger just chosen, drawn as a summary row rather
     1195                // than a second copy of the grid. The grid is not on screen any more,
     1196                // so without this the answer to step one is invisible from step two —
     1197                // and "Change" is the shortest way back to a different answer.
     1198                printf(
     1199                        '<section class="form2zoho-step%s" data-step="details" tabindex="-1" role="group" aria-labelledby="f2z-step-details-title">',
     1200                        $trigger_locked ? ' is-active' : ''
     1201                );
     1202
     1203                $chosen = null;
     1204                foreach ( FormSources::tiles() as $tile ) {
     1205                        if ( empty( $tile['locked'] ) && $form_type === $tile['key'] ) {
     1206                                $chosen = $tile;
     1207                                break;
     1208                        }
     1209                }
     1210
     1211                // A saved feed from a Pro source, opened while the add-on is off, has
     1212                // no selectable tile to copy. Its trigger is still the one it was saved
     1213                // with, so the card still names it.
     1214                if ( null === $chosen && $trigger_locked ) {
     1215                        $chosen = FormSources::tile_for( $form_type );
     1216                }
     1217
     1218                // Centred with the summary card and the fields under it. Steps one and
     1219                // two ask one question each and centre on the same axis; steps three
     1220                // and four are full-width forms and stay left-aligned. A left heading
     1221                // over a centred column reads as a column that failed to line up.
     1222                echo '<div class="form2zoho-step-head form2zoho-step-head--center">';
     1223                echo '<h2 id="f2z-step-details-title">' . esc_html__( 'Name this feed', 'techwave-form-sync-for-zoho' ) . '</h2>';
     1224                // A form plugin's trigger listens to a form; every other trigger
     1225                // listens to something that happens on the site. admin.js keeps the
     1226                // wording in step when the trigger is changed without a page load.
     1227                $is_event = '' !== (string) $form_type && ! FormSources::is_form( (string) $form_type );
     1228                echo '<p class="description form2zoho-details-help">' . esc_html( $is_event ? __( 'Give the feed a name you will recognise later, and pick the event it listens to.', 'techwave-form-sync-for-zoho' ) : __( 'Give the feed a name you will recognise later, and pick the form it listens to.', 'techwave-form-sync-for-zoho' ) ) . '</p>';
     1229                // The guide for the one trigger that was chosen, rather than the page
     1230                // listing all of them: by this step the question has stopped being
     1231                // "which of these?" and become "how do I wire this one up?", and the
     1232                // answer is a different page per form plugin.
     1233                self::render_trigger_docs_link( $chosen ? (string) $chosen['key'] : '' );
     1234                echo '</div>';
     1235
     1236                echo '<div class="form2zoho-chosen">';
     1237                echo '<div class="form2zoho-chosen-card" data-source="' . esc_attr( $chosen ? $chosen['key'] : '' ) . '">';
     1238                echo '<span class="form2zoho-chosen-icon">';
     1239                if ( $chosen && ! empty( $chosen['icon'] ) ) {
     1240                        echo '<img src="' . esc_url( $chosen['icon'] ) . '" alt="" />';
     1241                }
     1242                echo '</span>';
     1243                echo '<span class="form2zoho-chosen-text">';
     1244                echo '<span class="form2zoho-chosen-eyebrow">' . esc_html__( 'Trigger', 'techwave-form-sync-for-zoho' ) . '</span>';
     1245                echo '<span class="form2zoho-chosen-label">' . esc_html( $chosen ? $chosen['label'] : '' ) . '</span>';
     1246                echo '</span>';
     1247                if ( $trigger_locked ) {
     1248                        // A badge where Change would be, so the card says the trigger is
     1249                        // fixed rather than looking like it lost a button.
     1250                        echo '<span class="form2zoho-chosen-lock">';
     1251                        echo '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
     1252                        echo '<span>' . esc_html__( 'Locked', 'techwave-form-sync-for-zoho' ) . '</span>';
     1253                        echo '</span>';
     1254                } else {
     1255                        // The visible word is part of the accessible name (WCAG 2.5.3), so
     1256                        // aria-label extends "Change" rather than replacing it with something
     1257                        // a speech-input user cannot say.
     1258                        printf(
     1259                                '<button type="button" class="form2zoho-chosen-clear" aria-label="%s">',
     1260                                esc_attr__( 'Change trigger', 'techwave-form-sync-for-zoho' )
     1261                        );
     1262                        echo '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
     1263                        echo '<span>' . esc_html__( 'Change', 'techwave-form-sync-for-zoho' ) . '</span>';
     1264                        echo '</button>';
     1265                }
     1266                echo '</div>';
     1267                if ( $trigger_locked ) {
     1268                        echo '<p class="form2zoho-chosen-note">' . esc_html( self::locked_trigger_note() ) . '</p>';
     1269                }
     1270                echo '</div>';
     1271
     1272                // Filled and shown by admin.js when the chosen trigger's plugin turns
     1273                // out not to be running. It replaces the rest of the step rather than
     1274                // sitting above it: there is no form list to pick from and no feed to
     1275                // name, so leaving those on screen would be offering work that cannot
     1276                // be finished.
     1277                echo '<p class="form2zoho-trigger-unavailable" hidden></p>';
     1278
     1279                echo '<div class="form2zoho-step-body">';
     1280
     1281                // A stacked column rather than WordPress's two-column form-table: two
     1282                // fields under one summary card do not need a label column, and a
     1283                // label sitting a third of the screen away from its field is worse the
     1284                // wider the panel gets.
     1285                echo '<div class="form2zoho-fieldset">';
     1286
     1287                // Title.
     1288                echo '<div class="form2zoho-field">';
     1289                echo '<label for="f2z_title">' . esc_html__( 'Feed title', 'techwave-form-sync-for-zoho' ) . ' <span class="form2zoho-req" aria-hidden="true">*</span></label>';
     1290                echo '<input type="text" class="form2zoho-control--full" id="f2z_title" name="title" value="' . esc_attr( $title ) . '" placeholder="' . esc_attr__( 'Contact form → Zoho CRM leads', 'techwave-form-sync-for-zoho' ) . '" required />';
     1291                echo '<p class="description">' . esc_html__( 'Only used inside WordPress. It is how this feed is listed here and named in the delivery log.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1292                echo '</div>';
    8801293
    8811294                // Form name (dropdown of discovered forms from the chosen plugin).
    882                 echo '<tr id="f2z_form_row"><th><label for="f2z_form">' . esc_html__( 'Form', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
     1295                echo '<div class="form2zoho-field">';
     1296                echo '<label for="f2z_form"><span class="form2zoho-form-word">' . esc_html( $is_event ? __( 'Event', 'techwave-form-sync-for-zoho' ) : __( 'Form', 'techwave-form-sync-for-zoho' ) ) . '</span> <span class="form2zoho-req" aria-hidden="true">*</span></label>';
    8831297                // .form2zoho-control-row keeps the select and its reload button on one
    884                 // line and vertically centred (see admin.css section 4.3).
     1298                // line and vertically centred (see admin.css section 4.3), and busy()
     1299                // finds the reload button through it.
    8851300                echo '<div class="form2zoho-control-row">';
    886                 echo '<select id="f2z_form" name="form_name" required>';
     1301                echo '<select id="f2z_form" name="form_name" class="form2zoho-control--full" required>';
    8871302                if ( '' !== $form ) {
    8881303                        echo '<option value="' . esc_attr( $form ) . '" selected>' . esc_html( $form ) . '</option>';
    8891304                } else {
    890                         echo '<option value="">' . esc_html__( 'Loading forms…', 'techwave-form-sync-for-zoho' ) . '</option>';
     1305                        echo '<option value="">' . esc_html( $is_event ? __( 'Loading events…', 'techwave-form-sync-for-zoho' ) : __( 'Loading forms…', 'techwave-form-sync-for-zoho' ) ) . '</option>';
    8911306                }
    8921307                echo '</select>';
    893                 echo '<button type="button" class="form2zoho-reload-forms" aria-label="' . esc_attr__( 'Reload forms', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload forms', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
    894                 echo '</div>';
    895                 echo '<p class="description form2zoho-form-help">' . esc_html__( 'Pick the form to connect. The list is read from your saved forms.', 'techwave-form-sync-for-zoho' ) . '</p>';
    896                 echo '</td></tr>';
    897 
    898                 echo '</tbody></table>';
    899                 echo '<div class="form2zoho-tab-footer">';
    900                 echo '<span></span>';
    901                 echo '<button type="button" class="button button-primary form2zoho-tab-next" data-next="connection">' . esc_html__( 'Next', 'techwave-form-sync-for-zoho' ) . ' &rsaquo;</button>';
    902                 echo '</div>';
    903                 echo '</div>';
    904 
    905                 echo '<div class="form2zoho-tab-panel" data-tab-panel="connection">';
    906                 echo '<table class="form-table" role="presentation"><tbody>';
    907 
    908                 // Integration.
    909                 echo '<tr><th><label for="f2z_intgr">' . esc_html__( 'Zoho app', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    910                 echo '<select id="f2z_intgr" name="integration">';
    911                 foreach ( $this->registry->choices() as $key => $label ) {
    912                         echo '<option value="' . esc_attr( $key ) . '" ' . selected( $intgr, $key, false ) . '>' . esc_html( $label ) . '</option>';
    913                 }
    914                 echo '</select>';
    915                 echo '<p class="form2zoho-app-auth-warning" hidden></p>';
    916                 echo '</td></tr>';
    917 
    918                 // Connection.
    919                 echo '<tr><th><label for="f2z_conn">' . esc_html__( 'Connected account', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    920                 echo '<select id="f2z_conn" name="connection_id" required>';
    921                 echo '<option value="">' . esc_html__( '— Select —', 'techwave-form-sync-for-zoho' ) . '</option>';
     1308                echo '<button type="button" class="form2zoho-reload-forms" aria-label="' . esc_attr( $is_event ? __( 'Reload events', 'techwave-form-sync-for-zoho' ) : __( 'Reload forms', 'techwave-form-sync-for-zoho' ) ) . '" title="' . esc_attr( $is_event ? __( 'Reload events', 'techwave-form-sync-for-zoho' ) : __( 'Reload forms', 'techwave-form-sync-for-zoho' ) ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
     1309                echo '</div>';
     1310                echo '<p class="description form2zoho-form-help">' . esc_html( $is_event ? __( 'Pick the event that sends a record to Zoho.', 'techwave-form-sync-for-zoho' ) : __( 'Pick the form to connect. The list is read from your saved forms.', 'techwave-form-sync-for-zoho' ) ) . '</p>';
     1311                echo '</div>';
     1312
     1313                /**
     1314                 * Renders extra fields on the feed editor's second step, under the
     1315                 * Form picker and inside the same column.
     1316                 *
     1317                 * The companion hook to form2zoho_feed_editor_fields, which draws at
     1318                 * the foot of the form on the last step. This one is for a source that
     1319                 * needs something said about *which* form was chosen before the person
     1320                 * moves on — an add-on whose "forms" are endpoints it owns, say, and
     1321                 * which has to show the URL for the one selected.
     1322                 *
     1323                 * Whatever is printed here posts along with the feed; store it by
     1324                 * hooking form2zoho_save_feed. Escape your own output.
     1325                 *
     1326                 * @param array  $feed The feed being edited. Empty for a new one.
     1327                 * @param string $id   The feed id. Empty for a new one.
     1328                 */
     1329                do_action( 'form2zoho_feed_editor_details_fields', $feed, $id );
     1330
     1331                echo '</div>';
     1332                echo '</div>';
     1333
     1334                // Disabled until both fields are filled, and re-checked on every
     1335                // keystroke by admin.js. The attribute is set here rather than left to
     1336                // the script so the button is never briefly live before the page has
     1337                // finished loading.
     1338                echo '<div class="form2zoho-step-footer">';
     1339                // Back from here is back to the trigger grid, which a saved feed does
     1340                // not get. The spacer keeps Continue on the right.
     1341                if ( $trigger_locked ) {
     1342                        echo '<span class="form2zoho-step-footer__spacer"></span>';
     1343                } else {
     1344                        echo '<button type="button" class="button form2zoho-step-back" data-back="trigger">' . esc_html__( 'Back', 'techwave-form-sync-for-zoho' ) . '</button>';
     1345                }
     1346                printf(
     1347                        '<button type="button" class="button button-primary form2zoho-step-next" data-next="connection"%s>%s</button>',
     1348                        ( '' !== $title && '' !== $form ) ? '' : ' disabled',
     1349                        esc_html__( 'Continue', 'techwave-form-sync-for-zoho' )
     1350                );
     1351                echo '</div>';
     1352                echo '</section>';
     1353
     1354                // ---- Step 3: where the records go --------------------------------
     1355                //
     1356                // Two questions, not seven fields: "which Zoho account receives this"
     1357                // and "what should happen when the record is already there". They are
     1358                // two groups with their own headings, because a two-column form-table
     1359                // gave a single select and a five-control duplicate-matching block the
     1360                // same visual weight.
     1361                echo '<section class="form2zoho-step" data-step="connection" tabindex="-1" role="group" aria-labelledby="f2z-step-connection-title">';
     1362                echo '<div class="form2zoho-step-head">';
     1363                echo '<h2 id="f2z-step-connection-title">' . esc_html__( 'Where should records go?', 'techwave-form-sync-for-zoho' ) . '</h2>';
     1364                echo '<p class="description">' . esc_html__( 'Pick the account, then the Zoho app it is authorised for, and the kind of record to create.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1365                echo '</div>';
     1366
     1367                echo '<div class="form2zoho-step-body">';
     1368
     1369                // --- Group: destination ---
     1370                echo '<section class="form2zoho-group">';
     1371                echo '<div class="form2zoho-group__head">';
     1372                echo '<div class="form2zoho-group__title">';
     1373                echo '<h3><span class="form2zoho-group__icon" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>' . esc_html__( 'Zoho destination', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1374                echo '</div>';
     1375                // The action sits in the group's header rather than under the account
     1376                // picker. Under it, this was two lines of link and explanation inside
     1377                // one grid cell, which made that cell half as tall again as the app
     1378                // picker beside it — and left a hole under the app picker that read as
     1379                // a missing field. Here it is out of the grid entirely, both cells are
     1380                // a label and a control, and the row is level.
     1381                //
     1382                // The app's guide and the video sit beside it, in the same header, so
     1383                // the fields below stay one level row.
     1384                echo '<div class="form2zoho-dest-links">';
     1385                $this->render_app_docs_link( (string) $intgr );
     1386                $video = VideoGuide::url( 'actions' );
     1387                if ( '' !== $video ) {
     1388                        printf(
     1389                                '<a class="form2zoho-step-docs form2zoho-video-link form2zoho-youtube-link" href="%1$s" target="_blank" rel="noopener noreferrer" aria-label="%2$s"><svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="1" y="4" width="22" height="16" rx="5" fill="#ff0000"/><polygon points="10 8.5 16 12 10 15.5" fill="#fff"/></svg><span>%3$s</span><svg class="form2zoho-extlink" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>',
     1390                                esc_url( $video ),
     1391                                esc_attr__( 'Watch: every Zoho app, on YouTube (opens in a new tab)', 'techwave-form-sync-for-zoho' ),
     1392                                esc_html__( 'YouTube', 'techwave-form-sync-for-zoho' )
     1393                        );
     1394                }
     1395                printf(
     1396                        '<a href="%s" class="form2zoho-add-connection" target="_blank" rel="noopener noreferrer" aria-label="%s"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>%s</span></a>',
     1397                        esc_url( admin_url( 'admin.php?page=' . SettingsPage::SLUG ) ),
     1398                        // The visible words are the start of the accessible name, so a
     1399                        // speech-input user can say what they can see (WCAG 2.5.3) and the
     1400                        // arrow icon's meaning is spelled out for anyone not seeing it.
     1401                        esc_attr__( 'Connect account (opens in a new tab)', 'techwave-form-sync-for-zoho' ),
     1402                        esc_html__( 'Connect account', 'techwave-form-sync-for-zoho' )
     1403                );
     1404                echo '</div>';
     1405                echo '</div>';
     1406                echo '<div class="form2zoho-group__body form2zoho-grid form2zoho-grid--dest">';
     1407
     1408                // Connection — first, because the app list below depends on it: each
     1409                // account is authorised for its own set of Zoho apps, and admin.js
     1410                // narrows the app picker to those once an account is chosen.
     1411                //
     1412                // The list has a reload button and a way to add to it, because the
     1413                // answer to "which account?" is quite often "one I have not connected
     1414                // yet" — and until now that meant leaving a half-built feed to go and
     1415                // make one. The link opens the Connections screen in a new tab so this
     1416                // one survives; the reload button then brings the new account into
     1417                // this list without a page load, so nothing typed here is lost.
     1418                echo '<div class="form2zoho-field" id="f2z_conn_row">';
     1419                echo '<label for="f2z_conn">' . esc_html__( 'Account', 'techwave-form-sync-for-zoho' ) . ' <span class="form2zoho-req" aria-hidden="true">*</span></label>';
     1420                echo '<div class="form2zoho-control-row">';
     1421                echo '<select id="f2z_conn" name="connection_id" class="form2zoho-control--full" required>';
     1422                echo '<option value="">' . esc_html__( 'Select', 'techwave-form-sync-for-zoho' ) . '</option>';
    9221423                foreach ( $conn_choices as $cid => $clabel ) {
    9231424                        $conn_row = $this->connections->get( $cid );
    … …  
    9261427                }
    9271428                echo '</select>';
    928                 echo '</td></tr>';
     1429                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-connections" aria-label="' . esc_attr__( 'Reload connected accounts', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload connected accounts', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
     1430                echo '</div>';
     1431                echo '<p class="description form2zoho-form-help-warn" id="f2z_conn_notice" hidden></p>';
     1432                echo '</div>';
     1433
     1434                // Integration. Every app is rendered; admin.js removes the ones the
     1435                // chosen account is not authorised for.
     1436                echo '<div class="form2zoho-field">';
     1437                echo '<label for="f2z_intgr">' . esc_html__( 'Zoho app', 'techwave-form-sync-for-zoho' ) . '</label>';
     1438                echo '<select id="f2z_intgr" name="integration" class="form2zoho-control--full">';
     1439                foreach ( $this->registry->choices() as $key => $label ) {
     1440                        echo '<option value="' . esc_attr( $key ) . '" ' . selected( $intgr, $key, false ) . '>' . esc_html( $label ) . '</option>';
     1441                }
     1442                echo '</select>';
     1443                // The guide for the chosen app and the video are in the group header
     1444                // above; see the "Zoho destination" heading.
     1445                echo '<p class="form2zoho-app-auth-warning" hidden></p>';
     1446                echo '</div>';
    9291447
    9301448                // Organization — used by Books/Desk (required) and by Bigin
    … …  
    9371455                // instead (see DataCenters::record_url()'s 'campaigns'/'automation'
    9381456                // cases) rather than offering a field that can't really be filled.
    939                 echo '<tr id="f2z_org_row"><th><label for="f2z_org">' . esc_html__( 'Organization', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    940                 echo '<select id="f2z_org" name="org_id">';
    941                 echo '<option value="">' . esc_html__( '— None —', 'techwave-form-sync-for-zoho' ) . '</option>';
     1457                //
     1458                // The id stays on the wrapper because admin.js shows and hides this
     1459                // field by it — it was a <tr>, it is a <div>, and jQuery .toggle()
     1460                // treats both the same.
     1461                echo '<div class="form2zoho-field" id="f2z_org_row">';
     1462                echo '<label for="f2z_org">' . esc_html__( 'Organization', 'techwave-form-sync-for-zoho' ) . '</label>';
     1463                echo '<div class="form2zoho-control-row">';
     1464                echo '<select id="f2z_org" name="org_id" class="form2zoho-control--full">';
     1465                echo '<option value="">' . esc_html__( 'None', 'techwave-form-sync-for-zoho' ) . '</option>';
    9421466                if ( '' !== $org ) {
    9431467                        echo '<option value="' . esc_attr( $org ) . '" selected>' . esc_html( $org ) . '</option>';
    9441468                }
    9451469                echo '</select>';
    946                 echo '<p class="description" id="f2z_org_help">' . esc_html__( 'Required for Zoho Books and Zoho Desk. (Bigin does not use this field — its organization is captured automatically when the connection is authorized.)', 'techwave-form-sync-for-zoho' ) . '</p>';
    947                 echo '</td></tr>';
     1470                // A reload button inside the box, as the Module picker has, rather
     1471                // than a spinner beside it: the two pickers sat side by side saying
     1472                // "loading" two different ways. The button spins while the list is
     1473                // on its way, the way every reloadable list in the editor does.
     1474                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-orgs" aria-label="' . esc_attr__( 'Reload organizations', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload organizations', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
     1475                echo '</div>';
     1476                echo '<p class="description" id="f2z_org_help">' . esc_html__( 'Required for Zoho Books and Zoho Desk. Bigin captures its own organization when the connection is authorized.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1477                echo '</div>';
    9481478
    9491479                // Department (Zoho Desk only).
    950                 echo '<tr id="f2z_dept_row"><th><label for="f2z_dept">' . esc_html__( 'Department', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    951                 echo '<select id="f2z_dept" name="department_id">';
    952                 echo '<option value="">' . esc_html__( '— None —', 'techwave-form-sync-for-zoho' ) . '</option>';
     1480                echo '<div class="form2zoho-field" id="f2z_dept_row">';
     1481                echo '<label for="f2z_dept">' . esc_html__( 'Department', 'techwave-form-sync-for-zoho' ) . '</label>';
     1482                echo '<div class="form2zoho-control-row">';
     1483                echo '<select id="f2z_dept" name="department_id" class="form2zoho-control--full">';
     1484                echo '<option value="">' . esc_html__( 'None', 'techwave-form-sync-for-zoho' ) . '</option>';
    9531485                if ( '' !== $dept ) {
    9541486                        echo '<option value="' . esc_attr( $dept ) . '" selected>' . esc_html( $dept ) . '</option>';
    9551487                }
    9561488                echo '</select>';
     1489                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-depts" aria-label="' . esc_attr__( 'Reload departments', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload departments', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
     1490                echo '</div>';
    9571491                echo '<p class="description">' . esc_html__( 'Required for Zoho Desk tickets. Loads after you pick the organization.', 'techwave-form-sync-for-zoho' ) . '</p>';
    958                 echo '</td></tr>';
     1492                echo '</div>';
    9591493
    9601494                // Module (dropdown loaded from Zoho for the chosen app + account).
    9611495                // Relabelled to "Mailing List" by JS, and hidden entirely in favour
    962                 // of the Campaigns/Marketing Automation section below, when one of
     1496                // of the Campaigns/Marketing Automation group below, when one of
    9631497                // those two apps is selected — both reuse this same field to store
    9641498                // the chosen list, they just present it differently.
    965                 echo '<tr id="f2z_module_row"><th><label for="f2z_module" id="f2z_module_label">' . esc_html__( 'Module / resource', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
     1499                echo '<div class="form2zoho-field form2zoho-field--wide" id="f2z_module_row">';
     1500                echo '<label for="f2z_module" id="f2z_module_label">' . esc_html__( 'Module', 'techwave-form-sync-for-zoho' ) . ' <span class="form2zoho-req" aria-hidden="true">*</span></label>';
    9661501                echo '<div class="form2zoho-control-row">';
    967                 echo '<select id="f2z_module" name="module" required>';
     1502                echo '<select id="f2z_module" name="module" class="form2zoho-control--full" aria-describedby="f2z_module_help" required>';
    9681503                if ( '' !== $module ) {
    9691504                        echo '<option value="' . esc_attr( $module ) . '" selected>' . esc_html( $module ) . '</option>';
    9701505                } else {
    971                         echo '<option value="">' . esc_html__( '— Select an account first —', 'techwave-form-sync-for-zoho' ) . '</option>';
     1506                        echo '<option value="">' . esc_html__( 'Select an account first', 'techwave-form-sync-for-zoho' ) . '</option>';
    9721507                }
    9731508                echo '</select>';
    … …  
    9751510                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-module" aria-label="' . esc_attr__( 'Reload mailing lists', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload mailing lists', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
    9761511                echo '</div>';
    977                 echo '<p class="description" id="f2z_module_help">' . esc_html__( 'Loaded from Zoho once the app and account are chosen.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1512                echo '<p class="description screen-reader-text" id="f2z_module_help">' . esc_html__( 'Loaded from Zoho once the app and account are chosen.', 'techwave-form-sync-for-zoho' ) . '</p>';
    9781513                echo '<p class="description form2zoho-form-help-warn" id="f2z_module_empty_notice" hidden>' . esc_html__( 'No Mailing Lists found in Zoho Campaigns.', 'techwave-form-sync-for-zoho' ) . '</p>';
    979                 echo '</td></tr>';
    980 
    981                 // Campaigns / Marketing Automation — dedicated fields, hidden for
    982                 // every other app. Mirrors Zoho's own "Add Contact" screen.
     1514                echo '</div>';
     1515
     1516                /**
     1517                 * Fires inside the destination group, after the module picker.
     1518                 *
     1519                 * For one more answer about *this* Zoho account and module — the
     1520                 * shape the Organization and Department fields above already have.
     1521                 * Print a `.form2zoho-field` and it lines up with them.
     1522                 *
     1523                 * The other seam on this step, form2zoho_feed_editor_connection_fields,
     1524                 * fires below the whole group and is for a destination that needs
     1525                 * settings of its own; a single field printed there reads as an
     1526                 * orphan under the card rather than as part of it.
     1527                 *
     1528                 * Whatever is printed here posts along with the feed; store it by
     1529                 * hooking form2zoho_save_feed. Escape your own output.
     1530                 *
     1531                 * @param array  $feed The feed being edited. Empty for a new one.
     1532                 * @param string $id   The feed id. Empty for a new one.
     1533                 */
     1534                do_action( 'form2zoho_feed_editor_destination_fields', $feed, $id );
     1535
     1536                echo '</div>';
     1537                echo '</section>';
     1538
     1539                /**
     1540                 * Renders extra groups on the feed editor's third step, under the
     1541                 * destination group and above duplicate handling.
     1542                 *
     1543                 * For a destination that needs settings of its own — one that is not a
     1544                 * Zoho account, and so has nothing the Connected account and Module
     1545                 * pickers can describe. Print a `.form2zoho-group` section to sit with
     1546                 * the rest.
     1547                 *
     1548                 * Whatever is printed here posts along with the feed; store it by
     1549                 * hooking form2zoho_save_feed. Escape your own output.
     1550                 *
     1551                 * @param array  $feed The feed being edited. Empty for a new one.
     1552                 * @param string $id   The feed id. Empty for a new one.
     1553                 */
     1554                do_action( 'form2zoho_feed_editor_connection_fields', $feed, $id );
     1555
     1556                // --- Group: Campaigns / Marketing Automation ---
     1557                // Dedicated fields, hidden for every other app. Mirrors Zoho's own
     1558                // "Add Contact" screen. The id is the one admin.js toggles.
    9831559                $camp_sub     = isset( $feed['campaigns_subscription_type'] ) ? (string) $feed['campaigns_subscription_type'] : '';
    9841560                $camp_tag_raw = isset( $feed['campaigns_tag'] ) ? $feed['campaigns_tag'] : array();
    … …  
    9861562                $camp_topics  = isset( $feed['campaigns_topics'] ) && is_array( $feed['campaigns_topics'] ) ? $feed['campaigns_topics'] : array();
    9871563
    988                 echo '</tbody><tbody id="f2z_campaigns_extra" hidden>';
    989 
    990                 echo '<tr><th><label for="f2z_subscription_type">' . esc_html__( 'Subscription Type', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    991                 echo '<select id="f2z_subscription_type" name="campaigns_subscription_type">';
     1564                echo '<section class="form2zoho-group" id="f2z_campaigns_extra" hidden>';
     1565                echo '<div class="form2zoho-group__head">';
     1566                echo '<div class="form2zoho-group__title">';
     1567                echo '<h3>' . esc_html__( 'Contact options', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1568                echo '<p class="description">' . esc_html__( 'How the contact is subscribed, and what it is filed under.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1569                echo '</div>';
     1570                echo '</div>';
     1571                echo '<div class="form2zoho-group__body form2zoho-grid">';
     1572
     1573                echo '<div class="form2zoho-field">';
     1574                echo '<label for="f2z_subscription_type">' . esc_html__( 'Subscription type', 'techwave-form-sync-for-zoho' ) . '</label>';
     1575                echo '<select id="f2z_subscription_type" name="campaigns_subscription_type" class="form2zoho-control--full">';
    9921576                foreach ( array(
    9931577                        'Marketing'     => __( 'Marketing', 'techwave-form-sync-for-zoho' ),
    … …  
    9981582                }
    9991583                echo '</select>';
    1000                 echo '</td></tr>';
    1001 
    1002                 echo '<tr><th><label for="f2z_topics">' . esc_html__( 'Associate contacts with the following Topics', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
     1584                echo '</div>';
     1585
     1586                // Zoho adds a contact to a list whose sign-up form is on only once they
     1587                // confirm by email, so a feed that relied on that looked as though it
     1588                // ignored the list. "Right away" adds them directly; the other choice
     1589                // keeps Zoho's double opt-in. See CampaignsIntegration::send().
     1590                $camp_list_add = isset( $feed['campaigns_list_add'] ) && 'confirm' === $feed['campaigns_list_add'] ? 'confirm' : 'direct';
     1591                echo '<div class="form2zoho-field">';
     1592                echo '<label for="f2z_list_add">' . esc_html__( 'Add to list', 'techwave-form-sync-for-zoho' ) . '</label>';
     1593                echo '<select id="f2z_list_add" name="campaigns_list_add" class="form2zoho-control--full">';
     1594                foreach ( array(
     1595                        'direct'  => __( 'Right away', 'techwave-form-sync-for-zoho' ),
     1596                        'confirm' => __( 'After they confirm by email', 'techwave-form-sync-for-zoho' ),
     1597                ) as $val => $label ) {
     1598                        echo '<option value="' . esc_attr( $val ) . '" ' . selected( $camp_list_add, $val, false ) . '>' . esc_html( $label ) . '</option>';
     1599                }
     1600                echo '</select>';
     1601                echo '<p class="description">' . esc_html__( 'Right away puts the contact in the selected list immediately. After they confirm sends the confirmation email Zoho uses for a list with its sign-up form on, and adds the contact once it is clicked.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1602                echo '</div>';
     1603
     1604                echo '<div class="form2zoho-field" id="f2z_tag_row">';
     1605                echo '<label for="f2z_tag">' . esc_html__( 'Contact tags', 'techwave-form-sync-for-zoho' ) . '</label>';
    10031606                echo '<div class="form2zoho-control-row">';
    1004                 echo '<select id="f2z_topics" name="campaigns_topics[]" multiple>';
     1607                echo '<select id="f2z_tag" name="campaigns_tag[]" class="form2zoho-control--full" multiple>';
     1608                foreach ( $camp_tag as $tval ) {
     1609                        echo '<option value="' . esc_attr( $tval ) . '" selected>' . esc_html( $tval ) . '</option>';
     1610                }
     1611                echo '</select>';
     1612                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-tags" aria-label="' . esc_attr__( 'Reload tags', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload tags', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
     1613                echo '</div>';
     1614                echo '<p class="description">' . esc_html__( 'Optional. Pick one or more existing tags. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1615                echo '<p class="description form2zoho-form-help-warn" id="f2z_tag_notice" hidden></p>';
     1616                echo '</div>';
     1617
     1618                echo '<div class="form2zoho-field form2zoho-field--wide">';
     1619                echo '<label for="f2z_topics">' . esc_html__( 'Associate contacts with these topics', 'techwave-form-sync-for-zoho' ) . '</label>';
     1620                echo '<div class="form2zoho-control-row">';
     1621                echo '<select id="f2z_topics" name="campaigns_topics[]" class="form2zoho-control--full" multiple>';
    10051622                foreach ( $camp_topics as $tid ) {
    10061623                        echo '<option value="' . esc_attr( $tid ) . '" selected>' . esc_html( $tid ) . '</option>';
    … …  
    10091626                echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-topics" aria-label="' . esc_attr__( 'Reload topics', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload topics', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
    10101627                echo '</div>';
    1011                 echo '<p class="description">' . esc_html__( 'Optional — leave empty to skip topic association entirely. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1628                echo '<p class="description">' . esc_html__( 'Optional. Leave empty to skip topic association entirely. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one.', 'techwave-form-sync-for-zoho' ) . '</p>';
    10121629                echo '<p class="description form2zoho-form-help-warn" id="f2z_topics_notice" hidden></p>';
    1013                 echo '</td></tr>';
    1014 
    1015                 echo '<tr id="f2z_tag_row"><th><label for="f2z_tag">' . esc_html__( 'Contact Tags', 'techwave-form-sync-for-zoho' ) . '</label></th><td>';
    1016                 echo '<div class="form2zoho-control-row">';
    1017                 echo '<select id="f2z_tag" name="campaigns_tag[]" multiple>';
    1018                 foreach ( $camp_tag as $tval ) {
    1019                         echo '<option value="' . esc_attr( $tval ) . '" selected>' . esc_html( $tval ) . '</option>';
    1020                 }
    1021                 echo '</select>';
    1022                 echo '<button type="button" class="form2zoho-reload-btn form2zoho-reload-tags" aria-label="' . esc_attr__( 'Reload tags', 'techwave-form-sync-for-zoho' ) . '" title="' . esc_attr__( 'Reload tags', 'techwave-form-sync-for-zoho' ) . '"><svg class="form2zoho-reload-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>';
    1023                 echo '</div>';
    1024                 echo '<p class="description">' . esc_html__( 'Optional. Pick one or more existing tags. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one.', 'techwave-form-sync-for-zoho' ) . '</p>';
    1025                 echo '<p class="description form2zoho-form-help-warn" id="f2z_tag_notice" hidden></p>';
    1026                 echo '</td></tr>';
    1027 
    1028                 echo '</tbody><tbody>';
    1029 
    1030                 // Dedupe — shown for every app, including Campaigns/Marketing
    1031                 // Automation. Note: Campaigns/Marketing Automation already treat
    1032                 // the contact's email as the natural unique key (re-subscribing the
    1033                 // same email updates that contact instead of creating a new one),
    1034                 // so this box has no extra effect for those two apps beyond what
    1035                 // already happens automatically.
    1036                 $dd_list  = array_filter( array_map( 'trim', explode( ',', (string) $dd_flds ) ) );
    1037                 $dd_email = in_array( 'Email', $dd_list, true );
    1038                 $dd_phone = in_array( 'Phone', $dd_list, true );
    1039                 $dd_extra = trim( implode( ',', array_diff( $dd_list, array( 'Email', 'Phone' ) ) ) );
    1040 
    1041                 echo '<tr id="f2z_dedupe_row"><th>' . esc_html__( 'Duplicate check', 'techwave-form-sync-for-zoho' ) . '</th><td>';
    1042                 echo '<label><input type="checkbox" name="dedupe_enabled" value="1" ' . checked( true, $dd_on, false ) . ' /> ' . esc_html__( 'Update the matching Zoho record instead of creating a duplicate.', 'techwave-form-sync-for-zoho' ) . '</label>';
    1043                 echo '<p class="description">' . esc_html__( 'Each box below is checked independently — a record counts as a match if it shares the same email OR the same phone, not only when both match.', 'techwave-form-sync-for-zoho' ) . '</p>';
    1044                 echo '<div class="form2zoho-dedupe-grid">';
    1045                 echo '<label class="form2zoho-dedupe-field"><input type="checkbox" name="dedupe_email" value="1" ' . checked( true, $dd_email, false ) . ' /> ' . esc_html__( 'Match by Email', 'techwave-form-sync-for-zoho' ) . '</label>';
    1046                 echo '<label class="form2zoho-dedupe-field"><input type="checkbox" name="dedupe_phone" value="1" ' . checked( true, $dd_phone, false ) . ' /> ' . esc_html__( 'Match by Phone', 'techwave-form-sync-for-zoho' ) . '</label>';
    1047                 echo '</div>';
    1048                 echo '<input type="text" class="regular-text form2zoho-dedupe-extra" name="dedupe_extra" value="' . esc_attr( $dd_extra ) . '" placeholder="' . esc_attr__( 'Other Zoho field API names, comma-separated', 'techwave-form-sync-for-zoho' ) . '" />';
    1049                 echo '<p class="description">' . esc_html__( 'Optional: any other Zoho field API names to also check, comma-separated.', 'techwave-form-sync-for-zoho' ) . '</p>';
    1050                 echo '</td></tr>';
    1051 
    1052                 echo '</tbody></table>';
    1053                 echo '<div class="form2zoho-tab-footer">';
    1054                 echo '<button type="button" class="button form2zoho-tab-back" data-back="general">&lsaquo; ' . esc_html__( 'Back', 'techwave-form-sync-for-zoho' ) . '</button>';
    1055                 echo '<button type="button" class="button button-primary form2zoho-tab-next" data-next="mapping">' . esc_html__( 'Next', 'techwave-form-sync-for-zoho' ) . ' &rsaquo;</button>';
    1056                 echo '</div>';
    1057                 echo '</div>';
    1058 
    1059                 // Mapping.
    1060                 echo '<div class="form2zoho-tab-panel" data-tab-panel="mapping">';
    1061                 echo '<div class="form2zoho-mapping-section">';
    1062                 echo '<h2>' . esc_html__( 'Field mapping', 'techwave-form-sync-for-zoho' ) . '</h2>';
    1063                 echo '<p class="description">' . esc_html__( 'Map each Zoho field to a form field or a fixed value. Both lists load automatically: Zoho fields from the chosen module, form fields from the chosen form.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1630                echo '</div>';
     1631
     1632                echo '</div>';
     1633                echo '</section>';
     1634
     1635                // --- Group: duplicate handling ---
     1636                // Shown for every app, including Campaigns/Marketing Automation.
     1637                // Note: those two already treat the contact's email as the natural
     1638                // unique key (re-subscribing the same email updates that contact
     1639                // instead of creating a new one), so this group has no extra effect
     1640                // for them beyond what already happens automatically.
     1641                //
     1642                // A feed matches only on fields Zoho marks unique, offered as boxes
     1643                // once the module's fields load. With none ticked, CRM and Bigin still
     1644                // update rather than insert, and Zoho applies its own duplicate check
     1645                // (Email on Leads and Contacts, for example).
     1646
     1647                echo '<section class="form2zoho-group" id="f2z_dedupe_row">';
     1648                echo '<div class="form2zoho-group__head">';
     1649                echo '<div class="form2zoho-group__title">';
     1650                echo '<h3>' . esc_html__( 'Duplicate handling', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1651                echo '<p class="description">' . esc_html__( 'What to do when Zoho already holds a record for the same person.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1652                echo '</div>';
     1653                echo '</div>';
     1654                echo '<div class="form2zoho-group__body">';
     1655
     1656                // The switch first, then what it controls — the boxes below only mean
     1657                // something once it is on, and .form2zoho-dedupe-body dims them until
     1658                // it is (CSS :has(), no script).
     1659                echo '<label class="form2zoho-check form2zoho-check--lead">';
     1660                echo '<input type="checkbox" name="dedupe_enabled" value="1" ' . checked( true, $dd_on, false ) . ' />';
     1661                echo '<span>' . esc_html__( 'Update the matching Zoho record instead of creating a duplicate.', 'techwave-form-sync-for-zoho' ) . '</span>';
     1662                echo '</label>';
     1663
     1664                echo '<div class="form2zoho-dedupe-body">';
     1665
     1666                // What the feed was saved with, and whether the boxes below have been
     1667                // built from a field list that actually loaded. Until one has, saving
     1668                // keeps the stored list as it was, so a Zoho request that failed or is
     1669                // still on its way cannot quietly empty it. See collect_dedupe_fields().
     1670                echo '<input type="hidden" name="dedupe_saved" id="f2z_dedupe_saved" value="' . esc_attr( (string) $dd_flds ) . '" />';
     1671                echo '<input type="hidden" name="dedupe_loaded" id="f2z_dedupe_loaded" value="" />';
     1672
     1673                // Filled by admin.js once the module's fields arrive: one box per
     1674                // field Zoho itself enforces uniqueness on. Those are the fields a
     1675                // duplicate check is actually allowed to use.
     1676                //
     1677                // Rendered empty rather than omitted so the script has somewhere to
     1678                // put them, and hidden until there is something in it.
     1679                echo '<div class="form2zoho-field form2zoho-dedupe-unique" id="f2z_dedupe_unique" hidden>';
     1680                echo '<span class="form2zoho-dedupe-unique__label">' . esc_html__( 'Fields Zoho marks unique', 'techwave-form-sync-for-zoho' ) . '</span>';
     1681                echo '<p class="description">' . esc_html__( 'A record counts as a match if it shares any one of the ticked values. Leave them all unticked to use Zoho\'s own duplicate check.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1682                echo '<div class="form2zoho-dedupe-grid" id="f2z_dedupe_unique_list"></div>';
     1683                echo '</div>';
     1684
     1685                // Said in place of the boxes when the module has none, for the apps
     1686                // whose delivery would have used them. admin.js decides when.
     1687                echo '<p class="description form2zoho-dedupe-none" id="f2z_dedupe_none" hidden>' . esc_html__( 'This module has no fields that Zoho marks as unique, so Zoho\'s own duplicate check is used instead (Email on Leads and Contacts, for example). If the module has no duplicate check of its own, each submission creates a new record. To match on a field of your choice, set that field to not allow duplicate values in Zoho, then reload the module.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1688                echo '</div>';
     1689
     1690                echo '</div>';
     1691                echo '</section>';
     1692
     1693                echo '</div>';
     1694
     1695                echo '<div class="form2zoho-step-footer">';
     1696                echo '<button type="button" class="button form2zoho-step-back" data-back="details">' . esc_html__( 'Back', 'techwave-form-sync-for-zoho' ) . '</button>';
     1697                echo '<button type="button" class="button button-primary form2zoho-step-next" data-next="mapping">' . esc_html__( 'Continue', 'techwave-form-sync-for-zoho' ) . '</button>';
     1698                echo '</div>';
     1699                echo '</section>';
     1700
     1701                // ---- Step 4: the mapping, and the save ---------------------------
     1702                echo '<section class="form2zoho-step" data-step="mapping" tabindex="-1" role="group" aria-labelledby="f2z-step-mapping-title">';
     1703                echo '<div class="form2zoho-step-head">';
     1704                echo '<h2 id="f2z-step-mapping-title">' . esc_html__( 'Match the fields', 'techwave-form-sync-for-zoho' ) . '</h2>';
     1705                echo '<p class="description">' . esc_html__( 'Send each Zoho field a form field or a fixed value. Both lists load on their own: Zoho fields from the chosen module, form fields from the chosen form.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1706                echo '</div>';
     1707
     1708                echo '<div class="form2zoho-step-body">';
     1709                echo '<section class="form2zoho-group form2zoho-mapping-section">';
     1710                echo '<div class="form2zoho-group__head">';
     1711                echo '<div class="form2zoho-group__title">';
     1712                echo '<h3>' . esc_html__( 'Field mapping', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1713                echo '</div>';
     1714                echo '</div>';
     1715                echo '<div class="form2zoho-group__body">';
     1716
    10641717                echo '<p class="description form2zoho-form-help-warn" id="f2z_fields_notice" hidden></p>';
     1718                // For a destination that is only a URL and names no fields of its own:
     1719                // nothing needs matching, the whole submission is sent. admin.js shows
     1720                // this in place of the table. See syncPassthrough().
     1721                echo '<p class="description" id="f2z_passthrough_notice" hidden>' . esc_html__( 'Nothing to match here: every submitted field, uploaded files included, is sent to this URL as it is. To send only some fields or rename them, list the field names in the endpoint settings on the previous step.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1722                // The Zoho field list fills every select in the table below, so while it
     1723                // is on its way a loading card pauses the page, rather than a line of
     1724                // text sitting in this card. admin.js moves it to <body>, names what it
     1725                // is fetching, and shows it.
     1726                echo '<div class="form2zoho-page-loader" id="f2z_fields_loading" hidden>';
     1727                echo '<div class="form2zoho-loader-card" role="status" aria-live="polite">';
     1728                echo '<span class="form2zoho-loader-card__badge" aria-hidden="true">';
     1729                echo '<span class="form2zoho-loader-card__ring"></span>';
     1730                echo '<img class="form2zoho-loader-card__mark" src="' . esc_url( FORM2ZOHO_URL . 'assets/images/plugin_icon.png' ) . '" width="34" height="34" alt="" />';
     1731                echo '</span>';
     1732                echo '<p class="form2zoho-loader-card__title">' . esc_html__( 'Loading Zoho fields', 'techwave-form-sync-for-zoho' ) . '</p>';
     1733                echo '<p class="form2zoho-loader-card__text">' . esc_html__( 'Fetching the field list from Zoho…', 'techwave-form-sync-for-zoho' ) . '</p>';
     1734                echo '<p class="form2zoho-loader-card__slow" hidden>' . esc_html__( 'This is taking longer than usual. Zoho can be slow to answer, and the editor will carry on as soon as it does.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1735                echo '</div>';
     1736                echo '</div>';
    10651737                // Filled from JS (i18n.mappingReset) when switching the Zoho app,
    10661738                // account or module clears the rows below.
    … …  
    10731745                echo '<div class="form2zoho-table-scroll">';
    10741746                echo '<table class="widefat form2zoho-map-table"><thead><tr>';
    1075                 echo '<th scope="col">' . esc_html__( 'Zoho field', 'techwave-form-sync-for-zoho' ) . '</th>';
     1747                // Source first, because it decides which of the two columns after it is
     1748                // the live one — reading left to right now follows the order the row is
     1749                // actually filled in, rather than naming the destination before the
     1750                // question of where the value comes from has been asked.
    10761751                echo '<th scope="col">' . esc_html__( 'Source', 'techwave-form-sync-for-zoho' ) . '</th>';
    10771752                echo '<th scope="col">' . esc_html__( 'Form field', 'techwave-form-sync-for-zoho' ) . '</th>';
     1753                echo '<th scope="col">' . esc_html__( 'Zoho field', 'techwave-form-sync-for-zoho' ) . '</th>';
    10781754                echo '<th scope="col">' . esc_html__( 'Static value', 'techwave-form-sync-for-zoho' ) . '</th>';
    1079                 echo '<th></th>';
     1755                echo '<th scope="col"><span class="screen-reader-text">' . esc_html__( 'Actions', 'techwave-form-sync-for-zoho' ) . '</span></th>';
    10801756                echo '</tr></thead><tbody class="form2zoho-map-rows">';
    10811757
    … …  
    10861762                                'source' => isset( $rule['type'] ) ? (string) $rule['type'] : 'field',
    10871763                                'form'   => ( isset( $rule['type'] ) && 'field' === $rule['type'] ) ? (string) $rule['value'] : '',
    1088                                 'static' => ( isset( $rule['type'] ) && 'static' === $rule['type'] ) ? (string) $rule['value'] : '',
     1764                                'static' => ( isset( $rule['type'] ) && in_array( $rule['type'], array( 'static', FieldMapper::TYPE_TEMPLATE ), true ) ) ? (string) $rule['value'] : '',
     1765                                'ztype'  => isset( $rule['zoho_type'] ) ? (string) $rule['zoho_type'] : '',
    10891766                        );
    10901767                }
    … …  
    11041781                echo '</tbody></table>';
    11051782                echo '</div>';
    1106                 echo '<p><button type="button" class="button form2zoho-add-row">' . esc_html__( '+ Add mapping row', 'techwave-form-sync-for-zoho' ) . '</button></p>';
    1107                 echo '</div>';
     1783                echo '<p class="form2zoho-group__action"><button type="button" class="button form2zoho-add-row">' . esc_html__( '+ Add mapping row', 'techwave-form-sync-for-zoho' ) . '</button></p>';
     1784                echo '</div>';
     1785                echo '</section>';
    11081786
    11091787                // Row template for JS cloning.
    … …  
    11211799                );
    11221800                echo '</script>';
    1123                 echo '<div class="form2zoho-tab-footer">';
    1124                 echo '<button type="button" class="button form2zoho-tab-back" data-back="connection">&lsaquo; ' . esc_html__( 'Back', 'techwave-form-sync-for-zoho' ) . '</button>';
    1125                 submit_button( $id ? __( 'Save feed', 'techwave-form-sync-for-zoho' ) : __( 'Create feed', 'techwave-form-sync-for-zoho' ), 'primary', 'submit', false );
    1126                 echo '</div>';
    1127                 echo '</div>';
    1128 
    1129                 echo '</form>';
    1130 
    1131                 // Test tool — only useful once the feed is saved.
     1801
     1802                /**
     1803                 * Renders extra fields at the foot of the feed editor, still inside
     1804                 * the form and just above its Save button.
     1805                 *
     1806                 * Whatever is printed here posts along with the feed; store it by
     1807                 * hooking form2zoho_save_feed, and put it back on the outgoing job
     1808                 * with form2zoho_build_job. Per-feed conditional logic is the
     1809                 * intended use.
     1810                 *
     1811                 * Escape your own output: this prints straight into the page rather
     1812                 * than going through wp_kses() the way the mapping rows do.
     1813                 *
     1814                 * @param array  $feed The feed being edited. Empty for a new one.
     1815                 * @param string $id   The feed id. Empty for a new one.
     1816                 */
     1817                do_action( 'form2zoho_feed_editor_fields', $feed, $id );
     1818
     1819                // Test tool — only useful once the feed is saved, and it belongs to
     1820                // this step rather than under the whole editor: sitting outside the
     1821                // steps, it used to appear below the trigger grid and below the
     1822                // connection form too, offering to test a feed the person was in the
     1823                // middle of changing.
    11321824                if ( $id ) {
    1133                         echo '<hr />';
    1134                         echo '<h2>' . esc_html__( 'Test', 'techwave-form-sync-for-zoho' ) . '</h2>';
    1135                         echo '<p class="description">' . esc_html__( 'Sends one real test record to Zoho using this saved feed. Static values are sent as-is; mapped form fields get realistic sample data (a valid email, phone, etc.).', 'techwave-form-sync-for-zoho' ) . '</p>';
     1825                        echo '<section class="form2zoho-group">';
     1826                        echo '<div class="form2zoho-group__head">';
     1827                        echo '<div class="form2zoho-group__title">';
     1828                        echo '<h3>' . esc_html__( 'Test this feed', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1829                        echo '<p class="description">' . esc_html__( 'Sends one real test record to Zoho using the feed as last saved. Static values are sent as-is; mapped form fields get realistic sample data (a valid email, phone, and so on).', 'techwave-form-sync-for-zoho' ) . '</p>';
     1830                        echo '</div>';
     1831                        echo '</div>';
     1832                        echo '<div class="form2zoho-group__body">';
    11361833                        echo '<p><button type="button" class="button form2zoho-test" data-feed="' . esc_attr( $id ) . '">' . esc_html__( 'Send test record', 'techwave-form-sync-for-zoho' ) . '</button></p>';
    11371834                        echo '<div class="form2zoho-test-result"></div>';
    1138                 }
    1139                 echo '</div>';
    1140                 echo '</div>';
     1835                        echo '</div>';
     1836                        echo '</section>';
     1837                }
     1838
     1839                echo '</div>';
     1840
     1841                echo '<div class="form2zoho-step-footer">';
     1842                echo '<button type="button" class="button form2zoho-step-back" data-back="connection">' . esc_html__( 'Back', 'techwave-form-sync-for-zoho' ) . '</button>';
     1843                submit_button( $id ? __( 'Save feed', 'techwave-form-sync-for-zoho' ) : __( 'Create feed', 'techwave-form-sync-for-zoho' ), 'primary', 'submit', false );
     1844                echo '</div>';
     1845                echo '</section>';
     1846
     1847                echo '</form>';
     1848
     1849                echo '</div>';
     1850                echo '</div>';
     1851
     1852                // Outside .form2zoho-editor-pop and outside the form: the overlay is
     1853                // position:fixed, so nesting it would change nothing about where it
     1854                // draws and everything about what a stray Enter inside it submits.
     1855                // Prints nothing at all once the add-on is installed and no tile is
     1856                // left locked.
     1857                ProUpsell::render_modal();
     1858        }
     1859
     1860        /**
     1861         * A quiet link out to the documentation for one step.
     1862         *
     1863         * Quiet on purpose: it is there for the person who is stuck, and it must
     1864         * not compete with the thing the step is actually asking them to do. The
     1865         * new tab is deliberate — losing a half-built feed to read about it would
     1866         * be a poor trade — and it is said in the accessible name rather than left
     1867         * for the person to discover (WCAG 3.2.5), with the arrow carrying the
     1868         * same fact for anyone reading the screen rather than hearing it.
     1869         *
     1870         * @param string $step  Step key, e.g. 'trigger'.
     1871         * @param string $label Visible link text.
     1872         * @return void
     1873         */
     1874        private static function render_docs_link( $step, $label ) {
     1875                $url = self::docs_url( $step );
     1876
     1877                if ( '' === $url ) {
     1878                        return;
     1879                }
     1880
     1881                printf(
     1882                        '<a class="form2zoho-step-docs" href="%s" target="_blank" rel="noopener noreferrer" aria-label="%s">',
     1883                        esc_url( $url ),
     1884                        esc_attr( self::docs_link_aria( $label ) )
     1885                );
     1886                echo '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>';
     1887                echo '<span>' . esc_html( $label ) . '</span>';
     1888                echo '<svg class="form2zoho-extlink" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
     1889                echo '</a>';
     1890        }
     1891
     1892        /**
     1893         * The "<trigger> guide" link on the Details step.
     1894         *
     1895         * Always printed, and hidden rather than omitted when the chosen trigger
     1896         * has no page: the trigger can be changed without a page load, so the
     1897         * anchor has to already exist for admin.js to point somewhere else. A
     1898         * source with no documentation — every Pro trigger, and any a site adds
     1899         * through `form2zoho_form_sources` — simply keeps it hidden.
     1900         *
     1901         * @param string $source Chosen source key, or '' when none is chosen yet.
     1902         * @return void
     1903         */
     1904        private static function render_trigger_docs_link( $source ) {
     1905                $url   = '' === $source ? '' : self::trigger_docs_url( $source );
     1906                $label = '' === $source ? '' : self::trigger_docs_label( FormSources::label( $source ) );
     1907
     1908                printf(
     1909                        '<a class="form2zoho-step-docs form2zoho-trigger-docs" href="%s" target="_blank" rel="noopener noreferrer" aria-label="%s"%s>',
     1910                        esc_url( $url ),
     1911                        esc_attr( '' === $label ? '' : self::docs_link_aria( $label ) ),
     1912                        '' === $url ? ' hidden' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a literal.
     1913                );
     1914                echo '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>';
     1915                echo '<span>' . esc_html( $label ) . '</span>';
     1916                echo '<svg class="form2zoho-extlink" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
     1917                echo '</a>';
     1918        }
     1919
     1920        /**
     1921         * The "Docs" link to the chosen app's guide, in the destination header.
     1922         *
     1923         * Always printed and hidden when empty, for the same reason the trigger's
     1924         * is: the app can be changed without a page load, so the anchor has to be
     1925         * there for admin.js to point somewhere else.
     1926         *
     1927         * @param string $app Chosen integration id, or '' when none.
     1928         * @return void
     1929         */
     1930        private function render_app_docs_link( $app ) {
     1931                $url   = '' === $app ? '' : self::app_docs_url( $app );
     1932                $label = '' === $url ? '' : $this->app_docs_label( $app );
     1933
     1934                printf(
     1935                        '<a class="form2zoho-step-docs form2zoho-app-docs" href="%s" target="_blank" rel="noopener noreferrer" aria-label="%s"%s>',
     1936                        esc_url( $url ),
     1937                        esc_attr( '' === $label ? '' : self::docs_link_aria( $label ) ),
     1938                        '' === $url ? ' hidden' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a literal.
     1939                );
     1940                echo '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>';
     1941                echo '<span>' . esc_html__( 'Docs', 'techwave-form-sync-for-zoho' ) . '</span>';
     1942                echo '<svg class="form2zoho-extlink" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
     1943                echo '</a>';
     1944        }
     1945
     1946        /**
     1947         * "Zoho CRM guide", from the app's own name.
     1948         *
     1949         * The name the picker shows, read back from the registry rather than
     1950         * written out again here — an add-on registers its own label, and a
     1951         * second copy of the free plugin's six would go stale the first time one
     1952         * was reworded.
     1953         *
     1954         * @param string $app Integration id.
     1955         * @return string
     1956         */
     1957        private function app_docs_label( $app ) {
     1958                $labels = $this->registry->choices();
     1959                $label  = isset( $labels[ $app ] ) ? (string) $labels[ $app ] : $app;
     1960
     1961                /* translators: %s: a Zoho app's name, e.g. "Zoho CRM". */
     1962                return sprintf( __( '%s guide', 'techwave-form-sync-for-zoho' ), $label );
     1963        }
     1964
     1965        /**
     1966         * Where one Zoho app is documented.
     1967         *
     1968         * Only the apps this plugin ships. A destination an add-on registers —
     1969         * Zoho Inventory, a plain webhook — has no row here: its documentation
     1970         * belongs with the add-on, and a link offered from the free plugin would
     1971         * be one more thing to keep in step with a product it does not ship.
     1972         *
     1973         * @param string $app Integration id.
     1974         * @return string URL, or '' when that app has no page.
     1975         */
     1976        private static function app_docs_url( $app ) {
     1977                $base = 'https://techwavesolutions.net/formbridge-zoho/action/';
     1978                $urls = array(
     1979                        // Not "wordpress-to-…" like its neighbours: the page was
     1980                        // published under this slug and the matching one is taken by an
     1981                        // older copy of it.
     1982                        'crm'        => $base . 'wordpress-zoho-crm-integration/',
     1983                        'books'      => $base . 'wordpress-to-zoho-books-integration/',
     1984                        'desk'       => $base . 'wordpress-to-zoho-desk-integration/',
     1985                        'bigin'      => $base . 'wordpress-to-zoho-bigin-integration/',
     1986                        'campaigns'  => $base . 'wordpress-to-zoho-campaigns-integration/',
     1987                        'automation' => $base . 'wordpress-to-zoho-marketing-automation-integration/',
     1988                );
     1989
     1990                $url = isset( $urls[ $app ] ) ? $urls[ $app ] : '';
     1991
     1992                /**
     1993                 * Filter one Zoho app's documentation link.
     1994                 *
     1995                 * @param string $url URL, or '' for none.
     1996                 * @param string $app Integration id, e.g. "crm".
     1997                 */
     1998                return (string) apply_filters( 'form2zoho_docs_app_url', $url, $app );
     1999        }
     2000
     2001        /**
     2002         * Every Zoho app that has a guide, as the editor script needs it.
     2003         *
     2004         * Keyed over what the registry actually holds, so an add-on's destination
     2005         * is included the moment a site filters a URL in for it.
     2006         *
     2007         * @return array<string,array{url:string,label:string}>
     2008         */
     2009        private function app_docs_for_js() {
     2010                $out = array();
     2011
     2012                foreach ( array_keys( $this->registry->choices() ) as $app ) {
     2013                        $url = self::app_docs_url( (string) $app );
     2014
     2015                        if ( '' === $url ) {
     2016                                continue;
     2017                        }
     2018
     2019                        $out[ $app ] = array(
     2020                                'url'   => $url,
     2021                                'label' => $this->app_docs_label( (string) $app ),
     2022                        );
     2023                }
     2024
     2025                return $out;
     2026        }
     2027
     2028        /**
     2029         * "Contact Form 7 guide", from the trigger's own label.
     2030         *
     2031         * The plugin's label, not the documentation page's title: the two differ
     2032         * ("Contact Form 7" against "Contact Forms 7"), and the name to echo back
     2033         * is the one on the tile the person just clicked.
     2034         *
     2035         * @param string $label Source label.
     2036         * @return string
     2037         */
     2038        private static function trigger_docs_label( $label ) {
     2039                /* translators: %s: a form plugin's name, e.g. "Contact Form 7". */
     2040                return sprintf( __( '%s guide', 'techwave-form-sync-for-zoho' ), $label );
     2041        }
     2042
     2043        /**
     2044         * The accessible name for a documentation link.
     2045         *
     2046         * @param string $label Visible link text.
     2047         * @return string
     2048         */
     2049        private static function docs_link_aria( $label ) {
     2050                /* translators: %s: link text, e.g. "Trigger guide". */
     2051                return sprintf( __( '%s (opens in a new tab)', 'techwave-form-sync-for-zoho' ), $label );
     2052        }
     2053
     2054        /**
     2055         * Where one trigger is documented.
     2056         *
     2057         * Only the sources this plugin provides itself. A Pro trigger has no row
     2058         * here — the page that documents it belongs with the add-on, and a link
     2059         * offered from the free plugin would be one more thing to keep in step
     2060         * with a product it does not ship.
     2061         *
     2062         * Filterable per source, so a site hosting its own copy of the
     2063         * documentation can point at that instead — the same hook shape as
     2064         * docs_url() above.
     2065         *
     2066         * @param string $source Source key.
     2067         * @return string URL, or '' when that trigger has no page.
     2068         */
     2069        private static function trigger_docs_url( $source ) {
     2070                $base = 'https://techwavesolutions.net/formbridge-zoho/trigger/';
     2071                $urls = array(
     2072                        'elementor'  => $base . 'elementor-forms-zoho-integration/',
     2073                        'cf7'        => $base . 'contact-form-7-zoho-integration/',
     2074                        'wpforms'    => $base . 'wpforms-zoho-integration/',
     2075                        'fluent'     => $base . 'fluent-forms-zoho-integration/',
     2076                        'forminator' => $base . 'forminator-forms-zoho-integration/',
     2077                        'ninja'      => $base . 'ninja-forms-zoho-integration/',
     2078                        'gravity'    => $base . 'gravity-forms-zoho-integration/',
     2079                        'formidable' => $base . 'formidable-forms-zoho-integration/',
     2080                        'happyforms' => $base . 'happyforms-zoho-integration/',
     2081                        'sureforms'  => $base . 'sureforms-zoho-integration/',
     2082                );
     2083
     2084                $url = isset( $urls[ $source ] ) ? $urls[ $source ] : '';
     2085
     2086                /**
     2087                 * Filter one trigger's documentation link.
     2088                 *
     2089                 * @param string $url    Documentation URL, or '' for none.
     2090                 * @param string $source Source key, e.g. "cf7".
     2091                 */
     2092                return (string) apply_filters( 'form2zoho_docs_trigger_url', $url, $source );
     2093        }
     2094
     2095        /**
     2096         * Every trigger that has a guide, as the editor script needs it.
     2097         *
     2098         * Built through trigger_docs_url() rather than from the array directly so
     2099         * a site's filter reaches the client side too; the ones it leaves empty
     2100         * are dropped, which is what keeps the link hidden for them.
     2101         *
     2102         * @return array<string,array{url:string,label:string}>
     2103         */
     2104        private static function trigger_docs_for_js() {
     2105                $out = array();
     2106
     2107                foreach ( FormSources::keys() as $key ) {
     2108                        $url = self::trigger_docs_url( $key );
     2109
     2110                        if ( '' === $url ) {
     2111                                continue;
     2112                        }
     2113
     2114                        $out[ $key ] = array(
     2115                                'url'   => $url,
     2116                                'label' => self::trigger_docs_label( FormSources::label( $key ) ),
     2117                        );
     2118                }
     2119
     2120                return $out;
     2121        }
     2122
     2123        /**
     2124         * The documentation page for one step of the editor.
     2125         *
     2126         * Keyed by step rather than one link for the whole editor: "how do I set
     2127         * this up" is four different questions here, and a link that answers the
     2128         * wrong one is worse than none — it costs a click and a page load to find
     2129         * that out. Steps with no page of their own return an empty string, and
     2130         * render_docs_link() then prints nothing at all.
     2131         *
     2132         * Filterable so a site hosting its own copy of the documentation can point
     2133         * at that instead of sending its users to ours — the same hook shape as
     2134         * SettingsPage::docs_url().
     2135         *
     2136         * @param string $step Step key.
     2137         * @return string URL, or '' when that step has no documentation.
     2138         */
     2139        private static function docs_url( $step ) {
     2140                $urls = array(
     2141                        'trigger' => 'https://techwavesolutions.net/formbridge-zoho/trigger/',
     2142                );
     2143
     2144                $url = isset( $urls[ $step ] ) ? $urls[ $step ] : '';
     2145
     2146                /**
     2147                 * Filter a feed-editor step's documentation link.
     2148                 *
     2149                 * @param string $url  Documentation URL, or '' for none.
     2150                 * @param string $step Step key: trigger, details, connection, mapping.
     2151                 */
     2152                return (string) apply_filters( 'form2zoho_docs_feed_step_url', $url, $step );
     2153        }
     2154
     2155        /**
     2156         * The four-step rail across the top of the editor.
     2157         *
     2158         * The editor is a sequence, and a sequence with no map is four unrelated
     2159         * screens: the person cannot see how much is left, cannot tell which
     2160         * answers are already given, and has only a "Back" button to return by.
     2161         * This is that map — and, for the steps already passed, the way back to
     2162         * any of them in one click.
     2163         *
     2164         * It is not a progress bar. There is no filling track; each step is a
     2165         * marker with its own name, which is the part that was missing. admin.js
     2166         * keeps the is-current / is-done classes in step with the panel on screen
     2167         * and disables the markers for steps not yet reached, so this can never
     2168         * be used to skip a question the next step depends on.
     2169         *
     2170         * @param bool $trigger_locked Whether the feed is saved: the editor opens on
     2171         *                             step two, and the trigger step is drawn done
     2172         *                             and cannot be opened.
     2173         * @return void
     2174         */
     2175        private function render_stepper( $trigger_locked = false ) {
     2176                $steps = array(
     2177                        'trigger'    => array(
     2178                                'title' => __( 'Trigger', 'techwave-form-sync-for-zoho' ),
     2179                                'hint'  => __( 'Where records come from', 'techwave-form-sync-for-zoho' ),
     2180                        ),
     2181                        'details'    => array(
     2182                                'title' => __( 'Details', 'techwave-form-sync-for-zoho' ),
     2183                                'hint'  => __( 'Name and form', 'techwave-form-sync-for-zoho' ),
     2184                        ),
     2185                        'connection' => array(
     2186                                'title' => __( 'Destination', 'techwave-form-sync-for-zoho' ),
     2187                                'hint'  => __( 'Zoho app and account', 'techwave-form-sync-for-zoho' ),
     2188                        ),
     2189                        'mapping'    => array(
     2190                                'title' => __( 'Mapping', 'techwave-form-sync-for-zoho' ),
     2191                                'hint'  => __( 'Match the fields', 'techwave-form-sync-for-zoho' ),
     2192                        ),
     2193                );
     2194
     2195                $total = count( $steps );
     2196                $n     = 0;
     2197
     2198                echo '<nav class="form2zoho-stepper" aria-label="' . esc_attr__( 'Feed setup', 'techwave-form-sync-for-zoho' ) . '">';
     2199                echo '<ol class="form2zoho-stepper__list">';
     2200
     2201                foreach ( $steps as $key => $step ) {
     2202                        ++$n;
     2203
     2204                        $locked  = $trigger_locked && 'trigger' === $key;
     2205                        $current = $trigger_locked ? ( 'details' === $key ) : ( 1 === $n );
     2206
     2207                        printf(
     2208                                '<li class="form2zoho-stepper__item%s" data-step-for="%s"%s>',
     2209                                ( $current ? ' is-current' : '' ) . ( $locked ? ' is-done is-locked' : '' ),
     2210                                esc_attr( $key ),
     2211                                $locked ? ' title="' . esc_attr( self::locked_trigger_note() ) . '"' : ''
     2212                        );
     2213                        printf(
     2214                                '<button type="button" class="form2zoho-stepper__btn" data-goto="%s"%s%s>',
     2215                                esc_attr( $key ),
     2216                                $current ? ' aria-current="step"' : '',
     2217                                // Every step after the first starts unreachable. admin.js
     2218                                // enables each one as it is reached, so the rail can move
     2219                                // backwards freely and never forwards past an unanswered
     2220                                // question — the same rule the Continue buttons enforce.
     2221                                // A saved feed has answered them all, and only its locked
     2222                                // trigger stays shut.
     2223                                ( $trigger_locked ? $locked : 1 !== $n ) ? ' disabled' : ''
     2224                        );
     2225                        echo '<span class="form2zoho-stepper__marker" aria-hidden="true">' . esc_html( number_format_i18n( $n ) ) . '</span>';
     2226                        echo '<span class="form2zoho-stepper__text">';
     2227                        echo '<span class="form2zoho-stepper__title">';
     2228                        printf(
     2229                                /* translators: 1: step number, 2: total number of steps. */
     2230                                '<span class="screen-reader-text">' . esc_html__( 'Step %1$s of %2$s:', 'techwave-form-sync-for-zoho' ) . ' </span>',
     2231                                esc_html( number_format_i18n( $n ) ),
     2232                                esc_html( number_format_i18n( $total ) )
     2233                        );
     2234                        echo esc_html( $step['title'] );
     2235                        if ( $locked ) {
     2236                                echo ' <span class="screen-reader-text">' . esc_html__( '(locked)', 'techwave-form-sync-for-zoho' ) . '</span>';
     2237                        }
     2238                        echo '</span>';
     2239                        echo '<span class="form2zoho-stepper__hint">' . esc_html( $step['hint'] ) . '</span>';
     2240                        echo '</span>';
     2241                        echo '</button>';
     2242                        echo '</li>';
     2243                }
     2244
     2245                echo '</ol>';
     2246                echo '</nav>';
     2247        }
     2248
     2249        /**
     2250         * Why a saved feed's trigger cannot be changed, and what to do instead.
     2251         *
     2252         * Shown under the trigger card on step two and as the locked rail step's
     2253         * tooltip.
     2254         *
     2255         * @return string
     2256         */
     2257        private static function locked_trigger_note() {
     2258                return __( 'A saved feed keeps its trigger. To send a different form or event to Zoho, create a new feed.', 'techwave-form-sync-for-zoho' );
    11412259        }
    11422260
    … …  
    11522270                $form   = isset( $row['form'] ) ? (string) $row['form'] : '';
    11532271                $static = isset( $row['static'] ) ? (string) $row['static'] : '';
     2272                $ztype  = isset( $row['ztype'] ) ? (string) $row['ztype'] : '';
     2273
     2274                // Both of these put the row's value in the text box rather than in the
     2275                // Form field select.
     2276                $is_static = ( 'static' === $source || FieldMapper::TYPE_TEMPLATE === $source );
     2277
     2278                // ...but "Combine fields" keeps the Form field select live, because
     2279                // that is how a placeholder gets inserted.
     2280                $keeps_form = ( 'static' !== $source );
     2281
     2282                /*
     2283                 * Only one of Form field / Static value applies to a row, and the other
     2284                 * is disabled so the editor cannot be left saying two contradictory
     2285                 * things at once.
     2286                 *
     2287                 * Each disabled control is paired with a hidden input of the same name,
     2288                 * and exactly one of the pair is ever enabled. That is not decoration:
     2289                 * a browser does not submit a disabled control, and collect_mapping()
     2290                 * reads these four arrays *by index* — map_zoho[3] belongs with
     2291                 * map_static[3]. Drop one entry and every row after it silently pairs
     2292                 * with the wrong value, which would corrupt mappings rather than fail.
     2293                 * The hidden twin keeps one entry per row in every array.
     2294                 */
     2295                $off = ' disabled="disabled"';
    11542296
    11552297                $html = '<tr class="form2zoho-map-row">';
    1156 
    1157                 // Zoho field select — JS fills options from the module's fields; the
    1158                 // saved value is kept selected via data-selected even before load.
    1159                 $html .= '<td><select name="map_zoho[]" class="f2z-map-zoho" data-selected="' . esc_attr( $zoho ) . '">';
    1160                 $html .= '<option value="">' . esc_html__( '— Select Zoho field —', 'techwave-form-sync-for-zoho' ) . '</option>';
    1161                 if ( '' !== $zoho ) {
    1162                         $html .= '<option value="' . esc_attr( $zoho ) . '" selected>' . esc_html( $zoho ) . '</option>';
    1163                 }
    1164                 $html .= '</select></td>';
    11652298
    11662299                // Source.
    … …  
    11682301                $html .= '<option value="field" ' . selected( $source, 'field', false ) . '>' . esc_html__( 'Form field', 'techwave-form-sync-for-zoho' ) . '</option>';
    11692302                $html .= '<option value="static" ' . selected( $source, 'static', false ) . '>' . esc_html__( 'Static value', 'techwave-form-sync-for-zoho' ) . '</option>';
     2303                // One Zoho field, several answers. The value is written in the Static
     2304                // box as text with {field_id} placeholders, and the Form field select
     2305                // beside it stays usable as the way to insert one without having to
     2306                // know its id.
     2307                $html .= '<option value="' . esc_attr( FieldMapper::TYPE_TEMPLATE ) . '" ' . selected( $source, FieldMapper::TYPE_TEMPLATE, false ) . '>' . esc_html__( 'Combine fields', 'techwave-form-sync-for-zoho' ) . '</option>';
    11702308                $html .= '</select></td>';
    11712309
    11722310                // Form field select — JS fills options from the chosen form's fields.
    1173                 $html .= '<td><select name="map_form[]" class="f2z-map-form" data-selected="' . esc_attr( $form ) . '">';
    1174                 $html .= '<option value="">' . esc_html__( '— Select form field —', 'techwave-form-sync-for-zoho' ) . '</option>';
     2311                // Above it, on a "Combine fields" row, admin.js lists the fields the
     2312                // template uses, each with a button that takes it back out.
     2313                $html .= '<td><div class="f2z-map-tokens" hidden></div>';
     2314                $html .= '<select name="map_form[]" class="f2z-map-form" data-selected="' . esc_attr( $form ) . '"' . ( $keeps_form ? '' : $off ) . '>';
     2315                $html .= '<option value="">' . esc_html__( 'Select form field', 'techwave-form-sync-for-zoho' ) . '</option>';
    11752316                if ( '' !== $form ) {
    11762317                        $html .= '<option value="' . esc_attr( $form ) . '" selected>' . esc_html( $form ) . '</option>';
    11772318                }
    1178                 $html .= '</select></td>';
     2319                $html .= '</select>';
     2320                $html .= '<input type="hidden" name="map_form[]" class="f2z-map-form-slot" value=""' . ( $keeps_form ? $off : '' ) . ' /></td>';
     2321
     2322                // Zoho field select — JS fills options from the module's fields; the
     2323                // saved value is kept selected via data-selected even before load.
     2324                $html .= '<td><select name="map_zoho[]" class="f2z-map-zoho" data-selected="' . esc_attr( $zoho ) . '">';
     2325                $html .= '<option value="">' . esc_html__( 'Select Zoho field', 'techwave-form-sync-for-zoho' ) . '</option>';
     2326                if ( '' !== $zoho ) {
     2327                        $html .= '<option value="' . esc_attr( $zoho ) . '" selected>' . esc_html( $zoho ) . '</option>';
     2328                }
     2329                $html .= '</select>';
     2330                // Posts the Zoho field in place of the select once admin.js locks a row
     2331                // that a required field depends on, since a disabled select is not
     2332                // submitted. Disabled until then, so every row sends exactly one.
     2333                $html .= '<input type="hidden" name="map_zoho[]" class="f2z-map-zoho-slot" value="" disabled />';
     2334                // The type of the Zoho field selected above, so the delivery path knows
     2335                // whether it is filling a checkbox, a multi-select or plain text
     2336                // without asking Zoho again on every submission. Written by admin.js
     2337                // from the chosen option, and re-rendered here from the saved feed.
     2338                //
     2339                // Never disabled, unlike the pairs above: collect_mapping() reads these
     2340                // arrays by index, so every row must contribute exactly one entry to
     2341                // this one too.
     2342                $html .= '<input type="hidden" name="map_type[]" class="f2z-map-type" value="' . esc_attr( $ztype ) . '" /></td>';
    11792343
    11802344                // Static value.
    1181                 $html .= '<td><input type="text" name="map_static[]" class="f2z-map-static" value="' . esc_attr( $static ) . '" /></td>';
     2345                $html .= '<td><input type="text" name="map_static[]" class="f2z-map-static" value="' . esc_attr( $static ) . '"' . ( $is_static ? '' : $off ) . ' />';
     2346                $html .= '<input type="hidden" name="map_static[]" class="f2z-map-static-slot" value=""' . ( $is_static ? $off : '' ) . ' /></td>';
    11822347
    11832348                $html .= '<td><button type="button" class="button-link form2zoho-remove-row">' . esc_html__( 'Remove', 'techwave-form-sync-for-zoho' ) . '</button></td>';
    … …  
    12282393                        // exists in Zoho) instead of silently defaulting.
    12292394                        'campaigns_subscription_type' => isset( $feed['campaigns_subscription_type'] ) ? (string) $feed['campaigns_subscription_type'] : '',
     2395                        'campaigns_list_add'          => isset( $feed['campaigns_list_add'] ) ? (string) $feed['campaigns_list_add'] : '',
    12302396                        'campaigns_tag'               => isset( $feed['campaigns_tag'] ) ? array_values( array_filter( array_map( 'trim', (array) $feed['campaigns_tag'] ) ) ) : array(),
    12312397                        'campaigns_topics'            => isset( $feed['campaigns_topics'] ) && is_array( $feed['campaigns_topics'] ) ? $feed['campaigns_topics'] : array(),
     2398                        // Same two the queue passes, so a log line an integration writes
     2399                        // itself (a Campaigns tag Zoho would not attach, say) belongs to
     2400                        // this feed here as well.
     2401                        'feed_id'                     => $id,
     2402                        'form_id'                     => isset( $feed['form_name'] ) ? (string) $feed['form_name'] : '',
    12322403                );
    12332404
    … …  
    12402411                                $values[ (string) $rule['value'] ] = $this->sample_value( (string) $zoho_field );
    12412412                        }
     2413                }
     2414
     2415                // What an add-on puts on a live job through form2zoho_build_job (the
     2416                // Pro Actions' workflow setting, a CRM layout, an assignment rule)
     2417                // goes on the test as well, or "Workflows: None" would still run
     2418                // Zoho's workflows for every test record. Only keys this array does
     2419                // not already set are taken.
     2420                $job = FeedsRepository::build_job( $feed, $settings['form_id'], $values );
     2421                if ( isset( $job['settings'] ) && is_array( $job['settings'] ) ) {
     2422                        $settings += $job['settings'];
    12422423                }
    12432424
    … …  
    12872468                        $message = '' !== $record_id
    12882469                                /* translators: %s: Zoho record id. */
    1289                                 ? sprintf( __( 'Success — record updated in Zoho (id %s).', 'techwave-form-sync-for-zoho' ), $record_id )
    1290                                 : __( 'Success — a record was updated in Zoho.', 'techwave-form-sync-for-zoho' );
     2470                                ? sprintf( __( 'Success: record updated in Zoho (id %s).', 'techwave-form-sync-for-zoho' ), $record_id )
     2471                                : __( 'Success: a record was updated in Zoho.', 'techwave-form-sync-for-zoho' );
    12912472                } else {
    12922473                        $message = '' !== $record_id
    12932474                                /* translators: %s: Zoho record id. */
    1294                                 ? sprintf( __( 'Success — record created in Zoho (id %s).', 'techwave-form-sync-for-zoho' ), $record_id )
    1295                                 : __( 'Success — a test record was created in Zoho.', 'techwave-form-sync-for-zoho' );
     2475                                ? sprintf( __( 'Success: record created in Zoho (id %s).', 'techwave-form-sync-for-zoho' ), $record_id )
     2476                                : __( 'Success: a test record was created in Zoho.', 'techwave-form-sync-for-zoho' );
    12962477                }
    12972478
    … …  
    13522533                }
    13532534                if ( false !== strpos( $name, 'name' ) ) {
    1354                         return 'Techwave Form Sync Test';
    1355                 }
    1356 
    1357                 return 'Techwave Form Sync test';
     2535                        return 'FormBridge Test';
     2536                }
     2537
     2538                return 'FormBridge test';
    13582539        }
    13592540
    … …  
    13902571
    13912572        /**
    1392          * Compose the stored comma-separated dedupe field list from the separate
    1393          * Email / Phone checkboxes plus any extra field names typed in. Zoho's
    1394          * upsert API matches a record as a duplicate if it shares *any one* of
    1395          * these fields, so Email and Phone are effectively checked independently.
     2573         * The comma-separated list of fields a duplicate check matches on.
     2574         *
     2575         * Only the unique-field boxes fill it. Zoho's upsert treats a record as a
     2576         * duplicate if it shares any one of these, and with none ticked CRM and
     2577         * Bigin still upsert, leaving the choice of field to Zoho's own check.
     2578         *
     2579         * Until the editor has built those boxes from a field list that loaded,
     2580         * the stored list is kept as it was: a Zoho request that failed must not
     2581         * empty it on save.
    13962582         *
    13972583         * @return string
    13982584         */
    13992585        private function collect_dedupe_fields() {
    1400                 // Nonce already verified in handle_save().
    14012586                // phpcs:disable WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran in handle_save().
     2587                if ( empty( $_POST['dedupe_loaded'] ) ) {
     2588                        $posted = isset( $_POST['dedupe_saved'] ) ? explode( ',', sanitize_text_field( wp_unslash( $_POST['dedupe_saved'] ) ) ) : array();
     2589                } else {
     2590                        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element must match a Zoho API name exactly below, which is stricter than any sanitiser here.
     2591                        $posted = ! empty( $_POST['dedupe_unique'] ) && is_array( $_POST['dedupe_unique'] ) ? wp_unslash( $_POST['dedupe_unique'] ) : array();
     2592                }
     2593                // phpcs:enable WordPress.Security.NonceVerification.Missing
     2594
    14022595                $fields = array();
    1403                 if ( ! empty( $_POST['dedupe_email'] ) ) {
    1404                         $fields[] = 'Email';
    1405                 }
    1406                 if ( ! empty( $_POST['dedupe_phone'] ) ) {
    1407                         $fields[] = 'Phone';
    1408                 }
    1409                 if ( ! empty( $_POST['dedupe_extra'] ) ) {
    1410                         $extra  = sanitize_text_field( wp_unslash( $_POST['dedupe_extra'] ) );
    1411                         $fields = array_merge( $fields, array_filter( array_map( 'trim', explode( ',', $extra ) ) ) );
    1412                 }
    1413                 // phpcs:enable WordPress.Security.NonceVerification.Missing
     2596                foreach ( $posted as $name ) {
     2597                        $name = trim( (string) $name );
     2598
     2599                        // Allowlisted against Zoho's API-name shape, and rejected rather
     2600                        // than repaired: this ends up in an upsert's duplicate_check_fields,
     2601                        // and stripping a hand-made value down to something that looks like
     2602                        // a field name would send Zoho a duplicate check nobody chose.
     2603                        if ( 1 === preg_match( '/^[A-Za-z0-9_]+$/', $name ) ) {
     2604                                $fields[] = $name;
     2605                        }
     2606                }
     2607
    14142608                return implode( ',', array_unique( $fields ) );
    14152609        }
    … …  
    14192613         * to Elementor for anything unrecognised.
    14202614         *
     2615         * A locked key is rejected the same way. It is a real key — the picker
     2616         * draws a tile for it — but nothing on this site can read the submissions
     2617         * a feed pointed at it would describe, so accepting one would save a feed
     2618         * that silently never delivers. The disabled tile and the missing <option>
     2619         * stop it in the browser; this is the check that matters, because a POST
     2620         * does not have to come from that form.
     2621         *
    14212622         * @param string $raw Posted value.
    14222623         * @return string
    … …  
    14242625        private function sanitize_form_type( $raw ) {
    14252626                $key = sanitize_key( (string) $raw );
    1426                 return in_array( $key, FormSources::keys(), true ) ? $key : 'elementor';
     2627
     2628                if ( ! in_array( $key, FormSources::keys(), true ) || FormSources::is_locked( $key ) ) {
     2629                        return 'elementor';
     2630                }
     2631
     2632                return $key;
    14272633        }
    14282634
    … …  
    14402646                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above.
    14412647                $existing_id = isset( $_POST['feed_id'] ) ? sanitize_text_field( wp_unslash( $_POST['feed_id'] ) ) : '';
    1442                 $existing    = '' !== $existing_id ? $this->feeds->get( $existing_id ) : null;
     2648
     2649                // A new feed arrives with the id the editor picked for it. A double
     2650                // click, or a slow page pressed again, posts that same id twice, and
     2651                // saving under it updates the feed the first post created instead of
     2652                // adding a copy beside it.
     2653                if ( '' === $existing_id && isset( $_POST['new_feed_id'] ) ) {
     2654                        $new_id = sanitize_text_field( wp_unslash( $_POST['new_feed_id'] ) );
     2655                        if ( preg_match( '/^f_[a-f0-9]{12}$/', $new_id ) ) {
     2656                                $existing_id = $new_id;
     2657                        }
     2658                }
     2659
     2660                $existing = '' !== $existing_id ? $this->feeds->get( $existing_id ) : null;
    14432661
    14442662                // Filled in by collect_mapping() below, and reported after the redirect.
    1445                 $mapping_skipped = 0;
     2663                $mapping_skipped    = 0;
     2664                $mapping_duplicates = 0;
     2665                $mapping_invalid    = 0;
    14462666
    14472667                $feed = array(
    1448                         'id'                          => isset( $_POST['feed_id'] ) ? sanitize_text_field( wp_unslash( $_POST['feed_id'] ) ) : '',
     2668                        'id'                          => $existing_id,
    14492669                        'title'                       => isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '',
    1450                         'enabled'                     => ! empty( $_POST['enabled'] ),
     2670                        // Not posted by the editor any more — switching a feed on and off
     2671                        // belongs to the list, where the toggle sits beside the feed it
     2672                        // governs and takes one click. Reading the absent field as "off"
     2673                        // would have turned every feed off the first time it was saved,
     2674                        // so an existing feed keeps what it had and a new one starts on.
     2675                        'enabled'                     => is_array( $existing ) ? ! empty( $existing['enabled'] ) : true,
    14512676                        'form_name'                   => isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '',
    1452                         // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_form_type() applies sanitize_key() + an allowlist.
    1453                         'form_type'                   => $this->sanitize_form_type( isset( $_POST['form_type'] ) ? wp_unslash( $_POST['form_type'] ) : '' ),
     2677                        // A saved feed keeps the trigger it was created with, whatever is
     2678                        // posted. The editor no longer offers the change; this is the check
     2679                        // that holds when a POST does not come from the editor. An old feed
     2680                        // stored before form_type existed means Elementor, as it does in
     2681                        // render_editor().
     2682                        'form_type'                   => is_array( $existing )
     2683                                ? ( '' !== (string) ( $existing['form_type'] ?? '' ) ? (string) $existing['form_type'] : 'elementor' )
     2684                                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_form_type() applies sanitize_key() + an allowlist.
     2685                                : $this->sanitize_form_type( isset( $_POST['form_type'] ) ? wp_unslash( $_POST['form_type'] ) : '' ),
    14542686                        'integration'                 => isset( $_POST['integration'] ) ? sanitize_key( wp_unslash( $_POST['integration'] ) ) : 'crm',
    14552687                        'connection_id'               => isset( $_POST['connection_id'] ) ? sanitize_text_field( wp_unslash( $_POST['connection_id'] ) ) : '',
    … …  
    14592691                        'module_label'                => isset( $_POST['module_label'] ) ? sanitize_text_field( wp_unslash( $_POST['module_label'] ) ) : '',
    14602692                        'campaigns_subscription_type' => isset( $_POST['campaigns_subscription_type'] ) ? sanitize_text_field( wp_unslash( $_POST['campaigns_subscription_type'] ) ) : '',
     2693                        'campaigns_list_add'          => isset( $_POST['campaigns_list_add'] ) && 'confirm' === sanitize_key( wp_unslash( $_POST['campaigns_list_add'] ) ) ? 'confirm' : 'direct',
    14612694                        'campaigns_tag'               => isset( $_POST['campaigns_tag'] ) && is_array( $_POST['campaigns_tag'] ) ? array_values( array_filter( array_map( 'sanitize_text_field', wp_unslash( $_POST['campaigns_tag'] ) ) ) ) : array(),
    14622695                        'campaigns_topics'            => isset( $_POST['campaigns_topics'] ) && is_array( $_POST['campaigns_topics'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['campaigns_topics'] ) ) : array(),
    14632696                        'dedupe_enabled'              => ! empty( $_POST['dedupe_enabled'] ),
    14642697                        'dedupe_fields'               => $this->collect_dedupe_fields(),
    1465                         'mapping'                     => $this->collect_mapping( $mapping_skipped ),
     2698                        'mapping'                     => $this->collect_mapping( $mapping_skipped, $mapping_duplicates, $mapping_invalid ),
    14662699                        'created_at'                  => is_array( $existing ) && ! empty( $existing['created_at'] ) ? $existing['created_at'] : current_time( 'mysql' ),
     2700                        // The same moment in UTC, which is what the list converts to the
     2701                        // site's timezone when it shows it. A feed from before this existed
     2702                        // keeps an empty one rather than a guessed one.
     2703                        'created_at_gmt'              => is_array( $existing ) ? (string) ( $existing['created_at_gmt'] ?? '' ) : current_time( 'mysql', true ),
    14672704                );
    14682705
    14692706                $feed['title'] = $this->unique_title( $feed['title'], $feed['id'] );
     2707
     2708                /**
     2709                 * Filter a feed as it is about to be stored.
     2710                 *
     2711                 * Where an add-on persists the fields it printed through
     2712                 * form2zoho_feed_editor_fields: read them off $_POST — handle_save()
     2713                 * has already run both the capability check and
     2714                 * check_admin_referer() by this point — sanitize them, and return
     2715                 * them as extra keys on the feed.
     2716                 *
     2717                 * Deliberately placed before this method's own validation, so an
     2718                 * integration or connection id a callback writes is still checked
     2719                 * against what actually exists before anything is saved. A return
     2720                 * value that is not an array with an 'id' is ignored.
     2721                 *
     2722                 * @param array      $feed     Sanitized feed about to be saved.
     2723                 * @param array|null $existing The stored feed being replaced, null for a new one.
     2724                 */
     2725                $filtered = apply_filters( 'form2zoho_save_feed', $feed, $existing );
     2726                if ( is_array( $filtered ) && isset( $filtered['id'] ) ) {
     2727                        $feed = $filtered;
     2728                }
    14702729
    14712730                // Guard against an unknown integration id.
    … …  
    14932752                // Carried separately from $message so it can be reported alongside
    14942753                // whichever save outcome happened, rather than replacing it.
     2754                if ( $mapping_duplicates > 0 ) {
     2755                        $args['duplicates'] = $mapping_duplicates;
     2756                }
    14952757                if ( $mapping_skipped > 0 ) {
    14962758                        $args['skipped'] = $mapping_skipped;
     2759                }
     2760                if ( $mapping_invalid > 0 ) {
     2761                        $args['invalid'] = $mapping_invalid;
    14972762                }
    14982763
    … …  
    15332798         * Turn the parallel mapping arrays from the form into the stored shape.
    15342799         *
    1535          * @param int $skipped Out: how many rows named a Zoho field but carried
    1536          *                     no value, and were therefore left out.
     2800         * @param int $skipped    Out: how many rows named a Zoho field but carried
     2801         *                         no value, and were therefore left out.
     2802         * @param int $duplicates Out: how many rows aimed at a Zoho field an
     2803         *                         earlier row had already taken.
     2804         * @param int $invalid    Out: how many static values were not the kind
     2805         *                         of value their Zoho field accepts.
    15372806         * @return array<string,array{type:string,value:string}>
    15382807         */
    1539         private function collect_mapping( &$skipped = 0 ) {
     2808        private function collect_mapping( &$skipped = 0, &$duplicates = 0, &$invalid = 0 ) {
    15402809                // Nonce already verified in handle_save(); every element of these four
    15412810                // parallel arrays is passed through sanitize_text_field() in the loop below.
    … …  
    15452814                $form   = isset( $_POST['map_form'] ) ? (array) wp_unslash( $_POST['map_form'] ) : array();
    15462815                $stat   = isset( $_POST['map_static'] ) ? (array) wp_unslash( $_POST['map_static'] ) : array();
     2816                $ztypes = isset( $_POST['map_type'] ) ? (array) wp_unslash( $_POST['map_type'] ) : array();
    15472817                // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    15482818
    1549                 $mapping = array();
    1550                 $skipped = 0;
     2819                $mapping    = array();
     2820                $skipped    = 0;
     2821                $duplicates = 0;
     2822                $invalid    = 0;
    15512823
    15522824                foreach ( $zoho as $i => $field ) {
    … …  
    15582830                        }
    15592831
    1560                         $type = isset( $source[ $i ] ) && 'static' === $source[ $i ] ? 'static' : 'field';
    1561                         if ( 'static' === $type ) {
     2832                        $row_source = isset( $source[ $i ] ) ? $source[ $i ] : 'field';
     2833                        $type       = in_array( $row_source, array( 'static', FieldMapper::TYPE_TEMPLATE ), true ) ? $row_source : 'field';
     2834                        if ( 'field' !== $type ) {
    15622835                                $value = isset( $stat[ $i ] ) ? sanitize_text_field( $stat[ $i ] ) : '';
    15632836                        } else {
    … …  
    15762849                        }
    15772850
     2851                        // A static value the Zoho field will not accept: letters in a phone
     2852                        // number, text in a number field. It is sent exactly as typed, so
     2853                        // Zoho would refuse the record or store the junk. The editor stops
     2854                        // the save before this; here it is left out and reported, for a
     2855                        // request that did not come through the editor. See StaticValue.
     2856                        $posted_ztype = isset( $ztypes[ $i ] ) ? sanitize_key( $ztypes[ $i ] ) : '';
     2857                        if ( 'static' === $type && '' !== StaticValue::problem( $posted_ztype, $value ) ) {
     2858                                ++$invalid;
     2859                                continue;
     2860                        }
     2861
     2862                        // One Zoho field holds one value, so a second row aimed at the
     2863                        // same field cannot also be sent. This used to overwrite: the
     2864                        // later row won, the earlier one vanished on save, and the editor
     2865                        // came back showing one row where two had been typed. The first
     2866                        // is kept and the rest are counted, because the alternative —
     2867                        // last-one-wins — silently discards the row somebody wrote first.
     2868                        //
     2869                        // The editor no longer offers a field another row has taken, so
     2870                        // reaching this needs scripting off or a hand-made request. It is
     2871                        // still worth catching: the cost of not catching it is losing
     2872                        // somebody's work without a word.
     2873                        if ( isset( $mapping[ $field ] ) ) {
     2874                                ++$duplicates;
     2875                                continue;
     2876                        }
     2877
    15782878                        $mapping[ $field ] = array(
    15792879                                'type'  => $type,
    15802880                                'value' => $value,
    15812881                        );
     2882
     2883                        // What kind of Zoho field this row targets, when the app told us.
     2884                        // Stored for every type, not only the ones the mapper converts:
     2885                        // delivery also needs to know which rows are file fields, and a
     2886                        // row with no type then means only "saved before types were
     2887                        // stored", which delivery can still work out on its own.
     2888                        $ztype = isset( $ztypes[ $i ] ) ? sanitize_key( $ztypes[ $i ] ) : '';
     2889                        if ( '' !== $ztype ) {
     2890                                $mapping[ $field ]['zoho_type'] = $ztype;
     2891                        }
    15822892                }
    15832893
    … …  
    16292939                        $this->render_alert(
    16302940                                'warning',
    1631                                 __( 'Feed saved, but the Zoho account it referred to no longer exists. The feed has been switched off — pick a connected account and enable it again.', 'techwave-form-sync-for-zoho' )
     2941                                __( 'Feed saved, but the Zoho account it referred to no longer exists. The feed has been switched off. Pick a connected account and enable it again.', 'techwave-form-sync-for-zoho' )
    16322942                        );
    16332943                } elseif ( 'deleted' === $message ) {
    … …  
    16372947                // Reported on top of the save outcome above, not instead of it: a feed
    16382948                // can both lose its connection and have unfinished mapping rows.
     2949                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only count.
     2950                $duplicates = isset( $_GET['duplicates'] ) ? absint( wp_unslash( $_GET['duplicates'] ) ) : 0;
     2951                if ( $duplicates > 0 ) {
     2952                        $this->render_alert(
     2953                                'warning',
     2954                                sprintf(
     2955                                        /* translators: %d: number of mapping rows that aimed at a Zoho field another row had already taken. */
     2956                                        _n(
     2957                                                '%d mapping row was left out because another row already sends to that Zoho field. A field can only hold one value. Use the "Combine fields" source if you need several answers in one.',
     2958                                                '%d mapping rows were left out because other rows already send to those Zoho fields. A field can only hold one value. Use the "Combine fields" source if you need several answers in one.',
     2959                                                $duplicates,
     2960                                                'techwave-form-sync-for-zoho'
     2961                                        ),
     2962                                        $duplicates
     2963                                )
     2964                        );
     2965                }
     2966
    16392967                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only count.
    16402968                $skipped = isset( $_GET['skipped'] ) ? absint( wp_unslash( $_GET['skipped'] ) ) : 0;
    … …  
    16542982                        );
    16552983                }
     2984
     2985                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only count.
     2986                $invalid = isset( $_GET['invalid'] ) ? absint( wp_unslash( $_GET['invalid'] ) ) : 0;
     2987                if ( $invalid > 0 ) {
     2988                        $this->render_alert(
     2989                                'warning',
     2990                                sprintf(
     2991                                        /* translators: %d: number of mapping rows whose static value their Zoho field would not accept. */
     2992                                        _n(
     2993                                                '%d mapping row was left out because its static value is not the kind of value its Zoho field accepts, such as letters in a phone number. Correct it and save again.',
     2994                                                '%d mapping rows were left out because their static values are not the kind of value their Zoho fields accept, such as letters in a phone number. Correct them and save again.',
     2995                                                $invalid,
     2996                                                'techwave-form-sync-for-zoho'
     2997                                        ),
     2998                                        $invalid
     2999                                )
     3000                        );
     3001                }
    16563002        }
    16573003}
  • techwave-form-sync-for-zoho/trunk/includes/Admin/FeedsRepository.php

    r3649280 r3748247  
    99 *
    1010 * Feeds replace per-form configuration inside the Elementor editor — everything
    11  * is managed from WP Admin → Techwave Sync → Forms.
     11 * is managed from WP Admin → FormBridge → All Feeds.
    1212 *
    1313 * @package TechwaveFormSyncForZoho
    … …  
    9595         * @param string $form_id     Form identifier to record on the job.
    9696         * @param array  $form_values Submitted field values.
     97         * @param array  $files       Field id => absolute paths this submission
     98         *                            uploaded. Empty for a resubmit, and for any
     99         *                            form plugin whose adapter does not collect
     100         *                            them.
    97101         * @return array
    98102         */
    99         public static function build_job( array $feed, $form_id, array $form_values ) {
    100                 return array(
     103        public static function build_job( array $feed, $form_id, array $form_values, array $files = array() ) {
     104                $job = array(
    101105                        'integration' => isset( $feed['integration'] ) ? sanitize_key( $feed['integration'] ) : '',
    102106                        'form_id'     => (string) $form_id,
    … …  
    112116                                'mapping'                     => isset( $feed['mapping'] ) && is_array( $feed['mapping'] ) ? $feed['mapping'] : array(),
    113117                                'campaigns_subscription_type' => isset( $feed['campaigns_subscription_type'] ) ? (string) $feed['campaigns_subscription_type'] : '',
     118                                'campaigns_list_add'          => isset( $feed['campaigns_list_add'] ) ? (string) $feed['campaigns_list_add'] : '',
    114119                                'campaigns_tag'               => isset( $feed['campaigns_tag'] ) ? array_values( array_filter( array_map( 'trim', (array) $feed['campaigns_tag'] ) ) ) : array(),
    115120                                'campaigns_topics'            => isset( $feed['campaigns_topics'] ) && is_array( $feed['campaigns_topics'] ) ? $feed['campaigns_topics'] : array(),
    116121                        ),
    117122                        'form_values' => $form_values,
     123
     124                        /*
     125                         * Absolute paths to anything the submission uploaded, keyed by the
     126                         * same field id the values use.
     127                         *
     128                         * Kept out of 'form_values' deliberately. Those are the things a
     129                         * mapping row can point at, and they are shown in the editor and
     130                         * written to the log; a server path is neither, and putting one
     131                         * there would offer it as a mappable field and print it into
     132                         * somebody's CRM as text.
     133                         *
     134                         * Contact Form 7 and Elementor hand their uploads over and their
     135                         * adapters pass them in. Every other form plugin submits where it
     136                         * stored the file (a URL, an attachment id), so for each row that
     137                         * sends a form field to a Zoho file field the file is found from
     138                         * that value here. That also covers a Logs-screen resubmit, as
     139                         * long as the form plugin has kept the file.
     140                         */
     141                        'files'       => \Form2Zoho\Support\UploadedFiles::for_mapping(
     142                                isset( $feed['mapping'] ) && is_array( $feed['mapping'] ) ? $feed['mapping'] : array(),
     143                                $form_values,
     144                                $files,
     145                                // Empty on a feed saved before form_type existed, which means
     146                                // Elementor, as it does everywhere else.
     147                                isset( $feed['form_type'] ) && '' !== $feed['form_type'] ? (string) $feed['form_type'] : 'elementor',
     148                                (string) $form_id
     149                        ),
    118150                );
     151
     152                /**
     153                 * Filter the job built from a feed, before it is delivered.
     154                 *
     155                 * 'settings' is deliberately a whitelist of the keys this plugin
     156                 * itself understands, so per-feed settings an add-on stored through
     157                 * form2zoho_save_feed do not reach the integration on their own —
     158                 * this is where they get copied across.
     159                 *
     160                 * Also runs for a Logs-screen resubmit, which rebuilds the job from
     161                 * the feed's current configuration rather than the original request.
     162                 *
     163                 * @param array  $job         The job about to be delivered.
     164                 * @param array  $feed        The feed it was built from.
     165                 * @param string $form_id     Form identifier recorded on the job.
     166                 * @param array  $form_values Field id => submitted value.
     167                 */
     168                $filtered = apply_filters( 'form2zoho_build_job', $job, $feed, (string) $form_id, $form_values );
     169
     170                // A job with no integration id is dropped by Dispatcher::process_job()
     171                // without a log row, so a filter returning something unusable would
     172                // lose the submission silently rather than noisily.
     173                if ( is_array( $filtered ) && ! empty( $filtered['integration'] ) && isset( $filtered['settings'], $filtered['form_values'] ) ) {
     174                        return $filtered;
     175                }
     176
     177                return $job;
    119178        }
    120179
  • techwave-form-sync-for-zoho/trunk/includes/Admin/SettingsPage.php

    r3649280 r3748247  
    1111use Form2Zoho\Logging\Logger;
    1212use Form2Zoho\Support\Assets;
     13use Form2Zoho\Support\Branding;
    1314use Form2Zoho\Support\DataCenters;
    1415use Form2Zoho\Support\Encryption;
    1516use Form2Zoho\Support\Markup;
    1617use Form2Zoho\Support\Settings;
     18use Form2Zoho\Support\VideoGuide;
     19use Form2Zoho\Integrations\IntegrationRegistry;
     20use Form2Zoho\Core\Plugin;
    1721
    1822defined( 'ABSPATH' ) || exit;
    … …  
    2529final class SettingsPage {
    2630
    27         private const CAP  = 'manage_options';
    28         private const SLUG = 'form2zoho';
     31        private const CAP = 'manage_options';
     32
     33        /**
     34         * This screen's slug, the plugin's name as the address shows it.
     35         *
     36         * Public, so every link to Connections is built from it rather than typed
     37         * out. It was `form2zoho`, and LegacyPageSlugs sends that address here.
     38         */
     39        public const SLUG = 'formbridge';
    2940
    3041        /**
    … …  
    7687                add_action( 'admin_post_form2zoho_clear_logs', array( $this, 'handle_clear_logs' ) );
    7788                add_action( 'wp_ajax_form2zoho_render_tab', array( $this, 'ajax_render_tab' ) );
     89                add_filter( 'submenu_file', array( $this, 'current_submenu' ) );
     90                add_action( 'wp_ajax_form2zoho_logs_page', array( $this, 'ajax_logs_page' ) );
    7891        }
    7992
    … …  
    8497         */
    8598        public function add_menu() {
    86                 // Connections is a submenu, not the landing screen. FeedsPage owns the
    87                 // top-level entry (see FeedsPage::add_menu(), which runs first on a
    88                 // lower priority) because Forms is the screen someone opens every day
    89                 // while this one is visited once, during setup.
    90                 $this->hook_suffix = add_submenu_page(
    91                         FeedsPage::MENU_SLUG,
    92                         __( 'Connections', 'techwave-form-sync-for-zoho' ),
    93                         __( 'Connections', 'techwave-form-sync-for-zoho' ),
    94                         self::CAP,
    95                         self::SLUG,
    96                         array( $this, 'render' )
    97                 );
     99                // One sidebar entry per tab of this screen, in the order the masthead
     100                // lists them, so the two menus offer the same places: Logs,
     101                // Connections, Settings, Docs & Support, then any tab an add-on
     102                // added. Only Connections is a registered screen. The rest take the
     103                // tab's own URL for a slug, the way ProUpsell adds its link: there is
     104                // no screen of their own to register, they are tabs of this one.
     105                $tabs = self::tab_labels();
     106                unset( $tabs['connections'] );
     107
     108                $order = array();
     109                foreach ( array( 'logs', 'connections', 'settings' ) as $tab ) {
     110                        if ( 'connections' === $tab || isset( $tabs[ $tab ] ) ) {
     111                                $order[] = $tab;
     112                        }
     113                }
     114                $order = array_merge( $order, array_diff( array_keys( $tabs ), $order ) );
     115
     116                foreach ( $order as $tab ) {
     117                        if ( 'connections' === $tab ) {
     118                                // A submenu, not the landing screen. FeedsPage owns the
     119                                // top-level entry (see FeedsPage::add_menu(), which runs first
     120                                // on a lower priority) because Forms is the screen someone
     121                                // opens every day while this one is visited once, during setup.
     122                                $this->hook_suffix = add_submenu_page(
     123                                        FeedsPage::MENU_SLUG,
     124                                        __( 'Connections', 'techwave-form-sync-for-zoho' ),
     125                                        __( 'Connections', 'techwave-form-sync-for-zoho' ),
     126                                        self::CAP,
     127                                        self::SLUG,
     128                                        array( $this, 'render' )
     129                                );
     130                                continue;
     131                        }
     132
     133                        add_submenu_page(
     134                                FeedsPage::MENU_SLUG,
     135                                (string) $tabs[ $tab ],
     136                                esc_html( (string) $tabs[ $tab ] ),
     137                                self::CAP,
     138                                self::tab_menu_slug( (string) $tab )
     139                        );
     140                }
     141        }
     142
     143        /**
     144         * A tab's sidebar entry's slug: the tab's own URL.
     145         *
     146         * @param string $tab Tab slug.
     147         * @return string
     148         */
     149        private static function tab_menu_slug( $tab ) {
     150                return admin_url( 'admin.php?page=' . self::SLUG . '&tab=' . rawurlencode( $tab ) );
     151        }
     152
     153        /**
     154         * Mark the tab on screen, not Connections, as the sidebar's current item.
     155         *
     156         * WordPress picks the current item by the `page` argument alone, and
     157         * every tab's entry shares it — so Logs, Settings and Docs & Support
     158         * were all shown with Connections in bold. Naming the entry here is how
     159         * core itself is told otherwise.
     160         *
     161         * @param mixed $submenu_file The submenu entry WordPress is about to mark.
     162         * @return mixed
     163         */
     164        public function current_submenu( $submenu_file ) {
     165                // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing.
     166                $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
     167                $tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
     168                // phpcs:enable
     169
     170                if ( self::SLUG === $page && '' !== $tab && 'connections' !== $tab && array_key_exists( $tab, self::tab_labels() ) ) {
     171                        // Through plugin_basename(), as add_submenu_page() stored it.
     172                        return plugin_basename( self::tab_menu_slug( $tab ) );
     173                }
     174
     175                return $submenu_file;
    98176        }
    99177
    … …  
    144222                                'nonce'        => wp_create_nonce( 'form2zoho_ajax' ),
    145223                                'integrations' => $integrations,
     224                                'consoleUrls'  => DataCenters::console_urls(),
    146225                                'i18n'         => array(
    147226                                        'pickInt'     => __( 'Select a Zoho service', 'techwave-form-sync-for-zoho' ),
    … …  
    153232                                        'label'       => __( 'Label', 'techwave-form-sync-for-zoho' ),
    154233                                        'required'    => __( 'Required', 'techwave-form-sync-for-zoho' ),
    155                                         'copyHint'    => __( 'Copy these API names into a feed under Techwave Sync → Forms.', 'techwave-form-sync-for-zoho' ),
     234                                        'copyHint'    => __( 'Copy these API names into a feed under FormBridge → All Feeds.', 'techwave-form-sync-for-zoho' ),
    156235                                        'noResults'   => __( 'Nothing returned.', 'techwave-form-sync-for-zoho' ),
    157236                                        'sendingTest' => __( 'Sending…', 'techwave-form-sync-for-zoho' ),
    … …  
    173252                }
    174253
    175                 $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     254                $tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connections'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
     255                $tabs = $this->tab_list();
     256                if ( ! isset( $tabs[ $tab ] ) ) {
     257                        $tab = 'connections';
     258                }
    176259
    177260                echo '<div class="wrap form2zoho form2zoho-page-settings">';
    178                 echo '<div class="form2zoho-hero">';
    179                 echo '<span class="form2zoho-hero-logo"><span class="form2zoho-hero-logo-icon">' . esc_html__( 'TFS', 'techwave-form-sync-for-zoho' ) . '</span>' . esc_html__( 'Techwave Form Sync for Zoho', 'techwave-form-sync-for-zoho' ) . '</span>';
    180                 echo '</div>';
     261                HeaderNav::render( $tab );
    181262                echo '<div class="form2zoho-settings-pop" id="form2zoho-tab-body">';
    182263                $this->render_tab_body( $tab );
    … …  
    206287                $this->oauth_notice();
    207288                $this->action_notice();
    208                 $this->tabs( $tab );
    209289
    210290                if ( 'logs' === $tab ) {
    … …  
    212292                } elseif ( 'settings' === $tab ) {
    213293                        $this->render_settings();
     294                } elseif ( 'connections' === $tab ) {
     295                        $this->render_connections();
     296                } elseif ( 'support' === $tab ) {
     297                        $this->render_support();
    214298                } else {
    215                         $this->render_connections();
     299                        /**
     300                         * Renders the body of a tab added through form2zoho_settings_tabs.
     301                         *
     302                         * Only ever fires for a slug that filter registered — both entry
     303                         * points check the tab against the list first and fall back to
     304                         * Connections — so a callback does not have to guard against
     305                         * being called for somebody else's tab, only its own.
     306                         *
     307                         * Escape your own output: this prints straight into the page.
     308                         *
     309                         * @param string $tab Active tab slug.
     310                         */
     311                        do_action( 'form2zoho_render_settings_tab', $tab );
    216312                }
    217313        }
    … …  
    234330                check_ajax_referer( 'form2zoho_ajax', 'nonce' );
    235331
    236                 $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connections';
    237                 if ( ! in_array( $tab, array( 'connections', 'logs', 'settings' ), true ) ) {
     332                $tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connections';
     333                $tabs = $this->tab_list();
     334                if ( ! isset( $tabs[ $tab ] ) ) {
    238335                        $tab = 'connections';
    239336                }
    … …  
    262359
    263360        /**
    264          * Tab navigation.
    265          *
    266          * @param string $current Active tab.
    267          * @return void
    268          */
    269         private function tabs( $current ) {
     361         * Tab slug => label for every tab on this screen.
     362         *
     363         * Deliberately one list rather than three: the tab bar, the AJAX
     364         * tab-switch allowlist and the body dispatcher all read it, so an added
     365         * tab cannot end up rendered but unclickable, or clickable but rejected
     366         * as unknown.
     367         *
     368         * @return array<string,string>
     369         */
     370        private function tab_list() {
     371                return self::tab_labels();
     372        }
     373
     374        /**
     375         * The same list as tab_list(), for callers outside this class — the
     376         * masthead's navigation (HeaderNav) draws a link for every tab.
     377         *
     378         * @return array<string,string>
     379         */
     380        public static function tab_labels() {
    270381                $tabs = array(
    271382                        'connections' => __( 'Connections', 'techwave-form-sync-for-zoho' ),
    272383                        'logs'        => __( 'Logs', 'techwave-form-sync-for-zoho' ),
    273384                        'settings'    => __( 'Settings', 'techwave-form-sync-for-zoho' ),
    274                 );
    275                 echo '<h2 class="nav-tab-wrapper form2zoho-settings-tabs">';
    276                 foreach ( $tabs as $slug => $label ) {
    277                         $url = add_query_arg(
    278                                 array(
    279                                         'page' => self::SLUG,
    280                                         'tab'  => $slug,
    281                                 ),
    282                                 admin_url( 'admin.php' )
    283                         );
    284                         printf(
    285                                 '<a href="%s" class="nav-tab %s">%s</a>',
    286                                 esc_url( $url ),
    287                                 $slug === $current ? 'nav-tab-active' : '',
    288                                 esc_html( $label )
    289                         );
    290                 }
    291                 echo '</h2>';
     385                        'support'     => __( 'Docs & Support', 'techwave-form-sync-for-zoho' ),
     386                );
     387
     388                /**
     389                 * Filter the tabs on the plugin's settings screen.
     390                 *
     391                 * An added tab is drawn by hooking form2zoho_render_settings_tab,
     392                 * which receives the slug. A licence screen for an add-on belongs
     393                 * here.
     394                 *
     395                 * The three built-in tabs are put back afterwards if a callback
     396                 * drops one: the screen has nowhere to send a person whose tab
     397                 * vanished, and Connections is where every other fallback lands.
     398                 *
     399                 * @param array $tabs Tab slug => label.
     400                 */
     401                $filtered = apply_filters( 'form2zoho_settings_tabs', $tabs );
     402
     403                return is_array( $filtered ) ? array_merge( $tabs, $filtered ) : $tabs;
    292404        }
    293405
    … …  
    418530                        $this->render_alert(
    419531                                'error',
    420                                 __( 'This server is missing PHP\'s OpenSSL extension (with AES-256-GCM), so Zoho credentials cannot be encrypted. Adding a connection is disabled until your host enables it — the plugin will not store a client secret or refresh token unencrypted.', 'techwave-form-sync-for-zoho' ),
     532                                __( 'This server is missing PHP\'s OpenSSL extension (with AES-256-GCM), so Zoho credentials cannot be encrypted. Adding a connection is disabled until your host enables it. The plugin will not store a client secret or refresh token unencrypted.', 'techwave-form-sync-for-zoho' ),
    421533                                __( 'Cannot store credentials securely', 'techwave-form-sync-for-zoho' )
    422534                        );
    … …  
    495607                echo '<div class="form2zoho-modal-head">';
    496608                echo '<h2 class="form2zoho-modal-title" id="form2zoho-add-connection-title">' . esc_html__( 'Create a connection', 'techwave-form-sync-for-zoho' ) . '</h2>';
    497                 echo '<p class="description form2zoho-modal-sub">' . esc_html__( 'Register this site as a Server-based Applications in the Zoho API console, then paste the credentials below.', 'techwave-form-sync-for-zoho' ) . '</p>';
     609                // "Zoho API console" is the link, not a URL printed in the sentence:
     610                // the URL is long, region-dependent and nobody needs to read it. Its
     611                // href follows the data centre picker below — the console is a
     612                // different host per region, and the wrong one shows a console with
     613                // none of your clients in it, which looks exactly like having lost
     614                // them. admin.js keeps it in step; this is the starting value.
     615                echo '<p class="description form2zoho-modal-sub">';
     616                printf(
     617                        /* translators: %s: link to the Zoho API console, reading "Zoho API console". */
     618                        esc_html__( 'Register this site as a Server-based Application in the %s, then paste the credentials below.', 'techwave-form-sync-for-zoho' ),
     619                        sprintf(
     620                                '<a href="%s" class="form2zoho-console-link" target="_blank" rel="noopener noreferrer">%s</a>',
     621                                esc_url( DataCenters::console_url( 'us' ) ),
     622                                esc_html__( 'Zoho API console', 'techwave-form-sync-for-zoho' )
     623                        )
     624                );
     625                echo '</p>';
    498626                echo '</div>';
    499627
    … …  
    514642                echo '<code title="' . esc_attr( $redirect ) . '">' . esc_html( $redirect ) . '</code>';
    515643                echo '<button type="button" class="form2zoho-copy-text" data-copy="' . esc_attr( $redirect ) . '" title="' . esc_attr__( 'Copy to clipboard', 'techwave-form-sync-for-zoho' ) . '">';
    516                 echo '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
     644                // Both icons are printed. The stylesheet shows the tick while the
     645                // button carries .is-copied, so the copy is confirmed where it was
     646                // pressed.
     647                echo '<svg class="form2zoho-copy-text__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
     648                echo '<svg class="form2zoho-copy-text__done" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
    517649                echo '</button>';
    518650                echo '</span>';
    … …  
    525657                echo '<tr><th scope="row">' . esc_html__( 'Authorize for', 'techwave-form-sync-for-zoho' ) . '</th><td>';
    526658                // Same grid as the Edit access drawer, so the two app pickers match.
    527                 // Still a <p>: admin.js scopes the checkbox interlock with
    528                 // closest( 'td, p' ), and this <p> becomes that container.
     659                // The class is what admin.js scopes the "All applications" / per-app
     660                // interlock to, so it has to stay on the element wrapping both.
    529661                echo '<p class="form2zoho-apps-grid">';
    530662                // Nothing is pre-selected, and "All applications" in particular is not
    … …  
    542674                }
    543675                echo '</p>';
    544                 echo '<p class="description">' . esc_html__( 'Check only the Zoho apps this account will actually send to — Zoho asks permission for exactly what you pick here, and nothing else. You can add more later with "Edit access" on the connection, without deleting it. "All applications" requests every permission the plugin supports at once and is rarely what you want.', 'techwave-form-sync-for-zoho' ) . '</p>';
     676                echo '<p class="description">' . esc_html__( 'Check only the Zoho apps this account will actually send to. Zoho asks permission for exactly what you pick here, and nothing else. You can add more later with "Edit access" on the connection, without deleting it. "All applications" requests every permission the plugin supports at once and is rarely what you want.', 'techwave-form-sync-for-zoho' ) . '</p>';
    545677                echo '</td></tr>';
    546678
    … …  
    555687                submit_button( __( 'Connect with Zoho', 'techwave-form-sync-for-zoho' ), 'primary', 'submit', false );
    556688                echo '<button type="button" class="button form2zoho-cancel-add-connection">' . esc_html__( 'Cancel', 'techwave-form-sync-for-zoho' ) . '</button>';
     689
     690                // The written-out version of this dialog, at the far end of the row.
     691                //
     692                // Here rather than in the sentence at the top, which already carries
     693                // the one link that is part of the task — the console someone has to
     694                // open to get the credentials. A second link beside it would compete
     695                // with it, and these are different kinds of thing: that one is a step,
     696                // this one is help for when the steps are not enough. The footer is
     697                // also the half of the dialog that does not scroll, so it stays
     698                // reachable from the bottom of a long form on a short screen.
     699                printf(
     700                        '<a class="form2zoho-modal-docs" href="%s" target="_blank" rel="noopener noreferrer">',
     701                        esc_url( self::docs_url() )
     702                );
     703                echo '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>';
     704                echo '<span>' . esc_html__( 'Setup guide', 'techwave-form-sync-for-zoho' ) . '</span>';
     705                echo '<svg class="form2zoho-extlink" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
     706                echo '</a>';
     707                // The same ground, watched rather than read. Registering an
     708                // application in the Zoho console is the step people get stuck on, and
     709                // it is far easier to follow somebody doing it than to read it.
     710                VideoGuide::render(
     711                        'connections',
     712                        __( 'Watch: connect an account', 'techwave-form-sync-for-zoho' ),
     713                        'form2zoho-modal-docs'
     714                );
    557715                echo '</div>';
    558716                echo '</form>';
    … …  
    562720
    563721                echo '</div>';
     722        }
     723
     724        /**
     725         * The documentation for creating a connection.
     726         *
     727         * A page about this one task, so there is no anchor to land on and none
     728         * to go stale: the previous link pointed into a page about installing the
     729         * plugin — which someone reading this dialog has already done — and
     730         * needed a fragment to reach the part that was relevant.
     731         *
     732         * Filterable so a site that hosts its own copy of the documentation can
     733         * point at that instead of sending its users off to ours.
     734         *
     735         * @return string
     736         */
     737        private static function docs_url() {
     738                $url = 'https://techwavesolutions.net/formbridge-zoho/zoho-connection/create-zoho-connection/';
     739
     740                /**
     741                 * Filter the "Setup guide" link in the connection dialog.
     742                 *
     743                 * @param string $url Documentation URL.
     744                 */
     745                return (string) apply_filters( 'form2zoho_docs_connection_url', $url );
    564746        }
    565747
    … …  
    627809                                echo '</button>';
    628810                        }
     811                        /* This link is documented in FeedsPage's own zero state. */
     812                        VideoGuide::render( 'setup', __( 'Watch the full setup', 'techwave-form-sync-for-zoho' ) );
    629813                        echo '</div>';
    630814                        return;
    … …  
    634818                echo '<table class="form2zoho-conn-table"><thead><tr>';
    635819                echo '<th scope="col">' . esc_html__( 'Account', 'techwave-form-sync-for-zoho' ) . '</th>';
    636                 echo '<th scope="col">' . esc_html__( 'Authorized for', 'techwave-form-sync-for-zoho' ) . '</th>';
    637                 echo '<th scope="col">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>';
     820                echo '<th scope="col" class="form2zoho-col-center">' . esc_html__( 'Authorized for', 'techwave-form-sync-for-zoho' ) . '</th>';
     821                echo '<th scope="col" class="form2zoho-col-center">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>';
    638822                echo '<th scope="col" class="form2zoho-col-actions">' . esc_html__( 'Actions', 'techwave-form-sync-for-zoho' ) . '</th>';
    639823                echo '</tr></thead><tbody>';
    … …  
    693877                        echo '</td>';
    694878
    695                         echo '<td>';
     879                        echo '<td class="form2zoho-col-center">';
    696880                        echo '<div class="form2zoho-authorized-cell">';
    697881                        echo '<div class="form2zoho-authorized-pills">';
    … …  
    701885                                echo '<span class="form2zoho-pill form2zoho-pill-all">' . esc_html__( 'All applications', 'techwave-form-sync-for-zoho' ) . '</span>';
    702886                        } else {
     887                                $names = array();
    703888                                foreach ( $conn_apps as $app ) {
    704889                                        if ( isset( $app_labels[ $app ] ) ) {
    705                                                 echo '<span class="form2zoho-pill">' . esc_html( $app_labels[ $app ] ) . '</span>';
     890                                                $names[] = $app_labels[ $app ];
    706891                                        }
     892                                }
     893
     894                                // A long grant is a wall of pills that pushes the row several
     895                                // lines tall. The first few stay in view and the rest open in
     896                                // place behind a "+N more" button; its title lists them all
     897                                // for a quick look without a click.
     898                                $shown = count( $names ) > 4 ? 3 : count( $names );
     899
     900                                foreach ( array_slice( $names, 0, $shown ) as $name ) {
     901                                        echo '<span class="form2zoho-pill">' . esc_html( $name ) . '</span>';
     902                                }
     903
     904                                $rest = array_slice( $names, $shown );
     905
     906                                if ( array() !== $rest ) {
     907                                        $list_id = 'form2zoho-apps-more-' . sanitize_html_class( $id );
     908                                        echo '<span class="form2zoho-authorized-more-list" id="' . esc_attr( $list_id ) . '" hidden>';
     909                                        foreach ( $rest as $name ) {
     910                                                echo '<span class="form2zoho-pill">' . esc_html( $name ) . '</span>';
     911                                        }
     912                                        echo '</span>';
     913                                        /* translators: %d: number of further Zoho apps. */
     914                                        $more = sprintf( _n( '+%d more', '+%d more', count( $rest ), 'techwave-form-sync-for-zoho' ), count( $rest ) );
     915                                        echo '<button type="button" class="form2zoho-pill form2zoho-authorized-more" aria-expanded="false" aria-controls="' . esc_attr( $list_id ) . '" title="' . esc_attr( implode( ', ', $rest ) ) . '" data-more="' . esc_attr( $more ) . '" data-less="' . esc_attr__( 'Show less', 'techwave-form-sync-for-zoho' ) . '">' . esc_html( $more ) . '</button>';
    707916                                }
    708917                        }
    … …  
    711920                        echo '</div>';
    712921                        echo '</td>';
    713                         echo '<td>';
     922                        echo '<td class="form2zoho-col-center">';
    714923                        echo '<div class="form2zoho-conn-status-cell">';
    715924                        if ( $connected ) {
    … …  
    725934                        echo '</a>';
    726935                        echo '</div>';
    727                         if ( ! empty( $conn['bigin_org_error'] ) ) {
     936                        // Only while this connection is still meant to reach Bigin: a
     937                        // warning about an app it no longer covers is just noise, and one
     938                        // saved before Bigin was unticked would otherwise keep showing.
     939                        $covers_bigin = in_array( 'bigin', $conn_apps, true ) || in_array( 'all', $conn_apps, true );
     940                        if ( $covers_bigin && ! empty( $conn['bigin_org_error'] ) ) {
    728941                                echo '<div class="form2zoho-conn-warn">';
    729942                                echo '<span class="form2zoho-pill form2zoho-pill-warn">' . esc_html__( 'Bigin org lookup failed', 'techwave-form-sync-for-zoho' ) . '</span>';
    … …  
    756969                        echo '<input type="hidden" name="conn" value="' . esc_attr( $id ) . '" />';
    757970                        echo '<p class="description form2zoho-edit-apps-hint">' . esc_html__( 'Check every Zoho app this connection should have access to, then reauthorize. Zoho will ask you to approve the updated list.', 'techwave-form-sync-for-zoho' ) . '</p>';
    758                         // Stays a <p>: admin.js scopes the "All applications" / per-app
    759                         // checkbox interlock with closest( 'td, p' ), so this element is
    760                         // the group container that logic relies on.
     971                        // The class is the group container admin.js scopes the
     972                        // "All applications" / per-app interlock to.
    761973                        echo '<p class="form2zoho-apps-grid">';
    762974                        echo '<label class="form2zoho-app-check form2zoho-app-check-all"><input type="checkbox" name="apps[]" value="all" ' . checked( $is_all, true, false ) . ' class="form2zoho-apps-all" /> <span>' . esc_html__( 'All applications', 'techwave-form-sync-for-zoho' ) . '</span></label>';
     975                        // A connection authorized for "all" ticks every app, not none —
     976                        // which is both the truth about what it can reach and the starting
     977                        // point for narrowing the grant: untick the ones to drop and
     978                        // admin.js clears "All applications" for you, so what gets
     979                        // submitted is the shorter list rather than "all" again.
    763980                        foreach ( $app_labels as $app_key => $app_label ) {
    764981                                echo '<label class="form2zoho-app-check"><input type="checkbox" name="apps[]" value="' . esc_attr( $app_key ) . '" ' . checked( $is_all || in_array( $app_key, $conn_apps, true ), true, false ) . ' class="form2zoho-apps-one" /> <span>' . esc_html( $app_label ) . '</span></label>';
    … …  
    7961013                echo '<div class="form2zoho-modal-head">';
    7971014                echo '<h2 class="form2zoho-modal-title" id="form2zoho-fields-title"></h2>';
    798                 echo '<p class="description form2zoho-modal-sub">' . esc_html__( 'Copy an API name into a feed under Techwave Sync → Forms.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1015                echo '<p class="description form2zoho-modal-sub">' . esc_html__( 'Copy an API name into a feed under FormBridge → All Feeds.', 'techwave-form-sync-for-zoho' ) . '</p>';
    7991016                echo '</div>';
    8001017                echo '<div class="form2zoho-modal-scroll"><div class="form2zoho-fields-out"></div></div>';
    … …  
    8091026         */
    8101027        private function render_logs() {
    811                 $rows    = $this->logger->recent( 200 );
    812                 $last_id = ! empty( $rows ) ? (int) $rows[0]->id : 0;
     1028                $paging = $this->logs_paging_state();
     1029                $rows   = $this->logger->page( ( $paging['paged'] - 1 ) * $paging['per_page'], $paging['per_page'] );
     1030
     1031                // The newest id in the table, not the newest row on this page. On page
     1032                // two the newest row on screen is an old one, and starting the live
     1033                // poll from its id would fetch everything above it — the whole of page
     1034                // one — and prepend it here.
     1035                $last_id = $this->logger->newest_id();
    8131036
    8141037                $has_rows = ! empty( $rows );
    … …  
    8561079                echo '</span>';
    8571080                echo '<h2 class="form2zoho-emptystate__title">' . esc_html__( 'No log entries yet', 'techwave-form-sync-for-zoho' ) . '</h2>';
    858                 echo '<p class="form2zoho-emptystate__text">' . esc_html__( 'Every delivery to Zoho is recorded here — successes, failures and the exact data sent. New submissions appear instantly while Live is on.', 'techwave-form-sync-for-zoho' ) . '</p>';
     1081                echo '<p class="form2zoho-emptystate__text">' . esc_html__( 'Every delivery to Zoho is recorded here: successes, failures and the exact data sent. New submissions appear instantly while Live is on.', 'techwave-form-sync-for-zoho' ) . '</p>';
    8591082                echo '</div>';
    8601083
    … …  
    8651088                echo '<table class="widefat striped form2zoho-logs" id="form2zoho-logs-table" data-after-id="' . esc_attr( (string) $last_id ) . '" ' . ( empty( $rows ) ? 'hidden' : '' ) . '><thead><tr>';
    8661089                echo '<th scope="col" class="form2zoho-logs-checkcol"><label class="screen-reader-text" for="form2zoho-logs-select-all">' . esc_html__( 'Select all log entries', 'techwave-form-sync-for-zoho' ) . '</label><input type="checkbox" id="form2zoho-logs-select-all" /></th>';
    867                 echo '<th scope="col">' . esc_html__( 'Time (UTC)', 'techwave-form-sync-for-zoho' ) . '</th>';
     1090                echo '<th scope="col">' . esc_html( sprintf( /* translators: %s: the site's timezone, e.g. Asia/Dhaka or UTC+06:00. */ __( 'Time (%s)', 'techwave-form-sync-for-zoho' ), \Form2Zoho\Support\SiteTime::zone() ) ) . '</th>';
    8681091                echo '<th scope="col">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>';
    8691092                echo '<th scope="col">' . esc_html__( 'App', 'techwave-form-sync-for-zoho' ) . '</th>';
    … …  
    8741097                echo '<th scope="col"><span class="screen-reader-text">' . esc_html__( 'Details', 'techwave-form-sync-for-zoho' ) . '</span></th>';
    8751098                echo '</tr></thead><tbody id="form2zoho-logs-tbody">';
    876 
     1099                $this->render_logs_rows( $rows );
     1100                echo '</tbody></table>';
     1101                echo '</div>';
     1102
     1103                $this->render_logs_pagination( $paging, $has_rows );
     1104        }
     1105
     1106        /**
     1107         * The rows of the logs table, and nothing around them.
     1108         *
     1109         * Its own method because two callers need exactly these rows and nothing
     1110         * else: the page render, and the paging endpoint, which buffers this and
     1111         * hands back the markup for the tbody. One renderer means a row fetched by
     1112         * pressing Next cannot drift from a row that was there on load.
     1113         *
     1114         * @param array<int,object> $rows Log rows, newest first.
     1115         * @return void
     1116         */
     1117        private function render_logs_rows( array $rows ) {
    8771118                // Human-readable app names for the "App" column and the copyable
    8781119                // JSON detail — the raw integration id (e.g. "automation") is
    … …  
    8861127                        'automation' => __( 'Zoho Marketing Automation', 'techwave-form-sync-for-zoho' ),
    8871128                );
     1129
     1130                // Anything an add-on registered, named the way the feed editor names
     1131                // it. The list above is only what this plugin ships; without this a
     1132                // destination added by the add-on showed its bare id in the App
     1133                // column — "webhook" sitting under a heading whose other rows all say
     1134                // "Zoho CRM". The hard-coded rows win, so a translation here is never
     1135                // overridden by a registry that has not been translated.
     1136                //
     1137                // Read from the container rather than injected: this screen does not
     1138                // otherwise need the registry, and widening its constructor for one
     1139                // column of one table would put the dependency on every caller.
     1140                $registry = Plugin::instance()->get( 'registry' );
     1141
     1142                if ( $registry instanceof IntegrationRegistry ) {
     1143                        foreach ( $registry->choices() as $app_id => $app_label ) {
     1144                                if ( ! isset( $app_labels[ $app_id ] ) ) {
     1145                                        $app_labels[ $app_id ] = $app_label;
     1146                                }
     1147                        }
     1148                }
    8881149
    8891150                // Map feed id => [module, module_label] once, so each row below can
    … …  
    9231184                        } elseif ( 'suppressed' === $status ) {
    9241185                                $pill = '<span class="form2zoho-pill form2zoho-pill-warn">' . esc_html__( 'Suppressed', 'techwave-form-sync-for-zoho' ) . '</span>';
     1186                        } elseif ( 'skipped' === $status ) {
     1187                                // Held back by the feed's conditions: not sent, and not a failure.
     1188                                $pill = '<span class="form2zoho-pill form2zoho-pill-warn">' . esc_html__( 'Skipped', 'techwave-form-sync-for-zoho' ) . '</span>';
    9251189                        } else {
    9261190                                $pill = '<span class="form2zoho-pill form2zoho-pill-bad">' . esc_html__( 'Error', 'techwave-form-sync-for-zoho' ) . '</span>';
    9271191                        }
    9281192
    929                         $pretty_data        = array( 'time' => $row->created_at . ' UTC' ) + $context;
     1193                        $pretty_data        = array( 'time' => \Form2Zoho\Support\SiteTime::from_gmt( $row->created_at ) . ' (' . \Form2Zoho\Support\SiteTime::zone() . ')' ) + $context;
    9301194                        $pretty_data['app'] = isset( $app_labels[ $row->integration ] ) ? $app_labels[ $row->integration ] : $row->integration;
    9311195                        if ( '' !== (string) $row->form_id ) {
    … …  
    9381202                        echo '<tr class="form2zoho-log-row" data-row-id="' . esc_attr( (string) $row->id ) . '">';
    9391203                        echo '<td class="form2zoho-logs-checkcol"><input type="checkbox" class="form2zoho-log-check" value="' . esc_attr( (string) $row->id ) . '" /></td>';
    940                         echo '<td>' . esc_html( $row->created_at ) . '</td>';
     1204                        // Stored in UTC; shown in the timezone from Settings > General.
     1205                        echo '<td>' . esc_html( \Form2Zoho\Support\SiteTime::from_gmt( $row->created_at ) ) . '</td>';
    9411206                        echo '<td>' . wp_kses( $pill, Markup::pill() ) . '</td>';
    9421207                        echo '<td>' . esc_html( $app_label ) . '</td>';
    943                         echo '<td>' . ( '' !== $module ? '<code>' . esc_html( $module ) . '</code>' : '—' ) . '</td>';
    944                         echo '<td>' . ( '' !== (string) $row->form_id ? esc_html( $row->form_id ) : '—' ) . '</td>';
    945                         echo '<td>' . ( '' !== $rec_id ? '<code>' . esc_html( $rec_id ) . '</code>' : '—' ) . '</td>';
    946                         echo '<td>' . esc_html( $row->message ) . '</td>';
     1208                        echo '<td>' . ( '' !== $module ? '<code>' . esc_html( $module ) . '</code>' : '-' ) . '</td>';
     1209                        echo '<td>' . ( '' !== (string) $row->form_id ? esc_html( $row->form_id ) : '-' ) . '</td>';
     1210                        echo '<td>' . ( '' !== $rec_id ? '<code>' . esc_html( $rec_id ) . '</code>' : '-' ) . '</td>';
     1211                        echo '<td>' . esc_html( Logger::display_message( (string) $row->message ) ) . '</td>';
    9471212                        echo '<td><button type="button" class="button-link form2zoho-log-toggle">' . esc_html__( 'Details', 'techwave-form-sync-for-zoho' ) . '</button></td>';
    9481213                        echo '</tr>';
    … …  
    9581223                        echo '</td></tr>';
    9591224                }
    960                 echo '</tbody></table>';
     1225        }
     1226
     1227        /**
     1228         * Which page of the log is being asked for, and how big a page is.
     1229         *
     1230         * Read from the query string on a page load and from the request body on
     1231         * the paging endpoint, so both arrive at the same numbers through the same
     1232         * clamping. `paged` is clamped against the real page count rather than
     1233         * trusted: ?log_paged=9999 should land on the last page, not on an empty
     1234         * table that looks like a lost log.
     1235         *
     1236         * @param array $source Values to read (defaults to $_GET).
     1237         * @return array{paged:int,per_page:int,total:int,pages:int,allowed:int[]}
     1238         */
     1239        private function logs_paging_state( array $source = null ) {
     1240                // Twenty is a screenful. The screen used to render the newest 200 rows
     1241                // whatever it was showing — 200 rows of markup, each carrying a hidden
     1242                // <pre> of the full JSON payload, built and parsed to look at a dozen.
     1243                $allowed = array( 20, 50, 100 );
     1244
     1245                if ( null === $source ) {
     1246                        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only paging, no state changes.
     1247                        $source = wp_unslash( $_GET );
     1248                }
     1249
     1250                $per_page = isset( $source['log_per_page'] ) ? (int) $source['log_per_page'] : 20;
     1251                if ( ! in_array( $per_page, $allowed, true ) ) {
     1252                        $per_page = 20;
     1253                }
     1254
     1255                $paged = isset( $source['log_paged'] ) ? max( 1, (int) $source['log_paged'] ) : 1;
     1256                $total = $this->logger->count();
     1257                $pages = max( 1, (int) ceil( $total / $per_page ) );
     1258                $paged = min( $paged, $pages );
     1259
     1260                return array(
     1261                        'paged'    => $paged,
     1262                        'per_page' => $per_page,
     1263                        'total'    => $total,
     1264                        'pages'    => $pages,
     1265                        'allowed'  => $allowed,
     1266                );
     1267        }
     1268
     1269        /**
     1270         * The bar under the logs table: how big a page is, and which one this is.
     1271         *
     1272         * Buttons rather than links, unlike the Feeds list's identical-looking bar.
     1273         * The Logs screen is a tab, and admin-nav.js answers any link to a tab URL
     1274         * by re-fetching the whole tab — which would drop the paging arguments and
     1275         * land back on page one. A button asks the paging endpoint for the rows and
     1276         * swaps the tbody, which is also the cheaper thing to do.
     1277         *
     1278         * @param array $paging   From logs_paging_state().
     1279         * @param bool  $has_rows Whether this page has anything on it.
     1280         * @return void
     1281         */
     1282        private function render_logs_pagination( array $paging, $has_rows ) {
     1283                printf(
     1284                        '<div class="form2zoho-pagination form2zoho-logs-pagination" id="form2zoho-logs-pagination" data-paged="%d" data-pages="%d" data-per-page="%d" data-total="%d"%s>',
     1285                        (int) $paging['paged'],
     1286                        (int) $paging['pages'],
     1287                        (int) $paging['per_page'],
     1288                        (int) $paging['total'],
     1289                        $has_rows ? '' : ' hidden'
     1290                );
     1291
     1292                echo '<label class="form2zoho-perpage-wrap">' . esc_html__( 'Show', 'techwave-form-sync-for-zoho' ) . ' ';
     1293                // Deliberately NOT .form2zoho-perpage: that class is bound to a handler
     1294                // that navigates using a data-base URL, which is the Feeds list's way
     1295                // of paging and would reload this whole screen.
     1296                echo '<select class="form2zoho-logs-perpage" id="form2zoho-logs-perpage">';
     1297                foreach ( $paging['allowed'] as $pp ) {
     1298                        echo '<option value="' . esc_attr( (string) $pp ) . '" ' . selected( $pp, $paging['per_page'], false ) . '>' . esc_html( number_format_i18n( $pp ) ) . '</option>';
     1299                }
     1300                echo '</select> ' . esc_html__( 'per page', 'techwave-form-sync-for-zoho' ) . '</label>';
     1301
     1302                echo '<div class="form2zoho-pagenav">';
     1303                printf(
     1304                        '<button type="button" class="button form2zoho-logs-prev"%s>&lsaquo; %s</button>',
     1305                        $paging['paged'] <= 1 ? ' disabled' : '',
     1306                        esc_html__( 'Prev', 'techwave-form-sync-for-zoho' )
     1307                );
     1308                echo '<span class="form2zoho-pageinfo" id="form2zoho-logs-pageinfo">' . esc_html( self::logs_page_info( $paging ) ) . '</span>';
     1309                printf(
     1310                        '<button type="button" class="button form2zoho-logs-next"%s>%s &rsaquo;</button>',
     1311                        $paging['paged'] >= $paging['pages'] ? ' disabled' : '',
     1312                        esc_html__( 'Next', 'techwave-form-sync-for-zoho' )
     1313                );
     1314                echo '</div>';
     1315
     1316                echo '</div>';
     1317        }
     1318
     1319        /**
     1320         * "Page 2 of 7 · 132 entries".
     1321         *
     1322         * The total is worth the words. "Page 2 of 7" alone leaves the size of the
     1323         * log to be guessed at from the page count and whatever the per-page box
     1324         * happens to say.
     1325         *
     1326         * @param array $paging From logs_paging_state().
     1327         * @return string
     1328         */
     1329        private static function logs_page_info( array $paging ) {
     1330                return sprintf(
     1331                        /* translators: 1: current page number, 2: total pages, 3: total number of log entries. */
     1332                        _n( 'Page %1$s of %2$s · %3$s entry', 'Page %1$s of %2$s · %3$s entries', (int) $paging['total'], 'techwave-form-sync-for-zoho' ),
     1333                        number_format_i18n( (int) $paging['paged'] ),
     1334                        number_format_i18n( (int) $paging['pages'] ),
     1335                        number_format_i18n( (int) $paging['total'] )
     1336                );
     1337        }
     1338
     1339        /**
     1340         * AJAX: one page of the log, rendered.
     1341         *
     1342         * Returns the tbody's markup plus the numbers the bar prints, so the
     1343         * screen never has to work out for itself how many pages there are — a
     1344         * count it would get wrong the moment another submission landed.
     1345         *
     1346         * @return void
     1347         */
     1348        public function ajax_logs_page() {
     1349                if ( ! current_user_can( self::CAP ) ) {
     1350                        wp_send_json_error( array( 'message' => __( 'Permission denied.', 'techwave-form-sync-for-zoho' ) ), 403 );
     1351                }
     1352                check_ajax_referer( 'form2zoho_ajax', 'nonce' );
     1353
     1354                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_ajax_referer() ran above.
     1355                $paging = $this->logs_paging_state( wp_unslash( $_POST ) );
     1356                $rows   = $this->logger->page( ( $paging['paged'] - 1 ) * $paging['per_page'], $paging['per_page'] );
     1357
     1358                ob_start();
     1359                $this->render_logs_rows( $rows );
     1360                $html = ob_get_clean();
     1361
     1362                wp_send_json_success(
     1363                        array(
     1364                                'html'     => $html,
     1365                                'paged'    => $paging['paged'],
     1366                                'pages'    => $paging['pages'],
     1367                                'total'    => $paging['total'],
     1368                                'perPage'  => $paging['per_page'],
     1369                                'pageInfo' => self::logs_page_info( $paging ),
     1370                                'newestId' => $this->logger->newest_id(),
     1371                        )
     1372                );
     1373        }
     1374
     1375        /**
     1376         * The Docs & Support tab: where the documentation is, how to reach a
     1377         * person, and the video walkthroughs.
     1378         *
     1379         * Every link is one this plugin already uses elsewhere, and each is
     1380         * filterable (form2zoho_support_links), so a site or the Pro add-on can
     1381         * add a support email, a forum or a community page without a template.
     1382         *
     1383         * @return void
     1384         */
     1385        private function render_support() {
     1386                $ext = '<svg class="form2zoho-support-ext" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
     1387
     1388                /**
     1389                 * Filter the links on the Docs & Support tab.
     1390                 *
     1391                 * 'docs' and 'contact' are the two cards at the top; 'community' is
     1392                 * the list below them. Each item: url, title, text.
     1393                 *
     1394                 * @param array $links Links, grouped.
     1395                 */
     1396                $links = (array) apply_filters(
     1397                        'form2zoho_support_links',
     1398                        array(
     1399                                'docs'      => array(
     1400                                        'url'   => 'https://techwavesolutions.net/formbridge-zoho/',
     1401                                        'title' => __( 'Read the documentation', 'techwave-form-sync-for-zoho' ),
     1402                                        'text'  => __( 'Step-by-step guides for every trigger and every Zoho app. When a trigger, a destination or a field mapping is not behaving the way you expect, start here.', 'techwave-form-sync-for-zoho' ),
     1403                                        'label' => __( 'Browse the docs', 'techwave-form-sync-for-zoho' ),
     1404                                ),
     1405                                'contact'   => array(
     1406                                        'url'   => 'https://techwavesolutions.net/contact/',
     1407                                        'title' => __( 'Talk to a human', 'techwave-form-sync-for-zoho' ),
     1408                                        'text'  => __( 'Stuck on something the docs do not cover? Get in touch and our team will pick it up.', 'techwave-form-sync-for-zoho' ),
     1409                                        'label' => __( 'Contact us', 'techwave-form-sync-for-zoho' ),
     1410                                ),
     1411                                'community' => array(
     1412                                        array(
     1413                                                'url'   => VideoGuide::url( 'setup' ),
     1414                                                'title' => __( 'Video: the full setup', 'techwave-form-sync-for-zoho' ),
     1415                                                'text'  => __( 'From connecting Zoho to a first record, on YouTube.', 'techwave-form-sync-for-zoho' ),
     1416                                        ),
     1417                                        array(
     1418                                                'url'   => VideoGuide::url( 'connections' ),
     1419                                                'title' => __( 'Video: connecting a Zoho account', 'techwave-form-sync-for-zoho' ),
     1420                                                'text'  => __( 'Creating the Zoho API client and authorising it.', 'techwave-form-sync-for-zoho' ),
     1421                                        ),
     1422                                        array(
     1423                                                'url'   => VideoGuide::url( 'triggers' ),
     1424                                                'title' => __( 'Video: every trigger', 'techwave-form-sync-for-zoho' ),
     1425                                                'text'  => __( 'Each form plugin and event a feed can listen to.', 'techwave-form-sync-for-zoho' ),
     1426                                        ),
     1427                                        array(
     1428                                                'url'   => VideoGuide::url( 'actions' ),
     1429                                                'title' => __( 'Video: every Zoho app', 'techwave-form-sync-for-zoho' ),
     1430                                                'text'  => __( 'Sending records to CRM, Bigin, Desk, Books, Campaigns and Marketing Automation.', 'techwave-form-sync-for-zoho' ),
     1431                                        ),
     1432                                        array(
     1433                                                'url'   => self::docs_url(),
     1434                                                'title' => __( 'Guide: create a Zoho connection', 'techwave-form-sync-for-zoho' ),
     1435                                                'text'  => __( 'The written version of the connection setup.', 'techwave-form-sync-for-zoho' ),
     1436                                        ),
     1437                                ),
     1438                        )
     1439                );
     1440
     1441                echo '<div class="form2zoho-support">';
     1442
     1443                echo '<div class="form2zoho-support-cards">';
     1444                foreach ( array( 'docs', 'contact' ) as $key ) {
     1445                        $card = isset( $links[ $key ] ) && is_array( $links[ $key ] ) ? $links[ $key ] : array();
     1446                        if ( empty( $card['url'] ) ) {
     1447                                continue;
     1448                        }
     1449                        // An icon beside each heading, in the tile the list below uses for
     1450                        // its rows: an open book for the documentation, a headset for the
     1451                        // people who answer.
     1452                        $icon = 'docs' === $key
     1453                                ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>'
     1454                                : '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>';
     1455
     1456                        echo '<div class="form2zoho-support-card">';
     1457                        echo '<h3><span class="form2zoho-support-icon" aria-hidden="true">' . $icon . '</span><span>' . esc_html( (string) ( $card['title'] ?? '' ) ) . '</span></h3>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG literals above.
     1458                        echo '<p>' . esc_html( (string) ( $card['text'] ?? '' ) ) . '</p>';
     1459                        printf(
     1460                                '<a class="%1$s" href="%2$s" target="_blank" rel="noopener noreferrer">%3$s%4$s<span class="screen-reader-text"> %5$s</span></a>',
     1461                                'docs' === $key ? 'form2zoho-support-link' : 'button button-primary form2zoho-support-button',
     1462                                esc_url( (string) $card['url'] ),
     1463                                esc_html( (string) ( $card['label'] ?? '' ) ),
     1464                                'docs' === $key ? $ext : '', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG literal above.
     1465                                esc_html__( '(opens in a new tab)', 'techwave-form-sync-for-zoho' )
     1466                        );
     1467                        echo '</div>';
     1468                }
     1469                echo '</div>';
     1470
     1471                $items = isset( $links['community'] ) && is_array( $links['community'] ) ? $links['community'] : array();
     1472                $items = array_filter(
     1473                        $items,
     1474                        static function ( $item ) {
     1475                                return is_array( $item ) && ! empty( $item['url'] );
     1476                        }
     1477                );
     1478
     1479                if ( array() !== $items ) {
     1480                        echo '<h3 class="form2zoho-support-heading">' . esc_html__( 'Videos & guides', 'techwave-form-sync-for-zoho' ) . '</h3>';
     1481                        echo '<ul class="form2zoho-support-list">';
     1482                        foreach ( $items as $item ) {
     1483                                $video = false !== strpos( (string) $item['url'], 'youtube.com' );
     1484                                printf(
     1485                                        '<li><a href="%1$s" target="_blank" rel="noopener noreferrer"><span class="form2zoho-support-icon%2$s" aria-hidden="true">%3$s</span><span class="form2zoho-support-item"><strong>%4$s</strong><span>%5$s</span></span>%6$s<span class="screen-reader-text"> %7$s</span></a></li>',
     1486                                        esc_url( (string) $item['url'] ),
     1487                                        $video ? ' is-video' : '',
     1488                                        $video
     1489                                                ? '<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="4" width="22" height="16" rx="5" fill="#ff0000"/><polygon points="10 8.5 16 12 10 15.5" fill="#fff"/></svg>'
     1490                                                : '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG literals.
     1491                                        esc_html( (string) ( $item['title'] ?? '' ) ),
     1492                                        esc_html( (string) ( $item['text'] ?? '' ) ),
     1493                                        $ext, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG literal above.
     1494                                        esc_html__( '(opens in a new tab)', 'techwave-form-sync-for-zoho' )
     1495                                );
     1496                        }
     1497                        echo '</ul>';
     1498                }
     1499
    9611500                echo '</div>';
    9621501        }
    … …  
    9921531                        'shield',
    9931532                        __( 'Store submitted values in the log', 'techwave-form-sync-for-zoho' ),
    994                         __( 'Off by default. When off, only the field names are recorded, never what a visitor typed. Turn it on only if you need the Logs screen\'s one-click Resubmit, which replays the stored values — anything your forms collect, including personal data, is then kept in the log table for the retention period below.', 'techwave-form-sync-for-zoho' )
     1533                        __( 'Off by default. When off, only the field names are recorded, never what a visitor typed. Turn it on only if you need the Logs screen\'s one-click Resubmit, which replays the stored values. Anything your forms collect, including personal data, is then kept in the log table until the entry is deleted.', 'techwave-form-sync-for-zoho' )
    9951534                );
    9961535
    … …  
    9991538                        'trash',
    10001539                        __( 'Delete log entries automatically', 'techwave-form-sync-for-zoho' ),
    1001                         __( 'Deletes log entries older than this many days, once a day.', 'techwave-form-sync-for-zoho' ),
     1540                        __( 'Off by default, so log entries stay until you delete them. When on, entries older than this many days are deleted once a day.', 'techwave-form-sync-for-zoho' ),
    10021541                        '<label class="screen-reader-text" for="form2zoho_log_retention_days">' . esc_html__( 'Days to keep log entries', 'techwave-form-sync-for-zoho' ) . '</label>'
    10031542                        . '<span class="form2zoho-unitfield">'
    … …  
    10391578                printf(
    10401579                        /* translators: %s: administration email address. */
    1041                         esc_html__( 'Sends one message to %s to check this site can send mail at all.', 'techwave-form-sync-for-zoho' ),
     1580                        esc_html__( 'Sends one message to %s, the same way the notifications are sent.', 'techwave-form-sync-for-zoho' ),
    10421581                        esc_html( (string) get_option( 'admin_email' ) )
    10431582                );
    … …  
    10611600                        'trash',
    10621601                        __( 'Erase all plugin data when the plugin is deleted', 'techwave-form-sync-for-zoho' ),
    1063                         __( 'Drops the log table and removes every connection, feed, stored token and scheduled event. Off by default so deactivating and reinstalling does not destroy your setup. This cannot be undone.', 'techwave-form-sync-for-zoho' )
     1602                        __( 'Drops the log table and removes every connection, feed and stored token. Off by default so deactivating and reinstalling does not destroy your setup. This cannot be undone.', 'techwave-form-sync-for-zoho' )
    10641603                );
    10651604                $this->settings_section_close();
    … …  
    10781617                printf(
    10791618                        /* translators: %s: version number, e.g. 1.0.0. */
    1080                         esc_html__( 'Techwave Form Sync for Zoho version %s', 'techwave-form-sync-for-zoho' ),
     1619                        esc_html__( 'FormBridge for Zoho version %s', 'techwave-form-sync-for-zoho' ),
    10811620                        esc_html( defined( 'FORM2ZOHO_VERSION' ) ? FORM2ZOHO_VERSION : '' )
    10821621                );
  • techwave-form-sync-for-zoho/trunk/includes/Api/AbstractClient.php

    r3649280 r3748247  
    4343         */
    4444        protected $connection_id;
     45
     46        /**
     47         * The access token the last request was sent with, so a 401 forgets only
     48         * that one and not a newer token another request has saved since.
     49         *
     50         * @var string
     51         */
     52        private $last_token = '';
    4553
    4654        /**
    … …  
    93101
    94102        /**
     103         * Whether a field's metadata says a submitted value can never fill it.
     104         *
     105         * A mapping row puts one form value into one Zoho field, and most field
     106         * types take that happily. Two kinds cannot, and offering them in the
     107         * mapping dropdown is how somebody ends up with a feed that looks
     108         * configured and fails on every single submission.
     109         *
     110         * Uploads. Zoho's own docs are explicit that a File Upload field is
     111         * written as [ { "File_Id__s": "..." } ], where the id comes back from a
     112         * separate upload call to /crm/v6/file. No string a form can submit
     113         * satisfies that, so mapping a file field returned "INVALID_DATA: invalid
     114         * data (Files)" — and because Zoho rejects the whole request, the name,
     115         * email and message went nowhere either. Losing the lead over an
     116         * attachment is the expensive half of that.
     117         *
     118         * Subforms, which are a table of rows rather than a value, and the record
     119         * image, which is another upload wearing a different type name. All three
     120         * fail identically and for the same reason.
     121         *
     122         * The substring rule carries the intent — anything Zoho calls an upload —
     123         * so a type added later is covered without this list being extended. The
     124         * two exact names are the ones that do not say "upload" and still are one.
     125         * A live Leads module confirms the shape: fileupload x2, imageupload,
     126         * profileimage and subform all come back read_only=false, which is why
     127         * they were being offered.
     128         *
     129         * Distinct from read_only, which the callers check separately: those
     130         * fields cannot be written at all, whereas these can — just not from a
     131         * form field.
     132         *
     133         * @param array $field One row of Zoho's fields metadata.
     134         * @return bool
     135         */
     136        protected static function is_unfillable_field( array $field ) {
     137                $type = strtolower( (string) ( isset( $field['data_type'] ) ? $field['data_type'] : '' ) );
     138
     139                // An upload type is only offered by an app that can actually be handed
     140                // a file. Where it can, the mapped value is uploaded on the way out
     141                // and the record carries the id (see CrmIntegration::send). Where it
     142                // cannot, a form value could only ever arrive as text and Zoho would
     143                // refuse the whole record over it, so the field stays hidden.
     144                if ( false !== strpos( $type, 'upload' ) ) {
     145                        return ! static::supports_file_upload();
     146                }
     147
     148                return in_array( $type, array( 'subform', 'profileimage' ), true );
     149        }
     150
     151        /**
     152         * Whether this app has a file upload this plugin knows how to drive.
     153         *
     154         * False here on purpose: an app is opted in by its own client once both
     155         * halves exist — somewhere to post the bytes, and a documented shape for
     156         * naming the result in a record.
     157         *
     158         * @return bool
     159         */
     160        protected static function supports_file_upload() {
     161                return false;
     162        }
     163
     164        /**
     165         * POST one file as multipart/form-data.
     166         *
     167         * Separate from request(), which encodes everything as JSON. WordPress has
     168         * no multipart helper, so the body is assembled here — it is a few lines
     169         * and the alternative is a dependency for one request.
     170         *
     171         * The file is read into memory. Zoho caps an upload at 20MB, and
     172         * max_upload_size() below refuses anything larger before it is read, so
     173         * the ceiling is known rather than whatever the file happened to be.
     174         *
     175         * No 401 retry loop: this is called on the delivery path, once, with a
     176         * token the caller has just used successfully for the record itself.
     177         *
     178         * @param string $path      Path beginning with a slash.
     179         * @param string $file      Absolute path to the file on disk.
     180         * @param string $part_name Name of the multipart part.
     181         * @return array|WP_Error Decoded body or error.
     182         */
     183        protected function upload( $path, $file, $part_name = 'file', array $headers = array() ) {
     184                if ( ! is_readable( $file ) ) {
     185                        return new WP_Error(
     186                                'form2zoho_file_unreadable',
     187                                /* translators: %s: file path. */
     188                                sprintf( __( 'The uploaded file could not be read from %s.', 'techwave-form-sync-for-zoho' ), $file )
     189                        );
     190                }
     191
     192                $size = (int) filesize( $file );
     193
     194                // A budget for the whole request, not just this file. Each upload is
     195                // read into memory whole and copied once more into the multipart
     196                // body, so a submission naming several large files could exhaust the
     197                // PHP memory limit and take the visitor's submission down with it.
     198                if ( self::$uploaded_bytes + $size > self::MAX_REQUEST_BYTES ) {
     199                        return new WP_Error(
     200                                'form2zoho_upload_budget',
     201                                sprintf(
     202                                        /* translators: %d: the limit in megabytes. */
     203                                        __( 'This submission has already sent %dMB of files, which is all this request will carry.', 'techwave-form-sync-for-zoho' ),
     204                                        (int) ( self::MAX_REQUEST_BYTES / 1048576 )
     205                                )
     206                        );
     207                }
     208
     209                if ( $size > self::MAX_UPLOAD_BYTES ) {
     210                        return new WP_Error(
     211                                'form2zoho_file_too_large',
     212                                sprintf(
     213                                        /* translators: 1: file name, 2: Zoho's limit in megabytes. */
     214                                        __( '%1$s is larger than the %2$dMB Zoho accepts for a file field.', 'techwave-form-sync-for-zoho' ),
     215                                        basename( $file ),
     216                                        (int) ( self::MAX_UPLOAD_BYTES / 1048576 )
     217                                )
     218                        );
     219                }
     220
     221                $token = $this->connections->get_access_token( $this->connection_id );
     222
     223                if ( is_wp_error( $token ) ) {
     224                        return $token;
     225                }
     226
     227                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local upload this site just received, not a remote resource; WP_Filesystem is not initialised on a front-end submission.
     228                $contents = file_get_contents( $file );
     229
     230                if ( is_string( $contents ) ) {
     231                        self::$uploaded_bytes += strlen( $contents );
     232                }
     233
     234                if ( false === $contents ) {
     235                        return new WP_Error( 'form2zoho_file_unreadable', __( 'The uploaded file could not be read.', 'techwave-form-sync-for-zoho' ) );
     236                }
     237
     238                $boundary = 'f2z' . wp_generate_password( 24, false, false );
     239                $name     = str_replace( array( '"', "\r", "\n" ), '', basename( $file ) );
     240
     241                $body  = '--' . $boundary . "\r\n";
     242                $body .= 'Content-Disposition: form-data; name="' . $part_name . '"; filename="' . $name . '"' . "\r\n";
     243                $body .= 'Content-Type: application/octet-stream' . "\r\n\r\n";
     244                $body .= $contents . "\r\n";
     245                $body .= '--' . $boundary . '--' . "\r\n";
     246
     247                $response = wp_remote_post(
     248                        $this->connections->api_base( $this->connection_id, $this->service() ) . $path,
     249                        array(
     250                                // Longer than a JSON call on the front end: this carries the
     251                                // file itself, and ten seconds dropped larger uploads on slower
     252                                // hosts while smaller ones got through.
     253                                /** This filter is documented in includes/Api/AbstractClient.php */
     254                                'timeout' => (int) apply_filters( 'form2zoho_api_timeout', 30, $this->service() ),
     255                                // Extra headers first, so the three this request depends on
     256                                // cannot be overridden by them. Zoho Desk is the reason the
     257                                // parameter exists: every Desk call, uploads included, has to
     258                                // carry the organisation as an orgId header.
     259                                'headers' => array_merge(
     260                                        $headers,
     261                                        array(
     262                                                'Authorization' => 'Zoho-oauthtoken ' . $token,
     263                                                'Accept'        => 'application/json',
     264                                                'Content-Type'  => 'multipart/form-data; boundary=' . $boundary,
     265                                        )
     266                                ),
     267                                'body'    => $body,
     268                        )
     269                );
     270
     271                if ( is_wp_error( $response ) ) {
     272                        $response->add_data( array( 'host' => (string) wp_parse_url( $this->connections->api_base( $this->connection_id, $this->service() ), PHP_URL_HOST ) ) );
     273                        return $response;
     274                }
     275
     276                $status = (int) wp_remote_retrieve_response_code( $response );
     277                $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
     278                $data   = is_array( $data ) ? $data : array();
     279
     280                if ( $status < 200 || $status >= 300 ) {
     281                        $detail = $this->extract_error( $data );
     282
     283                        // The one refusal with a specific fix. Uploading needs a
     284                        // permission the plugin only started asking Zoho for recently, and
     285                        // Zoho grants the permissions that were requested when a
     286                        // connection was authorised — not the ones the plugin asks for
     287                        // today. So a connection made before then keeps working for
     288                        // everything except this, and says so in a way that reads like a
     289                        // bug unless the remedy is spelled out.
     290                        if ( 401 === $status || false !== stripos( $detail, 'OAUTH_SCOPE_MISMATCH' ) ) {
     291                                return new WP_Error(
     292                                        'form2zoho_http_' . $status,
     293                                        __( 'This connection was authorised before the plugin could upload files, so Zoho refused it. Open Connections, use "Edit access" on the account, and press Update & Reauthorize, then submit the form again.', 'techwave-form-sync-for-zoho' ),
     294                                        $data
     295                                );
     296                        }
     297
     298                        return new WP_Error(
     299                                'form2zoho_http_' . $status,
     300                                sprintf(
     301                                        /* translators: 1: HTTP status code, 2: detail from Zoho. */
     302                                        __( 'Zoho refused the file upload. HTTP %1$d. %2$s', 'techwave-form-sync-for-zoho' ),
     303                                        $status,
     304                                        $detail
     305                                ),
     306                                $data
     307                        );
     308                }
     309
     310                return $data;
     311        }
     312
     313        /**
     314         * The largest file Zoho will take on a single upload.
     315         *
     316         * Its own documented limit, not a guess: "the maximum size of each file is
     317         * 20MB". Checked here so an oversized file is reported as itself rather
     318         * than as whatever Zoho returns after the bytes have been sent.
     319         */
     320        private const MAX_UPLOAD_BYTES = 20971520;
     321
     322        /**
     323         * Bytes of file this request may upload in total, across every file and
     324         * every feed. Two 20MB files already sit above a default WordPress
     325         * memory limit once each is held in memory twice.
     326         */
     327        private const MAX_REQUEST_BYTES = 41943040;
     328
     329        /**
     330         * Bytes uploaded so far in this request.
     331         *
     332         * @var int
     333         */
     334        private static $uploaded_bytes = 0;
     335
     336        /**
    95337         * Perform an authenticated JSON request with one automatic retry on 401.
    96338         *
    … …  
    105347                // Retry once if the token was rejected — covers tokens that expired mid-flight.
    106348                if ( is_wp_error( $result ) && 'form2zoho_http_401' === $result->get_error_code() ) {
    107                         delete_transient( 'form2zoho_at_' . $this->connection_id );
     349                        $this->connections->forget_access_token( $this->connection_id, $this->last_token );
    108350                        $result = $this->dispatch( $method, $path, $args );
    109351                }
    110352
     353                // A read that never reached Zoho gets one more try when an admin is
     354                // waiting on it, such as the editor loading a module's fields: a
     355                // brief network hiccup should not surface as an error. Only a read,
     356                // because a write that timed out may still have landed, and
     357                // Dispatcher retries deliveries in the background anyway. Only for an
     358                // admin, so a visitor's form submission never waits twice.
     359                if ( is_wp_error( $result )
     360                        && self::never_reached_zoho( $result )
     361                        && in_array( strtoupper( $method ), array( 'GET', 'HEAD' ), true )
     362                        && is_admin()
     363                        && current_user_can( 'manage_options' ) ) {
     364                        $result = $this->dispatch( $method, $path, $args );
     365                }
     366
    111367                return $result;
     368        }
     369
     370        /**
     371         * Several GETs at once, each answered as request() would answer it.
     372         *
     373         * For lookups an admin is waiting on that take one request per item, where
     374         * one after another adds up to seconds. Zoho's answers read exactly as
     375         * request() reads them. Only what never got one, or got a rejected token,
     376         * is asked again through request(), which refreshes the token and retries.
     377         *
     378         * @param string[] $paths Paths beginning with a slash, keyed however the caller likes.
     379         * @param array    $args  Optional 'query' and 'headers', shared by every request.
     380         * @return array<string|int,array|WP_Error> Keyed as $paths.
     381         */
     382        protected function get_many( array $paths, array $args = array() ) {
     383                $out = array();
     384
     385                if ( count( $paths ) > 1 && class_exists( '\WpOrg\Requests\Requests' ) ) {
     386                        $token = $this->connections->get_access_token( $this->connection_id );
     387
     388                        if ( ! is_wp_error( $token ) ) {
     389                                $headers = array(
     390                                        'Authorization' => 'Zoho-oauthtoken ' . $token,
     391                                        'Accept'        => 'application/json',
     392                                );
     393                                if ( isset( $args['headers'] ) && is_array( $args['headers'] ) ) {
     394                                        $headers = array_merge( $headers, $args['headers'] );
     395                                }
     396
     397                                $requests = array();
     398                                foreach ( $paths as $key => $path ) {
     399                                        $url = $this->connections->api_base( $this->connection_id, $this->service() ) . $path;
     400                                        if ( ! empty( $args['query'] ) && is_array( $args['query'] ) ) {
     401                                                $url = add_query_arg( array_map( 'rawurlencode', $args['query'] ), $url );
     402                                        }
     403
     404                                        $requests[ $key ] = array(
     405                                                'url'     => $url,
     406                                                'headers' => $headers,
     407                                                'type'    => 'GET',
     408                                        );
     409                                }
     410
     411                                try {
     412                                        $responses = \WpOrg\Requests\Requests::request_multiple(
     413                                                $requests,
     414                                                array(
     415                                                        'timeout'          => $this->timeout(),
     416                                                        'useragent'        => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' ),
     417                                                        // The certificates WordPress itself uses for every HTTPS request.
     418                                                        'verify'           => ABSPATH . WPINC . '/certificates/ca-bundle.crt',
     419                                                        // This path does not go through the WordPress HTTP
     420                                                        // API, so nothing else would stop a redirect from
     421                                                        // leading somewhere other than the Zoho host asked
     422                                                        // for. These endpoints answer directly.
     423                                                        'follow_redirects' => false,
     424                                                )
     425                                        );
     426                                } catch ( \Exception $e ) {
     427                                        $responses = array();
     428                                }
     429
     430                                foreach ( $responses as $key => $response ) {
     431                                        if ( $response instanceof \WpOrg\Requests\Response && $response->status_code && 401 !== (int) $response->status_code ) {
     432                                                $out[ $key ] = $this->result( (int) $response->status_code, (string) $response->body, $paths[ $key ], false );
     433                                        }
     434                                }
     435                        }
     436                }
     437
     438                // In the order asked, whatever order the answers came back in.
     439                $ordered = array();
     440                foreach ( $paths as $key => $path ) {
     441                        $ordered[ $key ] = array_key_exists( $key, $out ) ? $out[ $key ] : $this->request( 'GET', $path, $args );
     442                }
     443
     444                return $ordered;
     445        }
     446
     447        /**
     448         * Seconds a Zoho request may take.
     449         *
     450         * @return int
     451         */
     452        private function timeout() {
     453                /**
     454                 * Filter the Zoho API request timeout, in seconds.
     455                 *
     456                 * Shorter on the front end on purpose. Delivery is synchronous
     457                 * (see Queue\Dispatcher for why), so this number is added directly
     458                 * to how long a visitor waits after pressing Submit — and with the
     459                 * token refresh and the one automatic 401 retry stacked on top, a
     460                 * flat 25 could hold a PHP worker for over a minute per feed while
     461                 * Zoho was slow. A transport timeout carries no "form2zoho_http_*"
     462                 * code, so Dispatcher::is_retryable() already treats it as
     463                 * transient: the submission moves to a background retry rather
     464                 * than being lost.
     465                 *
     466                 * @param int    $timeout Seconds.
     467                 * @param string $service Service key (crm|books|desk|...).
     468                 */
     469                return (int) apply_filters( 'form2zoho_api_timeout', is_admin() ? 25 : 10, $this->service() );
     470        }
     471
     472        /**
     473         * Whether an error came from the transport rather than from Zoho or from
     474         * this plugin. The same reading as Dispatcher::is_retryable(): every error
     475         * this plugin builds carries a "form2zoho_" code, and one that does not
     476         * came from wp_remote_request() itself.
     477         *
     478         * @param WP_Error $error The error.
     479         * @return bool
     480         */
     481        private static function never_reached_zoho( WP_Error $error ) {
     482                return 0 !== strpos( (string) $error->get_error_code(), 'form2zoho_' );
    112483        }
    113484
    … …  
    128499                        return $token;
    129500                }
     501                $this->last_token = $token;
    130502
    131503                $url = $this->connections->api_base( $this->connection_id, $this->service() ) . $path;
    … …  
    144516                $request = array(
    145517                        'method'  => strtoupper( $method ),
    146                         /**
    147                          * Filter the Zoho API request timeout, in seconds.
    148                          *
    149                          * Shorter on the front end on purpose. Delivery is synchronous
    150                          * (see Queue\Dispatcher for why), so this number is added directly
    151                          * to how long a visitor waits after pressing Submit — and with the
    152                          * token refresh and the one automatic 401 retry stacked on top, a
    153                          * flat 25 could hold a PHP worker for over a minute per feed while
    154                          * Zoho was slow. A transport timeout carries no "form2zoho_http_*"
    155                          * code, so Dispatcher::is_retryable() already treats it as
    156                          * transient: the submission moves to a background retry rather
    157                          * than being lost.
    158                          *
    159                          * @param int    $timeout Seconds.
    160                          * @param string $service Service key (crm|books|desk|...).
    161                          */
    162                         'timeout' => (int) apply_filters( 'form2zoho_api_timeout', is_admin() ? 25 : 10, $this->service() ),
     518                        'timeout' => $this->timeout(),
    163519                        'headers' => $headers,
    164520                );
    … …  
    179535
    180536                if ( is_wp_error( $response ) ) {
     537                        // Which host could not be reached, for the message the editor
     538                        // shows (see ErrorTranslator::transport()). The code is left as it
     539                        // is, so Dispatcher still reads it as worth retrying.
     540                        $response->add_data( array( 'host' => (string) wp_parse_url( $url, PHP_URL_HOST ) ) );
    181541                        return $response;
    182542                }
    183543
    184                 $status = (int) wp_remote_retrieve_response_code( $response );
    185                 $raw    = (string) wp_remote_retrieve_body( $response );
    186                 $data   = json_decode( $raw, true );
    187                 $data   = is_array( $data ) ? $data : array();
     544                return $this->result(
     545                        (int) wp_remote_retrieve_response_code( $response ),
     546                        (string) wp_remote_retrieve_body( $response ),
     547                        $path,
     548                        $is_write
     549                );
     550        }
     551
     552        /**
     553         * A Zoho response as the caller gets it: the decoded body, or an error.
     554         *
     555         * @param int    $status   HTTP status.
     556         * @param string $raw      Response body.
     557         * @param string $path     Path asked for.
     558         * @param bool   $is_write Whether the request was a write.
     559         * @return array|WP_Error
     560         */
     561        private function result( $status, $raw, $path, $is_write ) {
     562                $data = json_decode( $raw, true );
     563                $data = is_array( $data ) ? $data : array();
    188564
    189565                if ( 401 === $status ) {
    190                         return new WP_Error( 'form2zoho_http_401', __( 'Zoho rejected the access token.', 'techwave-form-sync-for-zoho' ), $data );
     566                        return new WP_Error(
     567                                'form2zoho_http_401',
     568                                // Reached when the stored app list looked fine but the grant
     569                                // behind it does not match — an app added to a connection and
     570                                // never reauthorized, or access revoked at Zoho's end. Says
     571                                // what to do about it, because "rejected the access token"
     572                                // on its own reads as a fault rather than as an instruction.
     573                                __( 'Zoho rejected the access token. The connection may need reauthorizing: open Connections, use "Edit access", and press Update & Reauthorize.', 'techwave-form-sync-for-zoho' ),
     574                                $data
     575                        );
    191576                }
    192577
  • techwave-form-sync-for-zoho/trunk/includes/Api/BiginClient.php

    r3649280 r3748247  
    6868                $out = array();
    6969                foreach ( (array) ( $res['fields'] ?? array() ) as $field ) {
    70                         if ( ! empty( $field['read_only'] ) ) {
     70                        if ( ! empty( $field['read_only'] ) || self::is_unfillable_field( $field ) ) {
    7171                                continue;
    7272                        }
    … …  
    7575                                'label'    => (string) $field['field_label'],
    7676                                'required' => ! empty( $field['system_mandatory'] ) || ! empty( $field['required'] ),
     77                                // Carried through to the mapping row and saved with it. A form
     78                                // value is text whatever it came from, and Zoho wants a real
     79                                // boolean for a checkbox and a real array for a multi-select —
     80                                // so the feed has to remember which of those it is aiming at.
     81                                // See FieldMapper::coerce().
     82                                'type'     => strtolower( (string) ( isset( $field['data_type'] ) ? $field['data_type'] : '' ) ),
     83                                // Whether Zoho refuses two records with the same value here.
     84                                // Present on every field and empty when it is not unique, so
     85                                // this is a real answer rather than an absence. The duplicate
     86                                // handling on the Destination step offers exactly these,
     87                                // instead of asking somebody to type API names from memory.
     88                                'unique'   => ! empty( $field['unique'] ),
    7789                        );
    7890                }
    … …  
    114126
    115127        /**
    116          * Create a record.
     128         * Create a record, or update the one it duplicates.
    117129         *
    118          * @param string $module          Module API name.
    119          * @param array  $record          Field => value pairs.
    120          * @param array  $duplicate_fields Fields used for upsert duplicate check (empty = plain insert).
     130         * @param string    $module           Module API name.
     131         * @param array     $record           Field => value pairs.
     132         * @param array     $duplicate_fields Unique fields to match an existing record on.
     133         * @param bool|null $upsert           Update a matching record instead of inserting. Null keeps
     134         *                                    the older rule: upsert only when fields are named.
    121135         * @return array|WP_Error
    122136         */
    123         public function create_record( $module, array $record, array $duplicate_fields = array() ) {
    124                 if ( ! empty( $duplicate_fields ) ) {
     137        public function create_record( $module, array $record, array $duplicate_fields = array(), $upsert = null, array $extras = array() ) {
     138                if ( null === $upsert ? ! empty( $duplicate_fields ) : $upsert ) {
     139                        $body = self::with_extras( array( 'data' => array( $record ) ), $extras );
     140
     141                        // Left out rather than sent empty when nothing is named, so Bigin
     142                        // applies its own duplicate check. See CrmClient::create_record().
     143                        if ( ! empty( $duplicate_fields ) ) {
     144                                $body['duplicate_check_fields'] = array_values( $duplicate_fields );
     145                        }
     146
    125147                        return $this->request(
    126148                                'POST',
    127149                                '/bigin/v2/' . rawurlencode( $module ) . '/upsert',
    128                                 array(
    129                                         'body' => array(
    130                                                 'data'                   => array( $record ),
    131                                                 'duplicate_check_fields' => array_values( $duplicate_fields ),
    132                                         ),
    133                                 )
     150                                array( 'body' => $body )
    134151                        );
    135152                }
    … …  
    138155                        'POST',
    139156                        '/bigin/v2/' . rawurlencode( $module ),
    140                         array( 'body' => array( 'data' => array( $record ) ) )
     157                        array( 'body' => self::with_extras( array( 'data' => array( $record ) ), $extras ) )
    141158                );
    142159        }
     160
     161        /**
     162         * Add the one request-level key Bigin documents: which automations run.
     163         *
     164         * Passed through even when empty — an empty list means run nothing, and
     165         * leaving the key out means run what Bigin has set up for the API.
     166         *
     167         * @param array $body   The request body so far.
     168         * @param array $extras What an add-on asked for.
     169         * @return array
     170         */
     171        private static function with_extras( array $body, array $extras ) {
     172                if ( array_key_exists( 'trigger', $extras ) && is_array( $extras['trigger'] ) ) {
     173                        $body['trigger'] = array_values( array_intersect( array( 'workflow', 'approval', 'blueprint' ), array_map( 'strval', $extras['trigger'] ) ) );
     174                }
     175
     176                return $body;
     177        }
    143178}
  • techwave-form-sync-for-zoho/trunk/includes/Api/BooksClient.php

    r3649280 r3748247  
    2323
    2424        /**
     25         * Whether this lookup must skip the cached settings (a reload button).
     26         *
     27         * @var bool
     28         */
     29        private $skip_cache = false;
     30
     31        /**
    2532         * {@inheritDoc}
    2633         */
    … …  
    5360                        }
    5461                        if ( '' !== $plan ) {
    55                                 $name .= ' — ' . $plan;
     62                                $name .= ' (' . $plan . ')';
    5663                        }
    5764
    … …  
    7279         * form usually cannot supply.
    7380         *
     81         * @param string $org_id Books organisation id. Without it only the
     82         *                       built-in resources are listed, because a custom
     83         *                       module belongs to an organisation.
     84         * @param bool   $fresh  Ask Books even when the custom modules are cached.
    7485         * @return array<int,array>
    7586         */
    76         public function modules() {
     87        public function modules( $org_id = '', $fresh = false ) {
     88                $built_in         = self::built_in_modules();
     89                $this->skip_cache = (bool) $fresh;
     90
     91                if ( '' === (string) $org_id ) {
     92                        return $built_in;
     93                }
     94
     95                return array_merge( $built_in, $this->cached_custom_modules( $org_id, $fresh ) );
     96        }
     97
     98        /**
     99         * The organisation's custom modules, kept for ten minutes.
     100         *
     101         * Finding them can take several requests (see custom_modules_one_by_one()),
     102         * seconds in all, and the editor asks every time an organisation is picked.
     103         * A module created in Books meanwhile shows up through the reload button
     104         * beside the module select, which passes $fresh.
     105         *
     106         * @param string $org_id Books organisation id.
     107         * @param bool   $fresh  Skip the cache.
     108         * @return array<int,array{api_name:string,label:string}>
     109         */
     110        private function cached_custom_modules( $org_id, $fresh ) {
     111                $key = 'form2zoho_books_cm_' . md5( $this->connection_id . '|' . $org_id );
     112
     113                if ( ! $fresh ) {
     114                        $cached = get_transient( $key );
     115
     116                        if ( is_array( $cached ) ) {
     117                                return $cached;
     118                        }
     119                }
     120
     121                $modules = $this->custom_modules( $org_id );
     122
     123                // Not kept when Books could not be asked at all, so a brief outage
     124                // does not hide the custom modules for the next ten minutes.
     125                if ( null !== $modules ) {
     126                        set_transient( $key, $modules, 10 * MINUTE_IN_SECONDS );
     127                }
     128
     129                return (array) $modules;
     130        }
     131
     132        /**
     133         * The custom modules this organisation has, as resources a feed can target.
     134         *
     135         * Books lets an organisation define its own modules, and until this was
     136         * asked for the picker showed only the eight built-in resources — so a
     137         * module somebody had created for their own business was simply absent,
     138         * with nothing on screen to say why.
     139         *
     140         * Failure is silent on purpose. This runs alongside a list that is already
     141         * correct, and an organisation with no custom modules, an older API, or a
     142         * grant that does not cover settings should still get the eight built-ins
     143         * rather than an error where the module picker used to be.
     144         *
     145         * @param string $org_id Books organisation id.
     146         * @return array<int,array{api_name:string,label:string}>|null Null when
     147         *                                                         Books could not be asked.
     148         */
     149        private function custom_modules( $org_id ) {
     150                // An organisation that refuses the list keeps refusing it, and asking
     151                // anyway cost a second on every lookup before the fallback could start.
     152                $refused = 'form2zoho_books_cm_refused_' . md5( $this->connection_id . '|' . $org_id );
     153
     154                if ( get_transient( $refused ) ) {
     155                        return $this->custom_modules_one_by_one( $org_id );
     156                }
     157
     158                $res = $this->request(
     159                        'GET',
     160                        '/books/v3/settings/modules',
     161                        array( 'query' => array( 'organization_id' => (string) $org_id ) )
     162                );
     163
     164                if ( is_wp_error( $res ) ) {
     165                        $data = $res->get_error_data();
     166
     167                        // Only Zoho's own no, not a network error that may pass.
     168                        if ( is_array( $data ) && isset( $data['http_status'] ) ) {
     169                                set_transient( $refused, 1, DAY_IN_SECONDS );
     170                        }
     171
     172                        return $this->custom_modules_one_by_one( $org_id );
     173                }
     174
     175                $out = array();
     176                foreach ( (array) ( $res['modules'] ?? array() ) as $module ) {
     177                        $row = self::module_row( (array) $module );
     178
     179                        if ( null !== $row ) {
     180                                $out[] = $row;
     181                        }
     182                }
     183
     184                return $out;
     185        }
     186
     187        /**
     188         * Find the custom modules when Books will not list them.
     189         *
     190         * Some organisations refuse the list call outright ("You don't have
     191         * permission to perform this operation"), even for an admin whose grant
     192         * covers settings — while still answering for each module by name. The
     193         * names are in the custom-field settings, where every field of a custom
     194         * module reports it as its `search_entity`, so each one found there is
     195         * asked for in turn.
     196         *
     197         * Asking one at a time is also what separates a module from the table
     198         * inside one: a table's fields report a `cm_` entity too, but Books does
     199         * not answer for it as a module, so it is left out here.
     200         *
     201         * A module with no custom fields of its own does not appear in those
     202         * settings, and so cannot be found this way.
     203         *
     204         * @param string $org_id Books organisation id.
     205         * @return array<int,array{api_name:string,label:string}>|null Null when
     206         *                                                         Books could not be asked.
     207         */
     208        private function custom_modules_one_by_one( $org_id ) {
     209                $query    = array( 'query' => array( 'organization_id' => (string) $org_id ) );
     210                $settings = $this->custom_field_settings( $org_id );
     211
     212                if ( is_wp_error( $settings ) ) {
     213                        return null;
     214                }
     215
     216                $names = array();
     217                foreach ( $settings as $group ) {
     218                        foreach ( (array) $group as $field ) {
     219                                $entity = is_array( $field ) && isset( $field['search_entity'] ) ? (string) $field['search_entity'] : '';
     220
     221                                if ( 0 === strpos( $entity, 'cm_' ) ) {
     222                                        $names[ $entity ] = true;
     223                                }
     224                        }
     225                }
     226
     227                $paths = array();
     228                foreach ( array_keys( $names ) as $name ) {
     229                        $paths[ $name ] = '/books/v3/settings/modules/' . rawurlencode( $name );
     230                }
     231
     232                // Asked together: one at a time was a second per module.
     233                $out = array();
     234                foreach ( $this->get_many( $paths, $query ) as $one ) {
     235                        if ( is_wp_error( $one ) ) {
     236                                continue;
     237                        }
     238
     239                        $row = self::module_row( (array) ( $one['module'] ?? array() ) );
     240
     241                        if ( null !== $row ) {
     242                                $out[] = $row;
     243                        }
     244                }
     245
     246                return $out;
     247        }
     248
     249        /**
     250         * One custom module, as a row of the module picker.
     251         *
     252         * @param array $module A module from Books' module settings.
     253         * @return array{api_name:string,label:string}|null Null without an api name.
     254         */
     255        private static function module_row( array $module ) {
     256                $api_name = isset( $module['api_name'] ) ? (string) $module['api_name'] : '';
     257
     258                if ( '' === $api_name ) {
     259                        return null;
     260                }
     261
     262                $label = isset( $module['plural_name'] ) && '' !== $module['plural_name']
     263                        ? (string) $module['plural_name']
     264                        : $api_name;
     265
     266                return array(
     267                        'api_name' => $api_name,
     268                        'label'    => $label,
     269                );
     270        }
     271
     272        /**
     273         * The eight resources this plugin can create from a form.
     274         *
     275         * @return array<int,array>
     276         */
     277        private static function built_in_modules() {
    77278                return array(
    78279                        array(
    … …  
    115316         *
    116317         * @param string $resource_key Resource key.
     318         * @param string $org_id       Books organisation id, needed to read the
     319         *                             fields of a custom module.
     320         * @param bool   $fresh        Ask Books even when its field settings are cached.
    117321         * @return array<int,array>
    118322         */
    119         public function fields( $resource_key ) {
    120                 $map = array(
     323        public function fields( $resource_key, $org_id = '', $fresh = false ) {
     324                $this->skip_cache = (bool) $fresh;
     325                $resource_key     = (string) $resource_key;
     326                $org_id       = (string) $org_id;
     327                $curated      = self::curated_fields();
     328
     329                // A custom module has no built-in fields at all — everything on it is
     330                // a custom field — so its list is only what the account reports.
     331                if ( ! isset( $curated[ $resource_key ] ) ) {
     332                        return '' === $org_id ? array() : $this->custom_fields( $resource_key, $org_id );
     333                }
     334
     335                $fields = self::shape_fields( $curated, $resource_key );
     336
     337                if ( '' === $org_id ) {
     338                        return $fields;
     339                }
     340
     341                // A built-in resource gets both. An organisation that has added, say,
     342                // a "Payment Mode" field to its invoices could not map anything to it
     343                // before: the curated list is what this plugin knows how to send, and
     344                // it has no way of knowing about a field somebody created last week.
     345                return array_merge( $fields, $this->custom_fields( self::ENTITIES[ $resource_key ], $org_id ) );
     346        }
     347
     348        /**
     349         * Books' own name for each resource, as its custom fields report it.
     350         *
     351         * The customfields endpoint tags every field with the entity it belongs
     352         * to, in the singular — "item" for the resource this plugin calls
     353         * "items" — so the two vocabularies have to be matched up somewhere. Here,
     354         * written out, rather than by trimming an "s" and hoping Books never adds
     355         * a resource whose plural is irregular.
     356         */
     357        private const ENTITIES = array(
     358                'contacts'       => 'contact',
     359                'items'          => 'item',
     360                'invoices'       => 'invoice',
     361                'estimates'      => 'estimate',
     362                'salesorders'    => 'salesorder',
     363                'purchaseorders' => 'purchaseorder',
     364                'bills'          => 'bill',
     365                'expenses'       => 'expense',
     366        );
     367
     368        /**
     369         * The organisation's own fields for one Books entity.
     370         *
     371         * Books returns every custom field the organisation has from one endpoint,
     372         * grouped, with each field naming the module it belongs to in
     373         * `search_entity`. There is no per-module call, so the whole set is fetched
     374         * and filtered here.
     375         *
     376         * Uploads and tables are dropped for the same reason they are on the CRM
     377         * side: a form submits text, and a field that wants a file reference or a
     378         * grid of rows cannot be filled from one. See
     379         * AbstractClient::is_unfillable_field().
     380         *
     381         * @param string $entity Books entity name: "item", "invoice", or a custom
     382         *                       module's api name such as "cm_sales_return2".
     383         * @param string $org_id Books organisation id.
     384         * @return array<int,array{api_name:string,label:string,required:bool}>
     385         */
     386        private function custom_fields( $entity, $org_id ) {
     387                $settings = $this->custom_field_settings( $org_id );
     388
     389                if ( is_wp_error( $settings ) ) {
     390                        return array();
     391                }
     392
     393                // Autonumber and formula fields are filled in by Books itself, and
     394                // refuse a value sent to them.
     395                $unfillable = array( 'image', 'table', 'file', 'attachment', 'autonumber', 'formula' );
     396
     397                $out  = array();
     398                $seen = array();
     399                foreach ( $settings as $group ) {
     400                        foreach ( (array) $group as $field ) {
     401                                if ( ! is_array( $field ) ) {
     402                                        continue;
     403                                }
     404
     405                                $owner = isset( $field['search_entity'] ) ? (string) $field['search_entity'] : '';
     406                                $name  = isset( $field['api_name'] ) ? (string) $field['api_name'] : '';
     407
     408                                if ( $owner !== $entity || '' === $name || empty( $field['is_active'] ) ) {
     409                                        continue;
     410                                }
     411
     412                                // One field can be reported under more than one group — a
     413                                // contact's fields come back again under "practice_client" —
     414                                // and a second row for it would offer the same field twice.
     415                                if ( isset( $seen[ $name ] ) ) {
     416                                        continue;
     417                                }
     418                                $seen[ $name ] = true;
     419
     420                                if ( in_array( strtolower( (string) ( $field['data_type'] ?? '' ) ), $unfillable, true ) ) {
     421                                        continue;
     422                                }
     423
     424                                $out[] = array(
     425                                        'api_name' => $name,
     426                                        'label'    => isset( $field['label'] ) && '' !== $field['label'] ? (string) $field['label'] : $name,
     427                                        'required' => ! empty( $field['is_mandatory'] ),
     428                                        'id'       => isset( $field['customfield_id'] ) ? (string) $field['customfield_id'] : '',
     429                                );
     430                        }
     431                }
     432
     433                return $out;
     434        }
     435
     436        /**
     437         * Every custom field the organisation has, grouped as Books reports them.
     438         *
     439         * One call answers for every module, and the editor asked it again for
     440         * each module picked and once more for the module list, a second each
     441         * time. Kept for ten minutes; the reload button beside the module select
     442         * skips it, so a field added in Books meanwhile can be picked up.
     443         *
     444         * @param string $org_id Books organisation id.
     445         * @return array|WP_Error
     446         */
     447        private function custom_field_settings( $org_id ) {
     448                $key = 'form2zoho_books_cfs_' . md5( $this->connection_id . '|' . $org_id );
     449
     450                if ( ! $this->skip_cache ) {
     451                        $cached = get_transient( $key );
     452
     453                        if ( is_array( $cached ) ) {
     454                                return $cached;
     455                        }
     456                }
     457
     458                $res = $this->request(
     459                        'GET',
     460                        '/books/v3/settings/customfields',
     461                        array( 'query' => array( 'organization_id' => (string) $org_id ) )
     462                );
     463
     464                if ( is_wp_error( $res ) ) {
     465                        return $res;
     466                }
     467
     468                $settings = (array) ( $res['customfields'] ?? array() );
     469                set_transient( $key, $settings, 10 * MINUTE_IN_SECONDS );
     470
     471                return $settings;
     472        }
     473
     474        /**
     475         * Curated writable fields per built-in Books resource.
     476         *
     477         * @return array<string,array<string,bool>>
     478         */
     479        private static function curated_fields() {
     480                return array(
    121481                        'contacts'       => array(
    122482                                'contact_name' => true,
    … …  
    172532                        ),
    173533                );
    174 
     534        }
     535
     536        /**
     537         * Turn the curated map into the row shape the editor renders.
     538         *
     539         * @param array<string,array<string,bool>> $map          Curated map.
     540         * @param string                           $resource_key Resource key.
     541         * @return array<int,array{api_name:string,label:string,required:bool}>
     542         */
     543        private static function shape_fields( array $map, $resource_key ) {
    175544                $fields = isset( $map[ $resource_key ] ) ? $map[ $resource_key ] : array();
    176545
    … …  
    203572                }
    204573
     574                $record = $this->pack_custom_fields( $record, $org_id );
     575
    205576                return $this->request(
    206577                        'POST',
    … …  
    211582                        )
    212583                );
     584        }
     585
     586        /**
     587         * Move an organisation's own fields into the array Books reads them from.
     588         *
     589         * A mapped custom field arrives here as a plain `cf_something` key,
     590         * because that is its API name and that is what the mapping table offers.
     591         * Books does not read it there. Its documented shape is
     592         * `custom_fields: [ { customfield_id, value } ]`, and a `cf_` key at the
     593         * top level of the body is not rejected — it is ignored. That is the worst
     594         * of the two outcomes: the record is created, the log says success, and
     595         * the field somebody mapped is simply not there.
     596         *
     597         * The ids come from the same settings call the mapping table was built
     598         * from, cached per organisation. It is one extra request at most once an
     599         * hour, and only on a feed that actually maps one of these — a record with
     600         * no `cf_` keys never asks.
     601         *
     602         * @param array  $record Mapped values, possibly containing cf_* keys.
     603         * @param string $org_id Books organisation id.
     604         * @return array
     605         */
     606        private function pack_custom_fields( array $record, $org_id ) {
     607                $named = array();
     608
     609                foreach ( $record as $key => $value ) {
     610                        if ( 0 === strpos( (string) $key, 'cf_' ) ) {
     611                                $named[ (string) $key ] = $value;
     612                        }
     613                }
     614
     615                if ( array() === $named ) {
     616                        return $record;
     617                }
     618
     619                $ids = $this->custom_field_ids( $org_id );
     620                $out = isset( $record['custom_fields'] ) && is_array( $record['custom_fields'] ) ? $record['custom_fields'] : array();
     621
     622                foreach ( $named as $key => $value ) {
     623                        unset( $record[ $key ] );
     624
     625                        // A field whose id cannot be resolved is dropped rather than sent
     626                        // in a shape Books will ignore. Either way it does not arrive; the
     627                        // difference is that this way the request stays the documented
     628                        // one.
     629                        if ( empty( $ids[ $key ] ) ) {
     630                                continue;
     631                        }
     632
     633                        $out[] = array(
     634                                'customfield_id' => $ids[ $key ],
     635                                'value'          => $value,
     636                        );
     637                }
     638
     639                if ( array() !== $out ) {
     640                        $record['custom_fields'] = $out;
     641                }
     642
     643                return $record;
     644        }
     645
     646        /**
     647         * api_name => customfield_id for one organisation, cached for an hour.
     648         *
     649         * @param string $org_id Books organisation id.
     650         * @return array<string,string>
     651         */
     652        private function custom_field_ids( $org_id ) {
     653                $key    = 'form2zoho_books_cf_' . md5( (string) $org_id );
     654                $cached = get_transient( $key );
     655
     656                if ( is_array( $cached ) ) {
     657                        return $cached;
     658                }
     659
     660                $res = $this->request(
     661                        'GET',
     662                        '/books/v3/settings/customfields',
     663                        array( 'query' => array( 'organization_id' => (string) $org_id ) )
     664                );
     665
     666                $ids = array();
     667
     668                if ( ! is_wp_error( $res ) ) {
     669                        foreach ( (array) ( $res['customfields'] ?? array() ) as $group ) {
     670                                foreach ( (array) $group as $field ) {
     671                                        if ( is_array( $field ) && ! empty( $field['api_name'] ) && ! empty( $field['customfield_id'] ) ) {
     672                                                $ids[ (string) $field['api_name'] ] = (string) $field['customfield_id'];
     673                                        }
     674                                }
     675                        }
     676                }
     677
     678                // Cached even when empty, so a failed or genuinely empty lookup does
     679                // not re-ask on every submission.
     680                set_transient( $key, $ids, HOUR_IN_SECONDS );
     681
     682                return $ids;
    213683        }
    214684
  • techwave-form-sync-for-zoho/trunk/includes/Api/CampaignsClient.php

    r3649280 r3748247  
    2424final class CampaignsClient extends AbstractClient {
    2525
     26        /** Mailing lists asked for per page. */
     27        private const LIST_PAGE = 200;
     28
     29        /** Topics asked for per page. */
     30        private const TOPIC_PAGE = 100;
     31
     32        /** Pages read at most, for lists and for topics. */
     33        private const MAX_PAGES = 25;
     34
    2635        /**
    2736         * {@inheritDoc}
    … …  
    4453         */
    4554        public function lists() {
    46                 $res = $this->request(
    47                         'GET',
    48                         '/api/v1.1/getmailinglists',
    49                         array(
    50                                 'query' => array(
    51                                         'resfmt'    => 'JSON',
    52                                         'sort'      => 'desc',
    53                                         'fromindex' => 1,
    54                                         'range'     => 200,
    55                                 ),
    56                         )
    57                 );
    58                 if ( is_wp_error( $res ) ) {
    59                         return $res;
    60                 }
    61 
    6255                $out = array();
    63                 foreach ( (array) ( $res['list_of_details'] ?? array() ) as $row ) {
    64                         $key = isset( $row['listkey'] ) ? (string) $row['listkey'] : '';
    65                         if ( '' === $key ) {
    66                                 continue;
    67                         }
    68                         $out[] = array(
    69                                 'api_name' => $key,
    70                                 'label'    => isset( $row['listname'] ) ? (string) $row['listname'] : $key,
     56
     57                // A page at a time: one call stopped at the first 200 lists, and an
     58                // account with more could not pick the rest. Capped, and stopped as
     59                // soon as a page brings nothing new, in case Zoho ever answers every
     60                // page with the first one.
     61                for ( $page = 0; $page < self::MAX_PAGES; $page++ ) {
     62                        $res = $this->request(
     63                                'GET',
     64                                '/api/v1.1/getmailinglists',
     65                                array(
     66                                        'query' => array(
     67                                                'resfmt'    => 'JSON',
     68                                                'sort'      => 'desc',
     69                                                'fromindex' => 1 + $page * self::LIST_PAGE,
     70                                                'range'     => self::LIST_PAGE,
     71                                        ),
     72                                )
    7173                        );
    72                 }
    73                 return $out;
     74                        if ( is_wp_error( $res ) ) {
     75                                // A later page failing still leaves the lists already read.
     76                                return $page ? array_values( $out ) : $res;
     77                        }
     78
     79                        $rows  = (array) ( $res['list_of_details'] ?? array() );
     80                        $added = 0;
     81                        foreach ( $rows as $row ) {
     82                                $key = isset( $row['listkey'] ) ? (string) $row['listkey'] : '';
     83                                if ( '' === $key || isset( $out[ $key ] ) ) {
     84                                        continue;
     85                                }
     86                                $out[ $key ] = array(
     87                                        'api_name' => $key,
     88                                        'label'    => isset( $row['listname'] ) ? (string) $row['listname'] : $key,
     89                                );
     90                                ++$added;
     91                        }
     92
     93                        if ( count( $rows ) < self::LIST_PAGE || ! $added ) {
     94                                break;
     95                        }
     96                }
     97
     98                return array_values( $out );
    7499        }
    75100
    … …  
    86111         */
    87112        public function topics() {
    88                 $res = $this->request(
    89                         'GET',
    90                         '/api/v1.1/topics',
    91                         array(
    92                                 'query' => array(
    93                                         'details' => wp_json_encode(
    94                                                 array(
    95                                                         'from_index' => 0,
    96                                                         'range'      => 100,
    97                                                 )
     113                $out  = array();
     114                $seen = array();
     115
     116                // Paged the same way as lists(): one call stopped at 100 topics.
     117                for ( $page = 0; $page < self::MAX_PAGES; $page++ ) {
     118                        $res = $this->request(
     119                                'GET',
     120                                '/api/v1.1/topics',
     121                                array(
     122                                        'query' => array(
     123                                                'details' => wp_json_encode(
     124                                                        array(
     125                                                                'from_index' => $page * self::TOPIC_PAGE,
     126                                                                'range'      => self::TOPIC_PAGE,
     127                                                        )
     128                                                ),
    98129                                        ),
    99                                 ),
    100                         )
    101                 );
    102                 if ( is_wp_error( $res ) ) {
    103                         return $res;
    104                 }
    105 
    106                 $out = array();
    107                 foreach ( (array) ( $res['topicDetails'] ?? array() ) as $row ) {
    108                         $id   = isset( $row['topicId'] ) ? (string) $row['topicId'] : '';
    109                         $name = isset( $row['topicName'] ) ? trim( (string) $row['topicName'] ) : '';
    110                         if ( '' === $id || '' === $name ) {
    111                                 continue;
    112                         }
    113                         $out[] = array(
    114                                 'id'   => $id,
    115                                 'name' => $name,
     130                                )
    116131                        );
    117                 }
     132                        if ( is_wp_error( $res ) ) {
     133                                return $page ? $out : $res;
     134                        }
     135
     136                        $rows  = (array) ( $res['topicDetails'] ?? array() );
     137                        $added = 0;
     138                        foreach ( $rows as $row ) {
     139                                $id   = isset( $row['topicId'] ) ? (string) $row['topicId'] : '';
     140                                $name = isset( $row['topicName'] ) ? trim( (string) $row['topicName'] ) : '';
     141                                if ( '' === $id || isset( $seen[ $id ] ) ) {
     142                                        continue;
     143                                }
     144                                $seen[ $id ] = true;
     145                                ++$added;
     146                                if ( '' === $name ) {
     147                                        continue;
     148                                }
     149                                $out[] = array(
     150                                        'id'   => $id,
     151                                        'name' => $name,
     152                                );
     153                        }
     154
     155                        if ( count( $rows ) < self::TOPIC_PAGE || ! $added ) {
     156                                break;
     157                        }
     158                }
     159
    118160                return $out;
    119161        }
    … …  
    449491                        'resfmt'      => 'JSON',
    450492                        'listkey'     => $listkey,
    451                         'contactinfo' => $this->pseudo_json( $contactinfo ),
    452                         'source'      => 'Techwave Form Sync for Zoho',
     493                        'contactinfo' => '',
     494                        'source'      => 'FormBridge for Zoho',
    453495                );
    454496                if ( '' !== $topic_id ) {
    455497                        $query['topic_id'] = $topic_id;
    456498                }
    457                 return $this->request( 'POST', '/api/v1.1/json/listsubscribe', array( 'query' => $query ) );
     499                return $this->post_info( '/api/v1.1/json/listsubscribe', $query, 'contactinfo', $contactinfo );
     500        }
     501
     502        /**
     503         * Add a contact to a list directly, with no confirmation email.
     504         *
     505         * Zoho's addlistsubscribersinbulk takes email addresses only, and adds
     506         * them to the list straight away even when its sign-up form is on, where
     507         * listsubscribe would only send a confirmation email. The contact's other
     508         * fields still go through subscribe().
     509         *
     510         * @param string $listkey Mailing list key.
     511         * @param string $email   Contact email.
     512         * @return array|WP_Error
     513         */
     514        public function add_to_list( $listkey, $email ) {
     515                return $this->request(
     516                        'POST',
     517                        '/api/v1.1/addlistsubscribersinbulk',
     518                        array(
     519                                'query' => array(
     520                                        'resfmt'   => 'JSON',
     521                                        'listkey'  => $listkey,
     522                                        'emailids' => $email,
     523                                ),
     524                        )
     525                );
    458526        }
    459527
    … …  
    471539                        'resfmt'      => 'JSON',
    472540                        'listkey'     => $listkey,
    473                         'contactinfo' => $this->pseudo_json( $contactinfo ),
     541                        'contactinfo' => '',
    474542                );
    475543                if ( '' !== $topic_id ) {
    476544                        $query['topic_id'] = $topic_id;
    477545                }
    478                 return $this->request( 'POST', '/api/v1.1/json/listunsubscribe', array( 'query' => $query ) );
     546                return $this->post_info( '/api/v1.1/json/listunsubscribe', $query, 'contactinfo', $contactinfo );
     547        }
     548
     549        /**
     550         * Post one contact payload, as JSON Zoho's own parser will accept.
     551         *
     552         * Zoho documents this parameter unquoted — {First Name:mac,Contact
     553         * Email:x@y.com} — a shape with no way to write a value containing the
     554         * comma that separates the pairs or the colon that separates a pair.
     555         * Building it meant replacing a comma in an address or a company name
     556         * with a space, and a colon in a subject line could end the payload
     557         * early. Real JSON quotes every key and value, so both survive.
     558         *
     559         * The documented shape is still tried when Zoho answers "please enter
     560         * correct XML(or)JSON format" (code 2001), so an account whose parser
     561         * only takes that shape keeps working exactly as before.
     562         *
     563         * @param string $path     API path.
     564         * @param array  $query    Query parameters, with the info key empty.
     565         * @param string $info_key 'contactinfo'.
     566         * @param array  $fields   Human-labelled field => value pairs.
     567         * @return array|WP_Error
     568         */
     569        private function post_info( $path, array $query, $info_key, array $fields ) {
     570                // The documented shape first, because it is the one Zoho's own
     571                // examples use and the one every account has always been sent.
     572                if ( ! self::needs_quoting( $fields ) ) {
     573                        $query[ $info_key ] = $this->pseudo_json( $fields );
     574
     575                        return $this->request( 'POST', $path, array( 'query' => $query ) );
     576                }
     577
     578                $query[ $info_key ] = (string) wp_json_encode( $fields );
     579
     580                $res = $this->request( 'POST', $path, array( 'query' => $query ) );
     581
     582                if ( ! self::refused( $res ) ) {
     583                        return $res;
     584                }
     585
     586                // Zoho would not take it quoted, so it goes the documented way,
     587                // which costs the comma or colon inside the value but sends the
     588                // submission.
     589                $query[ $info_key ] = $this->pseudo_json( $fields );
     590
     591                return $this->request( 'POST', $path, array( 'query' => $query ) );
     592        }
     593
     594        /**
     595         * Whether any value would lose something in the documented shape.
     596         *
     597         * @param array $fields Field => value pairs.
     598         * @return bool
     599         */
     600        private static function needs_quoting( array $fields ) {
     601                foreach ( $fields as $value ) {
     602                        if ( is_array( $value ) ) {
     603                                return true;
     604                        }
     605
     606                        if ( preg_match( '/[,:{}]/', (string) $value ) ) {
     607                                return true;
     608                        }
     609                }
     610
     611                return false;
     612        }
     613
     614        /**
     615         * Whether Zoho turned an attempt down, whatever reason it gave.
     616         *
     617         * Read widely on purpose: this decides only whether to try the same
     618         * submission again in the documented shape, and Zoho answers a payload
     619         * it could not read with more than one code ("please enter correct
     620         * XML(or)JSON format", but also a complaint about the email it failed
     621         * to find in it).
     622         *
     623         * @param array|WP_Error $res Zoho's answer.
     624         * @return bool
     625         */
     626        private static function refused( $res ) {
     627                if ( is_wp_error( $res ) ) {
     628                        return true;
     629                }
     630
     631                if ( ! is_array( $res ) ) {
     632                        return false;
     633                }
     634
     635                $body   = ( isset( $res['response'] ) && is_array( $res['response'] ) ) ? $res['response'] : $res;
     636                $status = isset( $body['status'] ) ? strtolower( trim( (string) $body['status'] ) ) : '';
     637                $code   = isset( $body['code'] ) ? trim( (string) $body['code'] ) : '';
     638
     639                return ! ( 'success' === $status || ( '' === $status && '0' === $code ) );
    479640        }
    480641}
  • techwave-form-sync-for-zoho/trunk/includes/Api/CrmClient.php

    r3649280 r3748247  
    5050
    5151        /**
     52         * Put one file in Zoho's file store and return the id a record refers to.
     53         *
     54         * The two-step flow Zoho documents for a File Upload field: the bytes go
     55         * to /files, and the record then carries
     56         * [ { "File_Id__s": "<id>" } ] — there is no way to put a file straight
     57         * into a record.
     58         *
     59         * @param string $file Absolute path to the file on disk.
     60         * @return string|WP_Error The encrypted file id.
     61         */
     62        public function upload_file( $file ) {
     63                $res = $this->upload( '/crm/v6/files', $file );
     64
     65                if ( is_wp_error( $res ) ) {
     66                        return $res;
     67                }
     68
     69                $id = isset( $res['data'][0]['details']['id'] ) ? (string) $res['data'][0]['details']['id'] : '';
     70
     71                if ( '' === $id ) {
     72                        return new WP_Error(
     73                                'form2zoho_file_no_id',
     74                                __( 'Zoho accepted the file but returned no id for it.', 'techwave-form-sync-for-zoho' ),
     75                                $res
     76                        );
     77                }
     78
     79                return $id;
     80        }
     81
     82        /**
     83         * Zoho CRM can be given a file, so its upload fields are worth offering.
     84         *
     85         * {@inheritDoc}
     86         */
     87        protected static function supports_file_upload() {
     88                return true;
     89        }
     90
     91        /**
    5292         * List writable fields for a module.
    5393         *
    … …  
    63103                $out = array();
    64104                foreach ( (array) ( $res['fields'] ?? array() ) as $field ) {
    65                         if ( ! empty( $field['read_only'] ) ) {
     105                        if ( ! empty( $field['read_only'] ) || self::is_unfillable_field( $field ) ) {
    66106                                continue;
    67107                        }
    … …  
    70110                                'label'    => (string) $field['field_label'],
    71111                                'required' => ! empty( $field['system_mandatory'] ) || ! empty( $field['required'] ),
     112                                // Carried through to the mapping row and saved with it. A form
     113                                // value is text whatever it came from, and Zoho wants a real
     114                                // boolean for a checkbox and a real array for a multi-select —
     115                                // so the feed has to remember which of those it is aiming at.
     116                                // See FieldMapper::coerce().
     117                                'type'     => strtolower( (string) ( isset( $field['data_type'] ) ? $field['data_type'] : '' ) ),
     118                                // Whether Zoho refuses two records with the same value here.
     119                                // Present on every field and empty when it is not unique, so
     120                                // this is a real answer rather than an absence. The duplicate
     121                                // handling on the Destination step offers exactly these,
     122                                // instead of asking somebody to type API names from memory.
     123                                'unique'   => ! empty( $field['unique'] ),
    72124                        );
    73125                }
    … …  
    76128
    77129        /**
    78          * Create a record.
    79          *
    80          * @param string $module          Module API name.
    81          * @param array  $record          Field => value pairs.
    82          * @param array  $duplicate_fields Fields used for upsert duplicate check (empty = plain insert).
     130         * Create a record, or update the one it duplicates.
     131         *
     132         * @param string    $module           Module API name.
     133         * @param array     $record           Field => value pairs.
     134         * @param array     $duplicate_fields Unique fields to match an existing record on.
     135         * @param bool|null $upsert           Update a matching record instead of inserting. Null keeps
     136         *                                    the older rule: upsert only when fields are named.
    83137         * @return array|WP_Error
    84138         */
    85         public function create_record( $module, array $record, array $duplicate_fields = array() ) {
    86                 if ( ! empty( $duplicate_fields ) ) {
     139        public function create_record( $module, array $record, array $duplicate_fields = array(), $upsert = null, array $extras = array() ) {
     140                if ( null === $upsert ? ! empty( $duplicate_fields ) : $upsert ) {
     141                        $body = self::with_extras( array( 'data' => array( $record ) ), $extras, true );
     142
     143                        // Left out rather than sent empty when nothing is named: Zoho then
     144                        // checks its own system-defined duplicate fields (Email on Leads
     145                        // and Contacts, for example) and the module's unique fields.
     146                        if ( ! empty( $duplicate_fields ) ) {
     147                                $body['duplicate_check_fields'] = array_values( $duplicate_fields );
     148                        }
     149
    87150                        // Upsert merges into an existing record when a duplicate is found.
    88151                        return $this->request(
    89152                                'POST',
    90153                                '/crm/v6/' . rawurlencode( $module ) . '/upsert',
    91                                 array(
    92                                         'body' => array(
    93                                                 'data'                   => array( $record ),
    94                                                 'duplicate_check_fields' => array_values( $duplicate_fields ),
    95                                         ),
    96                                 )
     154                                array( 'body' => $body )
    97155                        );
    98156                }
    … …  
    101159                        'POST',
    102160                        '/crm/v6/' . rawurlencode( $module ),
    103                         array( 'body' => array( 'data' => array( $record ) ) )
     161                        array( 'body' => self::with_extras( array( 'data' => array( $record ) ), $extras, false ) )
    104162                );
    105163        }
     164
     165        /**
     166         * Add the request-level keys Zoho documents for this endpoint, and only
     167         * those.
     168         *
     169         * The two endpoints do not accept the same set, and sending one the
     170         * endpoint does not document is a request Zoho may refuse outright:
     171         *
     172         *   insert  trigger (workflow, approval, blueprint, pathfinder,
     173         *           orchestration), lar_id, apply_feature_execution,
     174         *           skip_feature_execution
     175         *   upsert  trigger (workflow, approval, blueprint),
     176         *           apply_feature_execution
     177         *
     178         * `trigger` is passed through even when empty, because an empty list is
     179         * an instruction — run nothing — and not the same as leaving it out,
     180         * which runs the automations Zoho has set up for the API.
     181         *
     182         * @param array $body   The request body so far.
     183         * @param array $extras What an add-on asked for.
     184         * @param bool  $upsert Whether this is the upsert endpoint.
     185         * @return array
     186         */
     187        private static function with_extras( array $body, array $extras, $upsert ) {
     188                if ( array_key_exists( 'trigger', $extras ) && is_array( $extras['trigger'] ) ) {
     189                        $allowed = $upsert
     190                                ? array( 'workflow', 'approval', 'blueprint' )
     191                                : array( 'workflow', 'approval', 'blueprint', 'pathfinder', 'orchestration' );
     192
     193                        $body['trigger'] = array_values( array_intersect( $allowed, array_map( 'strval', $extras['trigger'] ) ) );
     194                }
     195
     196                if ( ! $upsert && isset( $extras['lar_id'] ) && preg_match( '/^\d+$/', (string) $extras['lar_id'] ) ) {
     197                        $body['lar_id'] = (string) $extras['lar_id'];
     198                }
     199
     200                foreach ( array( 'apply_feature_execution', 'skip_feature_execution' ) as $key ) {
     201                        if ( $upsert && 'skip_feature_execution' === $key ) {
     202                                continue;
     203                        }
     204
     205                        if ( isset( $extras[ $key ] ) && is_array( $extras[ $key ] ) && array() !== $extras[ $key ] ) {
     206                                $body[ $key ] = array_values( $extras[ $key ] );
     207                        }
     208                }
     209
     210                return $body;
     211        }
    106212}
  • techwave-form-sync-for-zoho/trunk/includes/Api/DeskClient.php

    r3649280 r3748247  
    122122         * @return array<int,array>
    123123         */
    124         public function fields( $module ) {
     124        public function fields( $module, $org_id = '' ) {
     125                $curated = $this->curated_fields( $module );
     126
     127                if ( '' === (string) $org_id ) {
     128                        return $curated;
     129                }
     130
     131                return array_merge( $curated, $this->custom_fields( (string) $module, (string) $org_id ) );
     132        }
     133
     134        /**
     135         * The organisation's own fields for one Desk module.
     136         *
     137         * Desk lets a helpdesk add its own fields to a layout, and until this was
     138         * asked for the mapping table offered only the curated list below — so a
     139         * "Vehicle Id" somebody had added to their tickets was unmappable, with
     140         * nothing on screen to say why.
     141         *
     142         * Failure is silent: this runs alongside a list that is already correct,
     143         * and an organisation with no custom fields, or a grant that does not
     144         * cover the settings read, should still get the curated ones rather than
     145         * an error where the field list used to be.
     146         *
     147         * @param string $module Desk module api name.
     148         * @param string $org_id Desk organization id.
     149         * @return array<int,array{api_name:string,label:string,required:bool}>
     150         */
     151        private function custom_fields( $module, $org_id ) {
     152                $res = $this->request(
     153                        'GET',
     154                        '/api/v1/organizationFields?module=' . rawurlencode( $module ),
     155                        array( 'headers' => array( 'orgId' => $org_id ) )
     156                );
     157
     158                if ( is_wp_error( $res ) ) {
     159                        return array();
     160                }
     161
     162                $out = array();
     163
     164                foreach ( (array) ( $res['data'] ?? array() ) as $field ) {
     165                        if ( ! is_array( $field ) || empty( $field['isCustomField'] ) || ! empty( $field['isReadOnly'] ) ) {
     166                                continue;
     167                        }
     168
     169                        $name = isset( $field['apiName'] ) ? (string) $field['apiName'] : '';
     170
     171                        if ( '' === $name ) {
     172                                continue;
     173                        }
     174
     175                        $out[] = array(
     176                                'api_name' => $name,
     177                                'label'    => isset( $field['displayLabel'] ) && '' !== $field['displayLabel'] ? (string) $field['displayLabel'] : $name,
     178                                'required' => ! empty( $field['isMandatory'] ),
     179                        );
     180                }
     181
     182                return $out;
     183        }
     184
     185        /**
     186         * Curated, create-ready fields for a Desk module.
     187         *
     188         * @param string $module Module api name.
     189         * @return array<int,array>
     190         */
     191        private function curated_fields( $module ) {
    125192                switch ( $module ) {
    126193                        case 'tickets':
    … …  
    244311         * @return array|WP_Error
    245312         */
    246         public function create_record( $org_id, $department_id, $module, array $record ) {
     313        public function create_record( $org_id, $department_id, $module, array $record, array $extras = array() ) {
    247314                if ( '' === (string) $org_id ) {
    248315                        return new WP_Error( 'form2zoho_desk_no_org', __( 'Select a Zoho Desk organization for this feed.', 'techwave-form-sync-for-zoho' ) );
    … …  
    254321                                return $record;
    255322                        }
    256                 }
     323
     324                        // Who owns the ticket and which product it is about. Both are
     325                        // Desk record ids, so digits only — prepare_ticket() rebuilds the
     326                        // ticket from an allowlist precisely so a stray value cannot reach
     327                        // the API, and these arrive from outside the mapping.
     328                        foreach ( array( 'assigneeId', 'productId' ) as $key ) {
     329                                if ( isset( $extras[ $key ] ) && preg_match( '/^\d+$/', (string) $extras[ $key ] ) ) {
     330                                        $record[ $key ] = (string) $extras[ $key ];
     331                                }
     332                        }
     333                }
     334
     335                $record = self::pack_custom_fields( $record );
    257336
    258337                $result = $this->request(
    … …  
    285364         * @return array|WP_Error
    286365         */
     366        /**
     367         * Move an organisation's own fields into the object Desk reads them from.
     368         *
     369         * A mapped custom field arrives as a plain `cf_something` key, because
     370         * that is its apiName and that is what the mapping table offers. Desk does
     371         * not read it there: it wants them collected under `cf`, and a `cf_` key
     372         * at the top level of the body is ignored rather than refused — the ticket
     373         * is created, the log says success, and the field is quietly missing.
     374         *
     375         * No lookup is needed, unlike Books: Desk keys this object by the same
     376         * apiName the field list already uses.
     377         *
     378         * @param array $record Mapped values, possibly containing cf_* keys.
     379         * @return array
     380         */
     381        private static function pack_custom_fields( array $record ) {
     382                $cf = isset( $record['cf'] ) && is_array( $record['cf'] ) ? $record['cf'] : array();
     383
     384                foreach ( $record as $key => $value ) {
     385                        if ( 0 === strpos( (string) $key, 'cf_' ) ) {
     386                                $cf[ (string) $key ] = $value;
     387                                unset( $record[ $key ] );
     388                        }
     389                }
     390
     391                if ( array() !== $cf ) {
     392                        $record['cf'] = $cf;
     393                }
     394
     395                return $record;
     396        }
     397
    287398        private function prepare_ticket( array $record, $department_id ) {
    288399                if ( '' === $department_id ) {
  • techwave-form-sync-for-zoho/trunk/includes/Api/MarketingAutomationClient.php

    r3649280 r3748247  
    2121final class MarketingAutomationClient extends AbstractClient {
    2222
     23        /** Mailing lists asked for per page. */
     24        private const LIST_PAGE = 200;
     25
     26        /** Topics asked for per page. */
     27        private const TOPIC_PAGE = 100;
     28
     29        /** Pages read at most, for lists and for topics. */
     30        private const MAX_PAGES = 25;
     31
    2332        /**
    2433         * {@inheritDoc}
    … …  
    3746         */
    3847        public function lists() {
    39                 $res = $this->request(
    40                         'GET',
    41                         '/api/v1/getmailinglists',
    42                         array(
    43                                 'query' => array(
    44                                         'resfmt'    => 'JSON',
    45                                         'sort'      => 'desc',
    46                                         'fromindex' => 1,
    47                                         'range'     => 200,
    48                                 ),
    49                         )
    50                 );
    51                 if ( is_wp_error( $res ) ) {
    52                         return $res;
    53                 }
    54 
    5548                $out = array();
    56                 foreach ( (array) ( $res['list_of_details'] ?? array() ) as $row ) {
    57                         $key = isset( $row['listkey'] ) ? (string) $row['listkey'] : '';
    58                         if ( '' === $key ) {
    59                                 continue;
    60                         }
    61                         $out[] = array(
    62                                 'api_name' => $key,
    63                                 'label'    => isset( $row['listname'] ) ? (string) $row['listname'] : $key,
     49
     50                // A page at a time: one call stopped at the first 200 lists, and an
     51                // account with more could not pick the rest. Capped, and stopped as
     52                // soon as a page brings nothing new, in case Zoho ever answers every
     53                // page with the first one.
     54                for ( $page = 0; $page < self::MAX_PAGES; $page++ ) {
     55                        $res = $this->request(
     56                                'GET',
     57                                '/api/v1/getmailinglists',
     58                                array(
     59                                        'query' => array(
     60                                                'resfmt'    => 'JSON',
     61                                                'sort'      => 'desc',
     62                                                'fromindex' => 1 + $page * self::LIST_PAGE,
     63                                                'range'     => self::LIST_PAGE,
     64                                        ),
     65                                )
    6466                        );
    65                 }
    66                 return $out;
     67                        if ( is_wp_error( $res ) ) {
     68                                // A later page failing still leaves the lists already read.
     69                                return $page ? array_values( $out ) : $res;
     70                        }
     71
     72                        $rows  = (array) ( $res['list_of_details'] ?? array() );
     73                        $added = 0;
     74                        foreach ( $rows as $row ) {
     75                                $key = isset( $row['listkey'] ) ? (string) $row['listkey'] : '';
     76                                if ( '' === $key || isset( $out[ $key ] ) ) {
     77                                        continue;
     78                                }
     79                                $out[ $key ] = array(
     80                                        'api_name' => $key,
     81                                        'label'    => isset( $row['listname'] ) ? (string) $row['listname'] : $key,
     82                                );
     83                                ++$added;
     84                        }
     85
     86                        if ( count( $rows ) < self::LIST_PAGE || ! $added ) {
     87                                break;
     88                        }
     89                }
     90
     91                return array_values( $out );
    6792        }
    6893
    … …  
    76101         */
    77102        public function topics() {
    78                 $res = $this->request(
    79                         'GET',
    80                         '/api/v1/topics',
    81                         array(
    82                                 'query' => array(
    83                                         'details' => wp_json_encode(
    84                                                 array(
    85                                                         'from_index' => 0,
    86                                                         'range'      => 100,
    87                                                 )
     103                $out  = array();
     104                $seen = array();
     105
     106                // Paged the same way as lists(): one call stopped at 100 topics.
     107                for ( $page = 0; $page < self::MAX_PAGES; $page++ ) {
     108                        $res = $this->request(
     109                                'GET',
     110                                '/api/v1/topics',
     111                                array(
     112                                        'query' => array(
     113                                                'details' => wp_json_encode(
     114                                                        array(
     115                                                                'from_index' => $page * self::TOPIC_PAGE,
     116                                                                'range'      => self::TOPIC_PAGE,
     117                                                        )
     118                                                ),
    88119                                        ),
    89                                 ),
    90                         )
    91                 );
    92                 if ( is_wp_error( $res ) ) {
    93                         return $res;
    94                 }
    95 
    96                 $out = array();
    97                 foreach ( (array) ( $res['topicDetails'] ?? array() ) as $row ) {
    98                         $id   = isset( $row['topicId'] ) ? (string) $row['topicId'] : '';
    99                         $name = isset( $row['topicName'] ) ? trim( (string) $row['topicName'] ) : '';
    100                         if ( '' === $id || '' === $name ) {
    101                                 continue;
    102                         }
    103                         $out[] = array(
    104                                 'id'   => $id,
    105                                 'name' => $name,
     120                                )
    106121                        );
    107                 }
     122                        if ( is_wp_error( $res ) ) {
     123                                return $page ? $out : $res;
     124                        }
     125
     126                        $rows  = (array) ( $res['topicDetails'] ?? array() );
     127                        $added = 0;
     128                        foreach ( $rows as $row ) {
     129                                $id   = isset( $row['topicId'] ) ? (string) $row['topicId'] : '';
     130                                $name = isset( $row['topicName'] ) ? trim( (string) $row['topicName'] ) : '';
     131                                if ( '' === $id || isset( $seen[ $id ] ) ) {
     132                                        continue;
     133                                }
     134                                $seen[ $id ] = true;
     135                                ++$added;
     136                                if ( '' === $name ) {
     137                                        continue;
     138                                }
     139                                $out[] = array(
     140                                        'id'   => $id,
     141                                        'name' => $name,
     142                                );
     143                        }
     144
     145                        if ( count( $rows ) < self::TOPIC_PAGE || ! $added ) {
     146                                break;
     147                        }
     148                }
     149
    108150                return $out;
    109151        }
    … …  
    328370                        'resfmt'   => 'JSON',
    329371                        'listkey'  => $listkey,
    330                         'leadinfo' => $this->pseudo_json( $leadinfo ),
    331                         'sources'  => 'Techwave Form Sync for Zoho',
     372                        'leadinfo' => '',
     373                        'sources'  => 'FormBridge for Zoho',
    332374                );
    333375                if ( '' !== $topic_id ) {
    334376                        $query['topic_id'] = $topic_id;
    335377                }
    336                 return $this->request( 'POST', '/api/v1/json/listsubscribe', array( 'query' => $query ) );
     378                return $this->post_info( '/api/v1/json/listsubscribe', $query, 'leadinfo', $leadinfo );
     379        }
     380
     381        /**
     382         * Add a lead to a list directly, with no confirmation email.
     383         *
     384         * Zoho's addleadsinbulk takes email addresses (and topics) only, and adds
     385         * them to the list straight away even when its sign-up form is on, where
     386         * listsubscribe would only send a confirmation email. The lead's other
     387         * fields still go through subscribe().
     388         *
     389         * @param string $listkey  Mailing list key.
     390         * @param string $email    Lead email.
     391         * @param string $topic_id Optional comma-separated topic ids.
     392         * @return array|WP_Error
     393         */
     394        public function add_to_list( $listkey, $email, $topic_id = '' ) {
     395                $query = array(
     396                        'resfmt'   => 'JSON',
     397                        'listkey'  => $listkey,
     398                        'emailids' => $email,
     399                );
     400                if ( '' !== $topic_id ) {
     401                        $query['topic_id'] = $topic_id;
     402                }
     403                return $this->request( 'POST', '/api/v1/addleadsinbulk', array( 'query' => $query ) );
    337404        }
    338405
    … …  
    349416                        'resfmt'   => 'JSON',
    350417                        'listkey'  => $listkey,
    351                         'leadinfo' => $this->pseudo_json( $leadinfo ),
     418                        'leadinfo' => '',
    352419                );
    353420                if ( '' !== $topic_id ) {
    354421                        $query['topic_id'] = $topic_id;
    355422                }
    356                 return $this->request( 'POST', '/api/v1/json/listunsubscribe', array( 'query' => $query ) );
     423                return $this->post_info( '/api/v1/json/listunsubscribe', $query, 'leadinfo', $leadinfo );
     424        }
     425
     426        /**
     427         * Post one lead payload, as JSON Zoho's own parser will accept.
     428         *
     429         * Zoho documents this parameter unquoted — {First Name:mac,Contact
     430         * Email:x@y.com} — a shape with no way to write a value containing the
     431         * comma that separates the pairs or the colon that separates a pair.
     432         * Building it meant replacing a comma in an address or a company name
     433         * with a space, and a colon in a subject line could end the payload
     434         * early. Real JSON quotes every key and value, so both survive.
     435         *
     436         * The documented shape is still tried when Zoho answers "please enter
     437         * correct XML(or)JSON format" (code 2001), so an account whose parser
     438         * only takes that shape keeps working exactly as before.
     439         *
     440         * @param string $path     API path.
     441         * @param array  $query    Query parameters, with the info key empty.
     442         * @param string $info_key 'leadinfo'.
     443         * @param array  $fields   Human-labelled field => value pairs.
     444         * @return array|WP_Error
     445         */
     446        private function post_info( $path, array $query, $info_key, array $fields ) {
     447                // The documented shape first, because it is the one Zoho's own
     448                // examples use and the one every account has always been sent.
     449                if ( ! self::needs_quoting( $fields ) ) {
     450                        $query[ $info_key ] = $this->pseudo_json( $fields );
     451
     452                        return $this->request( 'POST', $path, array( 'query' => $query ) );
     453                }
     454
     455                $query[ $info_key ] = (string) wp_json_encode( $fields );
     456
     457                $res = $this->request( 'POST', $path, array( 'query' => $query ) );
     458
     459                if ( ! self::refused( $res ) ) {
     460                        return $res;
     461                }
     462
     463                // Zoho would not take it quoted, so it goes the documented way,
     464                // which costs the comma or colon inside the value but sends the
     465                // submission.
     466                $query[ $info_key ] = $this->pseudo_json( $fields );
     467
     468                return $this->request( 'POST', $path, array( 'query' => $query ) );
     469        }
     470
     471        /**
     472         * Whether any value would lose something in the documented shape.
     473         *
     474         * @param array $fields Field => value pairs.
     475         * @return bool
     476         */
     477        private static function needs_quoting( array $fields ) {
     478                foreach ( $fields as $value ) {
     479                        if ( is_array( $value ) ) {
     480                                return true;
     481                        }
     482
     483                        if ( preg_match( '/[,:{}]/', (string) $value ) ) {
     484                                return true;
     485                        }
     486                }
     487
     488                return false;
     489        }
     490
     491        /**
     492         * Whether Zoho turned an attempt down, whatever reason it gave.
     493         *
     494         * Read widely on purpose: this decides only whether to try the same
     495         * submission again in the documented shape, and Zoho answers a payload
     496         * it could not read with more than one code ("please enter correct
     497         * XML(or)JSON format", but also a complaint about the email it failed
     498         * to find in it).
     499         *
     500         * @param array|WP_Error $res Zoho's answer.
     501         * @return bool
     502         */
     503        private static function refused( $res ) {
     504                if ( is_wp_error( $res ) ) {
     505                        return true;
     506                }
     507
     508                if ( ! is_array( $res ) ) {
     509                        return false;
     510                }
     511
     512                $body   = ( isset( $res['response'] ) && is_array( $res['response'] ) ) ? $res['response'] : $res;
     513                $status = isset( $body['status'] ) ? strtolower( trim( (string) $body['status'] ) ) : '';
     514                $code   = isset( $body['code'] ) ? trim( (string) $body['code'] ) : '';
     515
     516                return ! ( 'success' === $status || ( '' === $status && '0' === $code ) );
    357517        }
    358518}
  • techwave-form-sync-for-zoho/trunk/includes/Auth/ConnectionManager.php

    r3649280 r3748247  
    1212use Form2Zoho\Api\BiginClient;
    1313use Form2Zoho\Logging\Logger;
     14use Form2Zoho\Admin\SettingsPage;
    1415use WP_Error;
    1516
    … …  
    5051                // fields. settings.ALL would also grant write access to the account's
    5152                // layouts and configuration, which this plugin never touches.
    52                 'crm'        => 'ZohoCRM.modules.ALL,ZohoCRM.settings.modules.READ,ZohoCRM.settings.fields.READ',
     53                // ZohoCRM.Files.CREATE is what lets a submitted file reach a File
     54                // Upload field: the file is posted to /crm/v6/files first and the id
     55                // that comes back is what the record actually carries. Without it the
     56                // upload is refused and the field is left empty.
     57                //
     58                // Adding it widened the grant, so a connection authorized before this
     59                // still holds the old one — Zoho issues the scopes that were asked for
     60                // at the time, not the ones the plugin asks for today. Those
     61                // connections keep working for everything else and need "Edit access →
     62                // Update & Reauthorize" once before a file will upload.
     63                'crm'        => 'ZohoCRM.modules.ALL,ZohoCRM.settings.modules.READ,ZohoCRM.settings.fields.READ,ZohoCRM.Files.CREATE',
    5364                // One CREATE scope per resource BooksClient::modules() offers, plus
    5465                // settings for Items (a Books "settings" resource) and the
    … …  
    8293
    8394        /**
     95         * The scope map, after add-ons have had their say.
     96         *
     97         * A companion plugin that adds a Zoho app this plugin does not ship — an
     98         * Inventory or a Projects integration registered through
     99         * form2zoho_integrations — can register the integration object but could
     100         * not, until this existed, get its OAuth scope requested. The connection
     101         * would authorise without it and every call the new integration made would
     102         * come back 401, with nothing on screen explaining why.
     103         *
     104         * Rows are validated rather than trusted. This string is pasted straight
     105         * into an authorization URL, so a malformed value from someone else's
     106         * plugin would break the OAuth round trip for every app at once, not just
     107         * for theirs.
     108         *
     109         * @return array<string,string> App id => comma separated scopes.
     110         */
     111        private static function app_scopes() {
     112                /**
     113                 * Filter the OAuth scopes requested per Zoho app.
     114                 *
     115                 * Add a row keyed by the same app id the integration's id() returns.
     116                 * Overwriting one of the built-in rows is allowed but rarely wise —
     117                 * the shipped values are deliberately the narrowest that work.
     118                 *
     119                 * @param array<string,string> $scopes App id => comma separated scopes.
     120                 */
     121                $filtered = apply_filters( 'form2zoho_app_scopes', self::APP_SCOPES );
     122
     123                if ( ! is_array( $filtered ) ) {
     124                        return self::APP_SCOPES;
     125                }
     126
     127                $clean = array();
     128                foreach ( $filtered as $app => $scope ) {
     129                        $app = sanitize_key( (string) $app );
     130                        if ( '' === $app || ! is_string( $scope ) || '' === trim( $scope ) ) {
     131                                continue;
     132                        }
     133                        // Zoho scope names are word characters, dots and commas. Anything
     134                        // else would have to be encoded into the URL, and a scope needing
     135                        // encoding is a scope that is wrong.
     136                        if ( ! preg_match( '/^[A-Za-z0-9_.,]+$/', $scope ) ) {
     137                                continue;
     138                        }
     139                        $clean[ $app ] = $scope;
     140                }
     141
     142                // The built-ins are put back if a callback dropped one: a connection
     143                // silently losing its CRM scope because another plugin returned a
     144                // partial array is not a failure anybody could diagnose.
     145                return array_merge( self::APP_SCOPES, $clean );
     146        }
     147
     148        /**
    84149         * Encryption helper.
    85150         *
    … …  
    96161
    97162        /**
     163         * The action this plugin's OAuth callback lands on.
     164         *
     165         * Named for the plugin rather than for its internal prefix, because this
     166         * one string is not internal: it is the whole visible half of the Redirect
     167         * URI somebody pastes into Zoho's API console, sitting in their account
     168         * next to every other app they have registered. "form2zoho" names nothing
     169         * they installed.
     170         *
     171         * See Plugin::register() for the old action, still answered so an
     172         * authorization begun before this rename can still land.
     173         */
     174        public const OAUTH_CALLBACK = 'formbridge_oauth_callback';
     175
     176        /**
     177         * The action the callback used to land on, kept answerable.
     178         */
     179        public const OAUTH_CALLBACK_LEGACY = 'form2zoho_oauth_callback';
     180
     181        /**
    98182         * The fixed redirect URI registered in the Zoho console.
    99183         *
    … …  
    101185         */
    102186        public function redirect_uri() {
    103                 return admin_url( 'admin-post.php?action=form2zoho_oauth_callback' );
     187                return admin_url( 'admin-post.php?action=' . self::OAUTH_CALLBACK );
    104188        }
    105189
    … …  
    110194         * a connection is expected to name the app(s) it will actually send to.
    111195         *
     196         * An add-on app is listed only when it also registered its scope through
     197         * form2zoho_app_scopes: a box that grants nothing would be worse than none.
     198         *
    112199         * @return array<string,string>
    113200         */
    114201        public function app_choices() {
    115                 return array(
     202                $choices = array(
    116203                        'all'        => __( 'All applications', 'techwave-form-sync-for-zoho' ),
    117204                        'crm'        => __( 'Zoho CRM', 'techwave-form-sync-for-zoho' ),
    … …  
    122209                        'automation' => __( 'Zoho Marketing Automation', 'techwave-form-sync-for-zoho' ),
    123210                );
     211
     212                /**
     213                 * Filter the Zoho apps a connection can be authorized for.
     214                 *
     215                 * @param array<string,string> $extra App id => label to add. The
     216                 *                                    built-in rows cannot be changed.
     217                 */
     218                $extra = apply_filters( 'form2zoho_connection_app_choices', array() );
     219                if ( is_array( $extra ) ) {
     220                        $scopes = self::app_scopes();
     221                        foreach ( $extra as $app => $label ) {
     222                                $app = sanitize_key( (string) $app );
     223                                if ( '' !== $app && ! isset( $choices[ $app ] ) && isset( $scopes[ $app ] ) && is_string( $label ) && '' !== $label ) {
     224                                        $choices[ $app ] = $label;
     225                                }
     226                        }
     227                }
     228
     229                return $choices;
    124230        }
    125231
    … …  
    132238        private function scopes_for( array $apps ) {
    133239                if ( empty( $apps ) || in_array( 'all', $apps, true ) ) {
    134                         $apps = array_keys( self::APP_SCOPES );
    135                 }
    136 
     240                        $apps = array_keys( self::app_scopes() );
     241                }
     242
     243                $known  = self::app_scopes();
    137244                $scopes = array( self::BASE_SCOPE );
    138245                foreach ( $apps as $app ) {
    139                         if ( isset( self::APP_SCOPES[ $app ] ) ) {
    140                                 $scopes[] = self::APP_SCOPES[ $app ];
     246                        if ( isset( $known[ $app ] ) ) {
     247                                $scopes[] = $known[ $app ];
    141248                        }
    142249                }
    … …  
    165272                }
    166273
     274                $clean = self::clean_apps( $apps );
     275                if ( empty( $clean ) ) {
     276                        return new WP_Error( 'form2zoho_no_apps', __( 'Choose at least one Zoho app.', 'techwave-form-sync-for-zoho' ) );
     277                }
     278
     279                $all[ $id ]['apps'] = $clean;
     280
     281                // A connection that no longer reaches Bigin has nothing to say about
     282                // Bigin. Without this, unticking Bigin left the "Bigin org lookup
     283                // failed" warning on the Connections page for good: the lookup only
     284                // runs for a connection that covers Bigin, so nothing ever cleared it.
     285                if ( ! in_array( 'bigin', $clean, true ) && ! in_array( 'all', $clean, true ) ) {
     286                        unset( $all[ $id ]['bigin_org_error'], $all[ $id ]['bigin_org_domain'] );
     287                }
     288
     289                update_option( self::OPTION, $all, false );
     290                return true;
     291        }
     292
     293        /**
     294         * Allowlist a submitted app list, and let "all" stand on its own.
     295         *
     296         * The picker is a select-all, so ticking every app posts "all" *and* each
     297         * of the six. Keeping both would store a contradiction in waiting: "all"
     298         * already means every scope this plugin supports, including apps it has
     299         * not shipped yet, which is the entire reason it is a value rather than a
     300         * shorthand. A list that says "all, crm, books" stops meaning that the
     301         * day a seventh app is added.
     302         *
     303         * @param array<int,mixed> $apps Raw app ids from the request.
     304         * @return array<int,string> Allowlisted ids, or array( 'all' ).
     305         */
     306        private static function clean_apps( array $apps ) {
     307                $known = self::app_scopes();
    167308                $clean = array();
     309
    168310                foreach ( $apps as $app ) {
    169311                        $app = sanitize_key( $app );
    170                         if ( 'all' === $app || isset( self::APP_SCOPES[ $app ] ) ) {
     312
     313                        if ( 'all' === $app ) {
     314                                return array( 'all' );
     315                        }
     316
     317                        if ( isset( $known[ $app ] ) ) {
    171318                                $clean[] = $app;
    172319                        }
    173320                }
    174                 if ( empty( $clean ) ) {
    175                         return new WP_Error( 'form2zoho_no_apps', __( 'Choose at least one Zoho app.', 'techwave-form-sync-for-zoho' ) );
    176                 }
    177 
    178                 $all[ $id ]['apps'] = array_values( array_unique( $clean ) );
    179                 update_option( self::OPTION, $all, false );
    180                 return true;
     321
     322                return array_values( array_unique( $clean ) );
    181323        }
    182324
    … …  
    216358
    217359        /**
     360         * Whether a stored connection has finished authorising.
     361         *
     362         * A connection is saved as a draft as soon as its details are entered, and
     363         * only becomes usable when Zoho's consent screen comes back with a refresh
     364         * token. A feed pointed at a draft cannot deliver anything.
     365         *
     366         * @param mixed $conn A stored connection row.
     367         * @return bool
     368         */
     369        public static function is_authorised( $conn ) {
     370                return is_array( $conn ) && ! empty( $conn['connected'] ) && ! empty( $conn['refresh_token'] );
     371        }
     372
     373        /**
     374         * Authorised connections only, formatted for a <select> (id => label).
     375         *
     376         * @return array<string,string>
     377         */
     378        public function authorised_choices() {
     379                $out = array();
     380                foreach ( $this->all() as $id => $conn ) {
     381                        if ( self::is_authorised( $conn ) ) {
     382                                $out[ $id ] = isset( $conn['label'] ) ? $conn['label'] : $id;
     383                        }
     384                }
     385                return $out;
     386        }
     387
     388        /**
    218389         * Persist a new draft connection before the OAuth dance starts.
    219390         *
    … …  
    225396                        return new WP_Error(
    226397                                'form2zoho_no_openssl',
    227                                 __( 'This server cannot encrypt credentials: PHP\'s OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it — the plugin will not store a Zoho client secret unencrypted.', 'techwave-form-sync-for-zoho' )
     398                                __( 'This server cannot encrypt credentials: PHP\'s OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it. The plugin will not store a Zoho client secret unencrypted.', 'techwave-form-sync-for-zoho' )
    228399                        );
    229400                }
    … …  
    253424                }
    254425
    255                 $apps = array();
    256                 if ( isset( $args['apps'] ) && is_array( $args['apps'] ) ) {
    257                         foreach ( $args['apps'] as $app ) {
    258                                 $app = sanitize_key( $app );
    259                                 if ( 'all' === $app || isset( self::APP_SCOPES[ $app ] ) ) {
    260                                         $apps[] = $app;
    261                                 }
    262                         }
    263                 }
     426                $apps = self::clean_apps(
     427                        isset( $args['apps'] ) && is_array( $args['apps'] ) ? $args['apps'] : array()
     428                );
    264429                // Nothing picked is an error, not a licence to request everything.
    265430                // This used to fall back to "all", which meant an untouched picker
    … …  
    268433                        return new WP_Error( 'form2zoho_no_apps', __( 'Choose at least one Zoho app for this connection.', 'techwave-form-sync-for-zoho' ) );
    269434                }
    270                 $apps = array_values( array_unique( $apps ) );
    271 
    272435                $id  = 'conn_' . wp_generate_password( 12, false, false );
    273436                $all = $this->all();
    … …  
    301464                }
    302465
    303                 $state = wp_generate_password( 24, false, false );
    304                 set_transient( 'form2zoho_state_' . $state, $id, self::STATE_TTL );
     466                // random_bytes() rather than wp_generate_password(), which passes its
     467                // result through the `random_password` filter — a CSRF token should
     468                // not be reachable by another plugin's callback.
     469                $state = bin2hex( random_bytes( 16 ) );
     470
     471                // The user who started this authorization, so the callback can refuse
     472                // to finish it as somebody else on a site with several administrators.
     473                set_transient(
     474                        'form2zoho_state_' . $state,
     475                        array(
     476                                'connection_id' => $id,
     477                                'user_id'       => get_current_user_id(),
     478                        ),
     479                        self::STATE_TTL
     480                );
    305481
    306482                // Older connections saved before the app picker existed have no
    … …  
    331507                $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
    332508
    333                 $conn_id = $state ? get_transient( 'form2zoho_state_' . $state ) : false;
    334                 if ( ! $conn_id ) {
     509                $stored = $state ? get_transient( 'form2zoho_state_' . $state ) : false;
     510                if ( ! $stored ) {
    335511                        $this->redirect_back( 'state' );
    336512                }
    337513                delete_transient( 'form2zoho_state_' . $state );
     514
     515                // A state saved before this carried the user id is a plain id string.
     516                if ( is_array( $stored ) ) {
     517                        $conn_id = isset( $stored['connection_id'] ) ? (string) $stored['connection_id'] : '';
     518                        $started_by = isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0;
     519
     520                        if ( $started_by && get_current_user_id() !== $started_by ) {
     521                                $this->redirect_back( 'state' );
     522                        }
     523                } else {
     524                        $conn_id = (string) $stored;
     525                }
     526
     527                if ( '' === $conn_id ) {
     528                        $this->redirect_back( 'state' );
     529                }
    338530
    339531                $conn = $this->get( $conn_id );
    … …  
    384576                $apps = isset( $conn['apps'] ) && is_array( $conn['apps'] ) ? $conn['apps'] : array();
    385577                if ( ! in_array( 'bigin', $apps, true ) && ! in_array( 'all', $apps, true ) ) {
     578                        $this->forget_bigin_org( $id );
    386579                        return;
    387580                }
     581
     582                // Whatever went wrong last time was about the grant that has just
     583                // been replaced, so it is dropped before this one is asked.
     584                $this->forget_bigin_org( $id );
    388585
    389586                $client = new BiginClient( $this, new Logger(), $id );
    … …  
    408605
    409606        /**
     607         * Drop what was learned about a connection's Bigin organization.
     608         *
     609         * @param string $id Connection id.
     610         * @return void
     611         */
     612        private function forget_bigin_org( $id ) {
     613                $all = $this->all();
     614
     615                if ( ! isset( $all[ $id ] ) || ( ! isset( $all[ $id ]['bigin_org_error'] ) && ! isset( $all[ $id ]['bigin_org_domain'] ) ) ) {
     616                        return;
     617                }
     618
     619                unset( $all[ $id ]['bigin_org_error'], $all[ $id ]['bigin_org_domain'] );
     620                update_option( self::OPTION, $all, false );
     621        }
     622
     623        /**
    410624         * Store the resolved Bigin org domain segment on a connection.
    411625         *
    … …  
    487701                }
    488702
     703                // The editor loads several lists at once, and a busy form gets
     704                // submissions at once, so an expired token used to be refreshed by
     705                // every one of those requests together. Zoho allows only a few new
     706                // access tokens per refresh token in ten minutes, and a refresh it
     707                // turns away reads as a broken connection. One request refreshes
     708                // while the rest wait for its token.
     709                $deadline = microtime( true ) + (int) apply_filters( 'form2zoho_oauth_timeout', is_admin() ? 20 : 10 ) + 5;
     710
     711                // Whether this request is the one holding the lock. Only it may
     712                // release it at the end: a request that gave up waiting and refreshed
     713                // anyway would otherwise delete the row belonging to whoever is still
     714                // refreshing, and the stampede the lock prevents would start again.
     715                $holding = $this->lock_refresh( $id );
     716
     717                while ( ! $holding ) {
     718                        if ( microtime( true ) > $deadline ) {
     719                                // Waited long enough. Refresh without the lock rather than
     720                                // failing the submission.
     721                                break;
     722                        }
     723
     724                        usleep( 250000 );
     725
     726                        $token = $this->fresh_access_token( $id );
     727                        if ( '' !== $token ) {
     728                                return $token;
     729                        }
     730
     731                        $holding = $this->lock_refresh( $id );
     732                }
     733
     734                try {
     735                        // Another request may have finished refreshing between this one
     736                        // finding no token and taking the lock.
     737                        $token = $this->fresh_access_token( $id );
     738                        if ( '' !== $token ) {
     739                                return $token;
     740                        }
     741
     742                        return $this->refresh_access_token( $id );
     743                } finally {
     744                        if ( $holding ) {
     745                                $this->unlock_refresh( $id );
     746                        }
     747                }
     748        }
     749
     750        /**
     751         * Forget a cached access token Zoho has rejected.
     752         *
     753         * Only when it is still the cached one: a request answered 401 with an
     754         * old token must not throw away the new one another request just got.
     755         *
     756         * @param string $id    Connection id.
     757         * @param string $token The token that was rejected.
     758         * @return void
     759         */
     760        public function forget_access_token( $id, $token ) {
     761                if ( (string) $token === (string) $this->fresh_access_token( $id ) ) {
     762                        delete_transient( 'form2zoho_at_' . $id );
     763                }
     764        }
     765
     766        /**
     767         * The cached access token as stored now, not as this request first read it.
     768         *
     769         * Without a persistent object cache WordPress keeps each option it has
     770         * read (and each it found missing) in memory for the rest of the request,
     771         * so a token another request has just saved would never be seen here.
     772         *
     773         * @param string $id Connection id.
     774         * @return string Empty when there is none.
     775         */
     776        private function fresh_access_token( $id ) {
     777                if ( ! wp_using_ext_object_cache() ) {
     778                        wp_cache_delete( '_transient_form2zoho_at_' . $id, 'options' );
     779                        wp_cache_delete( '_transient_timeout_form2zoho_at_' . $id, 'options' );
     780                        wp_cache_delete( 'notoptions', 'options' );
     781                        wp_cache_delete( 'alloptions', 'options' );
     782                }
     783
     784                $token = get_transient( 'form2zoho_at_' . $id );
     785
     786                return is_string( $token ) ? $token : '';
     787        }
     788
     789        /**
     790         * Take the right to refresh a connection's token.
     791         *
     792         * A row inserted with INSERT IGNORE, because the options table's unique
     793         * name makes that atomic across requests, which add_option() and
     794         * transients are not. A lock left behind by a request that died is
     795         * taken over once it is older than any refresh could take.
     796         *
     797         * @param string $id Connection id.
     798         * @return bool Whether this request holds the lock.
     799         */
     800        private function lock_refresh( $id ) {
     801                global $wpdb;
     802
     803                $name = 'form2zoho_token_lock_' . $id;
     804
     805                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic lock cannot go through the options cache.
     806                $taken = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $name, (string) time() ) );
     807
     808                if ( 1 === (int) $taken ) {
     809                        return true;
     810                }
     811
     812                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
     813                $since = (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
     814
     815                if ( $since && time() - $since > 60 ) {
     816                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above.
     817                        $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, (string) $since ) );
     818                }
     819
     820                return false;
     821        }
     822
     823        /**
     824         * Give up the right to refresh a connection's token.
     825         *
     826         * @param string $id Connection id.
     827         * @return void
     828         */
     829        private function unlock_refresh( $id ) {
     830                global $wpdb;
     831
     832                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see lock_refresh().
     833                $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", 'form2zoho_token_lock_' . $id ) );
     834        }
     835
     836        /**
     837         * Ask Zoho for a new access token and cache it.
     838         *
     839         * @param string $id Connection id.
     840         * @return string|WP_Error
     841         */
     842        private function refresh_access_token( $id ) {
    489843                $conn = $this->get( $id );
    490844                if ( null === $conn || empty( $conn['refresh_token'] ) ) {
    … …  
    559913                update_option( self::OPTION, $all, false );
    560914                delete_transient( 'form2zoho_at_' . $id );
     915                $this->unlock_refresh( $id );
    561916
    562917                if ( null !== $conn ) {
    … …  
    586941                        add_query_arg(
    587942                                array(
    588                                         'page'      => 'form2zoho',
     943                                        'page'      => SettingsPage::SLUG,
    589944                                        'f2z_oauth' => rawurlencode( $status ),
    590945                                ),
  • techwave-form-sync-for-zoho/trunk/includes/Auth/OAuthClient.php

    r3649280 r3748247  
    126126
    127127                if ( is_wp_error( $response ) ) {
     128                        // Which host could not be reached; see ErrorTranslator::transport().
     129                        $response->add_data( array( 'host' => (string) wp_parse_url( $this->dc['accounts'], PHP_URL_HOST ) ) );
    128130                        return $response;
    129131                }
  • techwave-form-sync-for-zoho/trunk/includes/ContactForm7/SubmissionHandler.php

    r3649280 r3748247  
    8787                $values    = $this->collect_values();
    8888
    89                 $this->deliver_to_feeds( $form_name, $values );
     89                $this->deliver_to_feeds( $form_name, $values, $this->collect_files() );
    9090        }
    9191
    … …  
    108108                return is_array( $data ) ? $data : array();
    109109        }
     110
     111        /**
     112         * Absolute paths to whatever this submission uploaded.
     113         *
     114         * get_posted_data() carries a file field's *name* — useful as text, and
     115         * useless for sending the file anywhere. The paths live here instead, and
     116         * only for the length of the request: Contact Form 7 deletes these when it
     117         * has finished with the submission, which is why delivery is attempted
     118         * during the submission rather than queued.
     119         *
     120         * @return array<string,array<int,string>> Field id => absolute paths.
     121         */
     122        private function collect_files() {
     123                if ( ! class_exists( '\WPCF7_Submission' ) ) {
     124                        return array();
     125                }
     126
     127                $submission = \WPCF7_Submission::get_instance();
     128
     129                if ( ! $submission || ! method_exists( $submission, 'uploaded_files' ) ) {
     130                        return array();
     131                }
     132
     133                $out = array();
     134
     135                foreach ( (array) $submission->uploaded_files() as $field => $paths ) {
     136                        $real = array();
     137
     138                        // Newer Contact Form 7 hands back an array per field; older
     139                        // versions a single path.
     140                        foreach ( (array) $paths as $path ) {
     141                                $path = (string) $path;
     142
     143                                if ( '' !== $path && is_readable( $path ) ) {
     144                                        $real[] = $path;
     145                                }
     146                        }
     147
     148                        if ( array() !== $real ) {
     149                                $out[ (string) $field ] = $real;
     150                        }
     151                }
     152
     153                return $out;
     154        }
    110155}
  • techwave-form-sync-for-zoho/trunk/includes/Core/Activator.php

    r3649280 r3748247  
    2727         */
    2828        const SCHEMA_VERSION = 2;
     29
     30        /**
     31         * Transient that keeps the schema check from repeating on every request
     32         * when its option cannot be stored.
     33         */
     34        private const UPGRADE_MARKER = 'form2zoho_schema_checked';
    2935
    3036        /**
    … …  
    8490         */
    8591        public static function activate_single_site() {
     92                // Activating is an explicit "check now", so the marker maybe_upgrade()
     93                // sets must not hold the check back.
     94                delete_transient( self::UPGRADE_MARKER );
     95
    8696                self::create_log_table();
    8797
    … …  
    156166                }
    157167
     168                // This runs from the logger, which a visitor's form submission
     169                // reaches. If the option below cannot be written — a read-only
     170                // replica, a full disk, a filter holding the value — every later
     171                // submission would run dbDelta() again. The marker keeps that to
     172                // once per interval whatever happens to the option.
     173                if ( get_transient( self::UPGRADE_MARKER ) ) {
     174                        return;
     175                }
     176                set_transient( self::UPGRADE_MARKER, 1, 5 * MINUTE_IN_SECONDS );
     177
    158178                self::create_log_table();
    159179                self::ensure_secret();
  • techwave-form-sync-for-zoho/trunk/includes/Core/Deactivator.php

    r3649280 r3748247  
    2323         */
    2424        public static function deactivate() {
    25                 // Remove any pending WP-Cron events for our worker.
    26                 $timestamp = wp_next_scheduled( Dispatcher::HOOK_PROCESS );
    27                 while ( false !== $timestamp ) {
    28                         wp_unschedule_event( $timestamp, Dispatcher::HOOK_PROCESS );
    29                         $timestamp = wp_next_scheduled( Dispatcher::HOOK_PROCESS );
    30                 }
     25                // Every pending WP-Cron event for our worker and the daily log prune.
     26                // wp_unschedule_hook(), not wp_next_scheduled() in a loop: that only
     27                // finds events scheduled with no arguments, and each retry carries its
     28                // payload handle as one, so retries were left behind. Deactivation is
     29                // also the only place these are cleared: WordPress deactivates a
     30                // plugin before it can be deleted, so uninstall.php need not.
     31                wp_unschedule_hook( Dispatcher::HOOK_PROCESS );
     32                wp_unschedule_hook( Activator::PRUNE_HOOK );
    3133
    3234                // Cancel Action Scheduler jobs if that library is active.
    … …  
    3436                        as_unschedule_all_actions( Dispatcher::HOOK_PROCESS, array(), 'form2zoho' );
    3537                }
    36 
    37                 // Clear the daily log-prune event.
    38                 $prune = wp_next_scheduled( Activator::PRUNE_HOOK );
    39                 while ( false !== $prune ) {
    40                         wp_unschedule_event( $prune, Activator::PRUNE_HOOK );
    41                         $prune = wp_next_scheduled( Activator::PRUNE_HOOK );
    42                 }
    4338        }
    4439}
  • techwave-form-sync-for-zoho/trunk/includes/Core/Plugin.php

    r3649280 r3748247  
    99
    1010use Form2Zoho\Admin\Ajax;
     11use Form2Zoho\Admin\DashboardPage;
    1112use Form2Zoho\Admin\FeedsPage;
     13use Form2Zoho\Admin\NoticeGuard;
     14use Form2Zoho\Admin\LegacyPageSlugs;
    1215use Form2Zoho\Admin\FeedsRepository;
     16use Form2Zoho\Admin\ProUpsell;
    1317use Form2Zoho\Admin\SettingsPage;
    1418use Form2Zoho\Admin\DeactivationFeedback;
    … …  
    184188                        $ajax->register();
    185189                        $feeds->register();
     190                        // Overview screen, the first entry under the menu.
     191                        ( new DashboardPage( $this->services['feeds'], $this->services['connections'], $this->services['registry'], $this->services['logger'] ) )->register();
    186192                        // Optional "why are you deactivating?" prompt on the Plugins
    187193                        // screen. Hooks nothing outside plugins.php and its own endpoint,
    188194                        // and sends nothing unless the person presses the send button.
    189195                        ( new DeactivationFeedback() )->register();
     196                        // Other plugins' notices do not appear on this plugin's screens.
     197                        // See the class for what that costs, and why nothing of this
     198                        // plugin's own goes with them.
     199                        ( new NoticeGuard() )->register();
     200                        // The screens' old form2zoho addresses, from bookmarks and emails
     201                        // sent before the rename, land on the renamed screens.
     202                        ( new LegacyPageSlugs() )->register();
     203                        // One "Upgrade to Pro" link at the foot of the plugin's own
     204                        // submenu. It removes itself once the add-on is installed.
     205                        ( new ProUpsell() )->register();
    190206                        $this->services['settings']  = $settings;
    191207                        $this->services['ajax']      = $ajax;
    … …  
    194210
    195211                // OAuth redirect handler runs on the public side too (callback lands on admin-post).
    196                 add_action( 'admin_post_form2zoho_oauth_callback', array( $this->services['connections'], 'handle_oauth_callback' ) );
     212                add_action( 'admin_post_' . ConnectionManager::OAUTH_CALLBACK, array( $this->services['connections'], 'handle_oauth_callback' ) );
     213
     214                // The action the callback used to use, before it was renamed to say
     215                // which plugin it belongs to. Answered as well, not instead: someone
     216                // who pressed Connect just before updating is at Zoho's consent screen
     217                // right now holding the old URI, and the alternative to these two
     218                // lines is that their approval lands on a 400 and the connection they
     219                // were halfway through has to be started again.
     220                //
     221                // It does NOT save anyone from having to update the Authorized
     222                // Redirect URI in their Zoho console — authorization requests carry
     223                // the new URI from now on, and Zoho matches it against the registered
     224                // list. This only catches what was already in flight.
     225                add_action( 'admin_post_' . ConnectionManager::OAUTH_CALLBACK_LEGACY, array( $this->services['connections'], 'handle_oauth_callback' ) );
    197226
    198227                // Privacy exporter / eraser registration.
    … …  
    237266                $this->services['sureforms_submissions'] = new SureFormsSubmissionHandler( $this->services['feeds'], $this->services['dispatcher'], $this->services['logger'] );
    238267                $this->services['sureforms_submissions']->register();
     268
     269                /**
     270                 * Fires once every service exists and every hook above is registered.
     271                 *
     272                 * The moment an add-on should do its own wiring. Reach the shared
     273                 * collaborators through the container rather than building new ones,
     274                 * so a submission an add-on queues goes through the same dispatcher,
     275                 * logger and feed store as everything else:
     276                 *
     277                 *     add_action( 'form2zoho_loaded', function ( $plugin ) {
     278                 *         $dispatcher = $plugin->get( 'dispatcher' );
     279                 *     } );
     280                 *
     281                 * Service ids: logger, connections, registry, dispatcher, feeds, and
     282                 * in wp-admin also settings, ajax, feedspage. Anything else returns
     283                 * null.
     284                 *
     285                 * Runs on plugins_loaded, so it is early enough to add filters and
     286                 * late enough that get() never returns null for a service that
     287                 * exists on this request.
     288                 *
     289                 * @param Plugin $plugin The container.
     290                 */
     291                do_action( 'form2zoho_loaded', $this );
    239292        }
    240293
    … …  
    245298         */
    246299        public function prune_logs() {
    247                 // Gated by the "Delete log entries automatically" toggle, which is on
    248                 // by default: log rows can carry submitted field values, so keeping
    249                 // them forever unless someone opts out is the wrong default.
     300                // Gated by the "Delete log entries automatically" toggle, which is off
     301                // by default (see Settings::BOOLEANS): nothing is deleted until a site
     302                // chooses how long to keep its log.
    250303                if ( ! Settings::is_on( 'form2zoho_log_retention_enabled' ) ) {
    251304                        return;
    … …  
    295348                $lines[] = '';
    296349                /* translators: %s: URL to the plugin's Logs screen. */
    297                 $lines[] = sprintf( __( 'View the full log entry: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=form2zoho&tab=logs' ) );
     350                $lines[] = sprintf( __( 'View the full log entry: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=' . SettingsPage::SLUG . '&tab=logs' ) );
    298351
    299352                // Drop the lines that were conditionally left empty above. 'strlen'
    … …  
    358411                $lines[] = '';
    359412                /* translators: %s: URL to the plugin's Connections screen. */
    360                 $lines[] = sprintf( __( 'Check the connection here: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=form2zoho&tab=connections' ) );
     413                $lines[] = sprintf( __( 'Check the connection here: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=' . SettingsPage::SLUG . '&tab=connections' ) );
    361414
    362415                // Drop the lines that were conditionally left empty above. 'strlen'
    … …  
    404457                $lines[] = '';
    405458                /* translators: %s: URL to the plugin's Connections screen. */
    406                 $lines[] = sprintf( __( 'Manage connections here: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=form2zoho&tab=connections' ) );
     459                $lines[] = sprintf( __( 'Manage connections here: %s', 'techwave-form-sync-for-zoho' ), admin_url( 'admin.php?page=' . SettingsPage::SLUG . '&tab=connections' ) );
    407460
    408461                // Drop the lines that were conditionally left empty above. 'strlen'
    … …  
    457510         * @param string $subject Subject line.
    458511         * @param string $body    Plain-text body.
    459          * @return bool Whether wp_mail() accepted the message.
     512         * @return bool Whether the mailer accepted the message.
    460513         */
    461514        private function send_now( $to, $subject, $body ) {
    462                 $sent = wp_mail( $to, $subject, $body );
    463                 if ( ! $sent ) {
     515                // Through wp_mail(), so an SMTP plugin sends it. See Support\PhpMail.
     516                $sent = \Form2Zoho\Support\PhpMail::send( $to, $subject, $body );
     517
     518                if ( is_wp_error( $sent ) ) {
    464519                        $this->services['logger']->warning(
    465520                                sprintf(
    466                                         /* translators: %s: recipient email address. */
    467                                         __( 'A notification email to %s could not be sent. WordPress refused to hand it to a mailer — this site most likely has no working mail transport (many managed hosts need an SMTP plugin). The event itself was still recorded above.', 'techwave-form-sync-for-zoho' ),
    468                                         $to
     521                                        /* translators: 1: recipient email address. 2: the mailer's own reason, e.g. "Could not instantiate mail function." */
     522                                        __( 'A notification email to %1$s could not be sent. Reason: %2$s The event itself was still recorded above.', 'techwave-form-sync-for-zoho' ),
     523                                        $to,
     524                                        $sent->get_error_message()
    469525                                ),
    470526                                array( 'status' => 'suppressed' )
    471527                        );
    472                 }
    473                 return (bool) $sent;
     528
     529                        return false;
     530                }
     531
     532                return true;
    474533        }
    475534}
  • techwave-form-sync-for-zoho/trunk/includes/Elementor/SubmissionHandler.php

    r3649280 r3748247  
    8888                $values    = $this->collect_values( $record );
    8989
    90                 $this->deliver_to_feeds( $form_name, $values );
     90                $this->deliver_to_feeds( $form_name, $values, $this->collect_files( $record ) );
    9191        }
    9292
    … …  
    105105                return $out;
    106106        }
     107
     108        /**
     109         * Absolute paths to this submission's uploads, keyed by field id.
     110         *
     111         * Elementor keeps them on the record beside the values. An upload set to
     112         * "attach to email" is deleted once the form's actions have run, so its
     113         * file can only be read now, while the submission is being handled.
     114         *
     115         * @param object $record Elementor form record.
     116         * @return array<string,array<int,string>>
     117         */
     118        private function collect_files( $record ) {
     119                $out = array();
     120
     121                foreach ( (array) $record->get( 'files' ) as $id => $file ) {
     122                        $paths = \Form2Zoho\Support\UploadedFiles::paths( is_array( $file ) && isset( $file['path'] ) ? $file['path'] : array() );
     123
     124                        if ( array() !== $paths ) {
     125                                $out[ (string) $id ] = $paths;
     126                        }
     127                }
     128
     129                return $out;
     130        }
    107131}
  • techwave-form-sync-for-zoho/trunk/includes/Forminator/SubmissionHandler.php

    r3649280 r3748247  
    2020
    2121use Form2Zoho\Support\BaseSubmissionHandler;
     22use Form2Zoho\Support\UploadedFiles;
    2223
    2324defined( 'ABSPATH' ) || exit;
    … …  
    4748         */
    4849        private $processed = array();
     50
     51        /**
     52         * This request's submission, as Forminator itself saved it, by form id:
     53         * array( 'entry_id' => int, 'values' => array, 'files' => array ).
     54         *
     55         * @var array<string,array>
     56         */
     57        private $submitted = array();
    4958
    5059        /**
    … …  
    6170         */
    6271        public function register() {
     72                // Heard first, while the submission is being saved: its own field
     73                // data, with every upload already moved into the uploads folder.
     74                // Fires whether or not the site stores submissions, which is what
     75                // the entry read-back below cannot promise.
     76                add_action( 'forminator_custom_form_submit_before_set_fields', array( $this, 'remember' ), 10, 3 );
     77
    6378                add_action( 'forminator_form_after_save_entry', array( $this, 'handle' ), 20, 2 );
    64                 add_action( 'forminator_custom_form_submit_response', array( $this, 'handle' ), 20, 2 );
    6579                add_action( 'forminator_form_after_handle_submit', array( $this, 'handle' ), 20, 2 );
     80        }
     81
     82        /**
     83         * Keep this submission's values and uploaded files for handle().
     84         *
     85         * Forminator passes no entry id to the hooks handle() listens on, so
     86         * without this the values were read back from "the newest entry of this
     87         * form" — which is no entry at all when submissions are not stored or
     88         * the submission was marked as spam, and possibly someone else's when two
     89         * arrive in the same second. Either way the upload was lost and a mapped
     90         * Zoho file field was sent without its file.
     91         *
     92         * @param object $entry      Forminator_Form_Entry_Model being saved.
     93         * @param int    $form_id    Form id.
     94         * @param array  $field_data Items of array( 'name' => ..., 'value' => ... ).
     95         * @return void
     96         */
     97        public function remember( $entry, $form_id, $field_data ) {
     98                try {
     99                        $status = is_object( $entry ) && isset( $entry->status ) ? (string) $entry->status : '';
     100
     101                        // A saved draft or an abandoned form is not a submission.
     102                        if ( in_array( $status, array( 'draft', 'abandoned' ), true ) ) {
     103                                return;
     104                        }
     105
     106                        $values = array();
     107                        $files  = array();
     108
     109                        foreach ( is_array( $field_data ) ? $field_data : array() as $item ) {
     110                                $name = is_array( $item ) && isset( $item['name'] ) ? (string) $item['name'] : '';
     111
     112                                if ( '' === $name || '_' === $name[0] || 0 === stripos( $name, 'password-' ) ) {
     113                                        continue;
     114                                }
     115
     116                                $value = $item['value'] ?? '';
     117
     118                                if ( is_array( $value ) && isset( $value['file'] ) && is_array( $value['file'] ) ) {
     119                                        $url  = $value['file']['file_url'] ?? '';
     120                                        $path = $value['file']['file_path'] ?? '';
     121
     122                                        $values[ $name ] = is_array( $url ) ? implode( ', ', array_map( 'strval', array_filter( $url, 'is_scalar' ) ) ) : (string) $url;
     123
     124                                        $found = UploadedFiles::paths( $path );
     125                                        if ( array() !== $found ) {
     126                                                $files[ $name ] = $found;
     127                                        }
     128                                        continue;
     129                                }
     130
     131                                self::put( $values, $name, $value );
     132                        }
     133
     134                        $this->submitted[ (string) $form_id ] = array(
     135                                'entry_id' => is_object( $entry ) && isset( $entry->entry_id ) ? (int) $entry->entry_id : 0,
     136                                'values'   => $values,
     137                                'files'    => $files,
     138                        );
     139                } catch ( \Throwable $e ) {
     140                        // handle() still has the entry read-back to fall back on.
     141                        unset( $e );
     142                }
    66143        }
    67144
    … …  
    115192
    116193                $form_name = $this->form_title( $form_id );
    117                 $values    = $this->collect_values( $form_id, $entry_id );
     194                $submitted = $this->submitted[ (string) $form_id ] ?? null;
     195
     196                if ( null !== $submitted && array() !== $submitted['values'] ) {
     197                        $this->deliver_to_feeds( $form_name, $submitted['values'], $submitted['files'] );
     198                        return;
     199                }
     200
     201                $entry_id = $entry_id ? $entry_id : (int) ( $submitted['entry_id'] ?? 0 );
     202                $values   = $this->collect_values( $form_id, $entry_id );
    118203
    119204                $this->deliver_to_feeds( $form_name, $values );
    … …  
    159244
    160245        /**
     246         * Whether a posted key is shaped like one of Forminator's own field ids.
     247         *
     248         * Forminator names every field "&lt;type&gt;-&lt;n&gt;" — name-1, email-1,
     249         * upload-1 — so anything else in the request is not a field of the form
     250         * that was submitted. Taking every posted key let a request carry values
     251         * for names the form never had, which the mapping would then send to Zoho
     252         * as if the visitor had typed them.
     253         *
     254         * @param string $key Posted key.
     255         * @return bool
     256         */
     257        private static function is_field_key( $key ) {
     258                // An upload's value is Forminator's own record of the file it saved,
     259                // never something the request carries, so a posted "upload-1" can
     260                // only be an attempt to name a file this submission did not send.
     261                if ( 0 === stripos( $key, 'upload-' ) ) {
     262                        return false;
     263                }
     264
     265                // Forminator deliberately keeps passwords out of the entry; the POST
     266                // fallback must not put them back.
     267                if ( 0 === stripos( $key, 'password-' ) ) {
     268                        return false;
     269                }
     270
     271                return 1 === preg_match( '/^[a-z]+(?:-[a-z]+)*-\d+(?:-[a-z0-9_-]+)?$/i', $key );
     272        }
     273
     274        /**
    161275         * Read back the field values for the entry that was just saved, straight
    162276         * from Forminator's own entry meta.
    … …  
    177291                                } else {
    178292                                        // No usable entry id was passed — fall back to the most
    179                                         // recent entry for this form.
    180                                         $model   = new \Forminator_Form_Entry_Model();
    181                                         $entries = $model->get_entries(
    182                                                 $form_id,
    183                                                 array(
    184                                                         'order' => 'DESC',
    185                                                         'limit' => 1,
    186                                                 )
    187                                         );
    188                                         if ( ! empty( $entries ) ) {
    189                                                 $entry = new \Forminator_Form_Entry_Model( $entries[0]->entry_id );
    190                                         }
     293                                        // recent entry for this form. The model has no
     294                                        // get_entries(); calling it threw, so every submission
     295                                        // fell through to the POST data below, which has no
     296                                        // uploads in it.
     297                                        $entry = \Forminator_Form_Entry_Model::get_latest_entry_by_form_id( (int) $form_id );
    191298                                }
    192299
    193300                                if ( $entry ) {
    194                                         foreach ( (array) ( $entry->meta_data ?? array() ) as $meta ) {
    195                                                 $name  = is_object( $meta ) ? ( $meta->name ?? '' ) : ( $meta['name'] ?? '' );
    196                                                 $value = is_object( $meta ) ? ( $meta->value ?? '' ) : ( $meta['value'] ?? '' );
    197                                                 if ( '' !== (string) $name ) {
    198                                                         $out[ (string) $name ] = $value;
     301                                        // Keyed by field name: meta_data[ 'upload-1' ] =
     302                                        // array( 'id' => ..., 'value' => ... ). There is no 'name'
     303                                        // inside each item, so reading one skipped every field.
     304                                        foreach ( (array) ( $entry->meta_data ?? array() ) as $name => $meta ) {
     305                                                $name = (string) $name;
     306                                                // Forminator's own bookkeeping (_forminator_user_ip and
     307                                                // the like), not something the visitor filled in.
     308                                                if ( '' === $name || '_' === $name[0] ) {
     309                                                        continue;
    199310                                                }
     311                                                $value = is_object( $meta ) ? ( $meta->value ?? '' ) : ( is_array( $meta ) ? ( $meta['value'] ?? '' ) : '' );
     312                                                // An upload is stored as Forminator's record of it,
     313                                                // array( 'file' => array( 'file_url' => ..., 'file_path' => ... ) ).
     314                                                // Its URL is the value, as it is for other form
     315                                                // plugins, and UploadedFiles finds the file from it.
     316                                                if ( is_array( $value ) && isset( $value['file']['file_url'] ) ) {
     317                                                        $value = $value['file']['file_url'];
     318                                                        $out[ $name ] = $value;
     319                                                        continue;
     320                                                }
     321                                                self::put( $out, $name, $value );
    200322                                        }
    201323                                }
    … …  
    232354
    233355                        foreach ( $post as $key => $value ) {
    234                                 if ( is_string( $key ) && ! is_array( $value ) && ! isset( $out[ $key ] ) && preg_match( '/^[a-z0-9_-]+$/i', $key ) ) {
     356                                if ( is_string( $key ) && ! is_array( $value ) && ! isset( $out[ $key ] ) && self::is_field_key( $key ) ) {
    235357                                        $out[ $key ] = sanitize_text_field( $value );
    236358                                }
    … …  
    239361
    240362                return $out;
     363        }
     364
     365        /**
     366         * Add one field's value to the values handed to the feeds.
     367         *
     368         * A multi-part field (name, address) or a list of choices also offers
     369         * its parts under the flat keys the POST data used ("name-1-first-name"),
     370         * and the field itself becomes their text.
     371         *
     372         * @param array<string,mixed> $out   Values so far (by reference).
     373         * @param string              $name  Field name.
     374         * @param mixed               $value Its value.
     375         * @return void
     376         */
     377        private static function put( array &$out, $name, $value ) {
     378                if ( is_array( $value ) ) {
     379                        $parts = array();
     380                        foreach ( $value as $part_key => $part ) {
     381                                if ( is_scalar( $part ) && '' !== trim( (string) $part ) ) {
     382                                        $parts[] = (string) $part;
     383                                        if ( is_string( $part_key ) ) {
     384                                                $out[ $name . '-' . $part_key ] = (string) $part;
     385                                        }
     386                                }
     387                        }
     388                        $value = implode( array_values( $value ) === $value ? ', ' : ' ', $parts );
     389                }
     390
     391                $out[ (string) $name ] = $value;
    241392        }
    242393
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/AbstractIntegration.php

    r3649280 r3748247  
    1111use Form2Zoho\Logging\Logger;
    1212use Form2Zoho\Support\FieldMapper;
     13use Form2Zoho\Support\Settings;
    1314
    1415defined( 'ABSPATH' ) || exit;
    … …  
    4748        /**
    4849         * Most integrations are not org-scoped.
     50         *
     51         * @return bool
     52         */
     53        /**
     54         * Whether this destination is reached through a saved Zoho connection.
     55         *
     56         * True for everything Zoho, which is everything the free plugin ships. An
     57         * add-on may offer a destination that is simply a URL — nothing to
     58         * authorise, no account to pick — and the editor would otherwise insist on
     59         * a Zoho account it will never use, and refuse to load the rest of the
     60         * step until one was chosen.
     61         *
     62         * Not on IntegrationInterface on purpose. Adding a method there would
     63         * break any integration implementing the interface directly rather than
     64         * extending this class, for a question almost nothing needs to answer
     65         * differently. Callers ask with method_exists(), the same way they already
     66         * ask about get_departments().
     67         *
     68         * @return bool
     69         */
     70        public function needs_connection() {
     71                return true;
     72        }
     73
     74        /**
     75         * Whether this destination needs an organisation chosen (Books, Desk).
    4976         *
    5077         * @return bool
    … …  
    104131                }
    105132                return $new;
     133        }
     134
     135        /**
     136         * Request-level options an add-on asked for, for this one delivery.
     137         *
     138         * Some of what Zoho accepts is not a field of the record but a key beside
     139         * it in the request — which automations to run, which assignment rule to
     140         * apply — and a field mapping has no way to say those. An add-on copies
     141         * them into the job as settings['request_extras'] through
     142         * form2zoho_build_job, and each client passes on only the keys its own
     143         * endpoint documents, so nothing an add-on writes here can reach Zoho
     144         * unchecked.
     145         *
     146         * @param array $settings Job settings.
     147         * @return array
     148         */
     149        protected static function request_extras( array $settings ) {
     150                return isset( $settings['request_extras'] ) && is_array( $settings['request_extras'] )
     151                        ? $settings['request_extras']
     152                        : array();
     153        }
     154
     155        /**
     156         * An email address as the log may keep it.
     157         *
     158         * Campaigns and Marketing Automation have no record id — a contact is
     159         * keyed by its address — so the address is what the timeline shows. That
     160         * made it the one submitted value stored whatever "Store submitted
     161         * values" said, while the setting promises the opposite. With the setting
     162         * off the address is masked: enough to recognise a row, not a copy of
     163         * somebody's address in a table the admin asked to keep clean.
     164         *
     165         * @param string $email The address.
     166         * @return string
     167         */
     168        protected static function loggable_email( $email ) {
     169                $email = trim( (string) $email );
     170
     171                if ( '' === $email || Settings::is_on( 'form2zoho_store_form_values' ) ) {
     172                        return $email;
     173                }
     174
     175                $at = strrpos( $email, '@' );
     176
     177                if ( false === $at || 0 === $at ) {
     178                        return '***';
     179                }
     180
     181                return substr( $email, 0, 1 ) . '***' . substr( $email, $at );
     182        }
     183
     184        /**
     185         * Where a Zoho field's value came from, named as the editor names it.
     186         *
     187         * For an error message about one field: knowing a value is wrong helps
     188         * nobody who then has to guess which of a dozen mapping rows produced it.
     189         *
     190         * @param array  $settings   Feed settings, with the mapping.
     191         * @param string $zoho_field The Zoho field name the value was sent under.
     192         * @return string The form field id, or '' when it was not a form field.
     193         */
     194        protected static function mapped_source( array $settings, $zoho_field ) {
     195                $mapping = isset( $settings['mapping'] ) && is_array( $settings['mapping'] ) ? $settings['mapping'] : array();
     196                $row     = isset( $mapping[ $zoho_field ] ) && is_array( $mapping[ $zoho_field ] ) ? $mapping[ $zoho_field ] : array();
     197                $type    = isset( $row['type'] ) ? (string) $row['type'] : 'field';
     198
     199                if ( 'field' !== $type || empty( $row['value'] ) || is_array( $row['value'] ) ) {
     200                        return '';
     201                }
     202
     203                return (string) $row['value'];
    106204        }
    107205
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/BiginIntegration.php

    r3649280 r3748247  
    9898                }
    9999
    100                 $dupes = array();
    101                 if ( ! empty( $settings['dedupe_enabled'] ) && ! empty( $settings['dedupe_fields'] ) ) {
    102                         $dupes = array_filter( array_map( 'trim', explode( ',', (string) $settings['dedupe_fields'] ) ) );
     100                // Duplicate handling on means an upsert whether or not any unique
     101                // fields were ticked. With none, Bigin applies its own duplicate check.
     102                $upsert = ! empty( $settings['dedupe_enabled'] );
     103                $dupes  = array();
     104                if ( $upsert && ! empty( $settings['dedupe_fields'] ) ) {
     105                        $dupes = array_values( array_filter( array_map( 'trim', explode( ',', (string) $settings['dedupe_fields'] ) ) ) );
    103106                }
    104107
    105108                $client = $this->client( $settings );
    106                 $result = $client->create_record( $module, $record, $dupes );
     109                $result = $client->create_record( $module, $record, $dupes, $upsert, self::request_extras( $settings ) );
    107110
    108111                $outcome = $this->interpret( $result );
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/BooksIntegration.php

    r3649280 r3748247  
    6161         * fetched, so it cannot fail — hence no WP_Error in the return type.
    6262         *
    63          * @param array $context connection_id and optional org_id.
     63         * @param array $context connection_id, optional org_id, and fresh to
     64         *                       skip the cached custom modules.
    6465         * @return array<int,array>
    6566         */
    6667        public function get_modules( array $context ) {
    67                 return $this->client( $context )->modules();
     68                // The organisation is what makes the custom modules reachable — they
     69                // belong to it, not to the account — so it is passed on rather than
     70                // used only to build the client.
     71                $org_id = isset( $context['org_id'] ) ? (string) $context['org_id'] : '';
     72
     73                return $this->client( $context )->modules( $org_id, ! empty( $context['fresh'] ) );
    6874        }
    6975
    … …  
    7985                        return new WP_Error( 'form2zoho_no_module', __( 'Choose a Books resource first.', 'techwave-form-sync-for-zoho' ) );
    8086                }
    81                 return $this->client( $context )->fields( $module );
     87
     88                $org_id = isset( $context['org_id'] ) ? (string) $context['org_id'] : '';
     89
     90                return $this->client( $context )->fields( $module, $org_id, ! empty( $context['fresh'] ) );
    8291        }
    8392
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/CampaignsIntegration.php

    r3649280 r3748247  
    163163                $subscription = isset( $settings['campaigns_subscription_type'] ) ? strtolower( trim( (string) $settings['campaigns_subscription_type'] ) ) : '';
    164164
     165                // Zoho keys a contact by its email address and refuses every call
     166                // without a real one — "Invalid Contact Email address", or "Pattern
     167                // doesn't Match" from the list add. Both name the format, neither
     168                // names the mapping row that produced it, so the row is named here
     169                // before anything is sent.
     170                $email_row = $this->email_row( $record );
     171                if ( '' !== $email_row['value'] && ! is_email( $email_row['value'] ) ) {
     172                        $source = self::mapped_source( $settings, $email_row['key'] );
     173
     174                        return self::with_sent_fields(
     175                                new WP_Error(
     176                                        'form2zoho_bad_email',
     177                                        '' !== $source
     178                                                ? sprintf(
     179                                                        /* translators: 1: Zoho field name, 2: the form field it is mapped to. */
     180                                                        __( 'The value mapped to "%1$s" is not an email address, so Zoho refused it. It comes from the form field "%2$s". Map the form\'s email field there instead.', 'techwave-form-sync-for-zoho' ),
     181                                                        $email_row['key'],
     182                                                        $source
     183                                                )
     184                                                : sprintf(
     185                                                        /* translators: %s: Zoho field name. */
     186                                                        __( 'The value mapped to "%s" is not an email address, so Zoho refused it.', 'techwave-form-sync-for-zoho' ),
     187                                                        $email_row['key']
     188                                                ),
     189                                        array( 'email_field' => $email_row['key'] )
     190                                ),
     191                                $record
     192                        );
     193                }
     194
    165195                $client = $this->client( $settings );
    166196
    … …  
    170200                        $result = $client->unsubscribe( $listkey, $record, $topic_id );
    171201                } else {
     202                        // "Right away", the default: the contact is put in the list first,
     203                        // directly, so a list with its sign-up form on does not hold it
     204                        // back until a confirmation email is clicked. listsubscribe then
     205                        // writes the other fields and the topics for a contact already
     206                        // in the list. "After they confirm" leaves that to Zoho.
     207                        if ( ! isset( $settings['campaigns_list_add'] ) || 'confirm' !== $settings['campaigns_list_add'] ) {
     208                                $email = $this->find_email( $record );
     209                                if ( '' === $email ) {
     210                                        return self::with_sent_fields(
     211                                                new WP_Error( 'form2zoho_no_email', __( 'Map a form field to the contact email. Zoho needs it to add the contact to the list.', 'techwave-form-sync-for-zoho' ) ),
     212                                                $record
     213                                        );
     214                                }
     215
     216                                // Best effort. Zoho refuses this call for reasons that say
     217                                // nothing about the contact's own details — the contact is in the
     218                                // list already, the account's bulk-add limit is reached — and
     219                                // aborting here threw away a submission listsubscribe would
     220                                // have taken. The reason is logged, then the fields are sent.
     221                                $added = $this->interpret( $client->add_to_list( $listkey, $email ) );
     222                                if ( is_wp_error( $added ) ) {
     223                                        $this->logger->warning(
     224                                                sprintf(
     225                                                        /* translators: %s: reason Zoho gave. */
     226                                                        __( 'The submitted fields were sent, but the contact could not be added to the mailing list directly: %s', 'techwave-form-sync-for-zoho' ),
     227                                                        $added->get_error_message()
     228                                                ),
     229                                                array( 'status' => 'suppressed' ),
     230                                                'campaigns',
     231                                                isset( $settings['form_id'] ) ? (string) $settings['form_id'] : '',
     232                                                isset( $settings['feed_id'] ) ? (string) $settings['feed_id'] : ''
     233                                        );
     234                                }
     235                        }
     236
    172237                        $result = $client->subscribe( $listkey, $record, $topic_id );
    173238                }
    … …  
    184249                $email = $this->find_email( $record );
    185250                if ( '' !== $email ) {
    186                         $outcome['id'] = $email;
     251                        $outcome['id'] = self::loggable_email( $email );
     252                }
     253
     254                // On "Right away" this plugin puts the contact in the list itself, a
     255                // moment earlier, so Zoho then answers the fields call with "this
     256                // email address already exists in the list" — its normal wording for
     257                // an update, and alarming under a green Success row that also says
     258                // the record was created. Said plainly instead, and counted as the
     259                // update it is.
     260                if ( 'unsubscribed' !== $subscription
     261                        && isset( $outcome['zoho_message'] )
     262                        && preg_match( '/already exist/i', (string) $outcome['zoho_message'] ) ) {
     263                        $outcome['action']       = 'updated';
     264                        $outcome['zoho_message'] = '';
     265                        $outcome['message']      = '' !== $email
     266                                /* translators: %s: the contact's email address. */
     267                                ? sprintf( __( 'This contact was already in the mailing list, so Zoho updated it with the submitted fields (%s).', 'techwave-form-sync-for-zoho' ), self::loggable_email( $email ) )
     268                                : __( 'This contact was already in the mailing list, so Zoho updated it with the submitted fields.', 'techwave-form-sync-for-zoho' );
     269                }
     270
     271                // Otherwise the log said "Record created in Zoho" for a feed whose
     272                // whole job is to take the contact off the list.
     273                if ( 'unsubscribed' === $subscription ) {
     274                        $outcome['message'] = '' !== $email
     275                                /* translators: %s: the contact's email address. */
     276                                ? sprintf( __( 'Contact unsubscribed in Zoho (%s).', 'techwave-form-sync-for-zoho' ), self::loggable_email( $email ) )
     277                                : __( 'Contact unsubscribed in Zoho.', 'techwave-form-sync-for-zoho' );
    187278                }
    188279
    … …  
    191282                // exists. Best-effort: a tag that fails to associate (e.g. it was
    192283                // deleted in Zoho after this feed was saved) doesn't fail the whole
    193                 // submission, since the contact itself was already saved.
     284                // submission, since the contact itself was already saved. It is
     285                // logged, though: the answer was thrown away, so a feed whose tags
     286                // never arrived looked exactly like one whose tags did.
     287                //
     288                // Not for an unsubscribe, which takes the contact off the list. The
     289                // tags were being applied there too, tagging somebody on their way
     290                // out as if they had just signed up.
    194291                $tags = isset( $settings['campaigns_tag'] ) && is_array( $settings['campaigns_tag'] ) ? array_filter( array_map( 'trim', $settings['campaigns_tag'] ) ) : array();
    195                 if ( '' !== $email ) {
     292                if ( '' !== $email && 'unsubscribed' !== $subscription ) {
    196293                        foreach ( $tags as $tag_name ) {
    197                                 $client->associate_tag( $tag_name, $email );
     294                                $tagged = $this->interpret( $client->associate_tag( $tag_name, $email ) );
     295
     296                                if ( is_wp_error( $tagged ) ) {
     297                                        $this->logger->warning(
     298                                                sprintf(
     299                                                        /* translators: 1: tag name, 2: reason Zoho gave. */
     300                                                        __( 'The tag "%1$s" was not attached to the contact: %2$s', 'techwave-form-sync-for-zoho' ),
     301                                                        $tag_name,
     302                                                        $tagged->get_error_message()
     303                                                ),
     304                                                array( 'status' => 'suppressed' ),
     305                                                'campaigns',
     306                                                isset( $settings['form_id'] ) ? (string) $settings['form_id'] : '',
     307                                                isset( $settings['feed_id'] ) ? (string) $settings['feed_id'] : ''
     308                                        );
     309                                }
    198310                        }
    199311                }
    … …  
    223335                        return $result;
    224336                }
    225                 $status = isset( $result['status'] ) ? (string) $result['status'] : '';
    226                 if ( 'success' === $status ) {
     337                // Zoho answers some calls flat and others wrapped in "response",
     338                // on the same endpoints — the field lists already read both shapes.
     339                // Read flat only, and a wrapped success was reported as a failure:
     340                // for the direct list-add that also stopped the fields being sent.
     341                $body = ( isset( $result['response'] ) && is_array( $result['response'] ) ) ? $result['response'] : $result;
     342
     343                $status = isset( $body['status'] ) ? strtolower( trim( (string) $body['status'] ) ) : '';
     344                $code   = isset( $body['code'] ) ? trim( (string) $body['code'] ) : '';
     345
     346                // Code 0 is Zoho's "this worked", and some answers carry only that.
     347                if ( 'success' === $status || ( '' === $status && '0' === $code ) ) {
    227348                        // No id here on purpose: the subscribe response has no record
    228349                        // id, and the listkey it echoes back is the mailing list's
    … …  
    237358                                'id'           => '',
    238359                                'action'       => 'created',
    239                                 'zoho_message' => isset( $result['message'] ) ? (string) $result['message'] : '',
     360                                'zoho_message' => isset( $body['message'] ) ? (string) $body['message'] : '',
    240361                        );
    241362                }
    242                 $message = isset( $result['message'] ) ? (string) $result['message'] : __( 'Unexpected Campaigns response.', 'techwave-form-sync-for-zoho' );
     363
     364                $message = isset( $body['message'] ) && '' !== $body['message']
     365                        ? (string) $body['message']
     366                        : __( 'Unexpected Campaigns response.', 'techwave-form-sync-for-zoho' );
     367
     368                // Zoho's numbered reason, which its documentation lists and its
     369                // support asks for.
     370                if ( '' !== $code ) {
     371                        $message = sprintf(
     372                                /* translators: 1: Zoho's message, 2: Zoho's error code. */
     373                                __( '%1$s (Zoho code %2$s)', 'techwave-form-sync-for-zoho' ),
     374                                $message,
     375                                $code
     376                        );
     377                }
     378
    243379                return new WP_Error( 'form2zoho_campaigns_rejected', $message, $result );
    244380        }
    … …  
    256392         */
    257393        private function find_email( array $record ) {
     394                $row = $this->email_row( $record );
     395
     396                return $row['value'];
     397        }
     398
     399        /**
     400         * The record entry holding the email address, and which key it is under.
     401         *
     402         * The first key containing "email" used to win outright, so a feed that
     403         * also maps something like "Email Opt-in" could send that checkbox's
     404         * value to Zoho as the address, depending only on the order the rows
     405         * happened to be in. A key holding a real address is preferred now; the
     406         * first non-empty candidate is still returned when none holds one, so a
     407         * wrong value is reported rather than read as "nothing mapped".
     408         *
     409         * @param array $record Mapped display-name => value pairs.
     410         * @return array{key:string,value:string}
     411         */
     412        private function email_row( array $record ) {
     413                $candidates = array();
     414
     415                if ( isset( $record['Contact Email'] ) && ! is_array( $record['Contact Email'] ) ) {
     416                        $candidates['Contact Email'] = trim( (string) $record['Contact Email'] );
     417                }
     418
    258419                foreach ( $record as $key => $value ) {
    259                         if ( false !== stripos( (string) $key, 'email' ) && '' !== trim( (string) $value ) ) {
    260                                 return trim( (string) $value );
     420                        if ( is_string( $key ) && ! is_array( $value ) && false !== stripos( $key, 'email' ) ) {
     421                                $candidates[ $key ] = trim( (string) $value );
    261422                        }
    262423                }
    263                 return '';
     424
     425                $candidates = array_filter(
     426                        $candidates,
     427                        static function ( $value ) {
     428                                return '' !== $value;
     429                        }
     430                );
     431
     432                foreach ( $candidates as $key => $value ) {
     433                        if ( is_email( $value ) ) {
     434                                return array( 'key' => $key, 'value' => $value );
     435                        }
     436                }
     437
     438                foreach ( $candidates as $key => $value ) {
     439                        return array( 'key' => $key, 'value' => $value );
     440                }
     441
     442                return array( 'key' => '', 'value' => '' );
    264443        }
    265444}
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/CrmIntegration.php

    r3649280 r3748247  
    8989                }
    9090
    91                 $dupes = array();
    92                 if ( ! empty( $settings['dedupe_enabled'] ) && ! empty( $settings['dedupe_fields'] ) ) {
    93                         $dupes = array_filter( array_map( 'trim', explode( ',', (string) $settings['dedupe_fields'] ) ) );
     91                // Duplicate handling on means an upsert whether or not any unique
     92                // fields were ticked. With none, Zoho applies its own duplicate check.
     93                $upsert = ! empty( $settings['dedupe_enabled'] );
     94                $dupes  = array();
     95                if ( $upsert && ! empty( $settings['dedupe_fields'] ) ) {
     96                        $dupes = array_values( array_filter( array_map( 'trim', explode( ',', (string) $settings['dedupe_fields'] ) ) ) );
    9497                }
    9598
    9699                $client = $this->client( $settings );
    97                 $result = $client->create_record( $module, $record, $dupes );
     100
     101                $record = $this->attach_files( $record, $settings, $client );
     102
     103                $result = $client->create_record( $module, $record, $dupes, $upsert, self::request_extras( $settings ) );
    98104
    99105                return $this->interpret( $result );
     106        }
     107
     108        /**
     109         * Turn every mapped file field into the id Zoho wants there.
     110         *
     111         * A File Upload field cannot be written with a value. The file goes to
     112         * Zoho's file store first and the record then carries the id it was given:
     113         * [ { "File_Id__s": "<id>" } ]. Mapping one used to send the submitted
     114         * filename as text, which Zoho refused — and it refuses the whole request,
     115         * so a rejected attachment took the name, the email and the message with
     116         * it.
     117         *
     118         * Which rows are file fields is known from the mapping, where the Zoho
     119         * field's type was stored when the feed was saved. No metadata call is
     120         * made here; a submission that maps no file field does no extra work at
     121         * all.
     122         *
     123         * A file that cannot be sent is dropped from the record rather than
     124         * failing it. The reasons are all outside the submitter's control — too
     125         * large for Zoho, already cleaned up before a background retry ran, or a
     126         * connection still holding a grant issued before this plugin asked for
     127         * ZohoCRM.Files.CREATE — and none of them is a reason to throw away the
     128         * enquiry. The log carries the upload's own error either way.
     129         *
     130         * @param array     $record   Record built from the mapping.
     131         * @param array     $settings Feed settings, including any uploaded files.
     132         * @param CrmClient $client   Client for this connection.
     133         * @return array
     134         */
     135        private function attach_files( array $record, array $settings, CrmClient $client ) {
     136                $mapping = isset( $settings['mapping'] ) && is_array( $settings['mapping'] ) ? $settings['mapping'] : array();
     137                $files   = isset( $settings['files'] ) && is_array( $settings['files'] ) ? $settings['files'] : array();
     138
     139                foreach ( $mapping as $zoho_field => $row ) {
     140                        $zoho_field = (string) $zoho_field;
     141
     142                        if ( ! is_array( $row ) || ! isset( $record[ $zoho_field ] ) ) {
     143                                continue;
     144                        }
     145
     146                        $source = isset( $row['value'] ) ? (string) $row['value'] : '';
     147                        $paths  = isset( $files[ $source ] ) ? array_values( (array) $files[ $source ] ) : array();
     148                        $ztype  = isset( $row['zoho_type'] ) ? (string) $row['zoho_type'] : '';
     149
     150                        $known_file = in_array( $ztype, array( 'fileupload', 'imageupload' ), true );
     151
     152                        // A row with no recorded type but a real file behind it is treated
     153                        // as a file row too. Two things reach that state and both are
     154                        // ordinary: a feed saved before the editor started recording the
     155                        // Zoho field's type, and a field whose type Zoho reports under a
     156                        // name this plugin does not recognise. Without it the text below
     157                        // went to Zoho instead of the file, which is the whole of the
     158                        // "invalid data (Attachment)" failure.
     159                        $likely_file = '' === $ztype && array() !== $paths;
     160
     161                        if ( ! $known_file && ! $likely_file ) {
     162                                // Not a file target. If a file was uploaded for it anyway,
     163                                // send its name — Contact Form 7 puts the file's SHA-256 in
     164                                // the submitted value, and nobody wants a hash in their CRM.
     165                                // A URL, which is what most other form plugins submit, is more
     166                                // use in a text field than the bare file name, so it stays.
     167                                if ( array() !== $paths && ! ( is_string( $record[ $zoho_field ] ) && preg_match( '#^(?:https?:)?//#i', $record[ $zoho_field ] ) ) ) {
     168                                        $record[ $zoho_field ] = implode( ', ', array_map( 'basename', $paths ) );
     169                                }
     170
     171                                continue;
     172                        }
     173
     174                        // Whatever text the mapping produced is not something Zoho can use
     175                        // in a file field, so it goes whether or not a file replaces it.
     176                        unset( $record[ $zoho_field ] );
     177
     178                        $ids = array();
     179
     180                        foreach ( $paths as $path ) {
     181                                $id = $client->upload_file( (string) $path );
     182
     183                                if ( is_wp_error( $id ) ) {
     184                                        $this->logger->error(
     185                                                $id->get_error_message(),
     186                                                array(
     187                                                        'status' => 'error',
     188                                                        'field'  => $zoho_field,
     189                                                        'file'   => basename( (string) $path ),
     190                                                ),
     191                                                $this->id(),
     192                                                ''
     193                                        );
     194                                        continue;
     195                                }
     196
     197                                // File_Id__s, for a file field and an image field alike: the
     198                                // v6 API refuses a record whose file row carries anything else
     199                                // ("file_id", "Encrypted_Id") with MANDATORY_NOT_FOUND
     200                                // (File_Id__s), which is how a mapped upload failed the whole
     201                                // record. Checked against a live account, both field types.
     202                                $ids[] = array( 'File_Id__s' => $id );
     203                        }
     204
     205                        if ( array() !== $ids ) {
     206                                $record[ $zoho_field ] = $ids;
     207                        }
     208                }
     209
     210                return $record;
    100211        }
    101212
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/DeskIntegration.php

    r3649280 r3748247  
    9090                        return new WP_Error( 'form2zoho_no_module', __( 'Choose a Desk module first.', 'techwave-form-sync-for-zoho' ) );
    9191                }
    92                 return $this->client( $context )->fields( $module );
     92
     93                // The organisation is what makes its own fields reachable — they belong
     94                // to it, not to the account — so it is passed on rather than used only
     95                // to build the client.
     96                $org_id = isset( $context['org_id'] ) ? (string) $context['org_id'] : '';
     97
     98                return $this->client( $context )->fields( $module, $org_id );
    9399        }
    94100
    … …  
    125131                }
    126132
    127                 $result = $this->client( $settings )->create_record( $org_id, $dept_id, $module, $record );
     133                $result = $this->client( $settings )->create_record( $org_id, $dept_id, $module, $record, self::request_extras( $settings ) );
    128134                return $this->interpret( $result, $module );
    129135        }
  • techwave-form-sync-for-zoho/trunk/includes/Integrations/MarketingAutomationIntegration.php

    r3649280 r3748247  
    139139                $subscription = isset( $settings['campaigns_subscription_type'] ) ? strtolower( trim( (string) $settings['campaigns_subscription_type'] ) ) : '';
    140140
     141                // Zoho keys a lead by its email address and refuses every call
     142                // without a real one — "Invalid Lead Email address", or "Pattern
     143                // doesn't Match" from the list add. Both name the format, neither
     144                // names the mapping row that produced it, so the row is named here
     145                // before anything is sent.
     146                $email_row = $this->email_row( $record );
     147                if ( '' !== $email_row['value'] && ! is_email( $email_row['value'] ) ) {
     148                        $source = self::mapped_source( $settings, $email_row['key'] );
     149
     150                        return self::with_sent_fields(
     151                                new WP_Error(
     152                                        'form2zoho_bad_email',
     153                                        '' !== $source
     154                                                ? sprintf(
     155                                                        /* translators: 1: Zoho field name, 2: the form field it is mapped to. */
     156                                                        __( 'The value mapped to "%1$s" is not an email address, so Zoho refused it. It comes from the form field "%2$s". Map the form\'s email field there instead.', 'techwave-form-sync-for-zoho' ),
     157                                                        $email_row['key'],
     158                                                        $source
     159                                                )
     160                                                : sprintf(
     161                                                        /* translators: %s: Zoho field name. */
     162                                                        __( 'The value mapped to "%s" is not an email address, so Zoho refused it.', 'techwave-form-sync-for-zoho' ),
     163                                                        $email_row['key']
     164                                                ),
     165                                        array( 'email_field' => $email_row['key'] )
     166                                ),
     167                                $record
     168                        );
     169                }
     170
    141171                $client = $this->client( $settings );
    142172
    … …  
    144174                        $result = $client->unsubscribe( $listkey, $record, $topic_id );
    145175                } else {
     176                        // "Right away", the default: the lead is put in the list first,
     177                        // directly, so a list with its sign-up form on does not hold it
     178                        // back until a confirmation email is clicked. listsubscribe then
     179                        // writes the other fields for a lead already in the list. "After
     180                        // they confirm" leaves that to Zoho.
     181                        if ( ! isset( $settings['campaigns_list_add'] ) || 'confirm' !== $settings['campaigns_list_add'] ) {
     182                                $email = $this->find_email( $record );
     183                                if ( '' === $email ) {
     184                                        return self::with_sent_fields(
     185                                                new WP_Error( 'form2zoho_no_email', __( 'Map a form field to the lead email. Zoho needs it to add the lead to the list.', 'techwave-form-sync-for-zoho' ) ),
     186                                                $record
     187                                        );
     188                                }
     189
     190                                // Best effort. Zoho refuses this call for reasons that say
     191                                // nothing about the lead's own details — the lead is in the
     192                                // list already, the account's bulk-add limit is reached — and
     193                                // aborting here threw away a submission listsubscribe would
     194                                // have taken. The reason is logged, then the fields are sent.
     195                                $added = $this->interpret( $client->add_to_list( $listkey, $email, $topic_id ) );
     196                                if ( is_wp_error( $added ) ) {
     197                                        $this->logger->warning(
     198                                                sprintf(
     199                                                        /* translators: %s: reason Zoho gave. */
     200                                                        __( 'The submitted fields were sent, but the lead could not be added to the mailing list directly: %s', 'techwave-form-sync-for-zoho' ),
     201                                                        $added->get_error_message()
     202                                                ),
     203                                                array( 'status' => 'suppressed' ),
     204                                                'automation',
     205                                                isset( $settings['form_id'] ) ? (string) $settings['form_id'] : '',
     206                                                isset( $settings['feed_id'] ) ? (string) $settings['feed_id'] : ''
     207                                        );
     208                                }
     209                        }
     210
    146211                        $result = $client->subscribe( $listkey, $record, $topic_id );
    147212                }
    … …  
    157222                $email = $this->find_email( $record );
    158223                if ( '' !== $email ) {
    159                         $outcome['id'] = $email;
     224                        $outcome['id'] = self::loggable_email( $email );
     225                }
     226
     227                // On "Right away" this plugin puts the lead in the list itself, a
     228                // moment earlier, so Zoho then answers the fields call with "this
     229                // email address already exists in the list" — its normal wording for
     230                // an update, and alarming under a green Success row that also says
     231                // the record was created. Said plainly instead, and counted as the
     232                // update it is.
     233                if ( 'unsubscribed' !== $subscription
     234                        && isset( $outcome['zoho_message'] )
     235                        && preg_match( '/already exist/i', (string) $outcome['zoho_message'] ) ) {
     236                        $outcome['action']       = 'updated';
     237                        $outcome['zoho_message'] = '';
     238                        $outcome['message']      = '' !== $email
     239                                /* translators: %s: the lead's email address. */
     240                                ? sprintf( __( 'This lead was already in the mailing list, so Zoho updated it with the submitted fields (%s).', 'techwave-form-sync-for-zoho' ), self::loggable_email( $email ) )
     241                                : __( 'This lead was already in the mailing list, so Zoho updated it with the submitted fields.', 'techwave-form-sync-for-zoho' );
     242                }
     243
     244                // Otherwise the log said "Record created in Zoho" for a feed whose
     245                // whole job is to take the lead off the list.
     246                if ( 'unsubscribed' === $subscription ) {
     247                        $outcome['message'] = '' !== $email
     248                                /* translators: %s: the lead's email address. */
     249                                ? sprintf( __( 'Lead unsubscribed in Zoho (%s).', 'techwave-form-sync-for-zoho' ), self::loggable_email( $email ) )
     250                                : __( 'Lead unsubscribed in Zoho.', 'techwave-form-sync-for-zoho' );
    160251                }
    161252
    … …  
    193284                        return $result;
    194285                }
    195                 $status = isset( $result['status'] ) ? (string) $result['status'] : '';
    196                 if ( 'success' === $status ) {
     286                // Zoho answers some calls flat and others wrapped in "response",
     287                // on the same endpoints — the field lists already read both shapes.
     288                // Read flat only, and a wrapped success was reported as a failure:
     289                // for the direct list-add that also stopped the fields being sent.
     290                $body = ( isset( $result['response'] ) && is_array( $result['response'] ) ) ? $result['response'] : $result;
     291
     292                $status = isset( $body['status'] ) ? strtolower( trim( (string) $body['status'] ) ) : '';
     293                $code   = isset( $body['code'] ) ? trim( (string) $body['code'] ) : '';
     294
     295                // Code 0 is Zoho's "this worked", and some answers carry only that.
     296                if ( 'success' === $status || ( '' === $status && '0' === $code ) ) {
    197297                        // No id here on purpose — see CampaignsIntegration::interpret():
    198298                        // send() fills in the lead's email as the record identity.
    … …  
    206306                                'id'           => '',
    207307                                'action'       => 'created',
    208                                 'zoho_message' => isset( $result['message'] ) ? (string) $result['message'] : '',
     308                                'zoho_message' => isset( $body['message'] ) ? (string) $body['message'] : '',
    209309                        );
    210310                }
    211                 $message = isset( $result['message'] ) ? (string) $result['message'] : __( 'Unexpected Marketing Automation response.', 'techwave-form-sync-for-zoho' );
     311
     312                $message = isset( $body['message'] ) && '' !== $body['message']
     313                        ? (string) $body['message']
     314                        : __( 'Unexpected Marketing Automation response.', 'techwave-form-sync-for-zoho' );
     315
     316                // Zoho's numbered reason, which its documentation lists and its
     317                // support asks for.
     318                if ( '' !== $code ) {
     319                        $message = sprintf(
     320                                /* translators: 1: Zoho's message, 2: Zoho's error code. */
     321                                __( '%1$s (Zoho code %2$s)', 'techwave-form-sync-for-zoho' ),
     322                                $message,
     323                                $code
     324                        );
     325                }
     326
    212327                return new WP_Error( 'form2zoho_automation_rejected', $message, $result );
    213328        }
    … …  
    223338         */
    224339        private function find_email( array $record ) {
     340                $row = $this->email_row( $record );
     341
     342                return $row['value'];
     343        }
     344
     345        /**
     346         * The record entry holding the email address, and which key it is under.
     347         *
     348         * The first key containing "email" used to win outright, so a feed that
     349         * also maps something like "Email Opt-in" could send that checkbox's
     350         * value to Zoho as the address, depending only on the order the rows
     351         * happened to be in. A key holding a real address is preferred now; the
     352         * first non-empty candidate is still returned when none holds one, so a
     353         * wrong value is reported rather than read as "nothing mapped".
     354         *
     355         * @param array $record Mapped display-name => value pairs.
     356         * @return array{key:string,value:string}
     357         */
     358        private function email_row( array $record ) {
     359                $candidates = array();
     360
     361                if ( isset( $record['Lead Email'] ) && ! is_array( $record['Lead Email'] ) ) {
     362                        $candidates['Lead Email'] = trim( (string) $record['Lead Email'] );
     363                }
     364
    225365                foreach ( $record as $key => $value ) {
    226                         if ( false !== stripos( (string) $key, 'email' ) && '' !== trim( (string) $value ) ) {
    227                                 return trim( (string) $value );
     366                        if ( is_string( $key ) && ! is_array( $value ) && false !== stripos( $key, 'email' ) ) {
     367                                $candidates[ $key ] = trim( (string) $value );
    228368                        }
    229369                }
    230                 return '';
     370
     371                $candidates = array_filter(
     372                        $candidates,
     373                        static function ( $value ) {
     374                                return '' !== $value;
     375                        }
     376                );
     377
     378                foreach ( $candidates as $key => $value ) {
     379                        if ( is_email( $value ) ) {
     380                                return array( 'key' => $key, 'value' => $value );
     381                        }
     382                }
     383
     384                foreach ( $candidates as $key => $value ) {
     385                        return array( 'key' => $key, 'value' => $value );
     386                }
     387
     388                return array( 'key' => '', 'value' => '' );
    231389        }
    232390}
  • techwave-form-sync-for-zoho/trunk/includes/Logging/Logger.php

    r3649280 r3748247  
    4343         * list, because redact() walks the whole context — including form_values.
    4444         */
     45        /**
     46         * Longest message stored, in bytes. The column is TEXT (64 KB); this
     47         * leaves room for a multi-byte character to be cut whole.
     48         */
     49        private const MAX_MESSAGE_BYTES = 8192;
     50
     51        /**
     52         * Longest encoded context stored, in bytes. Enough for a submission's
     53         * field list and Zoho's answer, far short of what LONGTEXT would take.
     54         */
     55        private const MAX_CONTEXT_BYTES = 65536;
     56
    4557        private const REDACT_KEYS = array(
    4658                'access_token',
    … …  
    143155                }
    144156
     157                // Both are capped. A message is built from Zoho's own wording, and a
     158                // context from what a visitor submitted, so neither has a length this
     159                // plugin controls — and an uncapped row is a public form away from
     160                // filling the table. MySQL's TEXT column would also refuse an
     161                // over-long message outright in strict mode, losing the row.
     162                $json = wp_json_encode( $this->strip_empty( self::redact( $context ) ) );
     163
    145164                $row = array(
    146165                        'created_at'  => current_time( 'mysql', true ),
    … …  
    149168                        'form_id'     => substr( (string) $form_id, 0, 64 ),
    150169                        'feed_id'     => substr( (string) $feed_id, 0, 64 ),
    151                         'message'     => (string) $message,
    152                         'context'     => wp_json_encode( $this->strip_empty( self::redact( $context ) ) ),
     170                        'message'     => self::clip( (string) $message, self::MAX_MESSAGE_BYTES ),
     171                        'context'     => self::clip( (string) $json, self::MAX_CONTEXT_BYTES ),
    153172                );
    154173
    155174                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom log table.
    156175                $wpdb->insert( $wpdb->prefix . self::TABLE, $row );
     176        }
     177
     178        /**
     179         * Cut a string to a byte budget, on a character boundary, marking the cut.
     180         *
     181         * @param string $value Text to store.
     182         * @param int    $max   Maximum bytes.
     183         * @return string
     184         */
     185        private static function clip( $value, $max ) {
     186                if ( strlen( $value ) <= $max ) {
     187                        return $value;
     188                }
     189
     190                /* translators: marks a log entry that was too long to store in full. */
     191                $suffix = ' ' . __( '[cut: entry too long]', 'techwave-form-sync-for-zoho' );
     192
     193                return mb_strcut( $value, 0, max( 0, $max - strlen( $suffix ) ) ) . $suffix;
    157194        }
    158195
    … …  
    200237         */
    201238        public static function redact( $data ) {
     239                if ( is_string( $data ) ) {
     240                        return self::scrub( $data );
     241                }
     242
    202243                if ( ! is_array( $data ) ) {
    203244                        return $data;
    … …  
    209250                                continue;
    210251                        }
    211                         $out[ $key ] = is_array( $value ) ? self::redact( $value ) : $value;
     252                        if ( is_array( $value ) ) {
     253                                $out[ $key ] = self::redact( $value );
     254                                continue;
     255                        }
     256                        $out[ $key ] = is_string( $value ) ? self::scrub( $value ) : $value;
    212257                }
    213258                return $out;
     259        }
     260
     261        /**
     262         * Blank a credential written inside a string rather than under a key.
     263         *
     264         * The list above matches key names, which covers everything this plugin
     265         * puts in a context itself. What it cannot see is a credential quoted
     266         * back inside somebody else's text — an upstream error body flattened to
     267         * a string, a header echoed into a message — and that text is stored and
     268         * shown like any other. The shapes below are the ones a Zoho answer can
     269         * carry; anything else is left exactly as it was.
     270         *
     271         * @param string $text Text about to be stored.
     272         * @return string
     273         */
     274        private static function scrub( $text ) {
     275                if ( '' === $text || strlen( $text ) > self::MAX_CONTEXT_BYTES ) {
     276                        return $text;
     277                }
     278
     279                $patterns = array(
     280                        // "Zoho-oauthtoken 1000.abc…" / "Bearer abc…"
     281                        '/\b(Zoho-oauthtoken|Bearer)\s+[A-Za-z0-9._-]{8,}/i',
     282                        // "access_token":"…" and its refresh/secret siblings, JSON or query.
     283                        '/\b((?:access|refresh)_token|client_secret|zapikey)\b\s*["\']?\s*[:=]\s*["\']?[A-Za-z0-9._-]{8,}/i',
     284                );
     285
     286                return (string) preg_replace( $patterns, '***redacted***', $text );
    214287        }
    215288
    … …  
    240313
    241314        /**
     315         * How many rows the log holds.
     316         *
     317         * The Logs screen reads one page at a time, so it has to be told how many
     318         * pages there are — a page of rows cannot answer that about itself.
     319         *
     320         * @param string $level Optional level filter.
     321         * @return int
     322         */
     323        public function count( $level = '' ) {
     324                global $wpdb;
     325                $table = $wpdb->prefix . self::TABLE;
     326
     327                if ( '' !== $level ) {
     328                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     329                        return (int) $wpdb->get_var(
     330                                $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE level = %s", $level ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is an internal constant, not user input.
     331                        );
     332                }
     333
     334                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom log table; name is an internal constant.
     335                return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
     336        }
     337
     338        /**
     339         * The highest id in the log, or 0 when it is empty.
     340         *
     341         * This is the cursor the Logs screen's live poll counts from. It is not
     342         * the same as "the newest row on screen": on page two of the list, the
     343         * newest row shown is an old one, and starting the poll from its id would
     344         * fetch every row above it — the whole of page one — and prepend it.
     345         *
     346         * @return int
     347         */
     348        public function newest_id() {
     349                global $wpdb;
     350                $table = $wpdb->prefix . self::TABLE;
     351
     352                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom log table; name is an internal constant.
     353                return (int) $wpdb->get_var( "SELECT MAX(id) FROM {$table}" );
     354        }
     355
     356        /**
     357         * One page of log rows, newest first.
     358         *
     359         * The Logs screen used to ask for the newest 200 rows and render all of
     360         * them. On a busy site that is 200 rows of markup — each one carrying a
     361         * hidden <pre> of the full JSON payload — built, sent and parsed to show
     362         * twenty. This asks the database for the page being looked at and nothing
     363         * else.
     364         *
     365         * LIMIT/OFFSET on an id-ordered index, not a filter over everything: the
     366         * id is the primary key and the sort is the insertion order, so MySQL
     367         * walks straight to the offset.
     368         *
     369         * @param int    $offset Rows to skip.
     370         * @param int    $limit  Rows to return.
     371         * @param string $level  Optional level filter.
     372         * @return array<int,object>
     373         */
     374        public function page( $offset, $limit, $level = '' ) {
     375                global $wpdb;
     376                $table  = $wpdb->prefix . self::TABLE;
     377                $limit  = max( 1, min( 200, (int) $limit ) );
     378                $offset = max( 0, (int) $offset );
     379
     380                if ( '' !== $level ) {
     381                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     382                        return (array) $wpdb->get_results(
     383                                $wpdb->prepare( "SELECT * FROM {$table} WHERE level = %s ORDER BY id DESC LIMIT %d OFFSET %d", $level, $limit, $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is an internal constant, not user input.
     384                        );
     385                }
     386
     387                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     388                return (array) $wpdb->get_results(
     389                        $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is an internal constant, not user input.
     390                );
     391        }
     392
     393        /**
    242394         * Fetch log rows newer than a given id, oldest first — used to poll for
    243395         * "live" updates on the admin Logs screen without a full page reload.
    … …  
    275427                        $wpdb->prepare( "SELECT * FROM {$table} WHERE feed_id = %s ORDER BY id DESC LIMIT %d", (string) $feed_id, $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is an internal constant, not user input.
    276428                );
     429        }
     430
     431        /**
     432         * A stored log message as it should be shown.
     433         *
     434         * Rows written by older versions used an em dash ("Success — record
     435         * created ..."). Messages written now use a colon; this brings the old
     436         * rows into line on the Logs screen and the feed timeline without
     437         * rewriting anything stored.
     438         *
     439         * @param string $message Stored message.
     440         * @return string
     441         */
     442        public static function display_message( $message ) {
     443                return str_replace( array( ' — ', '—' ), array( ': ', '-' ), (string) $message );
     444        }
     445
     446        /**
     447         * Sent and failed deliveries since a point in time, counted per site day
     448         * and per feed, for the Dashboard.
     449         *
     450         * A delivery is a success row (level info, status success) or an error
     451         * row. "Send test record" rows are left out — they carry "test":true in
     452         * their context — so trying a feed out does not inflate the numbers.
     453         *
     454         * @param string $from_gmt       Earliest created_at, 'Y-m-d H:i:s' UTC.
     455         * @param int    $offset_seconds Site's UTC offset, so days are the site's days.
     456         * @return array<int,object> Rows of day, feed_id, outcome ('sent'|'failed'), total, last_at.
     457         */
     458        public function outcomes_since( $from_gmt, $offset_seconds = 0 ) {
     459                global $wpdb;
     460                $table = $wpdb->prefix . self::TABLE;
     461
     462                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom log table; name is an internal constant, not user input.
     463                return (array) $wpdb->get_results(
     464                        $wpdb->prepare(
     465                                "SELECT DATE(DATE_ADD(created_at, INTERVAL %d SECOND)) AS day, feed_id, IF(level = 'error', 'failed', 'sent') AS outcome, COUNT(*) AS total, MAX(created_at) AS last_at
     466                                FROM {$table}
     467                                WHERE created_at >= %s
     468                                        AND ( level = 'error' OR ( level = 'info' AND context LIKE %s ) )
     469                                        AND ( context IS NULL OR context NOT LIKE %s )
     470                                GROUP BY day, feed_id, outcome",
     471                                (int) $offset_seconds,
     472                                (string) $from_gmt,
     473                                '%' . $wpdb->esc_like( '"status":"success"' ) . '%',
     474                                '%' . $wpdb->esc_like( '"test":true' ) . '%'
     475                        )
     476                );      // phpcs:enable
     477        }
     478
     479        /**
     480         * The newest sent and failed deliveries, test runs left out — the same
     481         * rows outcomes_since() counts.
     482         *
     483         * @param int $limit Max rows.
     484         * @return array<int,object>
     485         */
     486        public function recent_outcomes( $limit = 5 ) {
     487                global $wpdb;
     488                $table = $wpdb->prefix . self::TABLE;
     489                $limit = max( 1, min( 50, (int) $limit ) );
     490
     491                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom log table; name is an internal constant, not user input.
     492                return (array) $wpdb->get_results(
     493                        $wpdb->prepare(
     494                                "SELECT * FROM {$table}
     495                                WHERE ( level = 'error' OR ( level = 'info' AND context LIKE %s ) )
     496                                        AND ( context IS NULL OR context NOT LIKE %s )
     497                                ORDER BY id DESC LIMIT %d",
     498                                '%' . $wpdb->esc_like( '"status":"success"' ) . '%',
     499                                '%' . $wpdb->esc_like( '"test":true' ) . '%',
     500                                $limit
     501                        )
     502                );      // phpcs:enable
    277503        }
    278504
  • techwave-form-sync-for-zoho/trunk/includes/NinjaForms/SubmissionHandler.php

    r3649280 r3748247  
    4949         */
    5050        public function handle( $form_data ) {
     51                // Wrapped like every other handler here: a listener that throws takes
     52                // the visitor's submission down with it, and the form plugin's own
     53                // success path never finishes. Failing to reach Zoho must never break
     54                // the form.
     55                try {
     56                        $this->handle_inner( $form_data );
     57                } catch ( \Throwable $e ) {
     58                        $this->logger->error(
     59                                'Ninja Forms submission handling failed unexpectedly: ' . $e->getMessage(),
     60                                array( 'status' => 'error' ),
     61                                self::SOURCE
     62                        );
     63                }
     64        }
     65
     66        /**
     67         * The work itself, wrapped by handle().
     68         *
     69         * @param array $form_data Ninja Forms' submitted form data.
     70         * @return void
     71         */
     72        private function handle_inner( $form_data ) {
    5173                if ( ! is_array( $form_data ) ) {
    5274                        return;
  • techwave-form-sync-for-zoho/trunk/includes/Privacy/PrivacyProvider.php

    r3649280 r3748247  
    5959
    6060                $content  = '<p>' . esc_html__( 'When a visitor submits a form that has a Zoho integration enabled, the values you mapped are sent to the Zoho service and data center configured for that form. Depending on the feed, that service may be Zoho CRM, Zoho Bigin, Zoho Books, Zoho Desk, Zoho Campaigns or Zoho Marketing Automation. This transfer happens so the record can be created in your Zoho account. Please review Zoho\'s own privacy policy for how they handle that data.', 'techwave-form-sync-for-zoho' ) . '</p>';
    61                 $content .= '<p>' . esc_html__( 'This plugin also stores a delivery log entry for each submission (timestamp, status and a redacted API response). When the "Store submitted values" option is enabled — it is off by default — that entry additionally contains the submitted field values so a failed delivery can be resubmitted; with the option off, only the field names are recorded. Access tokens, refresh tokens and client secrets are never written to these logs in readable form. Log entries are deleted automatically after the retention period set on the plugin\'s Settings tab.', 'techwave-form-sync-for-zoho' ) . '</p>';
     61                $content .= '<p>' . esc_html__( 'This plugin also stores a delivery log entry for each submission (timestamp, status and a redacted API response). When the "Store submitted values" option is enabled (it is off by default), that entry additionally contains the submitted field values so a failed delivery can be resubmitted; with the option off, only the field names are recorded. Access tokens, refresh tokens and client secrets are never written to these logs in readable form. Log entries are kept until they are deleted, either by hand or automatically after a set number of days if that option is turned on in the plugin\'s Settings tab.', 'techwave-form-sync-for-zoho' ) . '</p>';
    6262                $content .= '<p>' . esc_html__( 'If a delivery to Zoho fails for a temporary reason, the submission is retried in the background. The values needed for that retry are stored on this site, in a WordPress transient, until the retry has run or the stored copy expires (at most two hours). Nothing is sent anywhere else while it waits.', 'techwave-form-sync-for-zoho' ) . '</p>';
    6363
    6464                wp_add_privacy_policy_content(
    65                         __( 'Techwave Form Sync for Zoho', 'techwave-form-sync-for-zoho' ),
     65                        __( 'FormBridge for Zoho', 'techwave-form-sync-for-zoho' ),
    6666                        wp_kses_post( $content )
    6767                );
    … …  
    7676        public function register_exporter( $exporters ) {
    7777                $exporters[ self::SLUG ] = array(
    78                         'exporter_friendly_name' => __( 'Techwave Form Sync for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
     78                        'exporter_friendly_name' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
    7979                        'callback'               => array( $this, 'export' ),
    8080                );
    … …  
    9090        public function register_eraser( $erasers ) {
    9191                $erasers[ self::SLUG ] = array(
    92                         'eraser_friendly_name' => __( 'Techwave Form Sync for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
     92                        'eraser_friendly_name' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
    9393                        'callback'             => array( $this, 'erase' ),
    9494                );
    … …  
    181181                        $items[] = array(
    182182                                'group_id'    => 'form2zoho-logs',
    183                                 'group_label' => __( 'Techwave Form Sync for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
     183                                'group_label' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ),
    184184                                'item_id'     => 'log-' . (int) $row['id'],
    185185                                'data'        => $data,
  • techwave-form-sync-for-zoho/trunk/includes/Queue/Dispatcher.php

    r3649280 r3748247  
    7878
    7979        /**
     80         * Whether a destination needs a connected Zoho account to deliver.
     81         *
     82         * Asked by the submission handlers, which have no registry of their own.
     83         * An unknown id, or one registered without the method, is treated as
     84         * needing an account — the answer every Zoho app gives.
     85         *
     86         * @param string $integration_id Integration id, e.g. "crm" or "webhook".
     87         * @return bool
     88         */
     89        public function needs_connection( $integration_id ) {
     90                $integration = $this->registry->get( (string) $integration_id );
     91
     92                if ( null === $integration || ! method_exists( $integration, 'needs_connection' ) ) {
     93                        return true;
     94                }
     95
     96                return (bool) $integration->needs_connection();
     97        }
     98
     99        /**
    80100         * Deliver a job to Zoho now.
    81101         *
    … …  
    114134                                'Unknown integration in queue.',
    115135                                array(
    116                                         'status' => 'error',
    117                                         'job'    => $job,
     136                                        'status'      => 'error',
     137                                        // Not $job itself: it carries the submitted values and the
     138                                        // absolute path of every uploaded file, which every other
     139                                        // log row here puts through loggable_values() first.
     140                                        'feed_id'     => (string) ( $job['feed_id'] ?? '' ),
     141                                        'form_values' => self::loggable_values( isset( $job['form_values'] ) && is_array( $job['form_values'] ) ? $job['form_values'] : array() ),
    118142                                ),
    119143                                (string) $job['integration'],
    … …  
    127151                $attempt     = isset( $job['attempt'] ) ? (int) $job['attempt'] : 1;
    128152                $feed_id     = (string) ( $job['feed_id'] ?? '' );
     153
     154                // The last point at which what leaves for Zoho can still be changed.
     155                // Both filters run on a retry too — $job['attempt'] tells the calls
     156                // apart — so a callback that has side effects should check it.
     157
     158                /**
     159                 * Filter the submitted values a job is about to be sent with.
     160                 *
     161                 * Where a field transformation belongs (reformatting a date,
     162                 * splitting a full name, looking a value up elsewhere): the mapping
     163                 * resolves Zoho fields against these keys, so changing a value here
     164                 * changes what lands on the record without touching the feed.
     165                 *
     166                 * @param array  $form_values Field id => submitted value.
     167                 * @param array  $settings    Feed settings for this send.
     168                 * @param string $integration Integration id (crm|books|desk|...).
     169                 * @param array  $job         The whole job payload.
     170                 */
     171                $form_values = (array) apply_filters( 'form2zoho_job_form_values', $form_values, $settings, $integration->id(), $job );
     172
     173                /**
     174                 * Filter a job's feed settings immediately before it is sent.
     175                 *
     176                 * Covers the target rather than the data: 'mapping', 'module',
     177                 * 'connection_id', and any key an add-on put on the job through
     178                 * form2zoho_build_job.
     179                 *
     180                 * @param array  $settings    Feed settings for this send.
     181                 * @param array  $form_values Field id => submitted value.
     182                 * @param string $integration Integration id (crm|books|desk|...).
     183                 * @param array  $job         The whole job payload.
     184                 */
     185                $settings = (array) apply_filters( 'form2zoho_job_settings', $settings, $form_values, $integration->id(), $job );
     186
     187                // The uploaded files travel on the job, not in the values, so they are
     188                // handed to the integration here rather than pretending to be a
     189                // submitted field. Only an integration that knows about them looks.
     190                if ( ! empty( $job['files'] ) && is_array( $job['files'] ) ) {
     191                        $settings['files'] = $job['files'];
     192                }
     193
     194                // Which feed and form this send belongs to. An integration that
     195                // writes a log line of its own (Bigin's missing org lookup, a
     196                // Campaigns list-add Zoho refused) needs them, or its row lands in
     197                // the log belonging to no feed and no form.
     198                $settings['feed_id'] = $feed_id;
     199                $settings['form_id'] = (string) ( $job['form_id'] ?? '' );
    129200
    130201                $result = $integration->send( $settings, $form_values );
    … …  
    156227                        }
    157228
    158                         if ( 'updated' === $action ) {
     229                        // An integration may say what happened in its own words. Only a
     230                        // destination that is not Zoho needs to: "Record created in Zoho"
     231                        // is true of everything this plugin ships and of nothing an add-on
     232                        // might reach over plain HTTP, and a log that says it anyway is a
     233                        // log that cannot be trusted on the rows that matter.
     234                        $own_message = isset( $result['message'] ) && is_string( $result['message'] )
     235                                ? trim( $result['message'] )
     236                                : '';
     237
     238                        if ( '' !== $own_message ) {
     239                                $message = $own_message;
     240                        } elseif ( 'updated' === $action ) {
    159241                                $message = '' !== $record_id
    160242                                        /* translators: %s: Zoho record id. */
    … …  
    172254                        // Marketing Automation pending-confirmation subscribe visible in
    173255                        // the timeline instead of looking like an unexplained no-op.
    174                         if ( '' !== $zoho_message && false === stripos( $zoho_message, 'success' ) ) {
     256                        // Dropped only when Zoho says nothing beyond "it worked". Any
     257                        // message holding the word was dropped before, which hid the one
     258                        // that matters most: "A confirmation email is sent to the user.
     259                        // User needs to confirm to successfully subscribe."
     260                        if ( '' !== $zoho_message && ! preg_match( '/^(success|successful|successfully|ok|done)[.!]?$/i', trim( $zoho_message ) ) ) {
    175261                                $message .= ' ' . $zoho_message;
    176262                        }
    … …  
    213299                                $feed_id
    214300                        );
     301
     302                        /**
     303                         * Fires after Zoho accepted a submission.
     304                         *
     305                         * The counterpart to form2zoho_submission_failed. Note that a
     306                         * 'suppressed' status is still a success as far as HTTP went —
     307                         * Zoho took the request but did not add the contact, because
     308                         * its own suppression list said not to — so a callback that
     309                         * only cares about records that really exist should check
     310                         * $status rather than assume one was created.
     311                         *
     312                         * @param array  $job       The job payload.
     313                         * @param string $record_id Zoho record id, empty when none came back.
     314                         * @param string $status    'success' or 'suppressed'.
     315                         * @param mixed  $result    Raw integration result.
     316                         */
     317                        do_action( 'form2zoho_submission_succeeded', $job, $record_id, $log_status, $result );
     318                        self::drop_kept_files( $job );
    215319                        return;
    216320                }
    … …  
    257361                         */
    258362                        do_action( 'form2zoho_submission_failed', $job, $raw_message );
     363                        self::drop_kept_files( $job );
    259364                        return;
    260365                }
    … …  
    270375                // still shows the attempt count it took.
    271376                $this->schedule( $job, $delay );
     377        }
     378
     379        /**
     380         * Copy a job's uploaded files somewhere they will still be at retry time.
     381         *
     382         * The first attempt runs inside the submission, while the form plugin's
     383         * own copy exists. A retry runs later, and by then Contact Form 7 has
     384         * deleted its temporary copy (it does so as the submission ends), so the
     385         * retried record went out without its files. The copies live in a folder
     386         * of their own under uploads, closed to the web, and are deleted by
     387         * drop_kept_files() once the job is finished either way.
     388         *
     389         * @param array $job Job about to be scheduled for a retry.
     390         * @return array The job, pointing at the copies.
     391         */
     392        private static function keep_files( array $job ) {
     393                if ( ! empty( $job['kept_files_dir'] ) || empty( $job['files'] ) || ! is_array( $job['files'] ) ) {
     394                        return $job;
     395                }
     396
     397                $uploads = wp_get_upload_dir();
     398                if ( ! empty( $uploads['error'] ) ) {
     399                        return $job;
     400                }
     401
     402                $root = trailingslashit( $uploads['basedir'] ) . 'form2zoho-retry';
     403                $dir  = $root . '/' . wp_generate_password( 20, false );
     404
     405                if ( ! wp_mkdir_p( $dir ) ) {
     406                        return $job;
     407                }
     408
     409                // Nothing in here is meant to be fetched over the web.
     410                if ( ! file_exists( $root . '/index.php' ) ) {
     411                        file_put_contents( $root . '/index.php', "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
     412                        file_put_contents( $root . '/.htaccess', "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
     413                }
     414
     415                foreach ( $job['files'] as $field => $paths ) {
     416                        $kept = array();
     417                        foreach ( (array) $paths as $i => $path ) {
     418                                $path   = (string) $path;
     419                                $copy   = $dir . '/' . $i . '-' . sanitize_file_name( basename( $path ) );
     420                                $kept[] = ( is_readable( $path ) && copy( $path, $copy ) ) ? $copy : $path;
     421                        }
     422                        $job['files'][ $field ] = $kept;
     423                }
     424
     425                $job['kept_files_dir'] = $dir;
     426
     427                return $job;
     428        }
     429
     430        /**
     431         * Delete the copies keep_files() made, once the job is done.
     432         *
     433         * @param array $job The finished job.
     434         * @return void
     435         */
     436        private static function drop_kept_files( array $job ) {
     437                $dir = isset( $job['kept_files_dir'] ) ? (string) $job['kept_files_dir'] : '';
     438
     439                // Only ever a folder keep_files() made, never a path from elsewhere.
     440                $uploads = wp_get_upload_dir();
     441                $root    = trailingslashit( (string) $uploads['basedir'] ) . 'form2zoho-retry/';
     442                if ( '' === $dir || 0 !== strpos( $dir, $root ) || ! is_dir( $dir ) ) {
     443                        return;
     444                }
     445
     446                foreach ( (array) glob( $dir . '/*' ) as $file ) {
     447                        if ( is_file( $file ) ) {
     448                                wp_delete_file( $file );
     449                        }
     450                }
     451                rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
    272452        }
    273453
    … …  
    345525        private function schedule( array $job, $delay ) {
    346526                $when   = time() + max( 0, (int) $delay );
     527                $job    = self::keep_files( $job );
    347528                $handle = $this->stash( $job );
    348529
  • techwave-form-sync-for-zoho/trunk/includes/Support/BaseSubmissionHandler.php

    r3649280 r3748247  
    8989         * @return int Number of feeds delivered to.
    9090         */
    91         protected function deliver_to_feeds( $form_name, array $values ) {
     91        protected function deliver_to_feeds( $form_name, array $values, array $files = array() ) {
    9292                $form_name = (string) $form_name;
    9393                $queued    = 0;
    … …  
    9797                        $connection_id  = isset( $feed['connection_id'] ) ? (string) $feed['connection_id'] : '';
    9898
    99                         if ( '' === $integration_id || '' === $connection_id ) {
     99                        // A destination that is only a URL has no account to select — the
     100                        // editor hides the picker for it — so an empty connection is only
     101                        // a mistake for a destination that needs one. Checking it for every
     102                        // feed skipped each webhook feed with this error on every submission.
     103                        if ( '' === $integration_id
     104                                || ( '' === $connection_id && $this->dispatcher->needs_connection( $integration_id ) ) ) {
    100105                                $this->logger->error(
    101106                                        __( 'Feed is enabled but has no Zoho app or connected account selected.', 'techwave-form-sync-for-zoho' ),
    … …  
    108113                                        isset( $feed['id'] ) ? (string) $feed['id'] : ''
    109114                                );
     115                                // A submission that reaches no destination has failed, so the
     116                                // "email me when an integration fails" notice goes out for it
     117                                // as it does for a delivery Zoho refused. Documented in
     118                                // Queue\Dispatcher.
     119                                do_action(
     120                                        'form2zoho_submission_failed',
     121                                        array(
     122                                                'integration' => $integration_id,
     123                                                'form_id'     => $form_name,
     124                                                'feed_id'     => isset( $feed['id'] ) ? (string) $feed['id'] : '',
     125                                        ),
     126                                        __( 'Feed is enabled but has no Zoho app or connected account selected.', 'techwave-form-sync-for-zoho' )
     127                                );
    110128                                continue;
    111129                        }
    112130
    113                         $this->dispatcher->enqueue( FeedsRepository::build_job( $feed, $form_name, $values ) );
     131                        /**
     132                         * Filter whether one feed runs for this submission.
     133                         *
     134                         * The seam conditional logic hangs off. Returning false skips
     135                         * the feed silently and writes no log row — the same outcome as
     136                         * the feed never having matched the form, rather than a delivery
     137                         * that failed. By this point the feed is already known to be
     138                         * enabled and to name both an app and a connected account, so a
     139                         * callback only has to decide on the submitted values.
     140                         *
     141                         * @param bool   $deliver   Whether to deliver. Default true.
     142                         * @param array  $feed      The feed row.
     143                         * @param array  $values    Field id => submitted value.
     144                         * @param string $form_name Submitted form's name/title.
     145                         */
     146                        if ( ! apply_filters( 'form2zoho_should_deliver_feed', true, $feed, $values, $form_name ) ) {
     147                                continue;
     148                        }
     149
     150                        $this->dispatcher->enqueue( FeedsRepository::build_job( $feed, $form_name, $values, $files ) );
    114151                        ++$queued;
    115152                }
  • techwave-form-sync-for-zoho/trunk/includes/Support/ContactForm7Forms.php

    r3649280 r3748247  
    100100                                        'label' => self::humanize( $field_name ),
    101101                                );
     102
     103                                // An upload field, so the editor can offer it Zoho's file
     104                                // fields rather than every text box in the module.
     105                                if ( 'file' === $basetype ) {
     106                                        $fields[ $field_name ]['file'] = true;
     107                                }
    102108                        }
    103109
  • techwave-form-sync-for-zoho/trunk/includes/Support/DataCenters.php

    r3649280 r3748247  
    3131                                'label'      => 'United States (.com)',
    3232                                'accounts'   => 'https://accounts.zoho.com',
     33                                'console'    => 'https://api-console.zoho.com',
    3334                                'crm'        => 'https://www.zohoapis.com',
    3435                                'books'      => 'https://www.zohoapis.com',
    … …  
    4142                                'label'      => 'Europe (.eu)',
    4243                                'accounts'   => 'https://accounts.zoho.eu',
     44                                'console'    => 'https://api-console.zoho.eu',
    4345                                'crm'        => 'https://www.zohoapis.eu',
    4446                                'books'      => 'https://www.zohoapis.eu',
    … …  
    5153                                'label'      => 'India (.in)',
    5254                                'accounts'   => 'https://accounts.zoho.in',
     55                                'console'    => 'https://api-console.zoho.in',
    5356                                'crm'        => 'https://www.zohoapis.in',
    5457                                'books'      => 'https://www.zohoapis.in',
    … …  
    6164                                'label'      => 'Australia (.com.au)',
    6265                                'accounts'   => 'https://accounts.zoho.com.au',
     66                                'console'    => 'https://api-console.zoho.com.au',
    6367                                'crm'        => 'https://www.zohoapis.com.au',
    6468                                'books'      => 'https://www.zohoapis.com.au',
    … …  
    7175                                'label'      => 'Japan (.jp)',
    7276                                'accounts'   => 'https://accounts.zoho.jp',
     77                                'console'    => 'https://api-console.zoho.jp',
    7378                                'crm'        => 'https://www.zohoapis.jp',
    7479                                'books'      => 'https://www.zohoapis.jp',
    … …  
    8186                                'label'      => 'Canada (.ca)',
    8287                                'accounts'   => 'https://accounts.zohocloud.ca',
     88                                'console'    => 'https://api-console.zohocloud.ca',
    8389                                'crm'        => 'https://www.zohoapis.ca',
    8490                                'books'      => 'https://www.zohoapis.ca',
    … …  
    9197                                'label'      => 'Saudi Arabia (.sa)',
    9298                                'accounts'   => 'https://accounts.zoho.sa',
     99                                'console'    => 'https://api-console.zoho.sa',
    93100                                'crm'        => 'https://www.zohoapis.sa',
    94101                                'books'      => 'https://www.zohoapis.sa',
    … …  
    101108                                'label'      => 'China (.com.cn)',
    102109                                'accounts'   => 'https://accounts.zoho.com.cn',
     110                                'console'    => 'https://api-console.zoho.com.cn',
    103111                                'crm'        => 'https://www.zohoapis.com.cn',
    104112                                'books'      => 'https://www.zohoapis.com.cn',
    … …  
    121129                $key = is_string( $key ) ? strtolower( $key ) : '';
    122130                return isset( $all[ $key ] ) ? $all[ $key ] : $all['us'];
     131        }
     132
     133        /**
     134         * Where this region's Zoho API console lives.
     135         *
     136         * Written out per region rather than derived from the accounts host by
     137         * swapping the subdomain. It happens to be that today, including Canada's
     138         * odd zohocloud.ca, but a data centre that broke the pattern would break
     139         * silently — and this URL is the one a person follows *before* they have
     140         * a working connection, so a wrong one leaves them with nothing to try.
     141         *
     142         * @param string $key Region key (us, eu, in, ...).
     143         * @return string
     144         */
     145        public static function console_url( $key ) {
     146                $dc = self::get( $key );
     147
     148                return isset( $dc['console'] ) ? $dc['console'] : 'https://api-console.zoho.com';
     149        }
     150
     151        /**
     152         * Every region's console URL, keyed by region.
     153         *
     154         * For handing to the browser: the connection dialog's link has to follow
     155         * the data centre picker without a round trip.
     156         *
     157         * @return array<string,string>
     158         */
     159        public static function console_urls() {
     160                $out = array();
     161
     162                foreach ( self::all() as $key => $dc ) {
     163                        $out[ $key ] = isset( $dc['console'] ) ? $dc['console'] : '';
     164                }
     165
     166                return $out;
    123167        }
    124168
    … …  
    264308
    265309                        case 'books':
    266                                 if ( '' === (string) $org_id ) {
    267                                         return $host_set['books'] . '/app#/';
    268                                 }
     310                                // No organisation in the path. This used to build
     311                                // /app/{org}#/{module}/{id}, which loaded Zoho Books and then
     312                                // sat on the dashboard instead of opening the record: the
     313                                // server only routes /app, everything after the "#" is the web
     314                                // app's own business, and an organisation segment in between
     315                                // is not a route it knows.
     316                                //
     317                                // /app#/{module}/{id} is the shape Zoho's own documentation
     318                                // builds for a Books contact link. The organisation comes from
     319                                // the session rather than the URL, which is the one weakness of
     320                                // it: somebody signed in to a different organisation lands on
     321                                // that organisation's copy of the module. There is no
     322                                // documented way to name the organisation in a web link, and a
     323                                // guessed parameter that silently does nothing would be worse
     324                                // than the limitation.
    269325                                $module = '' !== (string) $module ? strtolower( $module ) : 'contacts';
    270                                 return $host_set['books'] . '/app/' . rawurlencode( $org_id ) . '#/' . rawurlencode( $module ) . '/' . rawurlencode( $record_id );
     326                                return $host_set['books'] . '/app#/' . rawurlencode( $module ) . '/' . rawurlencode( $record_id );
    271327
    272328                        case 'desk':
  • techwave-form-sync-for-zoho/trunk/includes/Support/ElementorForms.php

    r3649280 r3748247  
    139139                                                'label' => $label,
    140140                                        );
     141
     142                                        // An upload field, so the editor can offer it Zoho's file
     143                                        // fields rather than every text box in the module.
     144                                        if ( isset( $field['field_type'] ) && 'upload' === $field['field_type'] ) {
     145                                                $forms[ $name ][ $id ]['file'] = true;
     146                                        }
    141147                                }
    142148                        }
  • techwave-form-sync-for-zoho/trunk/includes/Support/Encryption.php

    r3649280 r3748247  
    5353                        return new WP_Error(
    5454                                'form2zoho_no_openssl',
    55                                 __( 'This server cannot encrypt credentials: PHP\'s OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it — the plugin will not store a Zoho client secret or refresh token unencrypted.', 'techwave-form-sync-for-zoho' )
     55                                __( 'This server cannot encrypt credentials: PHP\'s OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it. The plugin will not store a Zoho client secret or refresh token unencrypted.', 'techwave-form-sync-for-zoho' )
    5656                        );
    5757                }
    … …  
    6767                        return new WP_Error(
    6868                                'form2zoho_no_salt',
    69                                 __( 'This site has no AUTH_KEY defined in wp-config.php, so Zoho credentials cannot be encrypted safely. Add the WordPress security keys to wp-config.php and try again — the plugin will not store a client secret or refresh token that a copy of the database alone could decrypt.', 'techwave-form-sync-for-zoho' )
     69                                __( 'This site has no AUTH_KEY defined in wp-config.php, so Zoho credentials cannot be encrypted safely. Add the WordPress security keys to wp-config.php and try again. The plugin will not store a client secret or refresh token that a copy of the database alone could decrypt.', 'techwave-form-sync-for-zoho' )
    7070                        );
    7171                }
  • techwave-form-sync-for-zoho/trunk/includes/Support/ErrorTranslator.php

    r3649280 r3748247  
    3030                if ( '' === $raw ) {
    3131                        return __( 'Record creation failed.', 'techwave-form-sync-for-zoho' );
     32                }
     33
     34                // The request never reached Zoho at all.
     35                $network = self::transport( $raw );
     36                if ( '' !== $network ) {
     37                        return $network;
    3238                }
    3339
    … …  
    7783
    7884        /**
     85         * Say what a request that never reached Zoho means, in words.
     86         *
     87         * WordPress hands back cURL's own text for these ("cURL error 28:
     88         * Connection timed out after 10002 milliseconds"), which names neither
     89         * Zoho nor what to do. None of them is about the feed: the request did not
     90         * get as far as Zoho, so the fix is on this server's network, or simply
     91         * trying again.
     92         *
     93         * The 10 seconds in that example is not the plugin's timeout. WordPress's
     94         * HTTP library allows 10 seconds to open a connection whatever the overall
     95         * timeout, so a server that cannot reach Zoho at all fails there.
     96         *
     97         * @param string $raw  Error text from wp_remote_request().
     98         * @param string $host The host that could not be reached, when known.
     99         * @param bool   $full Add what to do about it, for a screen with room.
     100         * @return string Empty when this is not a connection failure.
     101         */
     102        public static function transport( $raw, $host = '', $full = false ) {
     103                $raw  = (string) $raw;
     104                $code = preg_match( '/cURL error (\d+)/i', $raw, $m ) ? (int) $m[1] : 0;
     105
     106                if ( 28 === $code || ( 0 === $code && false !== stripos( $raw, 'timed out' ) ) ) {
     107                        $why = __( 'the connection timed out', 'techwave-form-sync-for-zoho' );
     108                } elseif ( 6 === $code || ( 0 === $code && false !== stripos( $raw, 'getaddrinfo' ) ) ) {
     109                        $why = __( 'this server could not look up its address', 'techwave-form-sync-for-zoho' );
     110                } elseif ( 7 === $code || ( 0 === $code && false !== stripos( $raw, 'failed to connect' ) ) ) {
     111                        $why = __( 'the connection was refused or blocked', 'techwave-form-sync-for-zoho' );
     112                } elseif ( in_array( $code, array( 35, 51, 58, 60, 77 ), true ) ) {
     113                        $why = __( 'the secure connection could not be set up', 'techwave-form-sync-for-zoho' );
     114                } elseif ( $code > 0 ) {
     115                        $why = __( 'the connection failed', 'techwave-form-sync-for-zoho' );
     116                } else {
     117                        return '';
     118                }
     119
     120                $message = '' !== $host
     121                        /* translators: 1: host name, e.g. www.zohoapis.com. 2: what went wrong, e.g. "the connection timed out". */
     122                        ? sprintf( __( 'Could not reach Zoho (%1$s): %2$s.', 'techwave-form-sync-for-zoho' ), $host, $why )
     123                        /* translators: %s: what went wrong, e.g. "the connection timed out". */
     124                        : sprintf( __( 'Could not reach Zoho: %s.', 'techwave-form-sync-for-zoho' ), $why );
     125
     126                if ( ! $full ) {
     127                        return $message;
     128                }
     129
     130                $message .= ' ' . __( 'This is a network problem between this website and Zoho, not a problem with the feed. Try again in a moment. If it keeps happening, ask your hosting provider to allow outgoing HTTPS connections to Zoho.', 'techwave-form-sync-for-zoho' );
     131
     132                if ( $code > 0 ) {
     133                        /* translators: %d: cURL error number, to pass on to the hosting provider. */
     134                        $message .= ' ' . sprintf( __( '(cURL error %d)', 'techwave-form-sync-for-zoho' ), $code );
     135                }
     136
     137                return $message;
     138        }
     139
     140        /**
    79141         * Pull a Zoho field's API name out of the parenthesised part of an error
    80142         * string (e.g. "...(/lastName missing)" or "(Last_Name)") and turn it
  • techwave-form-sync-for-zoho/trunk/includes/Support/FieldMapper.php

    r3649280 r3748247  
    3636                        $type  = isset( $row['type'] ) ? $row['type'] : 'field';
    3737                        $value = isset( $row['value'] ) ? $row['value'] : '';
    38 
    39                         if ( 'static' === $type ) {
     38                        $ztype = isset( $row['zoho_type'] ) ? (string) $row['zoho_type'] : '';
     39
     40                        if ( self::TYPE_TEMPLATE === $type ) {
     41                                $resolved = self::render( (string) $value, $form_values );
     42                        } elseif ( 'static' === $type ) {
    4043                                $resolved = (string) $value;
    4144                        } else {
    … …  
    4346                        }
    4447
     48                        // Multi-select first, because it is the one target that wants the
     49                        // list kept as a list. Everything below flattens to a string.
     50                        if ( self::TYPE_MULTISELECT === $ztype ) {
     51                                $list = self::to_list( $resolved );
     52
     53                                if ( array() === $list ) {
     54                                        continue;
     55                                }
     56
     57                                self::assign( $record, $zoho_field, $list );
     58                                continue;
     59                        }
     60
    4561                        if ( is_array( $resolved ) ) {
    4662                                $resolved = implode( ', ', array_map( 'strval', $resolved ) );
    … …  
    5268                        }
    5369
    54                         // Support nested targets written with dot notation, e.g.
    55                         // "contact.lastName" => array( 'contact' => array( 'lastName' => ... ) ).
    56                         if ( false !== strpos( $zoho_field, '.' ) ) {
    57                                 self::set_nested( $record, explode( '.', $zoho_field ), $resolved );
    58                         } else {
    59                                 $record[ $zoho_field ] = $resolved;
    60                         }
     70                        // A checkbox reaches here as whatever its form plugin calls "on",
     71                        // which is not the same word twice: "1", "yes", "on", "selected",
     72                        // "true", or the checkbox's own label. Zoho wants a JSON boolean
     73                        // and rejects the record outright for anything else, so the answer
     74                        // is decided here rather than hoped for.
     75                        if ( self::TYPE_BOOLEAN === $ztype ) {
     76                                self::assign( $record, $zoho_field, self::to_bool( $resolved ) );
     77                                continue;
     78                        }
     79
     80                        self::assign( $record, $zoho_field, $resolved );
    6181                }
    6282
    6383                return $record;
     84        }
     85
     86        /**
     87         * Zoho field types this class treats as something other than text.
     88         *
     89         * Kept as constants and behind handles_type() so the editor stores a type
     90         * on a mapping row only when storing it changes what gets sent. Every
     91         * other type is text as far as delivery is concerned, and recording it
     92         * would be noise in the saved feed.
     93         */
     94        private const TYPE_BOOLEAN     = 'boolean';
     95        private const TYPE_MULTISELECT = 'multiselectpicklist';
     96
     97        /**
     98         * The row kind that builds its value out of several form fields.
     99         *
     100         * Stored in the same slot as 'field' and 'static', because it is the third
     101         * answer to the same question: where does this Zoho field's value come
     102         * from.
     103         */
     104        public const TYPE_TEMPLATE = 'template';
     105
     106        /**
     107         * Fill {field_id} placeholders from a submission.
     108         *
     109         * One Zoho field often wants several answers at once — a full name out of
     110         * two boxes, an address out of five, a description that quotes the
     111         * enquiry. A row could previously send one field or one fixed string, so
     112         * the only way to do that was to give up and send the fields separately.
     113         *
     114         * Braces rather than a "join these fields with a separator" control,
     115         * because the separator is rarely the whole of it: "{first} {last}" and
     116         * "Ref {order} for {email}" are the same feature, and only one of them is
     117         * expressible as a join.
     118         *
     119         * A placeholder naming a field the submission did not include becomes
     120         * empty rather than being left on screen as a literal "{whatever}" — an
     121         * unanswered optional question should leave a gap, not put braces in
     122         * somebody's CRM.
     123         *
     124         * @param string              $template    Text with {field_id} placeholders.
     125         * @param array<string,mixed> $form_values Submitted values.
     126         * @return string
     127         */
     128        private static function render( $template, array $form_values ) {
     129                return (string) preg_replace_callback(
     130                        // Doubled braces first, so a template that genuinely needs one can
     131                        // say so: "{{" is a literal "{". Without it there is no way to put
     132                        // a brace in a Zoho field through this box at all.
     133                        '/\{\{|\}\}|\{([^{}]+)\}/',
     134                        static function ( $m ) use ( $form_values ) {
     135                                if ( '{{' === $m[0] ) {
     136                                        return '{';
     137                                }
     138
     139                                if ( '}}' === $m[0] ) {
     140                                        return '}';
     141                                }
     142
     143                                $key = trim( $m[1] );
     144
     145                                if ( ! isset( $form_values[ $key ] ) ) {
     146                                        return '';
     147                                }
     148
     149                                $value = $form_values[ $key ];
     150
     151                                if ( is_array( $value ) ) {
     152                                        return implode( ', ', array_map( 'strval', $value ) );
     153                                }
     154
     155                                return is_scalar( $value ) ? (string) $value : '';
     156                        },
     157                        $template
     158                );
     159        }
     160
     161        /**
     162         * Whether a Zoho field type changes how a value is sent.
     163         *
     164         * @param string $ztype Zoho data_type, lower-cased.
     165         * @return bool
     166         */
     167        public static function handles_type( $ztype ) {
     168                return in_array( (string) $ztype, array( self::TYPE_BOOLEAN, self::TYPE_MULTISELECT ), true );
     169        }
     170
     171        /**
     172         * Put one value in the record, honouring dot-notation targets.
     173         *
     174         * Support nested targets written with dot notation, e.g.
     175         * "contact.lastName" => array( 'contact' => array( 'lastName' => ... ) ).
     176         *
     177         * @param array  $record     Record being built (by reference).
     178         * @param string $zoho_field Target field, possibly dotted.
     179         * @param mixed  $value      Value to set.
     180         * @return void
     181         */
     182        private static function assign( array &$record, $zoho_field, $value ) {
     183                if ( false !== strpos( $zoho_field, '.' ) ) {
     184                        self::set_nested( $record, explode( '.', $zoho_field ), $value );
     185                        return;
     186                }
     187
     188                $record[ $zoho_field ] = $value;
     189        }
     190
     191        /**
     192         * Read a submitted value as a yes/no.
     193         *
     194         * Only the negatives are listed. A checkbox that was not ticked usually
     195         * submits nothing at all and never reaches here, so the values that do
     196         * arrive are overwhelmingly some spelling of "on" — including the
     197         * checkbox's own label, which no list could ever anticipate. Naming the
     198         * false side and treating the rest as true is therefore the rule that
     199         * needs no maintenance, and it fails safe: an unrecognised value becomes
     200         * a ticked box rather than a rejected record.
     201         *
     202         * @param string $value Trimmed, non-empty submitted value.
     203         * @return bool
     204         */
     205        private static function to_bool( $value ) {
     206                $no = array( '0', 'false', 'no', 'n', 'off', 'unchecked', 'unselected', 'none', 'null' );
     207
     208                return ! in_array( strtolower( trim( (string) $value ) ), $no, true );
     209        }
     210
     211        /**
     212         * Read a submitted value as a list of choices.
     213         *
     214         * Form plugins disagree about multi-value fields: some hand over a real
     215         * array, some a comma-separated string, some a single string. Zoho wants
     216         * a JSON array either way, so all three are normalised to one here rather
     217         * than each form adapter being trusted to have done it.
     218         *
     219         * Splitting a string on commas is the one lossy step — a choice whose own
     220         * label contains a comma comes apart. That is accepted knowingly: an array
     221         * from the form plugin is never split, so this only ever applies to
     222         * plugins that already flattened the list and lost the boundary
     223         * themselves.
     224         *
     225         * @param mixed $value Raw submitted value.
     226         * @return array<int,string> Trimmed, non-empty choices.
     227         */
     228        private static function to_list( $value ) {
     229                $parts = is_array( $value ) ? $value : explode( ',', (string) $value );
     230
     231                $out = array();
     232                foreach ( $parts as $part ) {
     233                        if ( is_array( $part ) || is_object( $part ) ) {
     234                                continue;
     235                        }
     236
     237                        $part = trim( (string) $part );
     238                        if ( '' !== $part ) {
     239                                $out[] = $part;
     240                        }
     241                }
     242
     243                return array_values( array_unique( $out ) );
    64244        }
    65245
  • techwave-form-sync-for-zoho/trunk/includes/Support/FluentFormsForms.php

    r3649280 r3748247  
    143143                                        'label' => $label,
    144144                                );
     145
     146                                // An upload field, so the editor can offer it Zoho's file
     147                                // fields rather than every text box in the module.
     148                                if ( in_array( $element, array( 'input_file', 'input_image' ), true ) ) {
     149                                        $fields[ $name ]['file'] = true;
     150                                }
    145151                        }
    146152
  • techwave-form-sync-for-zoho/trunk/includes/Support/FormSources.php

    r3649280 r3748247  
    2424         * Source key => [label, discovery class, tile initials, tile colour].
    2525         *
    26          * @return array<string,array{label:string,class:class-string,initials:string,color:string}>
     26         * A row carries `class` unless it is `locked`, in which case there is no
     27         * discovery class to name — the add-on that would supply one is not
     28         * installed, which is the whole reason the row is locked.
     29         *
     30         * @return array<string,array{label:string,class?:class-string,initials:string,color:string,locked?:bool}>
    2731         */
    2832        private static function map() {
    29                 return array(
    30                         'elementor'  => array(
     33                $map = array(
     34                        'elementor'        => array(
    3135                                'label'    => __( 'Elementor Pro Forms', 'techwave-form-sync-for-zoho' ),
    3236                                'class'    => ElementorForms::class,
    … …  
    3438                                'color'    => '#92003b',
    3539                        ),
    36                         'cf7'        => array(
     40                        'cf7'              => array(
    3741                                'label'    => __( 'Contact Form 7', 'techwave-form-sync-for-zoho' ),
    3842                                'class'    => ContactForm7Forms::class,
    … …  
    4044                                'color'    => '#1e3a5f',
    4145                        ),
    42                         'wpforms'    => array(
     46                        'wpforms'          => array(
    4347                                'label'    => __( 'WPForms', 'techwave-form-sync-for-zoho' ),
    4448                                'class'    => WPFormsForms::class,
    … …  
    4650                                'color'    => '#e27730',
    4751                        ),
    48                         'fluent'     => array(
     52                        'fluent'           => array(
    4953                                'label'    => __( 'Fluent Forms', 'techwave-form-sync-for-zoho' ),
    5054                                'class'    => FluentFormsForms::class,
    … …  
    5256                                'color'    => '#0967d2',
    5357                        ),
    54                         'forminator' => array(
     58                        'forminator'       => array(
    5559                                'label'    => __( 'Forminator', 'techwave-form-sync-for-zoho' ),
    5660                                'class'    => ForminatorForms::class,
    … …  
    5862                                'color'    => '#0f2540',
    5963                        ),
    60                         'ninja'      => array(
     64                        'ninja'            => array(
    6165                                'label'    => __( 'Ninja Forms', 'techwave-form-sync-for-zoho' ),
    6266                                'class'    => NinjaFormsForms::class,
    … …  
    6468                                'color'    => '#e0332f',
    6569                        ),
    66                         'gravity'    => array(
     70                        'gravity'          => array(
    6771                                'label'    => __( 'Gravity Forms', 'techwave-form-sync-for-zoho' ),
    6872                                'class'    => GravityFormsForms::class,
    … …  
    7074                                'color'    => '#f15a29',
    7175                        ),
    72                         'formidable' => array(
     76                        'formidable'       => array(
    7377                                'label'    => __( 'Formidable Forms', 'techwave-form-sync-for-zoho' ),
    7478                                'class'    => FormidableFormsForms::class,
    … …  
    7680                                'color'    => '#4a4a4a',
    7781                        ),
    78                         'happyforms' => array(
     82                        'happyforms'       => array(
    7983                                'label'    => __( 'Happy Forms', 'techwave-form-sync-for-zoho' ),
    8084                                'class'    => HappyFormsForms::class,
    … …  
    8286                                'color'    => '#222222',
    8387                        ),
    84                         'sureforms'  => array(
     88                        'sureforms'        => array(
    8589                                'label'    => __( 'SureForms', 'techwave-form-sync-for-zoho' ),
    8690                                'class'    => SureFormsForms::class,
    … …  
    8892                                'color'    => '#0a4dd6',
    8993                        ),
     94
     95                        /*
     96                         * Sources this plugin advertises but does not provide.
     97                         *
     98                         * None of these carry a `class`, because there is nothing to read a
     99                         * WooCommerce customer or a LearnDash learner with until the Pro
     100                         * add-on is installed; `locked` is what tells the picker to draw the
     101                         * tile disabled with a Pro badge instead of hiding it. Showing them
     102                         * greyed out is the honest answer to "does this work with
     103                         * WooCommerce?" — leaving them out entirely answers "no" to a
     104                         * question whose answer is "yes, with the add-on".
     105                         *
     106                         * The Pro plugin replaces each of these rows through
     107                         * form2zoho_form_sources with one that carries a real class and no
     108                         * lock, so a tile becomes ordinary the moment it is installed. The
     109                         * key, label, initials and colour here are copied from the Pro
     110                         * source's registry_row() so that swap is invisible: the tile keeps
     111                         * its place in this list and its appearance, and only stops being
     112                         * disabled.
     113                         */
     114
     115                        // Divi's forms come first, beside the form plugins above, because
     116                        // that is what they are: a form somebody fills in. They are listed
     117                        // in FORM_KEYS for the same reason.
     118                        'divi'             => array(
     119                                'label'    => __( 'Divi Contact Form', 'techwave-form-sync-for-zoho' ),
     120                                'initials' => 'DV',
     121                                'color'    => '#8f42ec',
     122                                'locked'   => true,
     123                        ),
     124                        'divi_fb'          => array(
     125                                'label'    => __( 'Divi Form Builder', 'techwave-form-sync-for-zoho' ),
     126                                'initials' => 'DF',
     127                                'color'    => '#6c2eb9',
     128                                'locked'   => true,
     129                        ),
     130                        'woocommerce'      => array(
     131                                'label'    => __( 'WooCommerce Customer', 'techwave-form-sync-for-zoho' ),
     132                                'initials' => 'WC',
     133                                'color'    => '#7f54b3',
     134                                'locked'   => true,
     135                        ),
     136                        'memberpress'      => array(
     137                                'label'    => __( 'MemberPress Member', 'techwave-form-sync-for-zoho' ),
     138                                'initials' => 'MP',
     139                                'color'    => '#0073aa',
     140                                'locked'   => true,
     141                        ),
     142                        'learndash'        => array(
     143                                'label'    => __( 'LearnDash Learner', 'techwave-form-sync-for-zoho' ),
     144                                'initials' => 'LD',
     145                                'color'    => '#0b5e8a',
     146                                'locked'   => true,
     147                        ),
     148                        'tutorlms'         => array(
     149                                'label'    => __( 'Tutor LMS Student', 'techwave-form-sync-for-zoho' ),
     150                                'initials' => 'TL',
     151                                'color'    => '#1f5eff',
     152                                'locked'   => true,
     153                        ),
     154                        'givewp'           => array(
     155                                'label'    => __( 'GiveWP Donor', 'techwave-form-sync-for-zoho' ),
     156                                'initials' => 'GW',
     157                                'color'    => '#69b868',
     158                                'locked'   => true,
     159                        ),
     160                        'edd'              => array(
     161                                'label'    => __( 'EDD Customer', 'techwave-form-sync-for-zoho' ),
     162                                'initials' => 'ED',
     163                                'color'    => '#35495c',
     164                                'locked'   => true,
     165                        ),
     166                        'amelia'           => array(
     167                                'label'    => __( 'Amelia Customer', 'techwave-form-sync-for-zoho' ),
     168                                'initials' => 'AM',
     169                                'color'    => '#1a84ee',
     170                                'locked'   => true,
     171                        ),
     172                        'dokan'            => array(
     173                                'label'    => __( 'Dokan Vendor', 'techwave-form-sync-for-zoho' ),
     174                                'initials' => 'DK',
     175                                'color'    => '#f05025',
     176                                'locked'   => true,
     177                        ),
     178                        'wpjm'             => array(
     179                                'label'    => __( 'WP Job Manager', 'techwave-form-sync-for-zoho' ),
     180                                'initials' => 'JM',
     181                                'color'    => '#2d4b6e',
     182                                'locked'   => true,
     183                        ),
     184                        'eventtickets'     => array(
     185                                'label'    => __( 'The Events Calendar', 'techwave-form-sync-for-zoho' ),
     186                                'initials' => 'EV',
     187                                'color'    => '#334aff',
     188                                'locked'   => true,
     189                        ),
     190                        'latepoint'        => array(
     191                                'label'    => __( 'LatePoint Customer', 'techwave-form-sync-for-zoho' ),
     192                                'initials' => 'LP',
     193                                'color'    => '#4a5cf5',
     194                                'locked'   => true,
     195                        ),
     196                        'bookly'           => array(
     197                                'label'    => __( 'Bookly Customer', 'techwave-form-sync-for-zoho' ),
     198                                'initials' => 'BK',
     199                                'color'    => '#2a7de1',
     200                                'locked'   => true,
     201                        ),
     202                        'ssa'              => array(
     203                                'label'    => __( 'Simply Schedule Appointment', 'techwave-form-sync-for-zoho' ),
     204                                'initials' => 'SS',
     205                                'color'    => '#3f6ad8',
     206                                'locked'   => true,
     207                        ),
     208                        'fluentbooking'    => array(
     209                                'label'    => __( 'FluentBooking', 'techwave-form-sync-for-zoho' ),
     210                                'initials' => 'FB',
     211                                'color'    => '#2c5cff',
     212                                'locked'   => true,
     213                        ),
     214                        'wpbc'             => array(
     215                                'label'    => __( 'Booking Calendar', 'techwave-form-sync-for-zoho' ),
     216                                'initials' => 'BC',
     217                                'color'    => '#4c9a2a',
     218                                'locked'   => true,
     219                        ),
     220                        'easyappointments' => array(
     221                                'label'    => __( 'Easy Appointments Booking', 'techwave-form-sync-for-zoho' ),
     222                                'initials' => 'EA',
     223                                'color'    => '#2b7cb8',
     224                                'locked'   => true,
     225                        ),
     226                        'wcbookings'       => array(
     227                                'label'    => __( 'WooCommerce Booking', 'techwave-form-sync-for-zoho' ),
     228                                'initials' => 'WB',
     229                                'color'    => '#5c3d8f',
     230                                'locked'   => true,
     231                        ),
     232
     233                        // The one locked row that is not another plugin. An action hook
     234                        // needs nothing installed — it is a line of code on this site — so
     235                        // unlike the rows above, this tile is never unavailable for want
     236                        // of some other product. It is here for the same reason they are:
     237                        // leaving it out answers "no" to "can this take records from
     238                        // something that is not a form on this site?", and the answer is
     239                        // "yes, with the add-on".
     240                        'action_hook'      => array(
     241                                'label'    => __( 'Action Hook', 'techwave-form-sync-for-zoho' ),
     242                                'initials' => 'fn',
     243                                'color'    => '#334155',
     244                                'locked'   => true,
     245                        ),
    90246                );
     247
     248                /**
     249                 * Filter the registry of supported form plugins.
     250                 *
     251                 * How an add-on teaches this plugin to read a form plugin it does
     252                 * not ship support for. Rows are keyed by the short source key that
     253                 * every feed stores, and each one needs:
     254                 *
     255                 *   label    Translated name of the form plugin.
     256                 *   class    Discovery class carrying two public static methods —
     257                 *            is_available() : bool, and all( $fresh ) : array of
     258                 *            array{name:string,fields:array<array{id,label}>}.
     259                 *            See HappyFormsForms for the smallest example.
     260                 *   initials Two or three characters for the picker tile.
     261                 *   color    Hex background for that tile.
     262                 *
     263                 * A row missing any of those, or naming a class that cannot answer
     264                 * is_available()/all(), is dropped instead of being allowed to fatal
     265                 * partway through a visitor's form submission.
     266                 *
     267                 * @param array $map Source key => row.
     268                 */
     269                $filtered = apply_filters( 'form2zoho_form_sources', $map );
     270
     271                if ( ! is_array( $filtered ) ) {
     272                        return $map;
     273                }
     274
     275                $out = array();
     276                foreach ( $filtered as $key => $row ) {
     277                        $key = sanitize_key( $key );
     278
     279                        if ( '' === $key || ! is_array( $row ) ) {
     280                                continue;
     281                        }
     282
     283                        // A row that came back exactly as this class declared it is not
     284                        // re-checked. The check below asks class_exists(), which
     285                        // autoloads the class it names, and running that over all ten
     286                        // built-ins would load every discovery class on every call to
     287                        // keys()/labels()/label() — including during a visitor's form
     288                        // submission — to re-confirm what this file already states. With
     289                        // no filter registered $filtered is $map, so every row takes
     290                        // this path and nothing is loaded.
     291                        if ( isset( $map[ $key ] ) && $row === $map[ $key ] ) {
     292                                $out[ $key ] = $row;
     293                                continue;
     294                        }
     295
     296                        if ( ! isset( $row['label'], $row['initials'], $row['color'] ) ) {
     297                                continue;
     298                        }
     299
     300                        // A locked row is an advertisement, not a source: it names no
     301                        // class because the code that would read it is not installed. It
     302                        // is never asked to discover anything (see is_available() and
     303                        // discover()), so it is the one row allowed to arrive without one.
     304                        $locked = ! empty( $row['locked'] );
     305
     306                        if ( ! $locked ) {
     307                                if ( ! isset( $row['class'] ) || ! is_string( $row['class'] ) || ! class_exists( $row['class'] )
     308                                        || ! method_exists( $row['class'], 'is_available' )
     309                                        || ! method_exists( $row['class'], 'all' ) ) {
     310                                        continue;
     311                                }
     312                        }
     313
     314                        $out[ $key ] = array(
     315                                'label'    => (string) $row['label'],
     316                                'initials' => (string) $row['initials'],
     317                                'color'    => (string) $row['color'],
     318                        );
     319
     320                        if ( $locked ) {
     321                                $out[ $key ]['locked'] = true;
     322                        } else {
     323                                $out[ $key ]['class'] = $row['class'];
     324                        }
     325                }
     326
     327                // tile_for() and label() fall back to the 'elementor' row whenever a
     328                // feed names a source key that no longer exists, so that one row has
     329                // to survive whatever a filter did — including dropping it.
     330                if ( ! isset( $out['elementor'] ) ) {
     331                        $out = array( 'elementor' => $map['elementor'] ) + $out;
     332                }
     333
     334                return $out;
     335        }
     336
     337        /**
     338         * The sources that are form plugins: what a feed listens to there is a
     339         * form somebody fills in. Every other source, this plugin's or an
     340         * add-on's, is something that happens on the site — an order, a booking,
     341         * a sign-up — and the editor words its picker as an event.
     342         */
     343        private const FORM_KEYS = array( 'elementor', 'cf7', 'wpforms', 'fluent', 'forminator', 'ninja', 'gravity', 'formidable', 'happyforms', 'sureforms', 'divi', 'divi_fb' );
     344
     345        /**
     346         * The keys of the sources that are form plugins.
     347         *
     348         * @return array<int,string>
     349         */
     350        public static function form_keys() {
     351                return self::FORM_KEYS;
     352        }
     353
     354        /**
     355         * Whether a source is a form plugin rather than an event on the site.
     356         *
     357         * @param string $key Source key.
     358         * @return bool
     359         */
     360        public static function is_form( $key ) {
     361                return in_array( (string) $key, self::FORM_KEYS, true );
    91362        }
    92363
    … …  
    128399                        'initials' => $row['initials'],
    129400                        'color'    => $row['color'],
    130                         'icon'     => self::icon_url( $row ),
     401                        'icon'     => self::icon_url( isset( $map[ $key ] ) ? $key : 'elementor', $row ),
    131402                );
    132403        }
    … …  
    135406         * Everything the icon-tile picker UI needs, in display order.
    136407         *
    137          * @return array<int,array{key:string,label:string,initials:string,color:string,icon:string}>
     408         * `locked` marks a source the picker should draw disabled with a Pro
     409         * badge: it is advertised, but nothing installed can read it.
     410         *
     411         * @return array<int,array{key:string,label:string,initials:string,color:string,icon:string,locked:bool}>
    138412         */
    139413        public static function tiles() {
    … …  
    145419                                'initials' => $row['initials'],
    146420                                'color'    => $row['color'],
    147                                 'icon'     => self::icon_url( $row ),
     421                                'icon'     => self::icon_url( $key, $row ),
     422                                'locked'   => ! empty( $row['locked'] ),
    148423                        );
    149424                }
    … …  
    151426        }
    152427
    153         /**
    154          * URL for a tile's logo image.
    155          *
    156          * Always empty: the plugin deliberately ships no third-party product
    157          * logos, since those are trademarks rather than GPL-compatible assets.
    158          * Every tile therefore renders as its coloured initials square. Kept as
    159          * a seam so a site can supply its own artwork through the filter below
    160          * without the tile markup having to change.
    161          *
    162          * @param array $row One map() row.
     428        /** Where a source's logo lives, relative to the plugin root. */
     429        private const ICON_DIR = 'assets/images/triggers/';
     430
     431        /**
     432         * URL for a tile's logo image, or '' to fall back to coloured initials.
     433         *
     434         * A logo is used when `assets/images/triggers/<key>.webp` is present, so a
     435         * source without artwork keeps rendering as its initials square rather than
     436         * as a broken image — which is what the WooCommerce tile does today, and
     437         * what any source added by an add-on will do.
     438         *
     439         * NOTE ON TRADEMARKS: most of these are other companies' logos. They are
     440         * their marks, not GPL assets, and shipping them is a decision about
     441         * permission rather than about code — nominative use to identify which
     442         * product a tile selects is the usual basis for it. The exception is
     443         * action_hook.webp, which names no product and belongs to this plugin.
     444         * Anything added to that directory should be checked against the same
     445         * question first.
     446         *
     447         * @param string $key Source key. Already through sanitize_key() in map(),
     448         *                    so it cannot walk out of the directory.
     449         * @param array  $row One map() row.
    163450         * @return string
    164451         */
    165         private static function icon_url( array $row ) {
     452        private static function icon_url( $key, array $row ) {
     453                $url = self::has_icon( $key ) ? FORM2ZOHO_URL . self::ICON_DIR . $key . '.webp' : '';
     454
    166455                /**
    167456                 * Filter the logo URL used for one form-source tile.
    168457                 *
    169                  * @param string $url URL to the tile image. Empty renders initials.
     458                 * Returning '' renders the coloured initials instead.
     459                 *
     460                 * @param string $url URL to the tile image.
    170461                 * @param array  $row The source's map() row.
    171462                 */
    172                 return (string) apply_filters( 'form2zoho_form_source_icon', '', $row );
     463                return (string) apply_filters( 'form2zoho_form_source_icon', $url, $row );
     464        }
     465
     466        /**
     467         * Whether a logo file is shipped for this source.
     468         *
     469         * Memoised per key: tile_for() is called once per row on the feeds list, so
     470         * a site with twenty feeds would otherwise stat the same handful of files
     471         * twenty times over to answer a question whose answer cannot change within
     472         * one request.
     473         *
     474         * @param string $key Source key.
     475         * @return bool
     476         */
     477        private static function has_icon( $key ) {
     478                static $cache = array();
     479
     480                if ( ! isset( $cache[ $key ] ) ) {
     481                        $cache[ $key ] = is_readable( FORM2ZOHO_PATH . self::ICON_DIR . $key . '.webp' );
     482                }
     483
     484                return $cache[ $key ];
    173485        }
    174486
    … …  
    189501
    190502        /**
     503         * Whether this source needs an add-on that is not installed.
     504         *
     505         * The picker draws these disabled, and a feed may not be saved against
     506         * one — nothing on the site can read the submissions it would describe.
     507         *
     508         * @param string $key Source key.
     509         * @return bool
     510         */
     511        public static function is_locked( $key ) {
     512                $map = self::map();
     513
     514                return ! empty( $map[ $key ]['locked'] );
     515        }
     516
     517        /**
    191518         * Whether the form plugin behind a source key is actually active.
    192519         *
    … …  
    199526                        return false;
    200527                }
     528                if ( empty( $map[ $key ]['class'] ) ) {
     529                        return false;
     530                }
     531
    201532                $class = $map[ $key ]['class'];
    202533                return $class::is_available();
    … …  
    215546                        return array();
    216547                }
     548                if ( empty( $map[ $key ]['class'] ) ) {
     549                        return array();
     550                }
     551
    217552                $class = $map[ $key ]['class'];
    218553                return $class::all( $fresh );
  • techwave-form-sync-for-zoho/trunk/includes/Support/FormidableFormsForms.php

    r3649280 r3748247  
    108108                                );
    109109                                $prev_field_id       = $field_id;
     110
     111                                // An upload field, so the editor can offer it Zoho's file
     112                                // fields rather than every text box in the module.
     113                                if ( 'file' === $type ) {
     114                                        $fields[ $field_id ]['file'] = true;
     115                                }
    110116                        }
    111117
  • techwave-form-sync-for-zoho/trunk/includes/Support/ForminatorForms.php

    r3649280 r3748247  
    5555
    5656                try {
    57                         $forms = \Forminator_API::get_forms();
     57                        // Every form: without a page size Forminator returns ten, and a
     58                        // form past the tenth was missing from the feed editor's list.
     59                        $forms = \Forminator_API::get_forms( null, 1, -1 );
    5860
    5961                        foreach ( (array) $forms as $form ) {
    … …  
    9294                                                'label' => '' !== $label ? $label : $slug,
    9395                                        );
     96
     97                                        // An upload field, so the editor can offer it Zoho's file
     98                                        // fields rather than every text box in the module.
     99                                        // Forminator names every upload field "upload-N".
     100                                        if ( 0 === strpos( $slug, 'upload-' ) ) {
     101                                                $fields[ $slug ]['file'] = true;
     102                                        }
    94103                                }
    95104
  • techwave-form-sync-for-zoho/trunk/includes/Support/GravityFormsForms.php

    r3649280 r3748247  
    102102                                                $fields[ $input_id ] = array(
    103103                                                        'id'    => $input_id,
    104                                                         'label' => ( isset( $field['label'] ) ? $field['label'] . ' — ' : '' ) . $sub_label,
     104                                                        'label' => isset( $field['label'] ) ? $field['label'] . ' (' . $sub_label . ')' : $sub_label,
    105105                                                );
    106106                                        }
    … …  
    113113                                        'label' => $label,
    114114                                );
     115
     116                                // An upload field, so the editor can offer it Zoho's file
     117                                // fields rather than every text box in the module.
     118                                if ( in_array( $type, array( 'fileupload', 'post_image' ), true ) ) {
     119                                        $fields[ $id ]['file'] = true;
     120                                }
    115121                        }
    116122
  • techwave-form-sync-for-zoho/trunk/includes/Support/HappyFormsForms.php

    r3649280 r3748247  
    275275                                'label' => $label,
    276276                        );
     277
     278                        // An upload field, so the editor can offer it Zoho's file fields
     279                        // rather than every text box in the module.
     280                        if ( 'attachment' === $type ) {
     281                                $fields[ $field_id ]['file'] = true;
     282                        }
    277283                }
    278284
  • techwave-form-sync-for-zoho/trunk/includes/Support/Markup.php

    r3649280 r3748247  
    9090
    9191        /**
     92         * The masthead bar Branding::hero() builds: the lockup, plus the wrapper
     93         * and the version beside it.
     94         *
     95         * @return array<string,array<string,bool>>
     96         */
     97        public static function hero() {
     98                return array( 'div' => array( 'class' => true ) ) + self::hero_logo();
     99        }
     100
     101        /**
     102         * The masthead lockup Branding::hero_logo() builds: nested <span>s, the
     103         * bolder half of the name, and the mark itself.
     104         *
     105         * Still narrower than svg() — <rect> and <path> are the only two shapes
     106         * the mark is made of. What is new is that the shapes carry paint of
     107         * their own: the mark used to be one colour handed down from the <svg>,
     108         * and is now a full-colour logo, so `fill`, `stroke` and the stroke
     109         * settings have to survive the filter. Without them wp_kses() strips
     110         * every colour and the lockup renders as eight black boxes.
     111         *
     112         * @return array<string,array<string,bool>>
     113         */
     114        public static function hero_logo() {
     115                $paint = array(
     116                        'fill'            => true,
     117                        'stroke'          => true,
     118                        'stroke-width'    => true,
     119                        'stroke-linecap'  => true,
     120                        'stroke-linejoin' => true,
     121                );
     122
     123                return array(
     124                        'span'   => array( 'class' => true ),
     125                        'strong' => array(),
     126                        'img'    => array(
     127                                'class'  => true,
     128                                'src'    => true,
     129                                'width'  => true,
     130                                'height' => true,
     131                                'alt'    => true,
     132                        ),
     133                        'svg'    => array(
     134                                'class'       => true,
     135                                'width'       => true,
     136                                'height'      => true,
     137                                'viewbox'     => true,
     138                                'fill'        => true,
     139                                'aria-hidden' => true,
     140                                'focusable'   => true,
     141                        ),
     142                        'path'   => array( 'd' => true ) + $paint,
     143                        'rect'   => array(
     144                                'x'      => true,
     145                                'y'      => true,
     146                                'width'  => true,
     147                                'height' => true,
     148                                'rx'     => true,
     149                        ) + $paint,
     150                );
     151        }
     152
     153        /**
    92154         * A status pill: one <span> carrying its colour class.
    93155         *
    … …  
    139201         * saved value once the dropdown has been filled from Zoho.
    140202         *
     203         * The <div> is the empty box admin.js fills with a "Combine fields" row's
     204         * field chips. Left off this list, kses removed it from every row and the
     205         * chips had nowhere to go.
     206         *
    141207         * @return array<string,array<string,bool>>
    142208         */
    … …  
    145211                        'tr'     => array( 'class' => true ),
    146212                        'td'     => array( 'class' => true ),
     213                        'div'    => array(
     214                                'class'  => true,
     215                                'hidden' => true,
     216                        ),
    147217                        'select' => array(
    148218                                'name'          => true,
    149219                                'class'         => true,
    150220                                'data-selected' => true,
     221                                // A mapping row disables whichever of Form field / Static
     222                                // value does not apply to it; without this kses would strip
     223                                // the attribute and both would arrive editable.
     224                                'disabled'      => true,
    151225                        ),
    152226                        'option' => array(
    … …  
    160234                                'value'       => true,
    161235                                'placeholder' => true,
     236                                'disabled'    => true,
    162237                        ),
    163238                        'button' => array(
  • techwave-form-sync-for-zoho/trunk/includes/Support/NinjaFormsForms.php

    r3649280 r3748247  
    9898                                                'label' => $label,
    9999                                        );
     100
     101                                        // An upload field (the File Uploads add-on), so the editor
     102                                        // can offer it Zoho's file fields.
     103                                        if ( 'file_upload' === $type ) {
     104                                                $fields[ $id ]['file'] = true;
     105                                        }
    100106                                }
    101107
  • techwave-form-sync-for-zoho/trunk/includes/Support/Settings.php

    r3649280 r3748247  
    4444                // Logs screen's one-click Resubmit, and the Settings screen says so.
    4545                'form2zoho_store_form_values'        => '0',
    46                 'form2zoho_log_retention_enabled'    => '1',
     46                // OFF by default: log entries stay until someone deletes them, by hand
     47                // on the Logs tab or by turning this on. A site that has saved its
     48                // settings keeps whatever it saved.
     49                'form2zoho_log_retention_enabled'    => '0',
    4750                'form2zoho_email_notify_failures'    => '0',
    4851                'form2zoho_email_notify_connections' => '0',
  • techwave-form-sync-for-zoho/trunk/includes/Support/SureFormsForms.php

    r3649280 r3748247  
    225225                                                'label' => '' !== $label ? $label : ( 'Field ' . $field_id ),
    226226                                        );
     227
     228                                        // An upload field, so the editor can offer it Zoho's file
     229                                        // fields rather than every text box in the module.
     230                                        if ( 'srfm/upload' === $name ) {
     231                                                $fields[ $field_id ]['file'] = true;
     232                                        }
    227233                                }
    228234                        }
  • techwave-form-sync-for-zoho/trunk/includes/Support/WPFormsForms.php

    r3649280 r3748247  
    9999                                                $fields[ $sub_id ] = array(
    100100                                                        'id'    => $sub_id,
    101                                                         'label' => $label . ' — ' . ucfirst( $part ),
     101                                                        'label' => $label . ' (' . ucfirst( $part ) . ')',
    102102                                                );
    103103                                        }
    … …  
    109109                                        'label' => $label,
    110110                                );
     111
     112                                // An upload field, so the editor can offer it Zoho's file
     113                                // fields rather than every text box in the module.
     114                                if ( in_array( $type, array( 'file-upload', 'camera' ), true ) ) {
     115                                        $fields[ $id ]['file'] = true;
     116                                }
    111117                        }
    112118
  • techwave-form-sync-for-zoho/trunk/languages/techwave-form-sync-for-zoho.pot

    r3649280 r3748247  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Techwave Form Sync for Zoho 1.0.0\n"
     5"Project-Id-Version: FormBridge for Zoho 1.1.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/techwave-form-sync-for-zoho\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-08-15T04:32:15+00:00\n"
     12"POT-Creation-Date: 2026-09-22T06:33:18+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: techwave-form-sync-for-zoho.php
    19 #: includes/Admin/FeedsPage.php:122
    20 #: includes/Admin/FeedsPage.php:384
    21 #: includes/Admin/FeedsPage.php:394
    22 #: includes/Admin/SettingsPage.php:178
     19#: includes/Admin/FeedsPage.php:195
    2320#: includes/Privacy/PrivacyProvider.php:65
    24 msgid "Techwave Form Sync for Zoho"
     21msgid "FormBridge for Zoho"
    2522msgstr ""
    2623
    2724#. Plugin URI of the plugin
     25#: techwave-form-sync-for-zoho.php
     26msgid "https://techwavesolutions.net/techwave-form-sync-for-zoho"
     27msgstr ""
     28
     29#. Description of the plugin
     30#: techwave-form-sync-for-zoho.php
     31msgid "Send WordPress form submissions to Zoho CRM, Bigin, Books, Desk, Campaigns and Marketing Automation."
     32msgstr ""
     33
     34#. Author of the plugin
     35#: techwave-form-sync-for-zoho.php
     36msgid "Techwave Solutions"
     37msgstr ""
     38
    2839#. Author URI of the plugin
    2940#: techwave-form-sync-for-zoho.php
    … …  
    3142msgstr ""
    3243
    33 #. Description of the plugin
    34 #: techwave-form-sync-for-zoho.php
    35 msgid "Send WordPress form submissions to Zoho CRM, Bigin, Books, Desk, Campaigns and Marketing Automation."
    36 msgstr ""
    37 
    38 #. Author of the plugin
    39 #: techwave-form-sync-for-zoho.php
    40 msgid "Techwave Solutions"
    41 msgstr ""
    42 
    43 #: includes/Admin/Ajax.php:138
    44 #: includes/Admin/Ajax.php:193
    45 #: includes/Admin/Ajax.php:210
    46 #: includes/Admin/Ajax.php:230
    47 #: includes/Admin/Ajax.php:254
    48 #: includes/Admin/Ajax.php:348
    49 #: includes/Admin/Ajax.php:378
    50 #: includes/Admin/Ajax.php:405
    51 #: includes/Admin/Ajax.php:488
    52 #: includes/Admin/Ajax.php:698
    53 #: includes/Admin/Ajax.php:820
     44#: includes/Admin/Ajax.php:147
     45#: includes/Admin/Ajax.php:206
     46#: includes/Admin/Ajax.php:223
     47#: includes/Admin/Ajax.php:258
     48#: includes/Admin/Ajax.php:290
     49#: includes/Admin/Ajax.php:314
     50#: includes/Admin/Ajax.php:412
     51#: includes/Admin/Ajax.php:442
     52#: includes/Admin/Ajax.php:469
     53#: includes/Admin/Ajax.php:552
     54#: includes/Admin/Ajax.php:801
     55#: includes/Admin/Ajax.php:835
     56#: includes/Admin/Ajax.php:1041
    5457#: includes/Admin/DeactivationFeedback.php:226
    55 #: includes/Admin/FeedsPage.php:1196
    56 #: includes/Admin/FeedsPage.php:1433
    57 #: includes/Admin/FeedsPage.php:1507
    58 #: includes/Admin/SettingsPage.php:231
    59 #: includes/Admin/SettingsPage.php:1353
     58#: includes/Admin/FeedsPage.php:2328
     59#: includes/Admin/FeedsPage.php:2596
     60#: includes/Admin/FeedsPage.php:2729
     61#: includes/Admin/SettingsPage.php:261
     62#: includes/Admin/SettingsPage.php:1279
     63#: includes/Admin/SettingsPage.php:1699
    6064msgid "Permission denied."
    6165msgstr ""
    6266
    63 #: includes/Admin/Ajax.php:145
     67#: includes/Admin/Ajax.php:154
    6468msgid "This site has no valid administration email address set, so there is nowhere to send notifications. Set one under Settings → General."
    6569msgstr ""
    6670
    67 #: includes/Admin/Ajax.php:154
    68 msgid "This is a test message from Techwave Form Sync for Zoho."
    69 msgstr ""
    70 
    71 #: includes/Admin/Ajax.php:156
     71#: includes/Admin/Ajax.php:163
     72msgid "This is a test message from FormBridge for Zoho."
     73msgstr ""
     74
     75#: includes/Admin/Ajax.php:165
    7276msgid "If you are reading it, this site can send the notification emails the plugin offers under Settings → Notifications."
    7377msgstr ""
    7478
    7579#. translators: %s: site name.
    76 #: includes/Admin/Ajax.php:163
    77 #, php-format
    78 msgid "[%s] Techwave Form Sync test email"
    79 msgstr ""
    80 
    81 #: includes/Admin/Ajax.php:170
    82 msgid "This site could not send the email. WordPress refused to hand it to a mailer, which usually means no mail transport is configured — many hosts need an SMTP plugin such as WP Mail SMTP. Notification emails will not arrive until that is fixed."
     80#: includes/Admin/Ajax.php:172
     81#, php-format
     82msgid "[%s] FormBridge test email"
     83msgstr ""
     84
     85#. translators: %s: the mailer's own reason, e.g. "Could not instantiate mail function."
     86#: includes/Admin/Ajax.php:181
     87#, php-format
     88msgid "This site could not send the email with the PHP mail function. Reason: %s Ask your hosting provider to enable PHP mail (sendmail) for this site. Notification emails will not arrive until that is fixed."
    8389msgstr ""
    8490
    8591#. translators: %s: administration email address.
    86 #: includes/Admin/Ajax.php:179
    87 #, php-format
    88 msgid "Test email sent to %s. Check that inbox — if it never arrives, the message left WordPress but was rejected or filtered further down the line."
    89 msgstr ""
    90 
    91 #: includes/Admin/Ajax.php:216
     92#: includes/Admin/Ajax.php:192
     93#, php-format
     94msgid "Test email sent to %s with the PHP mail function. Check that inbox. If it never arrives, the message left this server but was rejected or filtered further down the line."
     95msgstr ""
     96
     97#: includes/Admin/Ajax.php:229
    9298msgid "Missing feed id."
    9399msgstr ""
    94100
    95 #: includes/Admin/Ajax.php:237
    96 #: includes/Admin/Ajax.php:261
    97 #: includes/Admin/Ajax.php:495
     101#: includes/Admin/Ajax.php:297
     102#: includes/Admin/Ajax.php:321
     103#: includes/Admin/Ajax.php:559
    98104msgid "Feed not found."
    99105msgstr ""
    100106
    101 #: includes/Admin/Ajax.php:265
     107#: includes/Admin/Ajax.php:325
    102108msgid "(copy)"
    103109msgstr ""
    104110
    105 #: includes/Admin/Ajax.php:301
     111#: includes/Admin/Ajax.php:365
    106112msgid "Not available for this Zoho app."
    107113msgstr ""
    108114
    109 #: includes/Admin/Ajax.php:387
    110 #: includes/Admin/Ajax.php:414
     115#: includes/Admin/Ajax.php:451
     116#: includes/Admin/Ajax.php:478
    111117msgid "No rows selected."
    112118msgstr ""
    113119
    114120#. translators: %d: log row id.
    115 #: includes/Admin/Ajax.php:441
     121#: includes/Admin/Ajax.php:505
    116122#, php-format
    117123msgid "Row #%d: no submitted values were stored for this entry, so it cannot be resubmitted. Turn on \"Store submitted values in the log\" under Settings to make future entries resubmittable."
    … …  
    119125
    120126#. translators: %d: log row id.
    121 #: includes/Admin/Ajax.php:453
     127#: includes/Admin/Ajax.php:517
    122128#, php-format
    123129msgid "Row #%d: its feed no longer exists, so it cannot be resubmitted."
    … …  
    125131
    126132#. translators: %d: log row id.
    127 #: includes/Admin/Ajax.php:462
     133#: includes/Admin/Ajax.php:526
    128134#, php-format
    129135msgid "Row #%d: its feed is currently disabled."
    130136msgstr ""
    131137
    132 #: includes/Admin/Ajax.php:597
    133 #: includes/Admin/SettingsPage.php:129
    134 #: includes/Admin/SettingsPage.php:880
    135 #: includes/Auth/ConnectionManager.php:117
     138#: includes/Admin/Ajax.php:570
     139msgid "Zoho's own duplicate check"
     140msgstr ""
     141
     142#: includes/Admin/Ajax.php:665
     143#: includes/Admin/SettingsPage.php:142
     144#: includes/Admin/SettingsPage.php:1054
     145#: includes/Auth/ConnectionManager.php:201
    136146#: includes/Integrations/CrmIntegration.php:35
    137147msgid "Zoho CRM"
    138148msgstr ""
    139149
    140 #: includes/Admin/Ajax.php:598
    141 #: includes/Admin/SettingsPage.php:130
    142 #: includes/Admin/SettingsPage.php:881
    143 #: includes/Auth/ConnectionManager.php:118
     150#: includes/Admin/Ajax.php:666
     151#: includes/Admin/SettingsPage.php:143
     152#: includes/Admin/SettingsPage.php:1055
     153#: includes/Auth/ConnectionManager.php:202
    144154#: includes/Integrations/BooksIntegration.php:35
    145155msgid "Zoho Books"
    146156msgstr ""
    147157
    148 #: includes/Admin/Ajax.php:599
    149 #: includes/Admin/SettingsPage.php:131
    150 #: includes/Admin/SettingsPage.php:882
    151 #: includes/Auth/ConnectionManager.php:119
     158#: includes/Admin/Ajax.php:667
     159#: includes/Admin/SettingsPage.php:144
     160#: includes/Admin/SettingsPage.php:1056
     161#: includes/Auth/ConnectionManager.php:203
    152162#: includes/Integrations/DeskIntegration.php:35
    153163msgid "Zoho Desk"
    154164msgstr ""
    155165
    156 #: includes/Admin/Ajax.php:600
    157 #: includes/Admin/SettingsPage.php:132
    158 #: includes/Admin/SettingsPage.php:883
    159 #: includes/Auth/ConnectionManager.php:120
     166#: includes/Admin/Ajax.php:668
     167#: includes/Admin/SettingsPage.php:145
     168#: includes/Admin/SettingsPage.php:1057
     169#: includes/Auth/ConnectionManager.php:204
    160170#: includes/Integrations/BiginIntegration.php:35
    161171msgid "Zoho Bigin"
    162172msgstr ""
    163173
    164 #: includes/Admin/Ajax.php:601
    165 #: includes/Admin/SettingsPage.php:133
    166 #: includes/Admin/SettingsPage.php:884
    167 #: includes/Auth/ConnectionManager.php:121
     174#: includes/Admin/Ajax.php:669
     175#: includes/Admin/SettingsPage.php:146
     176#: includes/Admin/SettingsPage.php:1058
     177#: includes/Auth/ConnectionManager.php:205
    168178#: includes/Integrations/CampaignsIntegration.php:53
    169179msgid "Zoho Campaigns"
    170180msgstr ""
    171181
    172 #: includes/Admin/Ajax.php:602
    173 #: includes/Admin/SettingsPage.php:134
    174 #: includes/Admin/SettingsPage.php:885
    175 #: includes/Auth/ConnectionManager.php:122
     182#: includes/Admin/Ajax.php:670
     183#: includes/Admin/SettingsPage.php:147
     184#: includes/Admin/SettingsPage.php:1059
     185#: includes/Auth/ConnectionManager.php:206
    176186#: includes/Integrations/MarketingAutomationIntegration.php:41
    177187msgid "Zoho Marketing Automation"
    … …  
    179189
    180190#. translators: %s: form plugin name, e.g. "Contact Form 7".
    181 #: includes/Admin/Ajax.php:711
    182 #, php-format
    183 msgid "%s is not active on this site."
    184 msgstr ""
    185 
    186 #: includes/Admin/Ajax.php:737
    187 #: includes/Admin/Ajax.php:796
    188 #: includes/Admin/FeedsPage.php:1208
     191#: includes/Admin/Ajax.php:848
     192#: includes/Admin/FeedsPage.php:402
     193#, php-format
     194msgid "%s is not installed or activated."
     195msgstr ""
     196
     197#: includes/Admin/Ajax.php:874
     198#: includes/Admin/Ajax.php:948
     199#: includes/Admin/FeedsPage.php:2340
    189200msgid "Unknown integration."
    190201msgstr ""
    191202
    192 #: includes/Admin/Ajax.php:791
     203#: includes/Admin/Ajax.php:943
    193204#: includes/Integrations/BiginIntegration.php:66
    194205#: includes/Integrations/CrmIntegration.php:57
    … …  
    196207msgstr ""
    197208
    198 #: includes/Admin/Ajax.php:830
     209#: includes/Admin/Ajax.php:1051
     210msgid "No integration selected."
     211msgstr ""
     212
     213#: includes/Admin/Ajax.php:1060
    199214msgid "Unknown or disconnected account."
    200215msgstr ""
    201216
    202 #: includes/Admin/Ajax.php:834
    203 msgid "No integration selected."
     217#. translators: 1: Zoho app name, e.g. "Zoho Books". 2: the same app name.
     218#: includes/Admin/Ajax.php:1114
     219#, php-format
     220msgid "This connection is not authorized for %1$s. Use \"Edit access\" on the connection, tick %2$s, then press Update & Reauthorize."
    204221msgstr ""
    205222
    … …  
    229246
    230247#: includes/Admin/DeactivationFeedback.php:175
    231 #: includes/Admin/FeedsPage.php:295
    232 #: includes/Admin/FeedsPage.php:744
    233 #: includes/Admin/FeedsPage.php:752
    234 #: includes/Admin/SettingsPage.php:492
    235 #: includes/Admin/SettingsPage.php:794
     248#: includes/Admin/FeedsPage.php:459
     249#: includes/Admin/FeedsPage.php:909
     250#: includes/Admin/FeedsPage.php:917
     251#: includes/Admin/ProUpsell.php:394
     252#: includes/Admin/SettingsPage.php:563
     253#: includes/Admin/SettingsPage.php:944
    236254msgid "Close"
    237255msgstr ""
    … …  
    242260
    243261#: includes/Admin/DeactivationFeedback.php:182
    244 msgid "If you have a moment, what made you deactivate Techwave Form Sync for Zoho? Answering is entirely optional — nothing is sent unless you choose to send it."
     262msgid "If you have a moment, what made you deactivate FormBridge for Zoho? Answering is entirely optional, and nothing is sent unless you choose to send it."
    245263msgstr ""
    246264
    … …  
    265283msgstr ""
    266284
    267 #: includes/Admin/FeedsPage.php:131
    268 msgid "Techwave Sync"
    269 msgstr ""
    270 
    271 #: includes/Admin/FeedsPage.php:150
    272 #: includes/Admin/FeedsPage.php:151
    273 msgid "Forms"
    274 msgstr ""
    275 
    276 #: includes/Admin/FeedsPage.php:237
    277 #: includes/Admin/FeedsPage.php:293
     285#: includes/Admin/FeedsPage.php:69
     286#: includes/Admin/FeedsPage.php:735
     287msgid "Feed Name"
     288msgstr ""
     289
     290#: includes/Admin/FeedsPage.php:70
     291#: includes/Admin/FeedsPage.php:736
     292#: includes/Admin/FeedsPage.php:1073
     293#: includes/Admin/FeedsPage.php:1220
     294#: includes/Admin/FeedsPage.php:2142
     295msgid "Trigger"
     296msgstr ""
     297
     298#: includes/Admin/FeedsPage.php:71
     299#: includes/Admin/FeedsPage.php:737
     300msgid "Action Name"
     301msgstr ""
     302
     303#: includes/Admin/FeedsPage.php:72
     304#: includes/Admin/FeedsPage.php:738
     305msgid "Created At"
     306msgstr ""
     307
     308#. translators: %s: the site's timezone, e.g. Asia/Dhaka or UTC+06:00.
     309#: includes/Admin/FeedsPage.php:73
     310#: includes/Admin/FeedsPage.php:481
     311#: includes/Admin/FeedsPage.php:739
     312#: includes/Admin/SettingsPage.php:778
     313#: includes/Admin/SettingsPage.php:1023
     314#: includes/Api/DeskClient.php:232
     315msgid "Status"
     316msgstr ""
     317
     318#: includes/Admin/FeedsPage.php:223
     319#: includes/Admin/FeedsPage.php:224
     320msgid "All Feeds"
     321msgstr ""
     322
     323#. translators: the product's name. First half of "FormBridge for Zoho", set in bold; a brand name, so normally left as-is.
     324#: includes/Admin/FeedsPage.php:248
     325#: includes/Support/Branding.php:162
     326msgid "FormBridge"
     327msgstr ""
     328
     329#: includes/Admin/FeedsPage.php:362
     330#: includes/Admin/FeedsPage.php:457
    278331msgid "Loading…"
    279332msgstr ""
    280333
    281 #: includes/Admin/FeedsPage.php:238
    282 #: includes/Admin/FeedsPage.php:313
     334#. translators: %s: link text, e.g. "Contact Form 7 guide".
     335#. translators: %s: link text, e.g. "Trigger guide".
     336#. translators: %s: link text, e.g. "Watch the setup video".
     337#: includes/Admin/FeedsPage.php:364
     338#: includes/Admin/FeedsPage.php:2015
     339#: includes/Support/VideoGuide.php:97
     340#, php-format
     341msgid "%s (opens in a new tab)"
     342msgstr ""
     343
     344#: includes/Admin/FeedsPage.php:365
     345#: includes/Admin/FeedsPage.php:477
    283346msgid "View Record in Zoho"
    284347msgstr ""
    285348
    286 #: includes/Admin/FeedsPage.php:239
    287 #: includes/Admin/FeedsPage.php:311
    288 #: includes/Admin/SettingsPage.php:871
     349#: includes/Admin/FeedsPage.php:366
     350#: includes/Admin/FeedsPage.php:475
     351#: includes/Admin/SettingsPage.php:1027
    289352msgid "Record ID"
    290353msgstr ""
    291354
    292 #: includes/Admin/FeedsPage.php:240
    293 #: includes/Admin/FeedsPage.php:294
     355#: includes/Admin/FeedsPage.php:367
     356#: includes/Admin/FeedsPage.php:458
    294357msgid "Request failed."
    295358msgstr ""
    296359
    297 #: includes/Admin/FeedsPage.php:241
     360#: includes/Admin/FeedsPage.php:368
    298361msgid "Status changed successfully"
    299362msgstr ""
    300363
    301 #: includes/Admin/FeedsPage.php:242
    302 msgid "— Select a form —"
    303 msgstr ""
    304 
    305 #: includes/Admin/FeedsPage.php:243
    306 msgid "— Select organization —"
    307 msgstr ""
    308 
    309 #: includes/Admin/FeedsPage.php:244
    310 msgid "— Select module —"
    311 msgstr ""
    312 
    313 #: includes/Admin/FeedsPage.php:245
    314 msgid "— Select department —"
    315 msgstr ""
    316 
    317 #: includes/Admin/FeedsPage.php:246
    318 #: includes/Admin/FeedsPage.php:1157
    319 msgid "— Select Zoho field —"
    320 msgstr ""
    321 
    322 #: includes/Admin/FeedsPage.php:247
    323 #: includes/Admin/FeedsPage.php:1171
    324 msgid "— Select form field —"
    325 msgstr ""
    326 
    327 #: includes/Admin/FeedsPage.php:248
     364#: includes/Admin/FeedsPage.php:369
     365msgid "Select a form"
     366msgstr ""
     367
     368#: includes/Admin/FeedsPage.php:370
     369msgid "Select organization"
     370msgstr ""
     371
     372#: includes/Admin/FeedsPage.php:371
     373msgid "Select module"
     374msgstr ""
     375
     376#: includes/Admin/FeedsPage.php:372
     377msgid "Select department"
     378msgstr ""
     379
     380#: includes/Admin/FeedsPage.php:373
     381#: includes/Admin/FeedsPage.php:2289
     382msgid "Select Zoho field"
     383msgstr ""
     384
     385#: includes/Admin/FeedsPage.php:374
     386#: includes/Admin/FeedsPage.php:2279
     387msgid "Select form field"
     388msgstr ""
     389
     390#: includes/Admin/FeedsPage.php:376
     391msgid "Type to search"
     392msgstr ""
     393
     394#: includes/Admin/FeedsPage.php:377
     395msgid "No field matches"
     396msgstr ""
     397
     398#. translators: shown in the mapping row's text box when the row combines several form fields.
     399#: includes/Admin/FeedsPage.php:379
     400msgid "e.g. {first-name} {last-name}. Pick a form field to insert one"
     401msgstr ""
     402
     403#. translators: %d: number of matching fields.
     404#: includes/Admin/FeedsPage.php:381
     405#, php-format
     406msgid "%d fields match"
     407msgstr ""
     408
     409#: includes/Admin/FeedsPage.php:382
    328410msgid "Mailing List"
    329411msgstr ""
    330412
    331 #: includes/Admin/FeedsPage.php:249
     413#: includes/Admin/FeedsPage.php:383
    332414msgid "Loaded from Zoho once the account is chosen."
    333415msgstr ""
    334416
    335 #: includes/Admin/FeedsPage.php:250
    336 #: includes/Admin/FeedsPage.php:964
     417#: includes/Admin/FeedsPage.php:384
     418#: includes/Admin/FeedsPage.php:1468
    337419msgid "Module / resource"
    338420msgstr ""
    339421
    340 #: includes/Admin/FeedsPage.php:251
    341 #: includes/Admin/FeedsPage.php:976
     422#: includes/Admin/FeedsPage.php:385
     423#: includes/Admin/FeedsPage.php:1480
    342424msgid "Loaded from Zoho once the app and account are chosen."
    343425msgstr ""
    344426
    345 #: includes/Admin/FeedsPage.php:252
    346 #: includes/Admin/FeedsPage.php:977
     427#: includes/Admin/FeedsPage.php:386
     428#: includes/Admin/FeedsPage.php:1481
    347429msgid "No Mailing Lists found in Zoho Campaigns."
    348430msgstr ""
    349431
    350 #: includes/Admin/FeedsPage.php:253
     432#: includes/Admin/FeedsPage.php:387
    351433msgid "No Mailing Lists found in Zoho Marketing Automation."
    352434msgstr ""
    353435
    354 #: includes/Admin/FeedsPage.php:254
     436#: includes/Admin/FeedsPage.php:388
    355437msgid "Search for topic"
    356438msgstr ""
    357439
    358 #: includes/Admin/FeedsPage.php:255
     440#: includes/Admin/FeedsPage.php:389
    359441msgid "Search for tag"
    360442msgstr ""
    361443
    362 #: includes/Admin/FeedsPage.php:256
    363 #: includes/Admin/FeedsPage.php:310
     444#: includes/Admin/FeedsPage.php:390
     445#: includes/Admin/FeedsPage.php:474
    364446msgid "No matches"
    365447msgstr ""
    366448
    367449#. translators: %s: the stale saved value that no longer matches anything Zoho returned.
    368 #: includes/Admin/FeedsPage.php:258
    369 #, php-format
    370 msgid "%s (not found — please reselect)"
    371 msgstr ""
    372 
    373 #: includes/Admin/FeedsPage.php:259
     450#: includes/Admin/FeedsPage.php:392
     451#, php-format
     452msgid "%s (not found, please reselect)"
     453msgstr ""
     454
     455#. translators: %s: Zoho app name.
     456#: includes/Admin/FeedsPage.php:394
     457#, php-format
     458msgid "%s (not authorised for this account)"
     459msgstr ""
     460
     461#: includes/Admin/FeedsPage.php:395
    374462msgid "Send one real test record to Zoho now?"
    375463msgstr ""
    376464
    377 #: includes/Admin/FeedsPage.php:260
     465#: includes/Admin/FeedsPage.php:396
    378466msgid "Test successful"
    379467msgstr ""
    380468
    381 #: includes/Admin/FeedsPage.php:261
     469#: includes/Admin/FeedsPage.php:397
    382470msgid "Test failed"
    383471msgstr ""
    384472
    385 #: includes/Admin/FeedsPage.php:262
    386 #: includes/Admin/FeedsPage.php:894
     473#: includes/Admin/FeedsPage.php:398
     474#: includes/Admin/FeedsPage.php:1286
    387475msgid "Pick the form to connect. The list is read from your saved forms."
    388476msgstr ""
    389477
    390 #: includes/Admin/FeedsPage.php:263
     478#: includes/Admin/FeedsPage.php:399
    391479msgid "This form could not be found in the current list. Please pick the form again and save, or submissions will never match."
    392480msgstr ""
    393481
    394 #: includes/Admin/FeedsPage.php:264
     482#: includes/Admin/FeedsPage.php:400
    395483msgid "Contact Form 7 is not active on this site."
    396484msgstr ""
    397485
    398486#. translators: %s: comma-separated list of required Zoho field names that are still unmapped.
    399 #: includes/Admin/FeedsPage.php:266
     487#: includes/Admin/FeedsPage.php:404
    400488#, php-format
    401489msgid "These required Zoho fields are not mapped yet: %s. Map them (or set a static value) before saving."
    402490msgstr ""
    403491
    404 #: includes/Admin/FeedsPage.php:267
    405 #: includes/Admin/FeedsPage.php:1069
     492#: includes/Admin/FeedsPage.php:405
     493#: includes/Admin/FeedsPage.php:1709
    406494msgid "Missing required fields"
    407495msgstr ""
    408496
    409497#. translators: %s: comma-separated list of Zoho field names whose mapping row is unfinished.
    410 #: includes/Admin/FeedsPage.php:269
    411 #, php-format
    412 msgid "These rows name a Zoho field but have nothing to send: %s. Pick a form field or type a static value, or remove the row — otherwise it will not be saved."
    413 msgstr ""
    414 
    415 #: includes/Admin/FeedsPage.php:270
     498#: includes/Admin/FeedsPage.php:407
     499#, php-format
     500msgid "These rows name a Zoho field but have nothing to send: %s. Pick a form field or type a static value, or remove the row. Otherwise it will not be saved."
     501msgstr ""
     502
     503#: includes/Admin/FeedsPage.php:408
    416504msgid "Unfinished mapping rows"
    417505msgstr ""
    418506
    419 #: includes/Admin/FeedsPage.php:271
    420 msgid "Every mapping row needs a Zoho field. The highlighted rows have none — pick a Zoho field for each, or remove the row."
    421 msgstr ""
    422 
    423 #: includes/Admin/FeedsPage.php:272
    424 msgid "The field mapping was cleared because the Zoho app, account or module changed — the previous fields belong to the target you just switched away from. Please map the fields again."
     507#: includes/Admin/FeedsPage.php:409
     508msgid "Every mapping row needs a Zoho field. The highlighted rows have none. Pick a Zoho field for each, or remove the row."
     509msgstr ""
     510
     511#: includes/Admin/FeedsPage.php:410
     512msgid "Creating feed…"
     513msgstr ""
     514
     515#: includes/Admin/FeedsPage.php:411
     516msgid "Saving feed…"
     517msgstr ""
     518
     519#. translators: %s: a form field's label.
     520#: includes/Admin/FeedsPage.php:413
     521#, php-format
     522msgid "Remove %s"
     523msgstr ""
     524
     525#: includes/Admin/FeedsPage.php:414
     526msgid "Try again"
     527msgstr ""
     528
     529#. translators: 1: Zoho module name, e.g. "Leads". 2: Zoho app name, e.g. "Zoho CRM".
     530#: includes/Admin/FeedsPage.php:416
     531#, php-format
     532msgid "Fetching %1$s fields from %2$s…"
     533msgstr ""
     534
     535#. translators: %s: Zoho app name, e.g. "Zoho Campaigns".
     536#: includes/Admin/FeedsPage.php:418
     537#, php-format
     538msgid "Fetching fields from %s…"
     539msgstr ""
     540
     541#: includes/Admin/FeedsPage.php:419
     542msgid "Static values Zoho will not accept"
     543msgstr ""
     544
     545#: includes/Admin/FeedsPage.php:420
     546#: includes/Admin/SettingsPage.php:167
     547msgid "Required"
     548msgstr ""
     549
     550#: includes/Admin/FeedsPage.php:421
     551msgid "Zoho needs this field on every record, so this row cannot be removed or pointed at another field."
     552msgstr ""
     553
     554#. translators: %s: Zoho field name, e.g. "Phone".
     555#: includes/Admin/FeedsPage.php:423
     556#, php-format
     557msgid "%s needs a phone number: digits, spaces and + - ( ) only."
     558msgstr ""
     559
     560#. translators: %s: Zoho field name, e.g. "Email".
     561#: includes/Admin/FeedsPage.php:425
     562#, php-format
     563msgid "%s needs an email address, such as name@example.com."
     564msgstr ""
     565
     566#. translators: %s: Zoho field name, e.g. "No. of Employees".
     567#: includes/Admin/FeedsPage.php:427
     568#, php-format
     569msgid "%s needs a whole number."
     570msgstr ""
     571
     572#. translators: %s: Zoho field name, e.g. "Annual Revenue".
     573#: includes/Admin/FeedsPage.php:429
     574#, php-format
     575msgid "%s needs a number, such as 12.50."
     576msgstr ""
     577
     578#. translators: %s: Zoho field name, e.g. "Date of Birth".
     579#: includes/Admin/FeedsPage.php:431
     580#, php-format
     581msgid "%s needs a date written as YYYY-MM-DD."
     582msgstr ""
     583
     584#. translators: %s: Zoho field name, e.g. "Follow-up Time".
     585#: includes/Admin/FeedsPage.php:433
     586#, php-format
     587msgid "%s needs a date and time written as YYYY-MM-DDTHH:MM:SS."
     588msgstr ""
     589
     590#. translators: %s: Zoho field name, e.g. "Website".
     591#: includes/Admin/FeedsPage.php:435
     592#, php-format
     593msgid "%s needs a web address, such as https://example.com."
     594msgstr ""
     595
     596#: includes/Admin/FeedsPage.php:436
     597msgid "The field mapping was cleared because the Zoho app, account or module changed. The previous fields belong to the target you just switched away from. Please map the fields again."
    425598msgstr ""
    426599
    427600#. translators: 1: currently authorized app(s), 2: app the person just picked.
    428 #: includes/Admin/FeedsPage.php:274
     601#: includes/Admin/FeedsPage.php:438
    429602#, php-format
    430603msgid "This account is only authorized for %1$s. You need to re-authorize it for %2$s before you can fetch this data."
    431604msgstr ""
    432605
    433 #: includes/Admin/FeedsPage.php:275
     606#: includes/Admin/FeedsPage.php:439
    434607msgid "Fix this under Connections → Edit access."
    435608msgstr ""
    436609
    437 #: includes/Admin/FeedsPage.php:296
     610#: includes/Admin/FeedsPage.php:460
    438611msgid "Please confirm"
    439612msgstr ""
    440613
    441 #: includes/Admin/FeedsPage.php:297
    442 #: includes/Admin/SettingsPage.php:555
     614#: includes/Admin/FeedsPage.php:461
     615#: includes/Admin/SettingsPage.php:646
    443616msgid "Cancel"
    444617msgstr ""
    445618
    446 #: includes/Admin/FeedsPage.php:301
    447 #: includes/Admin/FeedsPage.php:671
    448 #: includes/Admin/SettingsPage.php:740
     619#: includes/Admin/FeedsPage.php:465
     620#: includes/Admin/FeedsPage.php:836
     621#: includes/Admin/SettingsPage.php:886
    449622msgid "Delete"
    450623msgstr ""
    451624
    452 #: includes/Admin/FeedsPage.php:302
     625#: includes/Admin/FeedsPage.php:466
    453626msgid "Send"
    454627msgstr ""
    455628
    456 #: includes/Admin/FeedsPage.php:303
     629#: includes/Admin/FeedsPage.php:467
    457630msgid "Resubmit"
    458631msgstr ""
    459632
    460 #: includes/Admin/FeedsPage.php:304
     633#: includes/Admin/FeedsPage.php:468
    461634msgid "Copied"
    462635msgstr ""
    463636
    464 #: includes/Admin/FeedsPage.php:305
     637#: includes/Admin/FeedsPage.php:469
    465638msgid "Feed"
    466639msgstr ""
    467640
    468 #: includes/Admin/FeedsPage.php:306
    469 #: includes/Admin/FeedsPage.php:659
     641#: includes/Admin/FeedsPage.php:470
     642#: includes/Admin/FeedsPage.php:824
    470643msgid "Timeline"
    471644msgstr ""
    472645
    473 #: includes/Admin/FeedsPage.php:307
    474 #: includes/Admin/FeedsPage.php:838
     646#: includes/Admin/FeedsPage.php:471
    475647msgid "Enabled"
    476648msgstr ""
    477649
    478 #: includes/Admin/FeedsPage.php:308
     650#: includes/Admin/FeedsPage.php:472
    479651msgid "Disabled"
    480652msgstr ""
    481653
    482 #: includes/Admin/FeedsPage.php:309
     654#: includes/Admin/FeedsPage.php:473
    483655msgid "No activity logged for this feed yet."
    484656msgstr ""
    485657
    486 #: includes/Admin/FeedsPage.php:312
    487 #: includes/Admin/SettingsPage.php:869
     658#: includes/Admin/FeedsPage.php:476
     659#: includes/Admin/SettingsPage.php:1025
    488660msgid "Module"
    489661msgstr ""
    490662
    491 #: includes/Admin/FeedsPage.php:314
    492 #: includes/Admin/FeedsPage.php:881
    493 #: includes/Admin/SettingsPage.php:870
     663#: includes/Admin/FeedsPage.php:478
     664#: includes/Admin/FeedsPage.php:1272
     665#: includes/Admin/SettingsPage.php:1026
    494666#: includes/Privacy/PrivacyProvider.php:164
    495667msgid "Form"
    496668msgstr ""
    497669
    498 #: includes/Admin/FeedsPage.php:315
    499 #: includes/Admin/FeedsPage.php:858
    500 #: includes/Admin/FeedsPage.php:859
     670#: includes/Admin/FeedsPage.php:479
    501671msgid "Form plugin"
    502672msgstr ""
    503673
    504 #: includes/Admin/FeedsPage.php:316
     674#: includes/Admin/FeedsPage.php:480
     675#: includes/Admin/FeedsPage.php:2150
    505676msgid "Destination"
    506677msgstr ""
    507678
    508 #: includes/Admin/FeedsPage.php:317
    509 #: includes/Admin/FeedsPage.php:529
    510 #: includes/Admin/FeedsPage.php:576
    511 #: includes/Admin/SettingsPage.php:636
    512 #: includes/Admin/SettingsPage.php:867
    513 #: includes/Api/DeskClient.php:165
    514 msgid "Status"
    515 msgstr ""
    516 
    517 #: includes/Admin/FeedsPage.php:318
     679#: includes/Admin/FeedsPage.php:482
    518680msgid "Mapped fields"
    519681msgstr ""
    520682
    521 #: includes/Admin/FeedsPage.php:319
    522 #: includes/Admin/FeedsPage.php:1040
     683#: includes/Admin/FeedsPage.php:483
    523684msgid "Duplicate check"
    524685msgstr ""
    525686
    526687#. translators: %d: number of selected rows. Always 1 here.
    527 #: includes/Admin/FeedsPage.php:321
     688#: includes/Admin/FeedsPage.php:485
    528689#, php-format
    529690msgid "%d row selected"
    … …  
    531692
    532693#. translators: %d: number of selected rows.
    533 #: includes/Admin/FeedsPage.php:323
     694#: includes/Admin/FeedsPage.php:487
    534695#, php-format
    535696msgid "%d rows selected"
    536697msgstr ""
    537698
    538 #: includes/Admin/FeedsPage.php:324
     699#: includes/Admin/FeedsPage.php:488
    539700msgid "Delete this feed?"
    540701msgstr ""
    541702
    542703#. translators: %d: number of feeds.
    543 #: includes/Admin/FeedsPage.php:326
     704#: includes/Admin/FeedsPage.php:490
    544705#, php-format
    545706msgid "Delete %d feeds?"
    546707msgstr ""
    547708
    548 #: includes/Admin/FeedsPage.php:327
    549 #: includes/Admin/FeedsPage.php:507
     709#: includes/Admin/FeedsPage.php:491
     710#: includes/Admin/FeedsPage.php:677
    550711msgid "Select all"
    551712msgstr ""
    552713
    553 #: includes/Admin/FeedsPage.php:333
    554 #: includes/Admin/SettingsPage.php:921
     714#: includes/Admin/FeedsPage.php:497
     715#: includes/Admin/SettingsPage.php:1115
    555716msgid "Success"
    556717msgstr ""
    557718
    558 #: includes/Admin/FeedsPage.php:334
     719#: includes/Admin/FeedsPage.php:498
    559720msgid "Failed"
    560721msgstr ""
    561722
    562 #: includes/Admin/FeedsPage.php:335
    563 #: includes/Admin/SettingsPage.php:925
     723#: includes/Admin/FeedsPage.php:499
     724#: includes/Admin/SettingsPage.php:1119
    564725msgid "Error"
    565726msgstr ""
    566727
    567 #: includes/Admin/FeedsPage.php:336
     728#: includes/Admin/FeedsPage.php:500
    568729msgid "Retry"
    569730msgstr ""
    570731
    571 #: includes/Admin/FeedsPage.php:337
    572 #: includes/Admin/FeedsPage.php:655
     732#: includes/Admin/FeedsPage.php:501
     733#: includes/Admin/FeedsPage.php:820
    573734msgid "Info"
    574735msgstr ""
    575736
    576 #: includes/Admin/FeedsPage.php:338
    577 #: includes/Admin/SettingsPage.php:923
     737#: includes/Admin/FeedsPage.php:502
     738#: includes/Admin/SettingsPage.php:1117
    578739msgid "Suppressed"
    579740msgstr ""
    580741
    581 #: includes/Admin/FeedsPage.php:339
    582 #: includes/Admin/SettingsPage.php:873
    583 #: includes/Admin/SettingsPage.php:946
     742#: includes/Admin/FeedsPage.php:503
     743#: includes/Admin/FeedsPage.php:2146
     744#: includes/Admin/SettingsPage.php:1029
     745#: includes/Admin/SettingsPage.php:1141
    584746msgid "Details"
    585747msgstr ""
    586748
    587 #: includes/Admin/FeedsPage.php:340
    588 #: includes/Admin/SettingsPage.php:953
     749#: includes/Admin/FeedsPage.php:504
     750#: includes/Admin/SettingsPage.php:1148
    589751msgid "Copy"
    590752msgstr ""
    591753
    592 #: includes/Admin/FeedsPage.php:341
    593 #: includes/Admin/SettingsPage.php:514
    594 #: includes/Admin/SettingsPage.php:951
     754#: includes/Admin/FeedsPage.php:505
     755#: includes/Admin/SettingsPage.php:601
     756#: includes/Admin/SettingsPage.php:1146
    595757msgid "Copy to clipboard"
    596758msgstr ""
    597759
    598 #: includes/Admin/FeedsPage.php:344
    599 #: includes/Admin/SettingsPage.php:826
     760#: includes/Admin/FeedsPage.php:508
     761#: includes/Admin/SettingsPage.php:982
    600762msgid "Select failed"
    601763msgstr ""
    602764
    603 #: includes/Admin/FeedsPage.php:345
     765#: includes/Admin/FeedsPage.php:509
    604766msgid "Unselect failed"
    605767msgstr ""
    606768
    607 #: includes/Admin/FeedsPage.php:346
     769#: includes/Admin/FeedsPage.php:510
    608770msgid "This log entry will be permanently deleted. Continue?"
    609771msgstr ""
    610772
    611773#. translators: %d: number of log entries.
    612 #: includes/Admin/FeedsPage.php:348
     774#: includes/Admin/FeedsPage.php:512
    613775#, php-format
    614776msgid "%d log entries will be permanently deleted. Continue?"
    … …  
    616778
    617779#. translators: %d: number of log entries.
    618 #: includes/Admin/FeedsPage.php:350
     780#: includes/Admin/FeedsPage.php:514
    619781#, php-format
    620782msgid "%d log entries deleted."
    621783msgstr ""
    622784
    623 #: includes/Admin/FeedsPage.php:351
     785#: includes/Admin/FeedsPage.php:515
    624786msgid "Could not delete the selected entries."
    625787msgstr ""
    626788
    627 #: includes/Admin/FeedsPage.php:352
     789#: includes/Admin/FeedsPage.php:516
    628790msgid "Resend the selected entry to Zoho now?"
    629791msgstr ""
    630792
    631793#. translators: %d: number of log entries.
    632 #: includes/Admin/FeedsPage.php:354
     794#: includes/Admin/FeedsPage.php:518
    633795#, php-format
    634796msgid "Resend %d selected entries to Zoho now?"
    635797msgstr ""
    636798
    637 #: includes/Admin/FeedsPage.php:355
    638 msgid "Entry resubmitted — check the log for the result."
     799#: includes/Admin/FeedsPage.php:519
     800msgid "Entry resubmitted. Check the log for the result."
    639801msgstr ""
    640802
    641803#. translators: %d: number of log entries.
    642 #: includes/Admin/FeedsPage.php:357
    643 #, php-format
    644 msgid "%d entries resubmitted — check the log for the result."
    645 msgstr ""
    646 
    647 #: includes/Admin/FeedsPage.php:358
     804#: includes/Admin/FeedsPage.php:521
     805#, php-format
     806msgid "%d entries resubmitted. Check the log for the result."
     807msgstr ""
     808
     809#: includes/Admin/FeedsPage.php:522
    648810msgid "Nothing to resubmit."
    649811msgstr ""
    650812
    651 #: includes/Admin/FeedsPage.php:359
     813#: includes/Admin/FeedsPage.php:523
    652814msgid "Could not resubmit the selected entries."
    653815msgstr ""
    654816
    655 #: includes/Admin/FeedsPage.php:370
    656 #: includes/Admin/SettingsPage.php:171
     817#. translators: 1: current page number, 2: total pages, 3: total number of log entries. Kept in step with SettingsPage::logs_page_info(), which renders the same line on page load; this copy is only redrawn when live polling grows the count, so it never has to say "1 entry".
     818#: includes/Admin/FeedsPage.php:525
     819#, php-format
     820msgid "Page %1$s of %2$s · %3$s entries"
     821msgstr ""
     822
     823#: includes/Admin/FeedsPage.php:536
     824#: includes/Admin/SettingsPage.php:185
    657825msgid "You do not have permission to view this page."
    658826msgstr ""
    659827
    660 #: includes/Admin/FeedsPage.php:384
    661 #: includes/Admin/FeedsPage.php:394
    662 #: includes/Admin/SettingsPage.php:178
    663 msgid "TFS"
    664 msgstr ""
    665 
    666 #: includes/Admin/FeedsPage.php:421
     828#: includes/Admin/FeedsPage.php:585
    667829msgid "Feeds"
    668830msgstr ""
    669831
    670 #: includes/Admin/FeedsPage.php:422
     832#: includes/Admin/FeedsPage.php:586
    671833msgid "Each feed sends a matching Elementor or Contact Form 7 form to one Zoho destination. A form can have more than one feed."
    672834msgstr ""
    673835
    674 #: includes/Admin/FeedsPage.php:426
    675 #: includes/Admin/FeedsPage.php:458
     836#: includes/Admin/FeedsPage.php:590
     837#: includes/Admin/FeedsPage.php:622
    676838msgid "Add New Feed"
    677839msgstr ""
    678840
    679 #: includes/Admin/FeedsPage.php:454
     841#: includes/Admin/FeedsPage.php:618
    680842msgid "No feeds yet"
    681843msgstr ""
    682844
    683 #: includes/Admin/FeedsPage.php:455
     845#: includes/Admin/FeedsPage.php:619
    684846msgid "A feed connects one of your forms to one Zoho destination. Create your first one to start sending submissions."
    685847msgstr ""
    686848
    687 #: includes/Admin/FeedsPage.php:513
    688 msgid "All apps"
    689 msgstr ""
    690 
    691 #: includes/Admin/FeedsPage.php:520
     849#: includes/Admin/FeedsPage.php:629
     850#: includes/Admin/SettingsPage.php:769
     851msgid "Watch the full setup"
     852msgstr ""
     853
     854#: includes/Admin/FeedsPage.php:683
     855msgid "All Apps"
     856msgstr ""
     857
     858#: includes/Admin/FeedsPage.php:690
    692859msgid "Show/hide columns"
    693860msgstr ""
    694861
    695 #: includes/Admin/FeedsPage.php:525
    696 #: includes/Admin/FeedsPage.php:572
    697 msgid "Feed Name"
    698 msgstr ""
    699 
    700 #: includes/Admin/FeedsPage.php:526
    701 #: includes/Admin/FeedsPage.php:573
    702 msgid "Trigger"
    703 msgstr ""
    704 
    705 #: includes/Admin/FeedsPage.php:527
    706 #: includes/Admin/FeedsPage.php:574
    707 msgid "Action Name"
    708 msgstr ""
    709 
    710 #: includes/Admin/FeedsPage.php:528
    711 #: includes/Admin/FeedsPage.php:575
    712 msgid "Created At"
    713 msgstr ""
    714 
    715 #: includes/Admin/FeedsPage.php:537
    716 #: includes/Admin/SettingsPage.php:828
     862#: includes/Admin/FeedsPage.php:700
     863#: includes/Admin/SettingsPage.php:984
    717864msgid "Delete selected"
    718865msgstr ""
    719866
    720 #: includes/Admin/FeedsPage.php:550
     867#: includes/Admin/FeedsPage.php:713
    721868msgid "Search feeds by name"
    722869msgstr ""
    723870
    724 #: includes/Admin/FeedsPage.php:551
     871#: includes/Admin/FeedsPage.php:714
    725872msgid "Search feed name…"
    726873msgstr ""
    727874
    728875#. translators: %d: number of feeds.
    729 #: includes/Admin/FeedsPage.php:557
     876#: includes/Admin/FeedsPage.php:720
    730877#, php-format
    731878msgid "%d feed"
    … …  
    734881msgstr[1] ""
    735882
    736 #: includes/Admin/FeedsPage.php:571
     883#: includes/Admin/FeedsPage.php:734
    737884msgid "Select all feeds"
    738885msgstr ""
    739886
    740 #: includes/Admin/FeedsPage.php:577
    741 #: includes/Admin/SettingsPage.php:637
     887#: includes/Admin/FeedsPage.php:740
     888#: includes/Admin/FeedsPage.php:1722
     889#: includes/Admin/SettingsPage.php:779
    742890msgid "Actions"
    743891msgstr ""
    744892
    745 #: includes/Admin/FeedsPage.php:581
     893#: includes/Admin/FeedsPage.php:744
    746894msgid "No feeds match your search/filter."
    747895msgstr ""
    748896
    749 #: includes/Admin/FeedsPage.php:626
    750 #: includes/Admin/FeedsPage.php:1367
     897#: includes/Admin/FeedsPage.php:791
     898#: includes/Admin/FeedsPage.php:2505
    751899msgid "Untitled feed"
    752900msgstr ""
    753901
    754902#. translators: %s: feed title.
    755 #: includes/Admin/FeedsPage.php:639
     903#: includes/Admin/FeedsPage.php:804
    756904#, php-format
    757905msgid "Enable the feed \"%s\""
    758906msgstr ""
    759907
    760 #: includes/Admin/FeedsPage.php:663
     908#: includes/Admin/FeedsPage.php:828
    761909msgid "Edit"
    762910msgstr ""
    763911
    764 #: includes/Admin/FeedsPage.php:667
     912#: includes/Admin/FeedsPage.php:832
    765913msgid "Clone"
    766914msgstr ""
    767915
    768 #: includes/Admin/FeedsPage.php:669
     916#: includes/Admin/FeedsPage.php:834
    769917msgid "Delete this feed? Submissions for this form will stop being sent to Zoho."
    770918msgstr ""
    771919
    772 #: includes/Admin/FeedsPage.php:696
     920#: includes/Admin/FeedsPage.php:861
     921#: includes/Admin/SettingsPage.php:1221
    773922msgid "Show"
    774923msgstr ""
    775924
    776 #: includes/Admin/FeedsPage.php:701
     925#: includes/Admin/FeedsPage.php:866
     926#: includes/Admin/SettingsPage.php:1229
    777927msgid "per page"
    778928msgstr ""
    779929
    780 #: includes/Admin/FeedsPage.php:720
    781 #: includes/Admin/FeedsPage.php:722
     930#: includes/Admin/FeedsPage.php:885
     931#: includes/Admin/FeedsPage.php:887
     932#: includes/Admin/SettingsPage.php:1235
    782933msgid "Prev"
    783934msgstr ""
    784935
    785936#. translators: 1: current page, 2: total pages.
    786 #: includes/Admin/FeedsPage.php:728
     937#: includes/Admin/FeedsPage.php:893
    787938#, php-format
    788939msgid "Page %1$d of %2$d"
    789940msgstr ""
    790941
    791 #: includes/Admin/FeedsPage.php:735
    792 #: includes/Admin/FeedsPage.php:737
    793942#: includes/Admin/FeedsPage.php:900
    794 #: includes/Admin/FeedsPage.php:1054
     943#: includes/Admin/FeedsPage.php:902
     944#: includes/Admin/SettingsPage.php:1241
    795945msgid "Next"
    796946msgstr ""
    797947
    798 #: includes/Admin/FeedsPage.php:744
     948#: includes/Admin/FeedsPage.php:909
    799949msgid "Feed details"
    800950msgstr ""
    801951
    802 #: includes/Admin/FeedsPage.php:789
     952#: includes/Admin/FeedsPage.php:965
    803953msgid "Back to all feeds"
    804954msgstr ""
    805955
    806 #: includes/Admin/FeedsPage.php:792
    807 msgid "Edit feed"
    808 msgstr ""
    809 
    810 #: includes/Admin/FeedsPage.php:792
    811 msgid "Add feed"
    812 msgstr ""
    813 
    814956#. translators: %s: link to the Connections tab.
    815 #: includes/Admin/FeedsPage.php:800
     957#: includes/Admin/FeedsPage.php:978
    816958#, php-format
    817959msgid "No Zoho accounts connected yet. Connect one under %s first."
    818960msgstr ""
    819961
    820 #: includes/Admin/FeedsPage.php:801
    821 msgid "Techwave Sync → Connections"
    822 msgstr ""
    823 
    824 #: includes/Admin/FeedsPage.php:824
    825 msgid "General"
    826 msgstr ""
    827 
    828 #: includes/Admin/FeedsPage.php:825
    829 msgid "Zoho Connection"
    830 msgstr ""
    831 
    832 #: includes/Admin/FeedsPage.php:826
    833 msgid "Field Mapping"
    834 msgstr ""
    835 
    836 #: includes/Admin/FeedsPage.php:833
     962#: includes/Admin/FeedsPage.php:979
     963msgid "FormBridge → Connections"
     964msgstr ""
     965
     966#. translators: %s: connected account name.
     967#: includes/Admin/FeedsPage.php:991
     968#, php-format
     969msgid "%s (not authorised)"
     970msgstr ""
     971
     972#: includes/Admin/FeedsPage.php:1043
     973msgid "Choose a trigger"
     974msgstr ""
     975
     976#: includes/Admin/FeedsPage.php:1044
     977msgid "Which form or event should send records to Zoho? Picking one moves you straight on."
     978msgstr ""
     979
     980#: includes/Admin/FeedsPage.php:1049
     981msgid "Trigger guide"
     982msgstr ""
     983
     984#: includes/Admin/FeedsPage.php:1053
     985msgid "Watch: every trigger"
     986msgstr ""
     987
     988#: includes/Admin/FeedsPage.php:1066
     989msgid "Search triggers"
     990msgstr ""
     991
     992#: includes/Admin/FeedsPage.php:1068
     993msgid "Search triggers…"
     994msgstr ""
     995
     996#: includes/Admin/FeedsPage.php:1132
     997#: includes/Admin/ProUpsell.php:402
     998msgid "Pro"
     999msgstr ""
     1000
     1001#: includes/Admin/FeedsPage.php:1137
     1002msgid "Available in FormBridge Pro. Opens a dialog."
     1003msgstr ""
     1004
     1005#: includes/Admin/FeedsPage.php:1163
     1006msgid "No trigger matches that search."
     1007msgstr ""
     1008
     1009#: includes/Admin/FeedsPage.php:1203
     1010msgid "Name this feed"
     1011msgstr ""
     1012
     1013#: includes/Admin/FeedsPage.php:1204
     1014msgid "Give the feed a name you will recognise later, and pick the form it listens to."
     1015msgstr ""
     1016
     1017#: includes/Admin/FeedsPage.php:1228
     1018msgid "Locked"
     1019msgstr ""
     1020
     1021#: includes/Admin/FeedsPage.php:1236
     1022msgid "Change trigger"
     1023msgstr ""
     1024
     1025#: includes/Admin/FeedsPage.php:1239
     1026msgid "Change"
     1027msgstr ""
     1028
     1029#: includes/Admin/FeedsPage.php:1265
    8371030msgid "Feed title"
    8381031msgstr ""
    8391032
    840 #: includes/Admin/FeedsPage.php:853
    841 msgid "Send submissions for this feed."
    842 msgstr ""
    843 
    844 #: includes/Admin/FeedsPage.php:877
    845 msgid "Choose which form plugin this feed listens to."
    846 msgstr ""
    847 
    848 #: includes/Admin/FeedsPage.php:889
     1033#: includes/Admin/FeedsPage.php:1266
     1034msgid "Contact form → Zoho CRM leads"
     1035msgstr ""
     1036
     1037#: includes/Admin/FeedsPage.php:1267
     1038msgid "Only used inside WordPress. It is how this feed is listed here and named in the delivery log."
     1039msgstr ""
     1040
     1041#: includes/Admin/FeedsPage.php:1281
    8491042msgid "Loading forms…"
    8501043msgstr ""
    8511044
    852 #: includes/Admin/FeedsPage.php:892
     1045#: includes/Admin/FeedsPage.php:1284
    8531046msgid "Reload forms"
    8541047msgstr ""
    8551048
    856 #: includes/Admin/FeedsPage.php:908
     1049#: includes/Admin/FeedsPage.php:1320
     1050#: includes/Admin/FeedsPage.php:1664
     1051#: includes/Admin/FeedsPage.php:1809
     1052msgid "Back"
     1053msgstr ""
     1054
     1055#: includes/Admin/FeedsPage.php:1325
     1056#: includes/Admin/FeedsPage.php:1665
     1057msgid "Continue"
     1058msgstr ""
     1059
     1060#: includes/Admin/FeedsPage.php:1339
     1061msgid "Where should records go?"
     1062msgstr ""
     1063
     1064#: includes/Admin/FeedsPage.php:1340
     1065msgid "Pick the account, then the Zoho app it is authorised for, and the kind of record to create."
     1066msgstr ""
     1067
     1068#: includes/Admin/FeedsPage.php:1349
     1069msgid "Zoho destination"
     1070msgstr ""
     1071
     1072#: includes/Admin/FeedsPage.php:1363
     1073msgid "Connect a new account (opens in a new tab)"
     1074msgstr ""
     1075
     1076#: includes/Admin/FeedsPage.php:1364
     1077msgid "Connect a new account"
     1078msgstr ""
     1079
     1080#: includes/Admin/FeedsPage.php:1380
     1081msgid "Connected account"
     1082msgstr ""
     1083
     1084#: includes/Admin/FeedsPage.php:1383
     1085msgid "Select"
     1086msgstr ""
     1087
     1088#: includes/Admin/FeedsPage.php:1390
     1089msgid "Reload connected accounts"
     1090msgstr ""
     1091
     1092#: includes/Admin/FeedsPage.php:1398
    8571093#: includes/Privacy/PrivacyProvider.php:160
    8581094msgid "Zoho app"
    8591095msgstr ""
    8601096
    861 #: includes/Admin/FeedsPage.php:918
    862 msgid "Connected account"
    863 msgstr ""
    864 
    865 #: includes/Admin/FeedsPage.php:920
    866 msgid "— Select —"
    867 msgstr ""
    868 
    869 #: includes/Admin/FeedsPage.php:938
     1097#: includes/Admin/FeedsPage.php:1412
     1098msgid "Watch: every Zoho app"
     1099msgstr ""
     1100
     1101#: includes/Admin/FeedsPage.php:1430
    8701102msgid "Organization"
    8711103msgstr ""
    8721104
    873 #: includes/Admin/FeedsPage.php:940
    874 #: includes/Admin/FeedsPage.php:951
    875 msgid "— None —"
    876 msgstr ""
    877 
    878 #: includes/Admin/FeedsPage.php:945
    879 msgid "Required for Zoho Books and Zoho Desk. (Bigin does not use this field — its organization is captured automatically when the connection is authorized.)"
    880 msgstr ""
    881 
    882 #: includes/Admin/FeedsPage.php:949
     1105#: includes/Admin/FeedsPage.php:1433
     1106#: includes/Admin/FeedsPage.php:1452
     1107msgid "None"
     1108msgstr ""
     1109
     1110#: includes/Admin/FeedsPage.php:1444
     1111msgid "Required for Zoho Books and Zoho Desk. Bigin captures its own organization when the connection is authorized."
     1112msgstr ""
     1113
     1114#: includes/Admin/FeedsPage.php:1449
    8831115msgid "Department"
    8841116msgstr ""
    8851117
    886 #: includes/Admin/FeedsPage.php:956
     1118#: includes/Admin/FeedsPage.php:1459
    8871119msgid "Required for Zoho Desk tickets. Loads after you pick the organization."
    8881120msgstr ""
    8891121
    890 #: includes/Admin/FeedsPage.php:970
    891 msgid "— Select an account first —"
    892 msgstr ""
    893 
    894 #: includes/Admin/FeedsPage.php:974
     1122#: includes/Admin/FeedsPage.php:1474
     1123msgid "Select an account first"
     1124msgstr ""
     1125
     1126#: includes/Admin/FeedsPage.php:1478
    8951127msgid "Reload mailing lists"
    8961128msgstr ""
    8971129
    898 #: includes/Admin/FeedsPage.php:989
    899 msgid "Subscription Type"
    900 msgstr ""
    901 
    902 #: includes/Admin/FeedsPage.php:992
     1130#: includes/Admin/FeedsPage.php:1535
     1131msgid "Contact options"
     1132msgstr ""
     1133
     1134#: includes/Admin/FeedsPage.php:1536
     1135msgid "How the contact is subscribed, and what it is filed under."
     1136msgstr ""
     1137
     1138#: includes/Admin/FeedsPage.php:1542
     1139msgid "Subscription type"
     1140msgstr ""
     1141
     1142#: includes/Admin/FeedsPage.php:1545
    9031143msgid "Marketing"
    9041144msgstr ""
    9051145
    906 #: includes/Admin/FeedsPage.php:993
     1146#: includes/Admin/FeedsPage.php:1546
    9071147msgid "Non-marketing"
    9081148msgstr ""
    9091149
    910 #: includes/Admin/FeedsPage.php:994
     1150#: includes/Admin/FeedsPage.php:1547
    9111151msgid "Unsubscribed"
    9121152msgstr ""
    9131153
    914 #: includes/Admin/FeedsPage.php:1001
    915 msgid "Associate contacts with the following Topics"
    916 msgstr ""
    917 
    918 #: includes/Admin/FeedsPage.php:1008
     1154#: includes/Admin/FeedsPage.php:1560
     1155msgid "Add to list"
     1156msgstr ""
     1157
     1158#: includes/Admin/FeedsPage.php:1563
     1159msgid "Right away"
     1160msgstr ""
     1161
     1162#: includes/Admin/FeedsPage.php:1564
     1163msgid "After they confirm by email"
     1164msgstr ""
     1165
     1166#: includes/Admin/FeedsPage.php:1569
     1167msgid "Right away puts the contact in the selected list immediately. After they confirm sends the confirmation email Zoho uses for a list with its sign-up form on, and adds the contact once it is clicked."
     1168msgstr ""
     1169
     1170#: includes/Admin/FeedsPage.php:1573
     1171msgid "Contact tags"
     1172msgstr ""
     1173
     1174#: includes/Admin/FeedsPage.php:1580
     1175msgid "Reload tags"
     1176msgstr ""
     1177
     1178#: includes/Admin/FeedsPage.php:1582
     1179msgid "Optional. Pick one or more existing tags. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one."
     1180msgstr ""
     1181
     1182#: includes/Admin/FeedsPage.php:1587
     1183msgid "Associate contacts with these topics"
     1184msgstr ""
     1185
     1186#: includes/Admin/FeedsPage.php:1594
    9191187msgid "Reload topics"
    9201188msgstr ""
    9211189
    922 #: includes/Admin/FeedsPage.php:1010
    923 msgid "Optional — leave empty to skip topic association entirely. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one."
    924 msgstr ""
    925 
    926 #: includes/Admin/FeedsPage.php:1014
    927 msgid "Contact Tags"
    928 msgstr ""
    929 
    930 #: includes/Admin/FeedsPage.php:1021
    931 msgid "Reload tags"
    932 msgstr ""
    933 
    934 #: includes/Admin/FeedsPage.php:1023
    935 msgid "Optional. Pick one or more existing tags. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one."
    936 msgstr ""
    937 
    938 #: includes/Admin/FeedsPage.php:1041
     1190#: includes/Admin/FeedsPage.php:1596
     1191msgid "Optional. Leave empty to skip topic association entirely. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one."
     1192msgstr ""
     1193
     1194#: includes/Admin/FeedsPage.php:1618
     1195msgid "Duplicate handling"
     1196msgstr ""
     1197
     1198#: includes/Admin/FeedsPage.php:1619
     1199msgid "What to do when Zoho already holds a record for the same person."
     1200msgstr ""
     1201
     1202#: includes/Admin/FeedsPage.php:1629
    9391203msgid "Update the matching Zoho record instead of creating a duplicate."
    9401204msgstr ""
    9411205
    942 #: includes/Admin/FeedsPage.php:1042
    943 msgid "Each box below is checked independently — a record counts as a match if it shares the same email OR the same phone, not only when both match."
    944 msgstr ""
    945 
    946 #: includes/Admin/FeedsPage.php:1044
    947 msgid "Match by Email"
    948 msgstr ""
    949 
    950 #: includes/Admin/FeedsPage.php:1045
    951 msgid "Match by Phone"
    952 msgstr ""
    953 
    954 #: includes/Admin/FeedsPage.php:1047
    955 msgid "Other Zoho field API names, comma-separated"
    956 msgstr ""
    957 
    958 #: includes/Admin/FeedsPage.php:1048
    959 msgid "Optional: any other Zoho field API names to also check, comma-separated."
    960 msgstr ""
    961 
    962 #: includes/Admin/FeedsPage.php:1053
    963 #: includes/Admin/FeedsPage.php:1121
    964 msgid "Back"
    965 msgstr ""
    966 
    967 #: includes/Admin/FeedsPage.php:1061
     1206#: includes/Admin/FeedsPage.php:1648
     1207msgid "Fields Zoho marks unique"
     1208msgstr ""
     1209
     1210#: includes/Admin/FeedsPage.php:1649
     1211msgid "A record counts as a match if it shares any one of the ticked values. Leave them all unticked to use Zoho's own duplicate check."
     1212msgstr ""
     1213
     1214#: includes/Admin/FeedsPage.php:1655
     1215msgid "This module has no fields that Zoho marks as unique, so Zoho's own duplicate check is used instead (Email on Leads and Contacts, for example). If the module has no duplicate check of its own, each submission creates a new record. To match on a field of your choice, set that field to not allow duplicate values in Zoho, then reload the module."
     1216msgstr ""
     1217
     1218#: includes/Admin/FeedsPage.php:1672
     1219#: includes/Admin/FeedsPage.php:2155
     1220msgid "Match the fields"
     1221msgstr ""
     1222
     1223#: includes/Admin/FeedsPage.php:1673
     1224msgid "Send each Zoho field a form field or a fixed value. Both lists load on their own: Zoho fields from the chosen module, form fields from the chosen form."
     1225msgstr ""
     1226
     1227#: includes/Admin/FeedsPage.php:1680
    9681228msgid "Field mapping"
    9691229msgstr ""
    9701230
    971 #: includes/Admin/FeedsPage.php:1062
    972 msgid "Map each Zoho field to a form field or a fixed value. Both lists load automatically: Zoho fields from the chosen module, form fields from the chosen form."
    973 msgstr ""
    974 
    975 #: includes/Admin/FeedsPage.php:1074
     1231#: includes/Admin/FeedsPage.php:1699
     1232msgid "Loading Zoho fields"
     1233msgstr ""
     1234
     1235#: includes/Admin/FeedsPage.php:1700
     1236msgid "Fetching the field list from Zoho…"
     1237msgstr ""
     1238
     1239#: includes/Admin/FeedsPage.php:1701
     1240msgid "This is taking longer than usual. Zoho can be slow to answer, and the editor will carry on as soon as it does."
     1241msgstr ""
     1242
     1243#: includes/Admin/FeedsPage.php:1718
     1244msgid "Source"
     1245msgstr ""
     1246
     1247#: includes/Admin/FeedsPage.php:1719
     1248#: includes/Admin/FeedsPage.php:2265
     1249msgid "Form field"
     1250msgstr ""
     1251
     1252#: includes/Admin/FeedsPage.php:1720
    9761253msgid "Zoho field"
    9771254msgstr ""
    9781255
    979 #: includes/Admin/FeedsPage.php:1075
    980 msgid "Source"
    981 msgstr ""
    982 
    983 #: includes/Admin/FeedsPage.php:1076
    984 #: includes/Admin/FeedsPage.php:1165
    985 msgid "Form field"
    986 msgstr ""
    987 
    988 #: includes/Admin/FeedsPage.php:1077
    989 #: includes/Admin/FeedsPage.php:1166
     1256#: includes/Admin/FeedsPage.php:1721
     1257#: includes/Admin/FeedsPage.php:2266
    9901258msgid "Static value"
    9911259msgstr ""
    9921260
    993 #: includes/Admin/FeedsPage.php:1105
     1261#: includes/Admin/FeedsPage.php:1750
    9941262msgid "+ Add mapping row"
    9951263msgstr ""
    9961264
    997 #: includes/Admin/FeedsPage.php:1122
     1265#: includes/Admin/FeedsPage.php:1795
     1266msgid "Test this feed"
     1267msgstr ""
     1268
     1269#: includes/Admin/FeedsPage.php:1796
     1270msgid "Sends one real test record to Zoho using the feed as last saved. Static values are sent as-is; mapped form fields get realistic sample data (a valid email, phone, and so on)."
     1271msgstr ""
     1272
     1273#: includes/Admin/FeedsPage.php:1800
     1274msgid "Send test record"
     1275msgstr ""
     1276
     1277#: includes/Admin/FeedsPage.php:1810
    9981278msgid "Save feed"
    9991279msgstr ""
    10001280
    1001 #: includes/Admin/FeedsPage.php:1122
     1281#: includes/Admin/FeedsPage.php:1810
    10021282msgid "Create feed"
    10031283msgstr ""
    10041284
    1005 #: includes/Admin/FeedsPage.php:1131
    1006 msgid "Test"
    1007 msgstr ""
    1008 
    1009 #: includes/Admin/FeedsPage.php:1132
    1010 msgid "Sends one real test record to Zoho using this saved feed. Static values are sent as-is; mapped form fields get realistic sample data (a valid email, phone, etc.)."
    1011 msgstr ""
    1012 
    1013 #: includes/Admin/FeedsPage.php:1133
    1014 msgid "Send test record"
    1015 msgstr ""
    1016 
    1017 #: includes/Admin/FeedsPage.php:1180
     1285#. translators: %s: a Zoho app's name, e.g. "Zoho CRM".
     1286#. translators: %s: a form plugin's name, e.g. "Contact Form 7".
     1287#: includes/Admin/FeedsPage.php:1926
     1288#: includes/Admin/FeedsPage.php:2004
     1289#, php-format
     1290msgid "%s guide"
     1291msgstr ""
     1292
     1293#: includes/Admin/FeedsPage.php:2143
     1294msgid "Where records come from"
     1295msgstr ""
     1296
     1297#: includes/Admin/FeedsPage.php:2147
     1298msgid "Name and form"
     1299msgstr ""
     1300
     1301#: includes/Admin/FeedsPage.php:2151
     1302msgid "Zoho app and account"
     1303msgstr ""
     1304
     1305#: includes/Admin/FeedsPage.php:2154
     1306msgid "Mapping"
     1307msgstr ""
     1308
     1309#: includes/Admin/FeedsPage.php:2162
     1310msgid "Feed setup"
     1311msgstr ""
     1312
     1313#. translators: 1: step number, 2: total number of steps.
     1314#: includes/Admin/FeedsPage.php:2194
     1315#, php-format
     1316msgid "Step %1$s of %2$s:"
     1317msgstr ""
     1318
     1319#: includes/Admin/FeedsPage.php:2200
     1320msgid "(locked)"
     1321msgstr ""
     1322
     1323#: includes/Admin/FeedsPage.php:2222
     1324msgid "A saved feed keeps its trigger. To send a different form or event to Zoho, create a new feed."
     1325msgstr ""
     1326
     1327#: includes/Admin/FeedsPage.php:2271
     1328msgid "Combine fields"
     1329msgstr ""
     1330
     1331#: includes/Admin/FeedsPage.php:2312
    10181332msgid "Remove"
    10191333msgstr ""
    10201334
    1021 #: includes/Admin/FeedsPage.php:1203
     1335#: includes/Admin/FeedsPage.php:2335
    10221336msgid "Save the feed first, then test it."
    10231337msgstr ""
    10241338
    10251339#. translators: %s: Zoho record id.
    1026 #: includes/Admin/FeedsPage.php:1286
    1027 #, php-format
    1028 msgid "Success — record updated in Zoho (id %s)."
    1029 msgstr ""
    1030 
    1031 #: includes/Admin/FeedsPage.php:1287
    1032 msgid "Success — a record was updated in Zoho."
     1340#: includes/Admin/FeedsPage.php:2424
     1341#, php-format
     1342msgid "Success: record updated in Zoho (id %s)."
     1343msgstr ""
     1344
     1345#: includes/Admin/FeedsPage.php:2425
     1346msgid "Success: a record was updated in Zoho."
    10331347msgstr ""
    10341348
    10351349#. translators: %s: Zoho record id.
    1036 #: includes/Admin/FeedsPage.php:1291
    1037 #, php-format
    1038 msgid "Success — record created in Zoho (id %s)."
    1039 msgstr ""
    1040 
    1041 #: includes/Admin/FeedsPage.php:1292
    1042 msgid "Success — a test record was created in Zoho."
    1043 msgstr ""
    1044 
    1045 #: includes/Admin/FeedsPage.php:1611
    1046 #: includes/Admin/SettingsPage.php:393
     1350#: includes/Admin/FeedsPage.php:2429
     1351#, php-format
     1352msgid "Success: record created in Zoho (id %s)."
     1353msgstr ""
     1354
     1355#: includes/Admin/FeedsPage.php:2430
     1356msgid "Success: a test record was created in Zoho."
     1357msgstr ""
     1358
     1359#: includes/Admin/FeedsPage.php:2878
     1360#: includes/Admin/SettingsPage.php:464
    10471361msgid "Dismiss"
    10481362msgstr ""
    10491363
    1050 #: includes/Admin/FeedsPage.php:1624
     1364#: includes/Admin/FeedsPage.php:2891
    10511365msgid "Feed saved."
    10521366msgstr ""
    10531367
    1054 #: includes/Admin/FeedsPage.php:1628
    1055 msgid "Feed saved, but the Zoho account it referred to no longer exists. The feed has been switched off — pick a connected account and enable it again."
    1056 msgstr ""
    1057 
    1058 #: includes/Admin/FeedsPage.php:1631
     1368#: includes/Admin/FeedsPage.php:2895
     1369msgid "Feed saved, but the Zoho account it referred to no longer exists. The feed has been switched off. Pick a connected account and enable it again."
     1370msgstr ""
     1371
     1372#: includes/Admin/FeedsPage.php:2898
    10591373msgid "Feed deleted."
    10601374msgstr ""
    10611375
     1376#. translators: %d: number of mapping rows that aimed at a Zoho field another row had already taken.
     1377#: includes/Admin/FeedsPage.php:2910
     1378#, php-format
     1379msgid "%d mapping row was left out because another row already sends to that Zoho field. A field can only hold one value. Use the \"Combine fields\" source if you need several answers in one."
     1380msgid_plural "%d mapping rows were left out because other rows already send to those Zoho fields. A field can only hold one value. Use the \"Combine fields\" source if you need several answers in one."
     1381msgstr[0] ""
     1382msgstr[1] ""
     1383
    10621384#. translators: %d: number of mapping rows that were left out.
    1063 #: includes/Admin/FeedsPage.php:1643
     1385#: includes/Admin/FeedsPage.php:2928
    10641386#, php-format
    10651387msgid "%d mapping row was left out because it had no form field chosen and no static value typed. Finish the row and save again."
    … …  
    10681390msgstr[1] ""
    10691391
    1070 #: includes/Admin/SettingsPage.php:91
    1071 #: includes/Admin/SettingsPage.php:92
    1072 #: includes/Admin/SettingsPage.php:270
     1392#. translators: %d: number of mapping rows whose static value their Zoho field would not accept.
     1393#: includes/Admin/FeedsPage.php:2946
     1394#, php-format
     1395msgid "%d mapping row was left out because its static value is not the kind of value its Zoho field accepts, such as letters in a phone number. Correct it and save again."
     1396msgid_plural "%d mapping rows were left out because their static values are not the kind of value their Zoho fields accept, such as letters in a phone number. Correct them and save again."
     1397msgstr[0] ""
     1398msgstr[1] ""
     1399
     1400#: includes/Admin/ProUpsell.php:92
     1401msgid "Every new customer reaches Zoho about a minute after they register or check out, with name, email, phone and both addresses mapped to whichever fields you choose."
     1402msgstr ""
     1403
     1404#: includes/Admin/ProUpsell.php:93
     1405msgid "Members reach Zoho when a membership starts, renews, lapses or is cancelled, carrying the plan name and the expiry date with them."
     1406msgstr ""
     1407
     1408#: includes/Admin/ProUpsell.php:94
     1409msgid "Learners reach Zoho when they are given access to a course, and again when they finish it, with the course name and their progress."
     1410msgstr ""
     1411
     1412#: includes/Admin/ProUpsell.php:95
     1413msgid "Students reach Zoho when they enrol, when they complete a course, and when an enrolment is cancelled."
     1414msgstr ""
     1415
     1416#: includes/Admin/ProUpsell.php:96
     1417msgid "Donors reach Zoho the moment a donation completes, with the amount, the currency and the campaign they gave to."
     1418msgstr ""
     1419
     1420#: includes/Admin/ProUpsell.php:97
     1421msgid "Buyers reach Zoho when a purchase completes, with what they bought and what they have spent with you in total."
     1422msgstr ""
     1423
     1424#: includes/Admin/ProUpsell.php:98
     1425msgid "Customers reach Zoho when an appointment is booked, rescheduled or cancelled, with the service and the appointment time."
     1426msgstr ""
     1427
     1428#: includes/Admin/ProUpsell.php:99
     1429msgid "Vendors reach Zoho when a store is created, and again whenever the store profile changes, with shop name, address and payment contact included."
     1430msgstr ""
     1431
     1432#: includes/Admin/ProUpsell.php:100
     1433msgid "Employers reach Zoho when they submit a job listing, with the company name, the listing title and where the role is based."
     1434msgstr ""
     1435
     1436#: includes/Admin/ProUpsell.php:101
     1437msgid "Attendees reach Zoho as tickets are bought and RSVPs come in, with the event they are coming to and the ticket they hold."
     1438msgstr ""
     1439
     1440#: includes/Admin/ProUpsell.php:102
     1441msgid "Name any action your code fires (your own, your theme's, another plugin's) and a feed runs when it does. Its arguments become fields you can map, so anything on your site that already announces itself can reach Zoho through the same feeds, mapping and delivery log as your forms."
     1442msgstr ""
     1443
     1444#. translators: %s: source name, e.g. "WooCommerce Customer".
     1445#: includes/Admin/ProUpsell.php:119
     1446#, php-format
     1447msgid "%s is part of FormBridge Pro"
     1448msgstr ""
     1449
     1450#: includes/Admin/ProUpsell.php:130
     1451msgid "Ten triggers that are people, not forms"
     1452msgstr ""
     1453
     1454#: includes/Admin/ProUpsell.php:131
     1455msgid "Customers, members, learners, students, donors, vendors, employers and attendees, each one syncing on its own events with no form involved."
     1456msgstr ""
     1457
     1458#: includes/Admin/ProUpsell.php:134
     1459msgid "Zoho Inventory as a destination"
     1460msgstr ""
     1461
     1462#: includes/Admin/ProUpsell.php:135
     1463msgid "A seventh Zoho app alongside CRM, Bigin, Books, Desk, Campaigns and Marketing Automation."
     1464msgstr ""
     1465
     1466#: includes/Admin/ProUpsell.php:138
     1467msgid "Nothing new to learn"
     1468msgstr ""
     1469
     1470#: includes/Admin/ProUpsell.php:139
     1471msgid "A Pro trigger is an ordinary feed: the same field mapping, the same duplicate handling, the same delivery log and retries."
     1472msgstr ""
     1473
     1474#: includes/Admin/ProUpsell.php:142
     1475msgid "Updates in your dashboard"
     1476msgstr ""
     1477
     1478#: includes/Admin/ProUpsell.php:143
     1479msgid "Activate your licence once and Pro updates from the Plugins screen like anything else."
     1480msgstr ""
     1481
     1482#: includes/Admin/ProUpsell.php:257
     1483#: includes/Admin/ProUpsell.php:294
     1484#: includes/Admin/ProUpsell.php:300
     1485msgid "Upgrade to Pro"
     1486msgstr ""
     1487
     1488#: includes/Admin/ProUpsell.php:258
     1489#: includes/Admin/ProUpsell.php:449
     1490msgid "(opens in a new tab)"
     1491msgstr ""
     1492
     1493#: includes/Admin/ProUpsell.php:359
     1494msgid "Upgrade to Pro (opens in a new tab)"
     1495msgstr ""
     1496
     1497#: includes/Admin/ProUpsell.php:423
     1498msgid "Included with Pro"
     1499msgstr ""
     1500
     1501#: includes/Admin/ProUpsell.php:448
     1502msgid "Get FormBridge Pro"
     1503msgstr ""
     1504
     1505#: includes/Admin/ProUpsell.php:457
     1506msgid "Maybe later"
     1507msgstr ""
     1508
     1509#: includes/Admin/SettingsPage.php:104
     1510#: includes/Admin/SettingsPage.php:105
     1511#: includes/Admin/SettingsPage.php:305
    10731512msgid "Connections"
    10741513msgstr ""
    10751514
    1076 #: includes/Admin/SettingsPage.php:146
     1515#: includes/Admin/SettingsPage.php:160
    10771516msgid "Select a Zoho service"
    10781517msgstr ""
    10791518
    1080 #: includes/Admin/SettingsPage.php:147
     1519#: includes/Admin/SettingsPage.php:161
    10811520msgid "Select an organization"
    10821521msgstr ""
    10831522
    1084 #: includes/Admin/SettingsPage.php:148
     1523#: includes/Admin/SettingsPage.php:162
    10851524msgid "Load modules"
    10861525msgstr ""
    10871526
    1088 #: includes/Admin/SettingsPage.php:149
     1527#: includes/Admin/SettingsPage.php:163
    10891528msgid "Modules"
    10901529msgstr ""
    10911530
    1092 #: includes/Admin/SettingsPage.php:150
     1531#: includes/Admin/SettingsPage.php:164
    10931532msgid "Fields"
    10941533msgstr ""
    10951534
    1096 #: includes/Admin/SettingsPage.php:151
     1535#: includes/Admin/SettingsPage.php:165
    10971536msgid "API name"
    10981537msgstr ""
    10991538
    1100 #: includes/Admin/SettingsPage.php:152
    1101 #: includes/Admin/SettingsPage.php:520
     1539#: includes/Admin/SettingsPage.php:166
     1540#: includes/Admin/SettingsPage.php:611
    11021541msgid "Label"
    11031542msgstr ""
    11041543
    1105 #: includes/Admin/SettingsPage.php:153
    1106 msgid "Required"
    1107 msgstr ""
    1108 
    1109 #: includes/Admin/SettingsPage.php:154
    1110 msgid "Copy these API names into a feed under Techwave Sync → Forms."
    1111 msgstr ""
    1112 
    1113 #: includes/Admin/SettingsPage.php:155
     1544#: includes/Admin/SettingsPage.php:168
     1545msgid "Copy these API names into a feed under FormBridge → All Feeds."
     1546msgstr ""
     1547
     1548#: includes/Admin/SettingsPage.php:169
    11141549msgid "Nothing returned."
    11151550msgstr ""
    11161551
    1117 #: includes/Admin/SettingsPage.php:156
     1552#: includes/Admin/SettingsPage.php:170
    11181553msgid "Sending…"
    11191554msgstr ""
    11201555
    1121 #: includes/Admin/SettingsPage.php:157
    1122 #: includes/Admin/SettingsPage.php:1036
     1556#: includes/Admin/SettingsPage.php:171
     1557#: includes/Admin/SettingsPage.php:1377
    11231558msgid "Send test email"
    11241559msgstr ""
    11251560
    1126 #: includes/Admin/SettingsPage.php:158
    1127 #: includes/Auth/ConnectionManager.php:268
     1561#: includes/Admin/SettingsPage.php:172
     1562#: includes/Auth/ConnectionManager.php:411
    11281563msgid "Choose at least one Zoho app for this connection."
    11291564msgstr ""
    11301565
    1131 #: includes/Admin/SettingsPage.php:271
     1566#: includes/Admin/SettingsPage.php:306
    11321567msgid "Logs"
    11331568msgstr ""
    11341569
    1135 #: includes/Admin/SettingsPage.php:272
     1570#: includes/Admin/SettingsPage.php:307
    11361571msgid "Settings"
    11371572msgstr ""
    11381573
    1139 #: includes/Admin/SettingsPage.php:305
     1574#: includes/Admin/SettingsPage.php:358
     1575msgid "Back to feeds"
     1576msgstr ""
     1577
     1578#: includes/Admin/SettingsPage.php:376
    11401579msgid "Zoho account connected."
    11411580msgstr ""
    11421581
    1143 #: includes/Admin/SettingsPage.php:306
     1582#: includes/Admin/SettingsPage.php:377
    11441583msgid "Security check failed. Please try connecting again."
    11451584msgstr ""
    11461585
    1147 #: includes/Admin/SettingsPage.php:307
     1586#: includes/Admin/SettingsPage.php:378
    11481587msgid "Authorisation was cancelled or denied."
    11491588msgstr ""
    11501589
    1151 #: includes/Admin/SettingsPage.php:308
     1590#: includes/Admin/SettingsPage.php:379
    11521591msgid "Could not exchange the code for tokens. Check the client credentials and data centre."
    11531592msgstr ""
    11541593
    1155 #: includes/Admin/SettingsPage.php:309
     1594#: includes/Admin/SettingsPage.php:380
    11561595msgid "Zoho authorised the connection, but the refresh token could not be encrypted, so nothing was saved. Enable PHP's OpenSSL extension and try again."
    11571596msgstr ""
    11581597
    1159 #: includes/Admin/SettingsPage.php:328
     1598#: includes/Admin/SettingsPage.php:399
    11601599msgid "Settings saved."
    11611600msgstr ""
    11621601
    1163 #: includes/Admin/SettingsPage.php:329
     1602#: includes/Admin/SettingsPage.php:400
    11641603msgid "All log entries were deleted."
    11651604msgstr ""
    11661605
    1167 #: includes/Admin/SettingsPage.php:330
     1606#: includes/Admin/SettingsPage.php:401
    11681607msgid "Connection deleted."
    11691608msgstr ""
    11701609
    1171 #: includes/Admin/SettingsPage.php:419
    1172 msgid "This server is missing PHP's OpenSSL extension (with AES-256-GCM), so Zoho credentials cannot be encrypted. Adding a connection is disabled until your host enables it — the plugin will not store a client secret or refresh token unencrypted."
    1173 msgstr ""
    1174 
    1175 #: includes/Admin/SettingsPage.php:420
     1610#: includes/Admin/SettingsPage.php:490
     1611msgid "This server is missing PHP's OpenSSL extension (with AES-256-GCM), so Zoho credentials cannot be encrypted. Adding a connection is disabled until your host enables it. The plugin will not store a client secret or refresh token unencrypted."
     1612msgstr ""
     1613
     1614#: includes/Admin/SettingsPage.php:491
    11761615msgid "Cannot store credentials securely"
    11771616msgstr ""
    11781617
    1179 #: includes/Admin/SettingsPage.php:434
     1618#: includes/Admin/SettingsPage.php:505
    11801619msgid "This site has no AUTH_KEY in wp-config.php, so the Zoho credentials saved here were encrypted with a key derived only from your database. A copy of the database alone could read them. Add the WordPress security keys to wp-config.php, then use \"Reauthorize\" on each connection below to re-encrypt them properly."
    11811620msgstr ""
    11821621
    1183 #: includes/Admin/SettingsPage.php:435
     1622#: includes/Admin/SettingsPage.php:506
    11841623msgid "Stored credentials are weakly protected"
    11851624msgstr ""
    11861625
    1187 #: includes/Admin/SettingsPage.php:454
    1188 #: includes/Admin/SettingsPage.php:619
     1626#: includes/Admin/SettingsPage.php:525
     1627#: includes/Admin/SettingsPage.php:759
    11891628msgid "Connect one or more Zoho accounts. Each feed can then pick which account, app and module to use."
    11901629msgstr ""
    11911630
    1192 #: includes/Admin/SettingsPage.php:458
     1631#: includes/Admin/SettingsPage.php:529
    11931632msgid "Connected accounts"
    11941633msgstr ""
    11951634
    1196 #: includes/Admin/SettingsPage.php:469
    1197 #: includes/Admin/SettingsPage.php:625
     1635#: includes/Admin/SettingsPage.php:540
     1636#: includes/Admin/SettingsPage.php:765
    11981637msgid "Create Connection"
    11991638msgstr ""
    12001639
    1201 #: includes/Admin/SettingsPage.php:495
     1640#: includes/Admin/SettingsPage.php:566
    12021641msgid "Create a connection"
    12031642msgstr ""
    12041643
    1205 #: includes/Admin/SettingsPage.php:496
    1206 msgid "Register this site as a Server-based Applications in the Zoho API console, then paste the credentials below."
    1207 msgstr ""
    1208 
    1209 #: includes/Admin/SettingsPage.php:509
     1644#. translators: %s: link to the Zoho API console, reading "Zoho API console".
     1645#: includes/Admin/SettingsPage.php:576
     1646#, php-format
     1647msgid "Register this site as a Server-based Application in the %s, then paste the credentials below."
     1648msgstr ""
     1649
     1650#: includes/Admin/SettingsPage.php:580
     1651msgid "Zoho API console"
     1652msgstr ""
     1653
     1654#: includes/Admin/SettingsPage.php:596
    12101655msgid "Redirect URI for the Zoho console"
    12111656msgstr ""
    12121657
    1213 #: includes/Admin/SettingsPage.php:520
     1658#: includes/Admin/SettingsPage.php:611
    12141659msgid "A name to recognise this account."
    12151660msgstr ""
    12161661
    1217 #: includes/Admin/SettingsPage.php:521
     1662#: includes/Admin/SettingsPage.php:612
    12181663msgid "Data centre"
    12191664msgstr ""
    12201665
    1221 #: includes/Admin/SettingsPage.php:524
     1666#: includes/Admin/SettingsPage.php:615
    12221667msgid "Authorize for"
    12231668msgstr ""
    12241669
    1225 #: includes/Admin/SettingsPage.php:543
    1226 msgid "Check only the Zoho apps this account will actually send to — Zoho asks permission for exactly what you pick here, and nothing else. You can add more later with \"Edit access\" on the connection, without deleting it. \"All applications\" requests every permission the plugin supports at once and is rarely what you want."
    1227 msgstr ""
    1228 
    1229 #: includes/Admin/SettingsPage.php:546
     1670#: includes/Admin/SettingsPage.php:634
     1671msgid "Check only the Zoho apps this account will actually send to. Zoho asks permission for exactly what you pick here, and nothing else. You can add more later with \"Edit access\" on the connection, without deleting it. \"All applications\" requests every permission the plugin supports at once and is rarely what you want."
     1672msgstr ""
     1673
     1674#: includes/Admin/SettingsPage.php:637
    12301675msgid "Client ID"
    12311676msgstr ""
    12321677
    1233 #: includes/Admin/SettingsPage.php:547
     1678#: includes/Admin/SettingsPage.php:638
    12341679msgid "Client Secret"
    12351680msgstr ""
    12361681
    1237 #: includes/Admin/SettingsPage.php:554
     1682#: includes/Admin/SettingsPage.php:645
    12381683msgid "Connect with Zoho"
    12391684msgstr ""
    12401685
    1241 #: includes/Admin/SettingsPage.php:618
     1686#: includes/Admin/SettingsPage.php:662
     1687msgid "Setup guide"
     1688msgstr ""
     1689
     1690#: includes/Admin/SettingsPage.php:669
     1691msgid "Watch: connect an account"
     1692msgstr ""
     1693
     1694#: includes/Admin/SettingsPage.php:758
    12421695msgid "Connect your first Zoho account"
    12431696msgstr ""
    12441697
    1245 #: includes/Admin/SettingsPage.php:634
     1698#: includes/Admin/SettingsPage.php:776
    12461699msgid "Account"
    12471700msgstr ""
    12481701
    1249 #: includes/Admin/SettingsPage.php:635
     1702#: includes/Admin/SettingsPage.php:777
    12501703msgid "Authorized for"
    12511704msgstr ""
    12521705
    1253 #: includes/Admin/SettingsPage.php:700
    1254 #: includes/Admin/SettingsPage.php:761
    1255 #: includes/Auth/ConnectionManager.php:116
     1706#: includes/Admin/SettingsPage.php:842
     1707#: includes/Admin/SettingsPage.php:906
     1708#: includes/Auth/ConnectionManager.php:200
    12561709msgid "All applications"
    12571710msgstr ""
    12581711
    1259 #: includes/Admin/SettingsPage.php:709
     1712#: includes/Admin/SettingsPage.php:851
    12601713msgid "Edit access"
    12611714msgstr ""
    12621715
    1263 #: includes/Admin/SettingsPage.php:715
     1716#: includes/Admin/SettingsPage.php:857
    12641717msgid "Connected"
    12651718msgstr ""
    12661719
    1267 #: includes/Admin/SettingsPage.php:717
     1720#: includes/Admin/SettingsPage.php:859
    12681721msgid "Not authorised"
    12691722msgstr ""
    12701723
    1271 #: includes/Admin/SettingsPage.php:722
     1724#: includes/Admin/SettingsPage.php:864
    12721725msgid "Re-authorize"
    12731726msgstr ""
    12741727
    1275 #: includes/Admin/SettingsPage.php:722
     1728#: includes/Admin/SettingsPage.php:864
    12761729msgid "Re-authorize with the same access"
    12771730msgstr ""
    12781731
    1279 #: includes/Admin/SettingsPage.php:728
     1732#: includes/Admin/SettingsPage.php:874
    12801733msgid "Bigin org lookup failed"
    12811734msgstr ""
    12821735
    1283 #: includes/Admin/SettingsPage.php:729
     1736#: includes/Admin/SettingsPage.php:875
    12841737msgid "Reconnect to fix"
    12851738msgstr ""
    12861739
    1287 #: includes/Admin/SettingsPage.php:739
     1740#: includes/Admin/SettingsPage.php:885
    12881741msgid "Modules & fields"
    12891742msgstr ""
    12901743
    1291 #: includes/Admin/SettingsPage.php:740
     1744#: includes/Admin/SettingsPage.php:886
    12921745msgid "Delete this connection? Any feed using it will stop sending until a new connection is picked."
    12931746msgstr ""
    12941747
    1295 #: includes/Admin/SettingsPage.php:756
     1748#: includes/Admin/SettingsPage.php:902
    12961749msgid "Check every Zoho app this connection should have access to, then reauthorize. Zoho will ask you to approve the updated list."
    12971750msgstr ""
    12981751
    1299 #: includes/Admin/SettingsPage.php:766
     1752#: includes/Admin/SettingsPage.php:916
    13001753msgid "Update & Reauthorize"
    13011754msgstr ""
    13021755
    1303 #: includes/Admin/SettingsPage.php:797
    1304 msgid "Copy an API name into a feed under Techwave Sync → Forms."
    1305 msgstr ""
    1306 
    1307 #: includes/Admin/SettingsPage.php:827
     1756#: includes/Admin/SettingsPage.php:947
     1757msgid "Copy an API name into a feed under FormBridge → All Feeds."
     1758msgstr ""
     1759
     1760#: includes/Admin/SettingsPage.php:983
    13081761msgid "Resubmit selected"
    13091762msgstr ""
    13101763
    1311 #: includes/Admin/SettingsPage.php:838
     1764#: includes/Admin/SettingsPage.php:994
    13121765msgid "Live"
    13131766msgstr ""
    13141767
    1315 #: includes/Admin/SettingsPage.php:843
     1768#: includes/Admin/SettingsPage.php:999
    13161769msgid "Clear logs"
    13171770msgstr ""
    13181771
    1319 #: includes/Admin/SettingsPage.php:856
     1772#: includes/Admin/SettingsPage.php:1012
    13201773msgid "No log entries yet"
    13211774msgstr ""
    13221775
    1323 #: includes/Admin/SettingsPage.php:857
    1324 msgid "Every delivery to Zoho is recorded here — successes, failures and the exact data sent. New submissions appear instantly while Live is on."
    1325 msgstr ""
    1326 
    1327 #: includes/Admin/SettingsPage.php:865
     1776#: includes/Admin/SettingsPage.php:1013
     1777msgid "Every delivery to Zoho is recorded here: successes, failures and the exact data sent. New submissions appear instantly while Live is on."
     1778msgstr ""
     1779
     1780#: includes/Admin/SettingsPage.php:1021
    13281781msgid "Select all log entries"
    13291782msgstr ""
    13301783
    1331 #: includes/Admin/SettingsPage.php:866
    1332 msgid "Time (UTC)"
    1333 msgstr ""
    1334 
    1335 #: includes/Admin/SettingsPage.php:868
     1784#. translators: %s: the site's timezone, e.g. Asia/Dhaka or UTC+06:00.
     1785#: includes/Admin/SettingsPage.php:1022
     1786#, php-format
     1787msgid "Time (%s)"
     1788msgstr ""
     1789
     1790#: includes/Admin/SettingsPage.php:1024
    13361791msgid "App"
    13371792msgstr ""
    13381793
    1339 #: includes/Admin/SettingsPage.php:872
     1794#: includes/Admin/SettingsPage.php:1028
    13401795#: includes/Privacy/PrivacyProvider.php:168
    13411796msgid "Message"
    13421797msgstr ""
    13431798
    1344 #: includes/Admin/SettingsPage.php:979
    1345 #: includes/Admin/SettingsPage.php:985
     1799#. translators: 1: current page number, 2: total pages, 3: total number of log entries.
     1800#: includes/Admin/SettingsPage.php:1261
     1801#, php-format
     1802msgid "Page %1$s of %2$s · %3$s entry"
     1803msgid_plural "Page %1$s of %2$s · %3$s entries"
     1804msgstr[0] ""
     1805msgstr[1] ""
     1806
     1807#: includes/Admin/SettingsPage.php:1320
     1808#: includes/Admin/SettingsPage.php:1326
    13461809msgid "Logging"
    13471810msgstr ""
    13481811
    1349 #: includes/Admin/SettingsPage.php:980
     1812#: includes/Admin/SettingsPage.php:1321
    13501813msgid "What the Logs tab records, and how long it keeps it."
    13511814msgstr ""
    13521815
    1353 #: includes/Admin/SettingsPage.php:986
     1816#: includes/Admin/SettingsPage.php:1327
    13541817msgid "Record the outcome of every delivery on the Logs tab."
    13551818msgstr ""
    13561819
    1357 #: includes/Admin/SettingsPage.php:992
     1820#: includes/Admin/SettingsPage.php:1333
    13581821msgid "Store submitted values in the log"
    13591822msgstr ""
    13601823
    1361 #: includes/Admin/SettingsPage.php:993
    1362 msgid "Off by default. When off, only the field names are recorded, never what a visitor typed. Turn it on only if you need the Logs screen's one-click Resubmit, which replays the stored values — anything your forms collect, including personal data, is then kept in the log table for the retention period below."
    1363 msgstr ""
    1364 
    1365 #: includes/Admin/SettingsPage.php:999
     1824#: includes/Admin/SettingsPage.php:1334
     1825msgid "Off by default. When off, only the field names are recorded, never what a visitor typed. Turn it on only if you need the Logs screen's one-click Resubmit, which replays the stored values. Anything your forms collect, including personal data, is then kept in the log table until the entry is deleted."
     1826msgstr ""
     1827
     1828#: includes/Admin/SettingsPage.php:1340
    13661829msgid "Delete log entries automatically"
    13671830msgstr ""
    13681831
    1369 #: includes/Admin/SettingsPage.php:1000
    1370 msgid "Deletes log entries older than this many days, once a day."
    1371 msgstr ""
    1372 
    1373 #: includes/Admin/SettingsPage.php:1001
     1832#: includes/Admin/SettingsPage.php:1341
     1833msgid "Off by default, so log entries stay until you delete them. When on, entries older than this many days are deleted once a day."
     1834msgstr ""
     1835
     1836#: includes/Admin/SettingsPage.php:1342
    13741837msgid "Days to keep log entries"
    13751838msgstr ""
    13761839
    1377 #: includes/Admin/SettingsPage.php:1003
     1840#: includes/Admin/SettingsPage.php:1344
    13781841msgid "Days"
    13791842msgstr ""
    13801843
    1381 #: includes/Admin/SettingsPage.php:1007
     1844#: includes/Admin/SettingsPage.php:1348
    13821845msgid "days"
    13831846msgstr ""
    13841847
    1385 #: includes/Admin/SettingsPage.php:1013
     1848#: includes/Admin/SettingsPage.php:1354
    13861849msgid "Notifications"
    13871850msgstr ""
    13881851
    1389 #: includes/Admin/SettingsPage.php:1014
     1852#: includes/Admin/SettingsPage.php:1355
    13901853msgid "Emails sent to the site admin address when something needs attention."
    13911854msgstr ""
    13921855
    1393 #: includes/Admin/SettingsPage.php:1019
     1856#: includes/Admin/SettingsPage.php:1360
    13941857msgid "Email me when an integration fails"
    13951858msgstr ""
    13961859
    1397 #: includes/Admin/SettingsPage.php:1020
     1860#: includes/Admin/SettingsPage.php:1361
    13981861msgid "Sends a notification to the site admin email whenever a submission permanently fails to reach Zoho."
    13991862msgstr ""
    14001863
    1401 #: includes/Admin/SettingsPage.php:1026
     1864#: includes/Admin/SettingsPage.php:1367
    14021865msgid "Email me when a connection fails or is deleted"
    14031866msgstr ""
    14041867
    1405 #: includes/Admin/SettingsPage.php:1027
     1868#: includes/Admin/SettingsPage.php:1368
    14061869msgid "Sends a notification to the site admin email whenever a Zoho connection stops working or is removed from the Connections tab."
    14071870msgstr ""
    14081871
    14091872#. translators: %s: administration email address.
    1410 #: includes/Admin/SettingsPage.php:1040
    1411 #, php-format
    1412 msgid "Sends one message to %s to check this site can send mail at all."
    1413 msgstr ""
    1414 
    1415 #: includes/Admin/SettingsPage.php:1054
     1873#: includes/Admin/SettingsPage.php:1381
     1874#, php-format
     1875msgid "Sends one message to %s with the PHP mail function, the same way the notifications are sent."
     1876msgstr ""
     1877
     1878#: includes/Admin/SettingsPage.php:1395
    14161879msgid "Data & privacy"
    14171880msgstr ""
    14181881
    1419 #: includes/Admin/SettingsPage.php:1055
     1882#: includes/Admin/SettingsPage.php:1396
    14201883msgid "What happens to everything this plugin stored, if you ever delete it."
    14211884msgstr ""
    14221885
    1423 #: includes/Admin/SettingsPage.php:1061
     1886#: includes/Admin/SettingsPage.php:1402
    14241887msgid "Erase all plugin data when the plugin is deleted"
    14251888msgstr ""
    14261889
    1427 #: includes/Admin/SettingsPage.php:1062
    1428 msgid "Drops the log table and removes every connection, feed, stored token and scheduled event. Off by default so deactivating and reinstalling does not destroy your setup. This cannot be undone."
     1890#: includes/Admin/SettingsPage.php:1403
     1891msgid "Drops the log table and removes every connection, feed and stored token. Off by default so deactivating and reinstalling does not destroy your setup. This cannot be undone."
    14291892msgstr ""
    14301893
    14311894#. translators: %s: version number, e.g. 1.0.0.
    1432 #: includes/Admin/SettingsPage.php:1079
    1433 #, php-format
    1434 msgid "Techwave Form Sync for Zoho version %s"
    1435 msgstr ""
    1436 
    1437 #: includes/Api/AbstractClient.php:190
    1438 msgid "Zoho rejected the access token."
     1895#: includes/Admin/SettingsPage.php:1420
     1896#, php-format
     1897msgid "FormBridge for Zoho version %s"
     1898msgstr ""
     1899
     1900#. translators: %s: file path.
     1901#: includes/Api/AbstractClient.php:188
     1902#, php-format
     1903msgid "The uploaded file could not be read from %s."
     1904msgstr ""
     1905
     1906#. translators: %d: the limit in megabytes.
     1907#: includes/Api/AbstractClient.php:203
     1908#, php-format
     1909msgid "This submission has already sent %dMB of files, which is all this request will carry."
     1910msgstr ""
     1911
     1912#. translators: 1: file name, 2: Zoho's limit in megabytes.
     1913#: includes/Api/AbstractClient.php:214
     1914#, php-format
     1915msgid "%1$s is larger than the %2$dMB Zoho accepts for a file field."
     1916msgstr ""
     1917
     1918#: includes/Api/AbstractClient.php:235
     1919msgid "The uploaded file could not be read."
     1920msgstr ""
     1921
     1922#: includes/Api/AbstractClient.php:290
     1923msgid "This connection was authorised before the plugin could upload files, so Zoho refused it. Open Connections, use \"Edit access\" on the account, and press Update & Reauthorize, then submit the form again."
    14391924msgstr ""
    14401925
    14411926#. translators: 1: HTTP status code, 2: detail from Zoho.
    1442 #: includes/Api/AbstractClient.php:198
     1927#: includes/Api/AbstractClient.php:299
     1928#, php-format
     1929msgid "Zoho refused the file upload. HTTP %1$d. %2$s"
     1930msgstr ""
     1931
     1932#: includes/Api/AbstractClient.php:570
     1933msgid "Zoho rejected the access token. The connection may need reauthorizing: open Connections, use \"Edit access\", and press Update & Reauthorize."
     1934msgstr ""
     1935
     1936#. translators: 1: HTTP status code, 2: detail from Zoho.
     1937#: includes/Api/AbstractClient.php:580
    14431938#, php-format
    14441939msgid "Zoho API returned HTTP %1$d. %2$s"
    14451940msgstr ""
    14461941
    1447 #: includes/Api/AbstractClient.php:291
     1942#: includes/Api/AbstractClient.php:673
    14481943msgid "No detail provided."
    14491944msgstr ""
    14501945
    1451 #: includes/Api/BiginClient.php:108
     1946#: includes/Api/BiginClient.php:120
    14521947msgid "Zoho responded, but the organization info did not include a domain_name."
    14531948msgstr ""
    14541949
    1455 #: includes/Api/BooksClient.php:80
     1950#: includes/Api/BooksClient.php:281
    14561951msgid "Customers / Contacts"
    14571952msgstr ""
    14581953
    1459 #: includes/Api/BooksClient.php:84
     1954#: includes/Api/BooksClient.php:285
    14601955msgid "Items"
    14611956msgstr ""
    14621957
    1463 #: includes/Api/BooksClient.php:88
     1958#: includes/Api/BooksClient.php:289
    14641959msgid "Invoices"
    14651960msgstr ""
    14661961
    1467 #: includes/Api/BooksClient.php:92
     1962#: includes/Api/BooksClient.php:293
    14681963msgid "Estimates"
    14691964msgstr ""
    14701965
    1471 #: includes/Api/BooksClient.php:96
     1966#: includes/Api/BooksClient.php:297
    14721967msgid "Sales Orders"
    14731968msgstr ""
    14741969
    1475 #: includes/Api/BooksClient.php:100
     1970#: includes/Api/BooksClient.php:301
    14761971msgid "Purchase Orders"
    14771972msgstr ""
    14781973
    1479 #: includes/Api/BooksClient.php:104
     1974#: includes/Api/BooksClient.php:305
    14801975msgid "Bills"
    14811976msgstr ""
    14821977
    1483 #: includes/Api/BooksClient.php:108
     1978#: includes/Api/BooksClient.php:309
    14841979msgid "Expenses"
    14851980msgstr ""
    14861981
    1487 #: includes/Api/BooksClient.php:198
     1982#: includes/Api/BooksClient.php:567
    14881983msgid "Select a Zoho Books organisation for this form."
    14891984msgstr ""
    14901985
    1491 #: includes/Api/BooksClient.php:226
    1492 #: includes/Api/DeskClient.php:314
     1986#: includes/Api/BooksClient.php:696
     1987#: includes/Api/DeskClient.php:425
    14931988msgid "Web Lead"
    14941989msgstr ""
    14951990
    1496 #: includes/Api/CampaignsClient.php:158
     1991#: includes/Api/CampaignsClient.php:200
    14971992msgid "Zoho did not return any contact fields for this account."
    14981993msgstr ""
    14991994
    15001995#. translators: %s: comma-separated list of keys Zoho actually sent for one field row.
    1501 #: includes/Api/CampaignsClient.php:188
     1996#: includes/Api/CampaignsClient.php:230
    15021997#, php-format
    15031998msgid "Zoho returned %1$d contact field row(s), but none matched the expected format. Keys seen: %2$s"
    15041999msgstr ""
    15052000
    1506 #: includes/Api/CampaignsClient.php:190
    1507 #: includes/Api/CampaignsClient.php:358
    1508 #: includes/Api/MarketingAutomationClient.php:169
     2001#: includes/Api/CampaignsClient.php:232
     2002#: includes/Api/CampaignsClient.php:400
     2003#: includes/Api/MarketingAutomationClient.php:211
    15092004msgid "(row was not an object)"
    15102005msgstr ""
    15112006
    1512 #: includes/Api/CampaignsClient.php:302
     2007#: includes/Api/CampaignsClient.php:344
    15132008msgid "Zoho returned an empty or non-JSON response for the tags request. Re-authorize the connection (the token may be missing the ZohoCampaigns.contact scope) and try again."
    15142009msgstr ""
    15152010
    1516 #: includes/Api/CampaignsClient.php:311
     2011#: includes/Api/CampaignsClient.php:353
    15172012msgid "Zoho rejected the tags request."
    15182013msgstr ""
    15192014
    15202015#. translators: %s: comma-separated list of keys Zoho actually sent for one tag row.
    1521 #: includes/Api/CampaignsClient.php:356
     2016#: includes/Api/CampaignsClient.php:398
    15222017#, php-format
    15232018msgid "Zoho returned %1$d tag row(s), but none matched the expected format. Keys seen: %2$s"
     2019msgstr ""
     2020
     2021#: includes/Api/CrmClient.php:74
     2022msgid "Zoho accepted the file but returned no id for it."
    15242023msgstr ""
    15252024
    … …  
    15362035msgstr ""
    15372036
    1538 #: includes/Api/DeskClient.php:130
     2037#: includes/Api/DeskClient.php:197
    15392038msgid "Subject"
    15402039msgstr ""
    15412040
    1542 #: includes/Api/DeskClient.php:135
     2041#: includes/Api/DeskClient.php:202
    15432042msgid "Contact last name"
    15442043msgstr ""
    15452044
    1546 #: includes/Api/DeskClient.php:140
     2045#: includes/Api/DeskClient.php:207
    15472046msgid "Contact first name"
    15482047msgstr ""
    15492048
    1550 #: includes/Api/DeskClient.php:145
     2049#: includes/Api/DeskClient.php:212
    15512050msgid "Contact email"
    15522051msgstr ""
    15532052
    1554 #: includes/Api/DeskClient.php:150
     2053#: includes/Api/DeskClient.php:217
    15552054msgid "Contact phone"
    15562055msgstr ""
    15572056
    1558 #: includes/Api/DeskClient.php:155
    1559 #: includes/Api/DeskClient.php:199
     2057#: includes/Api/DeskClient.php:222
     2058#: includes/Api/DeskClient.php:266
    15602059msgid "Description"
    15612060msgstr ""
    15622061
    1563 #: includes/Api/DeskClient.php:160
     2062#: includes/Api/DeskClient.php:227
    15642063msgid "Priority"
    15652064msgstr ""
    15662065
    1567 #: includes/Api/DeskClient.php:174
     2066#: includes/Api/DeskClient.php:241
    15682067msgid "Last name"
    15692068msgstr ""
    15702069
    1571 #: includes/Api/DeskClient.php:179
     2070#: includes/Api/DeskClient.php:246
    15722071msgid "First name"
    15732072msgstr ""
    15742073
    1575 #: includes/Api/DeskClient.php:184
     2074#: includes/Api/DeskClient.php:251
    15762075msgid "Email"
    15772076msgstr ""
    15782077
    1579 #: includes/Api/DeskClient.php:189
    1580 #: includes/Api/DeskClient.php:218
     2078#: includes/Api/DeskClient.php:256
     2079#: includes/Api/DeskClient.php:285
    15812080msgid "Phone"
    15822081msgstr ""
    15832082
    1584 #: includes/Api/DeskClient.php:194
     2083#: includes/Api/DeskClient.php:261
    15852084msgid "Mobile"
    15862085msgstr ""
    15872086
    1588 #: includes/Api/DeskClient.php:208
     2087#: includes/Api/DeskClient.php:275
    15892088msgid "Account name"
    15902089msgstr ""
    15912090
    1592 #: includes/Api/DeskClient.php:213
     2091#: includes/Api/DeskClient.php:280
    15932092msgid "Website"
    15942093msgstr ""
    15952094
    1596 #: includes/Api/DeskClient.php:223
     2095#: includes/Api/DeskClient.php:290
    15972096msgid "City"
    15982097msgstr ""
    15992098
    1600 #: includes/Api/DeskClient.php:228
     2099#: includes/Api/DeskClient.php:295
    16012100msgid "Country"
    16022101msgstr ""
    16032102
    1604 #: includes/Api/DeskClient.php:248
     2103#: includes/Api/DeskClient.php:315
    16052104msgid "Select a Zoho Desk organization for this feed."
    16062105msgstr ""
    16072106
    1608 #: includes/Api/DeskClient.php:289
     2107#: includes/Api/DeskClient.php:400
    16092108msgid "Choose a Department for this Zoho Desk feed."
    16102109msgstr ""
    16112110
    1612 #: includes/Api/DeskClient.php:342
     2111#: includes/Api/DeskClient.php:453
    16132112msgid "New web form submission"
    16142113msgstr ""
    16152114
    1616 #: includes/Api/MarketingAutomationClient.php:141
     2115#: includes/Api/MarketingAutomationClient.php:183
    16172116msgid "Zoho did not return any lead fields for this account."
    16182117msgstr ""
    16192118
    16202119#. translators: %s: comma-separated list of keys Zoho actually sent for one field row.
    1621 #: includes/Api/MarketingAutomationClient.php:167
     2120#: includes/Api/MarketingAutomationClient.php:209
    16222121#, php-format
    16232122msgid "Zoho returned %1$d lead field row(s), but none matched the expected format. Keys seen: %2$s"
    16242123msgstr ""
    16252124
    1626 #: includes/Api/MarketingAutomationClient.php:251
     2125#: includes/Api/MarketingAutomationClient.php:293
    16272126msgid "Zoho Marketing Automation does not have a tags feature."
    16282127msgstr ""
    16292128
    1630 #: includes/Auth/ConnectionManager.php:164
    1631 #: includes/Auth/ConnectionManager.php:300
     2129#: includes/Auth/ConnectionManager.php:249
     2130#: includes/Auth/ConnectionManager.php:441
    16322131msgid "Connection not found."
    16332132msgstr ""
    16342133
    1635 #: includes/Auth/ConnectionManager.php:175
     2134#: includes/Auth/ConnectionManager.php:254
    16362135msgid "Choose at least one Zoho app."
    16372136msgstr ""
    16382137
    1639 #: includes/Auth/ConnectionManager.php:227
    1640 msgid "This server cannot encrypt credentials: PHP's OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it — the plugin will not store a Zoho client secret unencrypted."
    1641 msgstr ""
    1642 
    1643 #: includes/Auth/ConnectionManager.php:233
     2138#: includes/Auth/ConnectionManager.php:376
     2139msgid "This server cannot encrypt credentials: PHP's OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it. The plugin will not store a Zoho client secret unencrypted."
     2140msgstr ""
     2141
     2142#: includes/Auth/ConnectionManager.php:382
    16442143msgid "Please choose a valid Zoho data center."
    16452144msgstr ""
    16462145
    1647 #: includes/Auth/ConnectionManager.php:244
     2146#: includes/Auth/ConnectionManager.php:393
    16482147msgid "Client ID and Client Secret are required."
    16492148msgstr ""
    16502149
    1651 #: includes/Auth/ConnectionManager.php:247
     2150#: includes/Auth/ConnectionManager.php:396
    16522151msgid "That does not look like a Zoho Client ID / Client Secret. Copy both values exactly as shown in the Zoho API console."
    16532152msgstr ""
    16542153
    1655 #: includes/Auth/ConnectionManager.php:277
     2154#: includes/Auth/ConnectionManager.php:418
    16562155msgid "Zoho account"
    16572156msgstr ""
    16582157
    1659 #: includes/Auth/ConnectionManager.php:321
     2158#: includes/Auth/ConnectionManager.php:475
    16602159msgid "You are not allowed to do this."
    16612160msgstr ""
    16622161
    1663 #: includes/Auth/ConnectionManager.php:491
     2162#: includes/Auth/ConnectionManager.php:823
    16642163msgid "This Zoho connection is not authorised."
    16652164msgstr ""
    16662165
    1667 #: includes/Auth/ConnectionManager.php:502
     2166#: includes/Auth/ConnectionManager.php:834
    16682167msgid "Could not refresh the Zoho access token."
    16692168msgstr ""
    16702169
    1671 #: includes/Auth/OAuthClient.php:135
     2170#: includes/Auth/OAuthClient.php:137
    16722171msgid "Unreadable response from Zoho accounts server."
    16732172msgstr ""
    16742173
    16752174#. translators: %d: HTTP status code.
    1676 #: includes/Auth/OAuthClient.php:143
     2175#: includes/Auth/OAuthClient.php:145
    16772176#, php-format
    16782177msgid "Zoho returned HTTP %d during token request."
    … …  
    16802179
    16812180#. translators: %s: site name.
    1682 #: includes/Core/Plugin.php:284
     2181#: includes/Core/Plugin.php:334
    16832182#, php-format
    16842183msgid "[%s] A form submission failed to reach Zoho"
    16852184msgstr ""
    16862185
    1687 #: includes/Core/Plugin.php:287
     2186#: includes/Core/Plugin.php:337
    16882187msgid "A form submission could not be sent to Zoho and will not be retried again automatically."
    16892188msgstr ""
    16902189
    16912190#. translators: %s: integration/app id, e.g. "crm".
    1692 #: includes/Core/Plugin.php:290
     2191#: includes/Core/Plugin.php:340
    16932192#, php-format
    16942193msgid "App: %s"
    … …  
    16962195
    16972196#. translators: %s: form name.
    1698 #: includes/Core/Plugin.php:292
     2197#: includes/Core/Plugin.php:342
    16992198#, php-format
    17002199msgid "Form: %s"
    … …  
    17022201
    17032202#. translators: %s: error message.
    1704 #: includes/Core/Plugin.php:294
    1705 #: includes/Core/Plugin.php:357
     2203#: includes/Core/Plugin.php:344
     2204#: includes/Core/Plugin.php:407
    17062205#, php-format
    17072206msgid "Reason: %s"
    … …  
    17092208
    17102209#. translators: %s: URL to the plugin's Logs screen.
    1711 #: includes/Core/Plugin.php:297
     2210#: includes/Core/Plugin.php:347
    17122211#, php-format
    17132212msgid "View the full log entry: %s"
    … …  
    17152214
    17162215#. translators: %s: site name.
    1717 #: includes/Core/Plugin.php:350
     2216#: includes/Core/Plugin.php:400
    17182217#, php-format
    17192218msgid "[%s] A Zoho connection has failed"
    … …  
    17212220
    17222221#. translators: %s: connection label.
    1723 #: includes/Core/Plugin.php:354
     2222#: includes/Core/Plugin.php:404
    17242223#, php-format
    17252224msgid "The Zoho connection \"%s\" could not be authorised anymore. Form submissions using this connection will fail until it is re-authorized."
    … …  
    17272226
    17282227#. translators: %s: URL to the plugin's Connections screen.
    1729 #: includes/Core/Plugin.php:360
     2228#: includes/Core/Plugin.php:410
    17302229#, php-format
    17312230msgid "Check the connection here: %s"
    … …  
    17332232
    17342233#. translators: %s: site name.
    1735 #: includes/Core/Plugin.php:399
     2234#: includes/Core/Plugin.php:449
    17362235#, php-format
    17372236msgid "[%s] A Zoho connection was deleted"
    … …  
    17392238
    17402239#. translators: %s: connection label.
    1741 #: includes/Core/Plugin.php:403
     2240#: includes/Core/Plugin.php:453
    17422241#, php-format
    17432242msgid "The Zoho connection \"%s\" was deleted from the Connections tab. Any feed that used this connection will stop sending until a new connection is picked."
    … …  
    17452244
    17462245#. translators: %s: URL to the plugin's Connections screen.
    1747 #: includes/Core/Plugin.php:406
     2246#: includes/Core/Plugin.php:456
    17482247#, php-format
    17492248msgid "Manage connections here: %s"
    17502249msgstr ""
    17512250
    1752 #. translators: %s: recipient email address.
    1753 #: includes/Core/Plugin.php:467
    1754 #, php-format
    1755 msgid "A notification email to %s could not be sent. WordPress refused to hand it to a mailer — this site most likely has no working mail transport (many managed hosts need an SMTP plugin). The event itself was still recorded above."
     2251#. translators: 1: recipient email address. 2: the mailer's own reason, e.g. "Could not instantiate mail function."
     2252#: includes/Core/Plugin.php:520
     2253#, php-format
     2254msgid "A notification email to %1$s could not be sent with the PHP mail function. Reason: %2$s The event itself was still recorded above."
    17562255msgstr ""
    17572256
    … …  
    17612260
    17622261#: includes/Integrations/BiginIntegration.php:95
    1763 #: includes/Integrations/BooksIntegration.php:110
     2262#: includes/Integrations/BooksIntegration.php:119
    17642263#: includes/Integrations/CampaignsIntegration.php:149
    17652264#: includes/Integrations/CrmIntegration.php:86
    1766 #: includes/Integrations/DeskIntegration.php:122
     2265#: includes/Integrations/DeskIntegration.php:128
    17672266#: includes/Integrations/MarketingAutomationIntegration.php:125
    17682267msgid "Mapping produced no data to send."
    … …  
    17702269
    17712270#. translators: %s: reason the organization lookup failed.
    1772 #: includes/Integrations/BiginIntegration.php:153
    1773 #: includes/Integrations/BiginIntegration.php:174
     2271#: includes/Integrations/BiginIntegration.php:156
     2272#: includes/Integrations/BiginIntegration.php:177
    17742273#, php-format
    17752274msgid "\"View Record in Zoho\" link will open the Bigin homepage instead of the exact record: %s Reconnect this Bigin connection to fix it."
    17762275msgstr ""
    17772276
    1778 #: includes/Integrations/BiginIntegration.php:219
     2277#: includes/Integrations/BiginIntegration.php:222
    17792278msgid "Unexpected Bigin response."
    17802279msgstr ""
    17812280
    1782 #: includes/Integrations/BooksIntegration.php:79
     2281#: includes/Integrations/BooksIntegration.php:85
    17832282msgid "Choose a Books resource first."
    17842283msgstr ""
    17852284
    1786 #: includes/Integrations/BooksIntegration.php:98
     2285#: includes/Integrations/BooksIntegration.php:107
    17872286msgid "No Books resource configured."
    17882287msgstr ""
    17892288
    1790 #: includes/Integrations/BooksIntegration.php:143
     2289#: includes/Integrations/BooksIntegration.php:152
    17912290msgid "Unexpected Books response."
    17922291msgstr ""
    … …  
    17972296msgstr ""
    17982297
    1799 #: includes/Integrations/CampaignsIntegration.php:242
     2298#. translators: 1: Zoho field name, 2: the form field it is mapped to.
     2299#: includes/Integrations/CampaignsIntegration.php:180
     2300#: includes/Integrations/MarketingAutomationIntegration.php:156
     2301#, php-format
     2302msgid "The value mapped to \"%1$s\" is not an email address, so Zoho refused it. It comes from the form field \"%2$s\" — map the form's email field there instead."
     2303msgstr ""
     2304
     2305#. translators: %s: Zoho field name.
     2306#: includes/Integrations/CampaignsIntegration.php:186
     2307#: includes/Integrations/MarketingAutomationIntegration.php:162
     2308#, php-format
     2309msgid "The value mapped to \"%s\" is not an email address, so Zoho refused it."
     2310msgstr ""
     2311
     2312#: includes/Integrations/CampaignsIntegration.php:211
     2313msgid "Map a form field to the contact email. Zoho needs it to add the contact to the list."
     2314msgstr ""
     2315
     2316#. translators: %s: reason Zoho gave.
     2317#: includes/Integrations/CampaignsIntegration.php:226
     2318#, php-format
     2319msgid "The submitted fields were sent, but the contact could not be added to the mailing list directly: %s"
     2320msgstr ""
     2321
     2322#. translators: %s: the contact's email address.
     2323#: includes/Integrations/CampaignsIntegration.php:267
     2324#, php-format
     2325msgid "This contact was already in the mailing list, so Zoho updated it with the submitted fields (%s)."
     2326msgstr ""
     2327
     2328#: includes/Integrations/CampaignsIntegration.php:268
     2329msgid "This contact was already in the mailing list, so Zoho updated it with the submitted fields."
     2330msgstr ""
     2331
     2332#. translators: %s: the contact's email address.
     2333#: includes/Integrations/CampaignsIntegration.php:276
     2334#, php-format
     2335msgid "Contact unsubscribed in Zoho (%s)."
     2336msgstr ""
     2337
     2338#: includes/Integrations/CampaignsIntegration.php:277
     2339msgid "Contact unsubscribed in Zoho."
     2340msgstr ""
     2341
     2342#. translators: 1: tag name, 2: reason Zoho gave.
     2343#: includes/Integrations/CampaignsIntegration.php:300
     2344#, php-format
     2345msgid "The tag \"%1$s\" was not attached to the contact: %2$s"
     2346msgstr ""
     2347
     2348#: includes/Integrations/CampaignsIntegration.php:366
    18002349msgid "Unexpected Campaigns response."
     2350msgstr ""
     2351
     2352#. translators: 1: Zoho's message, 2: Zoho's error code.
     2353#: includes/Integrations/CampaignsIntegration.php:373
     2354#: includes/Integrations/MarketingAutomationIntegration.php:321
     2355#, php-format
     2356msgid "%1$s (Zoho code %2$s)"
    18012357msgstr ""
    18022358
    … …  
    18052361msgstr ""
    18062362
    1807 #: includes/Integrations/CrmIntegration.php:134
     2363#: includes/Integrations/CrmIntegration.php:240
    18082364msgid "Unexpected CRM response."
    18092365msgstr ""
    … …  
    18132369msgstr ""
    18142370
    1815 #: includes/Integrations/DeskIntegration.php:110
     2371#: includes/Integrations/DeskIntegration.php:116
    18162372msgid "No Desk module configured."
    18172373msgstr ""
    18182374
    1819 #: includes/Integrations/DeskIntegration.php:163
     2375#: includes/Integrations/DeskIntegration.php:169
    18202376msgid "Unexpected Desk response."
    18212377msgstr ""
    18222378
    1823 #: includes/Integrations/MarketingAutomationIntegration.php:211
     2379#: includes/Integrations/MarketingAutomationIntegration.php:185
     2380msgid "Map a form field to the lead email. Zoho needs it to add the lead to the list."
     2381msgstr ""
     2382
     2383#. translators: %s: reason Zoho gave.
     2384#: includes/Integrations/MarketingAutomationIntegration.php:200
     2385#, php-format
     2386msgid "The submitted fields were sent, but the lead could not be added to the mailing list directly: %s"
     2387msgstr ""
     2388
     2389#. translators: %s: the lead's email address.
     2390#: includes/Integrations/MarketingAutomationIntegration.php:240
     2391#, php-format
     2392msgid "This lead was already in the mailing list, so Zoho updated it with the submitted fields (%s)."
     2393msgstr ""
     2394
     2395#: includes/Integrations/MarketingAutomationIntegration.php:241
     2396msgid "This lead was already in the mailing list, so Zoho updated it with the submitted fields."
     2397msgstr ""
     2398
     2399#. translators: %s: the lead's email address.
     2400#: includes/Integrations/MarketingAutomationIntegration.php:249
     2401#, php-format
     2402msgid "Lead unsubscribed in Zoho (%s)."
     2403msgstr ""
     2404
     2405#: includes/Integrations/MarketingAutomationIntegration.php:250
     2406msgid "Lead unsubscribed in Zoho."
     2407msgstr ""
     2408
     2409#: includes/Integrations/MarketingAutomationIntegration.php:314
    18242410msgid "Unexpected Marketing Automation response."
     2411msgstr ""
     2412
     2413#. translators: marks a log entry that was too long to store in full.
     2414#: includes/Logging/Logger.php:191
     2415msgid "[cut: entry too long]"
    18252416msgstr ""
    18262417
    … …  
    18302421
    18312422#: includes/Privacy/PrivacyProvider.php:61
    1832 msgid "This plugin also stores a delivery log entry for each submission (timestamp, status and a redacted API response). When the \"Store submitted values\" option is enabled — it is off by default — that entry additionally contains the submitted field values so a failed delivery can be resubmitted; with the option off, only the field names are recorded. Access tokens, refresh tokens and client secrets are never written to these logs in readable form. Log entries are deleted automatically after the retention period set on the plugin's Settings tab."
     2423msgid "This plugin also stores a delivery log entry for each submission (timestamp, status and a redacted API response). When the \"Store submitted values\" option is enabled (it is off by default), that entry additionally contains the submitted field values so a failed delivery can be resubmitted; with the option off, only the field names are recorded. Access tokens, refresh tokens and client secrets are never written to these logs in readable form. Log entries are kept until they are deleted, either by hand or automatically after a set number of days if that option is turned on in the plugin's Settings tab."
    18332424msgstr ""
    18342425
    … …  
    18402431#: includes/Privacy/PrivacyProvider.php:92
    18412432#: includes/Privacy/PrivacyProvider.php:183
    1842 msgid "Techwave Form Sync for Zoho delivery logs"
     2433msgid "FormBridge for Zoho delivery logs"
    18432434msgstr ""
    18442435
    … …  
    18562447
    18572448#. translators: %s: Zoho record id.
    1858 #: includes/Queue/Dispatcher.php:161
     2449#: includes/Queue/Dispatcher.php:243
    18592450#, php-format
    18602451msgid "Record updated in Zoho (id %s)."
    18612452msgstr ""
    18622453
    1863 #: includes/Queue/Dispatcher.php:162
     2454#: includes/Queue/Dispatcher.php:244
    18642455msgid "Record updated in Zoho."
    18652456msgstr ""
    18662457
    18672458#. translators: %s: Zoho record id.
    1868 #: includes/Queue/Dispatcher.php:166
     2459#: includes/Queue/Dispatcher.php:248
    18692460#, php-format
    18702461msgid "Record created in Zoho (id %s)."
    18712462msgstr ""
    18722463
    1873 #: includes/Queue/Dispatcher.php:167
     2464#: includes/Queue/Dispatcher.php:249
    18742465msgid "Record created in Zoho."
    18752466msgstr ""
    18762467
    1877 #: includes/Support/BaseSubmissionHandler.php:101
     2468#: includes/Support/BaseSubmissionHandler.php:106
    18782469msgid "Feed is enabled but has no Zoho app or connected account selected."
    18792470msgstr ""
    18802471
     2472#. translators: %s: the plugin's version number, e.g. 1.1.0.
     2473#: includes/Support/Branding.php:133
     2474#, php-format
     2475msgid "Version %s"
     2476msgstr ""
     2477
     2478#. translators: second half of the product name "FormBridge for Zoho", set lighter beside it. "Zoho" is a trademark and stays as-is.
     2479#: includes/Support/Branding.php:164
     2480msgid "for Zoho"
     2481msgstr ""
     2482
    18812483#: includes/Support/Encryption.php:55
    1882 msgid "This server cannot encrypt credentials: PHP's OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it — the plugin will not store a Zoho client secret or refresh token unencrypted."
     2484msgid "This server cannot encrypt credentials: PHP's OpenSSL extension with AES-256-GCM support is unavailable. Ask your host to enable it. The plugin will not store a Zoho client secret or refresh token unencrypted."
    18832485msgstr ""
    18842486
    18852487#: includes/Support/Encryption.php:69
    1886 msgid "This site has no AUTH_KEY defined in wp-config.php, so Zoho credentials cannot be encrypted safely. Add the WordPress security keys to wp-config.php and try again — the plugin will not store a client secret or refresh token that a copy of the database alone could decrypt."
     2488msgid "This site has no AUTH_KEY defined in wp-config.php, so Zoho credentials cannot be encrypted safely. Add the WordPress security keys to wp-config.php and try again. The plugin will not store a client secret or refresh token that a copy of the database alone could decrypt."
    18872489msgstr ""
    18882490
    … …  
    18962498
    18972499#. translators: %s: Zoho field name.
    1898 #: includes/Support/ErrorTranslator.php:45
     2500#: includes/Support/ErrorTranslator.php:51
    18992501#, php-format
    19002502msgid "Zoho validation failed: %s is required."
    19012503msgstr ""
    19022504
    1903 #: includes/Support/ErrorTranslator.php:46
     2505#: includes/Support/ErrorTranslator.php:52
    19042506msgid "Record creation failed. A required field is missing."
    19052507msgstr ""
    19062508
    19072509#. translators: %s: Zoho field name.
    1908 #: includes/Support/ErrorTranslator.php:58
     2510#: includes/Support/ErrorTranslator.php:64
    19092511#, php-format
    19102512msgid "Zoho validation failed: %s is invalid."
    19112513msgstr ""
    19122514
    1913 #: includes/Support/ErrorTranslator.php:62
     2515#: includes/Support/ErrorTranslator.php:68
    19142516msgid "This record already exists in Zoho (duplicate)."
    19152517msgstr ""
    19162518
    1917 #: includes/Support/ErrorTranslator.php:66
     2519#: includes/Support/ErrorTranslator.php:72
    19182520msgid "Zoho rejected the connection. Reconnect the account under Connections."
    19192521msgstr ""
    19202522
    1921 #: includes/Support/ErrorTranslator.php:70
     2523#: includes/Support/ErrorTranslator.php:76
    19222524msgid "Record creation failed. Required fields are missing."
    19232525msgstr ""
    19242526
    1925 #: includes/Support/FormSources.php:31
     2527#: includes/Support/ErrorTranslator.php:107
     2528msgid "the connection timed out"
     2529msgstr ""
     2530
     2531#: includes/Support/ErrorTranslator.php:109
     2532msgid "this server could not look up its address"
     2533msgstr ""
     2534
     2535#: includes/Support/ErrorTranslator.php:111
     2536msgid "the connection was refused or blocked"
     2537msgstr ""
     2538
     2539#: includes/Support/ErrorTranslator.php:113
     2540msgid "the secure connection could not be set up"
     2541msgstr ""
     2542
     2543#: includes/Support/ErrorTranslator.php:115
     2544msgid "the connection failed"
     2545msgstr ""
     2546
     2547#. translators: 1: host name, e.g. www.zohoapis.com. 2: what went wrong, e.g. "the connection timed out".
     2548#: includes/Support/ErrorTranslator.php:122
     2549#, php-format
     2550msgid "Could not reach Zoho (%1$s): %2$s."
     2551msgstr ""
     2552
     2553#. translators: %s: what went wrong, e.g. "the connection timed out".
     2554#: includes/Support/ErrorTranslator.php:124
     2555#, php-format
     2556msgid "Could not reach Zoho: %s."
     2557msgstr ""
     2558
     2559#: includes/Support/ErrorTranslator.php:130
     2560msgid "This is a network problem between this website and Zoho, not a problem with the feed. Try again in a moment. If it keeps happening, ask your hosting provider to allow outgoing HTTPS connections to Zoho."
     2561msgstr ""
     2562
     2563#. translators: %d: cURL error number, to pass on to the hosting provider.
     2564#: includes/Support/ErrorTranslator.php:134
     2565#, php-format
     2566msgid "(cURL error %d)"
     2567msgstr ""
     2568
     2569#: includes/Support/FormSources.php:35
    19262570msgid "Elementor Pro Forms"
    19272571msgstr ""
    19282572
    1929 #: includes/Support/FormSources.php:37
     2573#: includes/Support/FormSources.php:41
    19302574msgid "Contact Form 7"
    19312575msgstr ""
    19322576
    1933 #: includes/Support/FormSources.php:43
     2577#: includes/Support/FormSources.php:47
    19342578msgid "WPForms"
    19352579msgstr ""
    19362580
    1937 #: includes/Support/FormSources.php:49
     2581#: includes/Support/FormSources.php:53
    19382582msgid "Fluent Forms"
    19392583msgstr ""
    19402584
    1941 #: includes/Support/FormSources.php:55
     2585#: includes/Support/FormSources.php:59
    19422586msgid "Forminator"
    19432587msgstr ""
    19442588
    1945 #: includes/Support/FormSources.php:61
     2589#: includes/Support/FormSources.php:65
    19462590msgid "Ninja Forms"
    19472591msgstr ""
    19482592
    1949 #: includes/Support/FormSources.php:67
     2593#: includes/Support/FormSources.php:71
    19502594msgid "Gravity Forms"
    19512595msgstr ""
    19522596
    1953 #: includes/Support/FormSources.php:73
     2597#: includes/Support/FormSources.php:77
    19542598msgid "Formidable Forms"
    19552599msgstr ""
    19562600
    1957 #: includes/Support/FormSources.php:79
     2601#: includes/Support/FormSources.php:83
    19582602msgid "Happy Forms"
    19592603msgstr ""
    19602604
    1961 #: includes/Support/FormSources.php:85
     2605#: includes/Support/FormSources.php:89
    19622606msgid "SureForms"
    19632607msgstr ""
     2608
     2609#: includes/Support/FormSources.php:115
     2610msgid "WooCommerce Customer"
     2611msgstr ""
     2612
     2613#: includes/Support/FormSources.php:121
     2614msgid "MemberPress Member"
     2615msgstr ""
     2616
     2617#: includes/Support/FormSources.php:127
     2618msgid "LearnDash Learner"
     2619msgstr ""
     2620
     2621#: includes/Support/FormSources.php:133
     2622msgid "Tutor LMS Student"
     2623msgstr ""
     2624
     2625#: includes/Support/FormSources.php:139
     2626msgid "GiveWP Donor"
     2627msgstr ""
     2628
     2629#: includes/Support/FormSources.php:145
     2630msgid "EDD Customer"
     2631msgstr ""
     2632
     2633#: includes/Support/FormSources.php:151
     2634msgid "Amelia Customer"
     2635msgstr ""
     2636
     2637#: includes/Support/FormSources.php:157
     2638msgid "Dokan Vendor"
     2639msgstr ""
     2640
     2641#: includes/Support/FormSources.php:163
     2642msgid "Job Manager Employer"
     2643msgstr ""
     2644
     2645#: includes/Support/FormSources.php:169
     2646msgid "Event Attendee"
     2647msgstr ""
     2648
     2649#: includes/Support/FormSources.php:175
     2650msgid "LatePoint Customer"
     2651msgstr ""
     2652
     2653#: includes/Support/FormSources.php:181
     2654msgid "Bookly Customer"
     2655msgstr ""
     2656
     2657#: includes/Support/FormSources.php:187
     2658msgid "Simply Schedule Appointment"
     2659msgstr ""
     2660
     2661#: includes/Support/FormSources.php:193
     2662msgid "FluentBooking Attendee"
     2663msgstr ""
     2664
     2665#: includes/Support/FormSources.php:199
     2666msgid "Booking Calendar"
     2667msgstr ""
     2668
     2669#: includes/Support/FormSources.php:205
     2670msgid "Easy Appointments Booking"
     2671msgstr ""
     2672
     2673#: includes/Support/FormSources.php:211
     2674msgid "WooCommerce Booking"
     2675msgstr ""
     2676
     2677#: includes/Support/FormSources.php:225
     2678msgid "Action Hook"
     2679msgstr ""
  • techwave-form-sync-for-zoho/trunk/readme.txt

    r3649280 r3748247  
    1 === Techwave Form Sync for Zoho ===
     1=== FormBridge for Zoho ===
    22Contributors: kaderoishi
    33Tags: zoho, crm, forms, integration, leads
    44Requires at least: 6.2
    5 Tested up to: 7.0
     5Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.0.0
     7Stable tag: 1.1.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1313== Description ==
    1414
    15 Techwave Form Sync for Zoho links the forms you already have to the Zoho apps you already use. When a visitor submits a form, the values you mapped are sent to the Zoho service configured for that form and a record is created in your Zoho account.
     15FormBridge for Zoho links the forms you already have to the Zoho apps you already use. When a visitor submits a form, the values you mapped are sent to the Zoho service configured for that form and a record is created in your Zoho account.
    1616
    17 Each form is configured on its own, in one place. One form can create a lead in Zoho CRM, another a contact in Bigin, another a customer in Zoho Books, another a ticket in Zoho Desk, and another can subscribe someone to a Zoho Campaigns or Marketing Automation mailing list — with different forms pointing at different Zoho accounts, organizations and data centers if that is what you need.
     17Each form is configured on its own, in one place. One form can create a lead in Zoho CRM, another a contact in Bigin, another a customer in Zoho Books, another a ticket in Zoho Desk, and another can subscribe someone to a Zoho Campaigns or Marketing Automation mailing list, with different forms pointing at different Zoho accounts, organizations and data centers if that is what you need.
    1818
    1919**What it covers**
    2020
    21 * **Ten form plugins, one mapping screen.** Elementor Pro Forms, Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable Forms, Forminator, Happy Forms and SureForms — all configured in the WordPress admin, not inside each form plugin's own editor, so a site running two form plugins still has one place to look.
     21* **Ten form plugins, one mapping screen.** Elementor Pro Forms, Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable Forms, Forminator, Happy Forms and SureForms, all configured in the WordPress admin, not inside each form plugin's own editor, so a site running two form plugins still has one place to look.
    2222* **Six Zoho apps, not just CRM.** Zoho CRM, Zoho Bigin, Zoho Books, Zoho Desk, Zoho Campaigns and Zoho Marketing Automation.
    2323* **Per-form feeds.** A "feed" is one form → one Zoho destination. A form can be pointed anywhere, cloned, disabled, or switched to another account without touching the form itself.
    … …  
    2525* **Every Zoho data center.** US (.com), EU (.eu), India (.in), Australia (.com.au), Japan (.jp), Canada (.ca), Saudi Arabia (.sa) and China (.com.cn). Endpoints switch automatically for the data center each account was created in.
    2626* **Field mapping with static values.** Map each Zoho field to a form field or to a fixed value, with a duplicate check (upsert) for Zoho CRM and Zoho Bigin so the same person does not become two records.
     27* **File uploads go too.** A file attached to a submission is sent to Zoho and lands in the file field you mapped it to, from every supported form plugin.
    2728* **Retries that do not lose submissions.** Delivery happens during the submission itself. A first attempt that fails for a transient reason is retried in the background instead of being dropped.
    2829* **Delivery logs and one-click resubmit.** Every delivery is recorded with its outcome, the exact fields sent, and a link straight to the record in Zoho. Tokens and secrets are redacted.
    … …  
    3132**Disclaimer**
    3233
    33 Techwave Form Sync for Zoho is an independent third-party plugin developed by Techwave Solutions and is not affiliated with, endorsed by, or sponsored by Zoho Corporation Pvt. Ltd. "Zoho", "Zoho CRM", "Bigin", "Zoho Books", "Zoho Desk", "Zoho Campaigns" and "Zoho Marketing Automation" are trademarks of Zoho Corporation Pvt. Ltd. All other product names referenced here — including Elementor, Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable Forms, Forminator, Happy Forms and SureForms — are trademarks of their respective owners and are used only to describe which form plugins this plugin can read submissions from. Using Techwave Form Sync for Zoho requires your own Zoho account, which is subject to Zoho's terms and pricing.
     34FormBridge for Zoho is an independent third-party plugin developed by Techwave Solutions and is not affiliated with, endorsed by, or sponsored by Zoho Corporation Pvt. Ltd. "Zoho", "Zoho CRM", "Bigin", "Zoho Books", "Zoho Desk", "Zoho Campaigns" and "Zoho Marketing Automation" are trademarks of Zoho Corporation Pvt. Ltd. All other product names referenced here (including Elementor, Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable Forms, Forminator, Happy Forms and SureForms) are trademarks of their respective owners and are used only to describe which form plugins this plugin can read submissions from. Using FormBridge for Zoho requires your own Zoho account, which is subject to Zoho's terms and pricing.
    3435
    3536**How mapping works**
    3637
    37 All mapping is done in the WordPress admin under **Techwave Sync → Forms**, not inside any form plugin's own editor. You create a feed: pick the form plugin, pick the form by name, then choose the Zoho app, account, organization (where the app needs one) and module, and map each Zoho field to a form field or a static value. To find the exact Zoho API names, open **Techwave Sync → Connections** and use the **Modules & fields** button for that account.
     38All mapping is done in the WordPress admin under **FormBridge → All Feeds**, not inside any form plugin's own editor. You create a feed: pick the form plugin, pick the form by name, then choose the Zoho app, account, organization (where the app needs one) and module, and map each Zoho field to a form field or a static value. To find the exact Zoho API names, open **FormBridge → Connections** and use the **Modules & fields** button for that account.
    3839
    3940== External services ==
    … …  
    4546* **What is sent:** the Client ID and Client Secret you entered from your own Zoho API console, the redirect URI of your site, and the permission scopes for the Zoho apps you ticked when creating the connection. Afterwards, the refresh token belonging to your connection is sent to obtain a short-lived access token.
    4647* **When:** when you create or re-authorize a connection, and whenever a cached access token has expired and needs refreshing.
    47 * **Why:** to obtain and renew authorization to write records into your own Zoho account. No password is ever sent or stored — the plugin never sees your Zoho login.
     48* **Why:** to obtain and renew authorization to write records into your own Zoho account. No password is ever sent or stored, and the plugin never sees your Zoho login.
    4849* **Where:** the accounts host for that connection's data center, e.g. `accounts.zoho.com`, or the matching `.eu`, `.in`, `.com.au`, `.jp`, `.ca`, `.sa`, `.com.cn` host.
    4950
    5051**2. Zoho application APIs**
    5152
    52 * **What is sent:** the form field values you mapped for that feed (and only those — unmapped fields are not sent), plus the module or resource name, the selected account, and the organization/department where the Zoho app requires one. This can include personal data, because form submissions usually do: names, email addresses, phone numbers and message text.
     53* **What is sent:** the form field values you mapped for that feed (and only those; unmapped fields are not sent), plus the module or resource name, the selected account, and the organization/department where the Zoho app requires one. This can include personal data, because form submissions usually do: names, email addresses, phone numbers and message text.
    5354* **When:** each time a visitor submits a form that has an enabled feed pointing at Zoho, and again on an automatic background retry if the first attempt failed for a temporary reason. Also when you press **Resubmit** on the Logs screen. Metadata reads (listing modules, fields, mailing lists, tags, departments) happen only while you are configuring a feed in wp-admin.
    54 * **Why:** to create the record — lead, contact, customer, ticket or list subscription — that the feed is configured to create.
    55 * **Where:** the API host for the connection's data center: `www.zohoapis.com` for CRM, Bigin and Books; `desk.zoho.com` for Desk; `campaigns.zoho.com` for Campaigns; `marketingautomation.zoho.com` for Marketing Automation — each with the regional variant matching the chosen data center.
     55* **Why:** to create the record (lead, contact, customer, ticket or list subscription) that the feed is configured to create.
     56* **Where:** the API host for the connection's data center: `www.zohoapis.com` for CRM, Bigin and Books; `desk.zoho.com` for Desk; `campaigns.zoho.com` for Campaigns; `marketingautomation.zoho.com` for Marketing Automation, each with the regional variant matching the chosen data center.
    5657
    5758Zoho's own terms govern what happens to the data once it arrives:
    … …  
    6263**3. Deactivation feedback (optional, opt-in)**
    6364
    64 When you deactivate Techwave Form Sync for Zoho from the Plugins screen, the plugin asks why. Answering is entirely optional and can be skipped, and the plugin deactivates either way.
     65When you deactivate FormBridge for Zoho from the Plugins screen, the plugin asks why. Answering is entirely optional and can be skipped, and the plugin deactivates either way.
    6566
    66 * **What is sent:** the reason you pick from the list, the note you type if you type one, and the plugin's version number. Nothing else — no site address, no account or email address, no user details, no WordPress/PHP/server information, and no identifier of any kind. There is nothing in the request that says which site it came from.
     67* **What is sent:** the reason you pick from the list, the note you type if you type one, and the plugin's version number. Nothing else: no site address, no account or email address, no user details, no WordPress/PHP/server information, and no identifier of any kind. There is nothing in the request that says which site it came from.
    6768* **When:** only when you press **Send Feedback & Deactivate**. Pressing **Skip & Deactivate**, closing the dialog, or deactivating with JavaScript disabled sends no request at all. Nothing is sent at any other time.
    6869* **Why:** so the developer knows which problems make people stop using the plugin. It has no effect on how the plugin works.
    … …  
    8081**Data stored on your own site**
    8182
    82 * **Delivery logs** are written to a table in your own database (timestamp, outcome, Zoho app, module, record id, and the error text when something failed). Submitted values are included only if you switch **Store submitted values in the log** on; it is off by default. Log entries are deleted automatically after the retention period on the Settings tab (30 days by default).
    83 * **Retry data.** If a delivery temporarily fails and a retry is scheduled, the submitted values required for the retry may be temporarily stored by WordPress — in a transient on your own site — until the retry is completed or the retry data expires (at most two hours). It is deleted as soon as the retry runs, whether that attempt succeeds or fails for good, and it is never sent anywhere other than Zoho.
     83* **Delivery logs** are written to a table in your own database (timestamp, outcome, Zoho app, module, record id, and the error text when something failed). Submitted values are included only if you switch **Store submitted values in the log** on; it is off by default. Log entries are kept until you delete them, unless you turn on **Delete log entries automatically** on the Settings tab (off by default), which removes entries older than the number of days you choose.
     84* **Retry data.** If a delivery temporarily fails and a retry is scheduled, the submitted values required for the retry may be temporarily stored by WordPress (in a transient on your own site) until the retry is completed or the retry data expires (at most two hours). It is deleted as soon as the retry runs, whether that attempt succeeds or fails for good, and it is never sent anywhere other than Zoho.
    8485* **Credentials.** Client secrets and refresh tokens are encrypted (AES-256-GCM) before being saved; access tokens are short-lived and cached in transients.
    8586
    … …  
    90913. In your Zoho API console (https://api-console.zoho.com), create a **Server-based Application**. Set the **Authorized Redirect URI** to the value shown on the plugin's **Connections** tab.
    91924. Copy the Client ID and Client Secret.
    92 5. In wp-admin go to **Techwave Sync → Connections → Create Connection**: pick your data center, paste the Client ID and Secret, and tick the Zoho app(s) this account will actually send to. Then connect and approve access on Zoho's own consent screen.
    93 6. For Elementor: edit your form and, under **Form settings → Additional Options**, set a unique **Form Name**, then save the page. For every other supported plugin the form's own title is used, so no extra setup is needed. Either way, no submit action needs to be added — the plugin listens for submissions itself.
    94 7. Go to **Techwave Sync → Forms → Add New**. Choose the form plugin, pick the form, choose the Zoho app, account, organization and module, map your fields, and save the feed. New submissions are sent from then on.
     935. In wp-admin go to **FormBridge → Connections → Create Connection**: pick your data center, paste the Client ID and Secret, and tick the Zoho app(s) this account will actually send to. Then connect and approve access on Zoho's own consent screen.
     946. For Elementor: edit your form and, under **Form settings → Additional Options**, set a unique **Form Name**, then save the page. For every other supported plugin the form's own title is used, so no extra setup is needed. Either way, no submit action needs to be added, because the plugin listens for submissions itself.
     957. Go to **FormBridge → All Feeds → Add New Feed**. Choose the form plugin, pick the form, choose the Zoho app, account, organization and module, map your fields, and save the feed. New submissions are sent from then on.
    9596
    9697== Frequently Asked Questions ==
    … …  
    106107
    107108= Which permissions does the plugin ask Zoho for? =
    108 Only the ones needed by the app(s) you tick when creating the connection — CRM permissions if you picked CRM, Desk permissions if you picked Desk, and so on. Nothing is pre-selected, and no app you did not pick is requested. You can widen a connection's access later with **Edit access** on the Connections tab, which re-runs Zoho's consent screen for the updated list.
     109Only the ones needed by the app(s) you tick when creating the connection: CRM permissions if you picked CRM, Desk permissions if you picked Desk, and so on. Nothing is pre-selected, and no app you did not pick is requested. You can widen a connection's access later with **Edit access** on the Connections tab, which re-runs Zoho's consent screen for the updated list.
    109110
    110111= Which data centers are supported? =
    … …  
    115116
    116117= What happens if a request to Zoho fails? =
    117 A failure that looks temporary (a network problem, a rate limit, Zoho briefly unavailable) is retried in the background a few times with an increasing delay. A failure that will never succeed on a retry — a rejected value, a bad mapping, a missing required field — is recorded immediately instead. Either way the outcome ends up on the Logs screen, and a `form2zoho_submission_failed` action fires when a submission is given up on.
     118A failure that looks temporary (a network problem, a rate limit, Zoho briefly unavailable) is retried in the background a few times with an increasing delay. A failure that will never succeed on a retry (a rejected value, a bad mapping, a missing required field) is recorded immediately instead. Either way the outcome ends up on the Logs screen, and a `form2zoho_submission_failed` action fires when a submission is given up on.
    118119
    119120= Is form data stored on my site? =
    120 Only if you ask for it. **Store submitted values in the log** is off by default: log entries then record the submitted field *names* but never their contents. Turning it on stores the values too, which is what makes the Logs screen's one-click **Resubmit** possible — and means whatever your forms collect, including personal data, is kept in the log table for the retention period. Separately, a submission awaiting an automatic retry is held briefly in a transient (see **External services** above). Access tokens, refresh tokens and client secrets are always redacted from logs.
     121Only if you ask for it. **Store submitted values in the log** is off by default: log entries then record the submitted field *names* but never their contents. Turning it on stores the values too, which is what makes the Logs screen's one-click **Resubmit** possible, and means whatever your forms collect, including personal data, is kept in the log table until those entries are deleted, by hand or by **Delete log entries automatically** (off by default). Separately, a submission awaiting an automatic retry is held briefly in a transient (see **External services** above). Access tokens, refresh tokens and client secrets are always redacted from logs.
    121122
    122123= My submission did not reach Zoho. What do I check? =
    123 Make sure the form has a name and that a feed points at that exact form (pick it from the dropdown). Then open **Techwave Sync → Logs** and look for the entry — the error text there is the reason Zoho gave.
     124Make sure the form has a name and that a feed points at that exact form (pick it from the dropdown). Then open **FormBridge → Logs** and look for the entry. The error text there is the reason Zoho gave.
    124125
    125126= How do I remove everything the plugin created? =
    126 Turn on **Erase all plugin data when the plugin is deleted** under **Techwave Sync → Settings**, then delete the plugin from the Plugins screen. That drops the log table and removes every option, connection, feed, cached token and scheduled event belonging to this plugin — and nothing else. The setting is off by default so an accidental deactivate-and-reinstall does not destroy your setup. Deactivating alone never deletes anything; it only stops scheduled work.
     127Turn on **Erase all plugin data when the plugin is deleted** under **FormBridge → Settings**, then delete the plugin from the Plugins screen. That drops the log table and removes every option, connection, feed and cached token belonging to this plugin, and nothing else. The setting is off by default so an accidental deactivate-and-reinstall does not destroy your setup. Deactivating alone never deletes anything; it only stops scheduled work.
    127128
    128129== Privacy ==
    … …  
    130131Form submissions usually contain personal data, and this plugin's whole purpose is to transmit the mapped fields of a submission to Zoho, a third-party service, as described under **External services** above. Review Zoho's privacy policy and make sure your own privacy notice covers this transfer.
    131132
    132 On your own site, the plugin stores delivery log entries in a custom database table — including the submitted field values when **Store submitted values in the log** is enabled, which it is not by default — for the configured retention period (30 days by default). A submission waiting on an automatic retry is held in a WordPress transient until the retry runs or that copy expires, at most two hours.
     133On your own site, the plugin stores delivery log entries in a custom database table (including the submitted field values when **Store submitted values in the log** is enabled, which it is not by default) until they are deleted, by hand on the Logs screen or automatically after a set number of days when **Delete log entries automatically** is turned on (it is off by default). A submission waiting on an automatic retry is held in a WordPress transient until the retry runs or that copy expires, at most two hours.
    133134
    134135The plugin registers a personal-data exporter and eraser, so log entries mentioning a given email address are included in, and removed by, WordPress's own **Tools → Export/Erase Personal Data** flows. Suggested privacy-policy text is added to **Settings → Privacy**.
    … …  
    136137Access tokens, refresh tokens, client secrets and authorization headers are never written to the logs in readable form.
    137138
    138 The optional deactivation feedback described under **External services** carries no personal data and nothing that identifies your site — only the reason you pick, the note you choose to type, and the plugin version — and it is sent only if you press the send button.
     139The optional deactivation feedback described under **External services** carries no personal data and nothing that identifies your site (only the reason you pick, the note you choose to type, and the plugin version), and it is sent only if you press the send button.
    139140
    140141== Screenshots ==
    141142
    142 1. The Feeds list under Techwave Sync → Forms. Every feed shows which form it listens to, where it sends, and an on/off switch — with search, filtering by Zoho app, and per-row Info, Timeline, Clone and Delete actions.
    143 2. Choosing the form. Pick the form plugin from the tiles, then pick the form itself from a dropdown read out of your saved forms — no form ID to look up, and no editing inside the form plugin.
     1431. The feeds list under FormBridge → All Feeds. Every feed shows which form it listens to, where it sends, and an on/off switch, with search, filtering by Zoho app, and per-row Info, Timeline, Clone and Delete actions.
     1442. Choosing the trigger. Pick the form plugin from the tiles, then pick the form itself on the next step from a dropdown read out of your saved forms, with no form ID to look up, and no editing inside the form plugin.
    1441453. Field mapping. Each Zoho field is matched to a form field or a fixed static value. Both lists load automatically: the Zoho fields from the module you chose, the form fields from the form you chose.
    1451464. The Connections tab. Connect more than one Zoho account, each with its own data centre, and see at a glance which Zoho apps each one is authorized for.
    1461475. Creating a connection. Paste the Client ID and Secret from your Zoho API console, tick only the apps you need, and approve on Zoho's own consent screen. Secrets are encrypted before they are stored.
    147 6. The Logs tab. Every delivery is recorded — success, failure, or suppressed — with the exact data sent, a link straight to the record in Zoho, and one-click resubmit. New entries appear live.
     1486. The Logs tab. Every delivery is recorded (success, failure, or suppressed) with the exact data sent, a link straight to the record in Zoho, and one-click resubmit. New entries appear live.
    1481497. The Settings tab. Logging, submitted-value storage, automatic log cleanup, failure email alerts, and full data erasure on uninstall, grouped by what they affect.
    149150
    150151== Changelog ==
     152
     153= 1.1.0 - 15 October 2026 =
     154The plugin is now called FormBridge for Zoho, and the admin screens have been rebuilt around it. Your feeds, connections, logs and settings are untouched and no reconnection is needed; the menu is now **FormBridge**, so anywhere older documentation says Techwave Sync, look for FormBridge instead.
     155
     156* Renamed throughout the interface, including the deactivation prompt, the personal-data exporter and eraser, and the test email. Zoho Campaigns and Marketing Automation now record "FormBridge for Zoho" as the source on new subscribers; subscribers added before this update keep the source they were recorded with.
     157* **Feeds are now created in a four-step editor** — Trigger, Details, Destination, Mapping — instead of one long form. The trigger step shows each supported form plugin as a tile with its logo and says so when the plugin behind a tile is not running.
     158* **New Dashboard screen**, and the plugin's navigation has moved into a header bar above every screen.
     159* **Uploaded files are sent to Zoho** from every supported form plugin, into the file field you map them to.
     160* Field mapping reworked: the row for a required Zoho field is locked so it cannot be removed, a static value is checked against the kind of Zoho field it fills, "Combine fields" lists the fields it uses, duplicate matching only offers fields Zoho marks unique, and the field list loads once with a visible wait instead of being refetched.
     161* Logs: entries are now kept until you delete them, with **Delete log entries automatically** as an opt-in setting (it replaces the old fixed 30-day retention, so logs no longer disappear on their own). The screen reads a page at a time, shows times in the WordPress timezone, and reports plainly when Zoho cannot be reached.
     162* Zoho Campaigns and Marketing Automation: every mailing list and topic is read, not just the first page; a contact is added to the chosen list straight away; and commas and colons in submitted values are preserved.
     163* Connections: pick the account first and only the apps it is authorised for are offered, a Zoho Books organisation's custom modules and fields are listed, the redirect URI confirms when it is copied, and "Zoho API console" links to the right region's console.
     164* The plugin's notification emails now go through `wp_mail()`.
     165* Sources that need the Pro add-on appear as locked tiles on the trigger step, so you can see what it covers. Nothing is shown as locked on a site that already has it.
     166* Added hooks so a companion plugin can extend this one without patching it: `form2zoho_loaded` for wiring, `form2zoho_form_sources` to teach it another form plugin, `form2zoho_feed_editor_fields`, `form2zoho_save_feed` and `form2zoho_build_job` for per-feed settings, `form2zoho_should_deliver_feed`, `form2zoho_job_form_values` and `form2zoho_job_settings` on the delivery path, `form2zoho_submission_succeeded` alongside the existing failure action, and `form2zoho_settings_tabs` with `form2zoho_render_settings_tab` for the settings screen.
     167* Tested against WordPress 7.1.
    151168
    152169= 1.0.0 - 3 August 2026 =
    … …  
    163180* Personal-data exporter and eraser, suggested privacy-policy text, and opt-in full data erasure on uninstall.
    164181* Fully translatable, keyboard accessible, and usable from 320px up.
     182
     183== Upgrade Notice ==
     184
     185= 1.1.0 =
     186Renamed to FormBridge for Zoho, with rebuilt admin screens, a four-step feed editor, a Dashboard, and file uploads sent to Zoho. Feeds, connections and settings carry over; no reconnection needed. Note: log entries are now kept until you delete them, with automatic deletion opt-in.
  • techwave-form-sync-for-zoho/trunk/techwave-form-sync-for-zoho.php

    r3649280 r3748247  
    11<?php
    22/**
    3  * Plugin Name:       Techwave Form Sync for Zoho
    4  * Plugin URI:        https://techwavesolutions.net/techwave-form-sync-for-zoho
     3 * Plugin Name:       FormBridge for Zoho
     4 * Plugin URI:        https://techwavesolutions.net/formbridge-for-zoho/
    55 * Description:       Send WordPress form submissions to Zoho CRM, Bigin, Books, Desk, Campaigns and Marketing Automation.
    6  * Version:           1.0.0
     6 * Version:           1.1.0
    77 * Requires at least: 6.2
    88 * Requires PHP:      7.4
    … …  
    2121defined( 'ABSPATH' ) || exit;
    2222
    23 define( 'FORM2ZOHO_VERSION', '1.0.0' );
     23define( 'FORM2ZOHO_VERSION', '1.1.0' );
    2424// Human-readable date this version was published — shown on the Version
    2525// tab. Keep in sync with the readme.txt changelog entry for the same
    2626// version.
    27 define( 'FORM2ZOHO_VERSION_DATE', '3 August 2026' );
     27define( 'FORM2ZOHO_VERSION_DATE', '15 October 2026' );
    2828define( 'FORM2ZOHO_FILE', __FILE__ );
    2929define( 'FORM2ZOHO_PATH', plugin_dir_path( __FILE__ ) );
  • techwave-form-sync-for-zoho/trunk/uninstall.php

    r3649280 r3748247  
    66 * when they have turned on "Erase all plugin data when the plugin is deleted"
    77 * on the Settings screen. When they have, it removes every database artefact
    8  * the plugin created: the log table, options, cached tokens, OAuth state and
    9  * any scheduled events.
     8 * the plugin created: the log table, options, cached tokens and OAuth state.
    109 *
    1110 * Off by default on purpose: simply deleting the plugin should not destroy
    … …  
    144143                }
    145144
    146                 // 3. Sweep the options table for any remaining transient of ours: the
     145                // 3. Delete the per-user column preference. Stored in user meta rather
     146                // than in an option, so the loop above cannot reach it — and left
     147                // behind it would quietly hide columns for anyone who reinstalled.
     148                // One meta key across all users on uninstall; there is no core helper
     149                // for that. All three sniffs go in one annotation: a trailing
     150                // phpcs:ignore replaces the one on the line above rather than adding
     151                // to it, so splitting them would leave two of them reported.
     152                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
     153                $wpdb->delete( $wpdb->usermeta, array( 'meta_key' => 'form2zoho_hidden_columns' ) );
     154
     155                // 4. Sweep the options table for any remaining transient of ours: the
    147156                // access-token cache, OAuth state, parked retry payloads, the
    148157                // connection-failure mail throttle, the admin error notice, and the
    … …  
    159168                $like    = $wpdb->esc_like( '_transient_form2zoho_' ) . '%';
    160169                $timeout = $wpdb->esc_like( '_transient_timeout_form2zoho_' ) . '%';
     170                // Not a transient: the short-lived row that keeps two requests from
     171                // refreshing one connection's token at the same time. It is deleted
     172                // as soon as the refresh ends, but a request that died mid-refresh
     173                // leaves one behind, and the two patterns above would not match it.
     174                $locks = $wpdb->esc_like( 'form2zoho_token_lock_' ) . '%';
    161175
    162176                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off cleanup on uninstall.
    163177                $wpdb->query(
    164178                        $wpdb->prepare(
    165                                 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
     179                                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s",
    166180                                $like,
    167                                 $timeout
     181                                $timeout,
     182                                $locks
    168183                        )
    169184                );
    … …  
    176191                // away the whole site's cache — WordPress core, the theme and every
    177192                // other plugin included — to clean up a handful of keys of its own.
    178 
    179                 // 4. Clear scheduled events.
    180                 wp_clear_scheduled_hook( 'form2zoho_process_submission' );
    181                 wp_clear_scheduled_hook( 'form2zoho_prune_logs' );
    182 
    183                 if ( function_exists( 'as_unschedule_all_actions' ) ) {
    184                         as_unschedule_all_actions( 'form2zoho_process_submission', array(), 'form2zoho' );
    185                 }
     193                //
     194                // Scheduled work is not handled here: WordPress deactivates a plugin
     195                // before it can be deleted, and Form2Zoho\Core\Deactivator clears it
     196                // then, whether or not this erase was opted in to.
    186197        }
    187198}
Note: See TracChangeset for help on using the changeset viewer.