Changeset 3748247
- Timestamp:
- 10/15/2026 11:02:03 AM (10 days ago)
- Location:
- techwave-form-sync-for-zoho/trunk
- Files:
-
- 45 added
- 56 edited
-
assets/css/admin.css (modified) (46 diffs)
-
assets/images (added)
-
assets/images/logo.svg (added)
-
assets/images/plugin_icon.png (added)
-
assets/images/triggers (added)
-
assets/images/triggers/action_hook.webp (added)
-
assets/images/triggers/amelia.webp (added)
-
assets/images/triggers/bookly.webp (added)
-
assets/images/triggers/cf7.webp (added)
-
assets/images/triggers/divi.webp (added)
-
assets/images/triggers/divi_fb.webp (added)
-
assets/images/triggers/dokan.webp (added)
-
assets/images/triggers/easyappointments.webp (added)
-
assets/images/triggers/edd.webp (added)
-
assets/images/triggers/elementor.webp (added)
-
assets/images/triggers/eventtickets.webp (added)
-
assets/images/triggers/fluent.webp (added)
-
assets/images/triggers/fluentbooking.webp (added)
-
assets/images/triggers/formidable.webp (added)
-
assets/images/triggers/forminator.webp (added)
-
assets/images/triggers/givewp.webp (added)
-
assets/images/triggers/gravity.webp (added)
-
assets/images/triggers/happyforms.webp (added)
-
assets/images/triggers/latepoint.webp (added)
-
assets/images/triggers/learndash.webp (added)
-
assets/images/triggers/memberpress.webp (added)
-
assets/images/triggers/ninja.webp (added)
-
assets/images/triggers/ssa.webp (added)
-
assets/images/triggers/sureforms.webp (added)
-
assets/images/triggers/tutorlms.webp (added)
-
assets/images/triggers/wcbookings.webp (added)
-
assets/images/triggers/woocommerce.webp (added)
-
assets/images/triggers/wpbc.webp (added)
-
assets/images/triggers/wpforms.webp (added)
-
assets/images/triggers/wpjm.webp (added)
-
assets/js/admin-nav.js (modified) (8 diffs)
-
assets/js/admin.js (modified) (72 diffs)
-
includes/Admin/Ajax.php (modified) (23 diffs)
-
includes/Admin/DashboardPage.php (added)
-
includes/Admin/DeactivationFeedback.php (modified) (2 diffs)
-
includes/Admin/FeedsPage.php (modified) (58 diffs)
-
includes/Admin/FeedsRepository.php (modified) (3 diffs)
-
includes/Admin/HeaderNav.php (added)
-
includes/Admin/LegacyPageSlugs.php (added)
-
includes/Admin/NoticeGuard.php (added)
-
includes/Admin/ProUpsell.php (added)
-
includes/Admin/SettingsPage.php (modified) (39 diffs)
-
includes/Api/AbstractClient.php (modified) (6 diffs)
-
includes/Api/BiginClient.php (modified) (4 diffs)
-
includes/Api/BooksClient.php (modified) (7 diffs)
-
includes/Api/CampaignsClient.php (modified) (5 diffs)
-
includes/Api/CrmClient.php (modified) (5 diffs)
-
includes/Api/DeskClient.php (modified) (4 diffs)
-
includes/Api/MarketingAutomationClient.php (modified) (5 diffs)
-
includes/Auth/ConnectionManager.php (modified) (20 diffs)
-
includes/Auth/OAuthClient.php (modified) (1 diff)
-
includes/ContactForm7/SubmissionHandler.php (modified) (2 diffs)
-
includes/Core/Activator.php (modified) (3 diffs)
-
includes/Core/Deactivator.php (modified) (2 diffs)
-
includes/Core/Plugin.php (modified) (9 diffs)
-
includes/Elementor/SubmissionHandler.php (modified) (2 diffs)
-
includes/Forminator/SubmissionHandler.php (modified) (8 diffs)
-
includes/Integrations/AbstractIntegration.php (modified) (3 diffs)
-
includes/Integrations/BiginIntegration.php (modified) (1 diff)
-
includes/Integrations/BooksIntegration.php (modified) (2 diffs)
-
includes/Integrations/CampaignsIntegration.php (modified) (7 diffs)
-
includes/Integrations/CrmIntegration.php (modified) (1 diff)
-
includes/Integrations/DeskIntegration.php (modified) (2 diffs)
-
includes/Integrations/MarketingAutomationIntegration.php (modified) (6 diffs)
-
includes/Logging/Logger.php (modified) (7 diffs)
-
includes/NinjaForms/SubmissionHandler.php (modified) (1 diff)
-
includes/Privacy/PrivacyProvider.php (modified) (4 diffs)
-
includes/Queue/Dispatcher.php (modified) (9 diffs)
-
includes/Support/BaseSubmissionHandler.php (modified) (3 diffs)
-
includes/Support/Branding.php (added)
-
includes/Support/ContactForm7Forms.php (modified) (1 diff)
-
includes/Support/DataCenters.php (modified) (10 diffs)
-
includes/Support/ElementorForms.php (modified) (1 diff)
-
includes/Support/Encryption.php (modified) (2 diffs)
-
includes/Support/ErrorTranslator.php (modified) (2 diffs)
-
includes/Support/FieldMapper.php (modified) (3 diffs)
-
includes/Support/FluentFormsForms.php (modified) (1 diff)
-
includes/Support/FormSources.php (modified) (18 diffs)
-
includes/Support/FormidableFormsForms.php (modified) (1 diff)
-
includes/Support/ForminatorForms.php (modified) (2 diffs)
-
includes/Support/GravityFormsForms.php (modified) (2 diffs)
-
includes/Support/HappyFormsForms.php (modified) (1 diff)
-
includes/Support/Markup.php (modified) (4 diffs)
-
includes/Support/NinjaFormsForms.php (modified) (1 diff)
-
includes/Support/PhpMail.php (added)
-
includes/Support/Settings.php (modified) (1 diff)
-
includes/Support/SiteTime.php (added)
-
includes/Support/StaticValue.php (added)
-
includes/Support/SureFormsForms.php (modified) (1 diff)
-
includes/Support/UploadedFiles.php (added)
-
includes/Support/VideoGuide.php (added)
-
includes/Support/WPFormsForms.php (modified) (2 diffs)
-
languages/techwave-form-sync-for-zoho.pot (modified) (35 diffs)
-
readme.txt (modified) (13 diffs)
-
techwave-form-sync-for-zoho.php (modified) (2 diffs)
-
uninstall.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
techwave-form-sync-for-zoho/trunk/assets/css/admin.css
r3649280 r3748247 1 1 /** 2 * Techwave Form Syncfor Zoho — admin UI design system.2 * FormBridge for Zoho — admin UI design system. 3 3 * 4 4 * Everything is scoped to `.form2zoho` (the wrapper every screen prints) so … … 159 159 --f2z-label-w: 200px; 160 160 --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 { 162 194 max-width: 100%; 163 margin-bottom: var(--f2z-space-8);164 195 color: var(--f2z-text); 165 /* The plugin's own base size, so nothing inside inherits wp-admin's 14px166 in one place and 13px in another. Every component still declares its167 own size; this only catches what would otherwise be inherited. */168 196 font-size: var(--f2z-fs-base); 169 197 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); 170 206 } 171 207 … … 446 482 } 447 483 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 448 500 /* A multi-select in a control row keeps its list-box height; it is the only 449 501 control here that is taller than one line, so the row aligns to its top. */ … … 633 685 } 634 686 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 635 711 .form2zoho input[type="checkbox"]:hover:not(:disabled), 636 712 .form2zoho input[type="radio"]:hover:not(:disabled) { … … 844 920 } 845 921 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 { 847 943 background: var(--f2z-ok-bg); 848 944 border-color: var(--f2z-ok-border); … … 1121 1217 .form2zoho .form2zoho-remove-row, 1122 1218 .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); 1123 1233 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); 1124 1250 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; 1129 1253 } 1130 1254 … … 1218 1342 } 1219 1343 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 1220 1489 /* Destructive icon buttons. */ 1221 1490 .form2zoho-bulk-delete { … … 1280 1549 } 1281 1550 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 1297 1551 /* The Logs toolbar is the Feeds list's `.form2zoho-listbar` — same two-group 1298 1552 layout, same spacing — so it only needs its own bottom margin. It used to 1299 1553 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. */ 1300 1563 .form2zoho-logs-toolbar { 1564 padding-top: var(--f2z-space-5); 1301 1565 margin-bottom: var(--f2z-space-4); 1302 1566 } … … 1364 1628 * used to be declared through four parallel `.form2zoho-page-* .form2zoho-hero` 1365 1629 * 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. */ 1367 1643 .form2zoho-hero { 1368 1644 display: flex; 1369 1645 align-items: center; 1370 1646 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; 1375 1665 } 1376 1666 … … 1378 1668 display: inline-flex; 1379 1669 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. */ 1389 1675 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 { 1391 1690 font-weight: var(--f2z-fw-bold); 1392 1691 } 1393 1692 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); 1407 1699 } 1408 1700 … … 1414 1706 .form2zoho-settings-pop { 1415 1707 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; 1417 1712 padding: var(--f2z-space-6); 1418 1713 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. 1432 1730 * 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. */ 1445 1739 .wrap.form2zoho > .wp-header-end ~ .notice, 1446 1740 .wrap.form2zoho > .wp-header-end ~ div.updated, … … 1449 1743 } 1450 1744 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. */ 1464 1752 1465 1753 /* --- Screen header row (title + primary action) ------------------------ */ … … 1569 1857 } 1570 1858 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 1581 1859 .form2zoho-apps-grid { 1582 1860 display: grid; … … 1586 1864 } 1587 1865 1588 /* "All applications" is the master toggle that disables the rest, so it owns1589 a full row with a rule under it. It has to stay in the same container as1590 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'). */ 1591 1869 .form2zoho-app-check-all { 1592 1870 grid-column: 1 / -1; … … 1600 1878 } 1601 1879 1602 .form2zoho-mapping-section h2 {1603 margin-top: 0;1604 }1605 1606 1880 /* ========================================================================== 1607 1881 7. NAVIGATION … … 1613 1887 selector, rather than the settings bar carrying its own near-copy of the 1614 1888 same six declarations. */ 1615 .form2zoho-tabs,1616 1889 /* Element-qualified and three classes deep on purpose. Core declares this at 1617 1890 `.wrap h2.nav-tab-wrapper` (0,2,1) — see the comment in its own source, … … 1625 1898 flex-wrap: wrap; 1626 1899 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. */ 1627 1907 margin: 0 0 var(--f2z-space-6); 1628 1908 padding: 0; … … 1630 1910 } 1631 1911 1632 .form2zoho-tab-btn,1633 1912 .form2zoho .form2zoho-settings-tabs .nav-tab { 1634 1913 appearance: none; … … 1647 1926 } 1648 1927 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 1650 1959 .form2zoho .form2zoho-settings-tabs .nav-tab:hover, 1651 1960 .form2zoho .form2zoho-settings-tabs .nav-tab:focus { … … 1655 1964 } 1656 1965 1657 .form2zoho-tab-btn.is-active,1658 1966 .form2zoho .form2zoho-settings-tabs .nav-tab-active, 1659 1967 .form2zoho .form2zoho-settings-tabs .nav-tab-active:hover, … … 1664 1972 } 1665 1973 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 { 1667 1993 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 { 1672 2146 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); 1673 2761 } 1674 2762 … … 1677 2765 align-items: center; 1678 2766 gap: var(--f2z-space-2); 1679 margin: 0 0 var(--f2z-space- 3);2767 margin: 0 0 var(--f2z-space-4); 1680 2768 color: var(--f2z-muted); 1681 2769 font-size: var(--f2z-fs-base); … … 1794 2882 1795 2883 /* 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"] { 1799 2889 width: 90px; 1800 2890 min-width: 90px; … … 1803 2893 } 1804 2894 1805 .form2zoho-actions-col { 2895 .form2zoho-actions-col, 2896 .form2zoho .form2zoho-integrations-table th.form2zoho-actions-col { 1806 2897 width: 72px; 1807 2898 text-align: center; … … 1839 2930 .form2zoho-map-table td { 1840 2931 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; 1841 3009 } 1842 3010 … … 1904 3072 gap: var(--f2z-space-3); 1905 3073 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; 1906 3080 } 1907 3081 … … 1942 3116 } 1943 3117 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. */ 1949 3121 .form2zoho-authorized-cell { 1950 3122 display: flex; … … 1954 3126 width: fit-content; 1955 3127 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. */ 1958 3132 .form2zoho-authorized-pills { 1959 3133 display: flex; 1960 3134 flex-wrap: wrap; 3135 justify-content: center; 1961 3136 gap: var(--f2z-space-1); 1962 3137 } 1963 3138 3139 /* The folded-away apps join the same row of pills when opened. */ 3140 .form2zoho-authorized-more-list { 3141 display: contents; 3142 } 3143 1964 3144 .form2zoho-conn-status-cell { 1965 3145 display: flex; 1966 3146 align-items: center; 3147 justify-content: center; 1967 3148 gap: var(--f2z-space-1); 1968 3149 } … … 1973 3154 1974 3155 .form2zoho-conn-error-note { 1975 margin: 2px 00;3156 margin: 2px auto 0; 1976 3157 max-width: 260px; 1977 3158 } … … 1981 3162 text-align: right; 1982 3163 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; 1983 3170 } 1984 3171 … … 2044 3231 } 2045 3232 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 2046 3251 /* ========================================================================== 2047 3252 9. FEEDBACK … … 2107 3312 color: var(--f2z-brand-dark); 2108 3313 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; 2109 3335 } 2110 3336 … … 2531 3757 } 2532 3758 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 2533 3778 /* `.form-table` already carries table-layout:fixed (see section 6); the 2534 3779 dialog only needs its label column not to collapse. */ … … 2624 3869 } 2625 3870 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 2626 4130 /* ========================================================================== 2627 4131 11. COMPONENTS … … 2639 4143 } 2640 4144 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. */ 2641 4151 .form2zoho-toolbar-left, 2642 4152 .form2zoho-toolbar-right { … … 2644 4154 align-items: center; 2645 4155 flex-wrap: wrap; 2646 gap: var(--f2z-space- 2);4156 gap: var(--f2z-space-3); 2647 4157 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); 2648 4165 } 2649 4166 … … 2747 4264 border-radius: 50%; 2748 4265 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; 2749 4318 } 2750 4319 … … 3034 4603 } 3035 4604 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 3036 4616 /* Size comes from `.button.button-small` on the same element; only its 3037 4617 position in the timeline entry is this rule's business. */ … … 3349 4929 min-width: 560px; 3350 4930 } 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 } 3351 5005 } 3352 5006 3353 5007 @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. */ 3354 5069 .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); 3356 5072 } 3357 5073 … … 3420 5136 /* The tab bar scrolls sideways rather than wrapping to two rows, which 3421 5137 would push the panel below the fold. */ 3422 .form2zoho-tabs,3423 5138 .form2zoho .form2zoho-settings-tabs { 3424 5139 flex-wrap: nowrap; … … 3443 5158 padding-left: var(--f2z-space-4); 3444 5159 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; 3445 5167 } 3446 5168 … … 3480 5202 } 3481 5203 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 3482 5214 /* …except the one inside a switch, which is a transparent overlay rather 3483 5215 than a visible box: it has to keep covering its own 40x22 track. Sizing … … 3492 5224 3493 5225 @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; 3498 5246 } 3499 5247 … … 3505 5253 } 3506 5254 3507 .form2zoho-editor-wrap h1 {3508 font-size: var(--f2z-fs-xl);3509 }3510 3511 5255 /* Tap targets get the full width; the pair still reads as one group. */ 3512 .form2zoho- tab-footer {5256 .form2zoho-step-footer { 3513 5257 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 { 3517 5263 flex: 1 1 auto; 3518 5264 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; 3519 5281 } 3520 5282 … … 3598 5360 14. MOTION & CONTRAST PREFERENCES 3599 5361 ========================================================================== */ 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 } 3600 5376 3601 5377 @keyframes form2zoho-spin { … … 3690 5466 border: 1px solid CanvasText; 3691 5467 } 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 1 1 /** 2 * Techwave Form Syncfor Zoho — in-plugin AJAX navigation.2 * FormBridge for Zoho — in-plugin AJAX navigation. 3 3 * 4 4 * Every link inside the plugin (pagination, feed search/filter, tabs, … … 120 120 return false; 121 121 } 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; 124 126 } 125 127 … … 291 293 } 292 294 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 293 403 function swap( url, pushHistory ) { 294 404 // Don't show the spinner for fast responses — only if the request … … 301 411 var doc = new window.DOMParser().parseFromString( html, 'text/html' ); 302 412 var newWrap = doc.querySelector( '.wrap.form2zoho' ); 303 if ( ! newWrap ) {413 if ( ! newWrap || needsAssets( doc ) ) { 304 414 window.location.href = url; 305 415 return; … … 309 419 } 310 420 document.querySelector( '.wrap.form2zoho' ).outerHTML = newWrap.outerHTML; 421 syncAdminMenu( doc ); 311 422 playEnter( '.wrap.form2zoho' ); 312 423 if ( pushHistory ) { … … 336 447 a.href = url; 337 448 var page = decodeURIComponent( ( a.search.match( /[?&]page=([^&]*)/ ) || [] )[ 1 ] || '' ); 338 return 'form 2zoho' === page;339 } 340 341 /** 342 * Is this a Connections/Logs/Settings/Version tab link (page=form 2zoho,343 * exactly — not the feed-builder pages, which use page=form 2zoho-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)? 344 455 * These four share one PHP class and only ever swap one fragment, so 345 456 * they get the fast AJAX path instead of a full page fetch. … … 378 489 $body.html( data.html ); 379 490 playEnter( $body ); 491 markNav( url ); 492 markSidebar( url ); 380 493 if ( data.title ) { 381 494 document.title = data.title; … … 387 500 syncAssets( data ); 388 501 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 } ); 389 568 } 390 569 -
techwave-form-sync-for-zoho/trunk/assets/js/admin.js
r3649280 r3748247 1 1 /** 2 * Techwave Form Syncfor Zoho — admin helper.2 * FormBridge for Zoho — admin helper. 3 3 * 4 4 * Drives the "Modules & fields" discovery panel on the Connections tab. It 5 5 * 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 Syncfor Zoho \u2192 Forms.6 * to use in the form-to-Zoho mapping under FormBridge for Zoho \u2192 Forms. 7 7 */ 8 8 ( function ( $ ) { … … 21 21 // Clearing our own namespace first stops handlers from stacking up. 22 22 $( 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(); 23 28 24 29 /** … … 173 178 */ 174 179 function status( $panel, text ) { 180 clearStatus( $panel ); 181 $panel.append( $( '<p class="form2zoho-status description"></p>' ).text( text ) ); 182 } 183 184 function clearStatus( $panel ) { 175 185 $panel.find( '.form2zoho-status' ).remove(); 176 $panel.append( $( '<p class="form2zoho-status description"></p>' ).text( text ) );177 186 } 178 187 … … 197 206 $( document ).on( 'click.f2zNav', '.form2zoho-close', function () { 198 207 $( '#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 ); 199 223 } ); 200 224 … … 228 252 229 253 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 } 230 264 231 265 // Render organization picker if needed and not already chosen. … … 303 337 ( items || [] ).forEach( function ( it ) { 304 338 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 } 305 350 if ( String( it.value ) === String( current ) ) { 306 351 o.selected = true; … … 327 372 var needsOrg = cfg.needsOrg || {}; 328 373 var deptApps = cfg.deptApps || []; 374 var noConnApps = cfg.noConnApps || []; 329 375 var state = { forms: [], zohoFields: [] }; 330 376 … … 337 383 var $module = $( '#f2z_module' ); 338 384 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 339 431 // Show the org row only for org-scoped apps (Books / Desk / Bigin). 340 432 function toggleOrg() { … … 352 444 var connectionApps = cfg.connectionApps || {}; 353 445 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 } 354 521 355 522 function checkAppAuthorization() { … … 381 548 ); 382 549 } 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 } ); 383 641 } 384 642 … … 545 803 } 546 804 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 ) { 548 834 if ( ! isCampaignsApp() || ! $conn.val() ) { 549 835 return $.Deferred().resolve().promise(); 550 836 } 551 837 var ctx = contextKey(); 838 busy( $( '#f2z_topics' ), true ); 552 839 return post( 'form2zoho_browse_topics', { 553 840 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 ); 555 845 } ).done( function ( res ) { 556 846 if ( ctx !== contextKey() ) { … … 571 861 } 572 862 573 function loadTags( ) {863 function loadTags( fresh ) { 574 864 // Tags exist only in Zoho Campaigns — Marketing Automation has 575 865 // no tags feature, so never call the tags endpoint for it. … … 578 868 } 579 869 var ctx = contextKey(); 870 busy( $( '#f2z_tag' ), true ); 580 871 return post( 'form2zoho_browse_tags', { 581 872 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 ); 583 877 } ).done( function ( res ) { 584 878 if ( ctx !== contextKey() ) { … … 607 901 return; 608 902 } 903 busy( $dept, true ); 609 904 post( 'form2zoho_browse_departments', { 610 905 connection_id: conn, 611 906 integration: integration, 612 907 org_id: org 908 } ).always( function () { 909 busy( $dept, false ); 613 910 } ).done( function ( res ) { 614 911 if ( res && res.success ) { … … 630 927 function fillFormFieldSelects() { 631 928 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 }; 633 930 } ); 634 931 $( '.f2z-map-form' ).each( function () { 635 932 buildOptions( $( this ), items, i18n.selectField ); 636 933 } ); 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 } 637 1146 } 638 1147 639 1148 // Refill every "Zoho field" select from the loaded module fields. 640 1149 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 641 1156 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 }; 643 1158 } ); 644 1159 $( '.f2z-map-zoho' ).each( function () { 645 1160 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' ) ); 646 1165 } ); 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 ]; 647 1188 } 648 1189 649 1190 // 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 ) { 652 1229 if ( res && res.success ) { 653 1230 state.forms = res.data.forms || []; … … 655 1232 $form, 656 1233 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 }; 658 1237 } ), 659 i18n.selectForm1238 formWord( 'selectForm', 'selectEvent' ) 660 1239 ); 661 1240 fillFormFieldSelects(); … … 667 1246 } ); 668 1247 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 669 1255 if ( false === res.data.available ) { 670 1256 $help.text( res.data.message || i18n.cf7Unavailable ).addClass( 'form2zoho-form-help-warn' ); 671 1257 } else if ( ! stillExists ) { 672 $help.text( i18n.formStale).addClass( 'form2zoho-form-help-warn' );1258 $help.text( formWord( 'formStale', 'eventStale' ) ).addClass( 'form2zoho-form-help-warn' ); 673 1259 } else { 674 $help.text( i18n.formHelp).removeClass( 'form2zoho-form-help-warn' );1260 $help.text( formWord( 'formHelp', 'eventHelp' ) ).removeClass( 'form2zoho-form-help-warn' ); 675 1261 } 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' ); 676 1267 } 677 1268 } ); 678 1269 } 679 1270 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 680 1285 // 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 ) { 682 1291 var integration = $intgr.val(); 683 1292 var conn = $conn.val(); 684 if ( ! integration || ! conn) {685 return ;1293 if ( ! integration || ( needsConnection() && ! conn ) ) { 1294 return $.Deferred().resolve().promise(); 686 1295 } 687 1296 toggleOrg(); 688 1297 toggleDept(); 689 1298 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', { 691 1305 connection_id: conn, 692 1306 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 } 694 1315 } ).done( function ( res ) { 695 if ( ctx !== contextKey() ) { 1316 // A second organization picked before this one answered. 1317 if ( ctx !== contextKey() || orgId !== ( $org.val() || '' ) ) { 696 1318 return; 697 1319 } … … 714 1336 715 1337 loadDepartments(); 716 loadFields( );1338 loadFields( fresh ); 717 1339 } ); 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 ); 718 1399 } 719 1400 … … 722 1403 // mailing list), so for those two apps this only needs the 723 1404 // 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 ) { 725 1415 var integration = $intgr.val(); 726 1416 var conn = $conn.val(); 727 1417 var module = $module.val(); 728 1418 var needsModule = ! isCampaignsApp(); 729 if ( ! integration || ! conn || ( needsModule && ! module ) ) { 1419 if ( needsConnection() ) { 1420 syncPassthrough( false ); 1421 } 1422 if ( ! integration || ( needsConnection() && ! conn ) || ( needsModule && ! module ) ) { 730 1423 return; 731 1424 } 1425 var key = [ integration, conn, $org.val() || '', needsModule ? module : '' ].join( '|' ); 1426 if ( ! fresh && key === fieldsKey ) { 1427 return; 1428 } 1429 fieldsKey = key; 732 1430 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 } 733 1437 post( 'form2zoho_browse_fields', { 734 1438 connection_id: conn, 735 1439 integration: integration, 736 1440 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 } 738 1447 } ).done( function ( res ) { 1448 if ( key !== fieldsKey ) { 1449 // A different module or organization was picked meanwhile. 1450 return; 1451 } 739 1452 if ( ctx !== contextKey() ) { 740 1453 return; … … 742 1455 if ( res && res.success ) { 743 1456 state.zohoFields = res.data.fields || []; 1457 state.fieldsLoaded = true; 744 1458 fillZohoFieldSelects(); 745 1459 $( '#f2z_fields_notice' ).prop( 'hidden', true ); 1460 syncPassthrough( ! needsConnection() && ! state.zohoFields.length ); 746 1461 } 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 ); 748 1464 } 749 1465 } ).fail( function () { 1466 if ( key !== fieldsKey ) { 1467 return; 1468 } 1469 fieldsKey = ''; 750 1470 if ( ctx !== contextKey() ) { 751 1471 return; 752 1472 } 753 $( '#f2z_fields_notice' ).text( i18n.error ).prop( 'hidden', false);1473 showFieldsError( i18n.error ); 754 1474 } ); 755 1475 } 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 } ); 756 1507 757 1508 // Every mapping row names a field of one specific module, inside one … … 772 1523 // switching app on a brand-new feed shouldn't raise a warning 773 1524 // 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. 774 1536 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' ) ); 779 1543 } ).length > 0; 780 1544 1545 // Any dropdown open in the table is closed before rows go, for the 1546 // same reason as the Remove button. 1547 comboCloseWithin( $rows ); 781 1548 $rows.slice( 1 ).remove(); 782 1549 var $first = $rows.first().removeClass( 'is-incomplete' ); … … 785 1552 // refill put the old field straight back in. 786 1553 $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 ); 787 1558 $first.find( '.f2z-map-static' ).val( '' ); 788 1559 $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(); 789 1564 790 1565 $( '#f2z-mapping-alert' ).prop( 'hidden', true ); … … 819 1594 // Events. 820 1595 $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 } ); 821 1612 $intgr.on( 'change', function () { 822 1613 // A module/topic/tag id from a *different* Zoho app is … … 834 1625 // from. 835 1626 $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( '' ); 836 1630 // The module select is being emptied here, so its remembered 837 1631 // value has to follow — otherwise picking the new app's first … … 843 1637 $( '#f2z_topics' ).val( [] ).trigger( 'f2z:optionsUpdated' ); 844 1638 $( '#f2z_tag' ).val( [] ).trigger( 'f2z:optionsUpdated' ); 1639 clearOrganization(); 845 1640 state.zohoFields = []; 1641 state.fieldsLoaded = false; 1642 fieldsKey = ''; 846 1643 fillZohoFieldSelects(); 1644 // Before loadModules(), which asks whether this destination needs 1645 // an account before deciding it has enough to go on. 1646 toggleConnection(); 847 1647 loadModules(); 848 1648 checkAppAuthorization(); … … 856 1656 resetMapping(); 857 1657 } 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 } 858 1667 loadModules(); 859 1668 checkAppAuthorization(); … … 866 1675 loadTopics(); 867 1676 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. 868 1681 $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 ); 871 1690 } ); 872 1691 // Keep the hidden module_label input in sync with the selected … … 875 1694 // Automation. Synced on change and again on save, in case options 876 1695 // were rebuilt after the last change event. 877 function syncModuleLabel( ) {1696 function syncModuleLabel( picked ) { 878 1697 var val = $module.val() || ''; 879 1698 var text = $.trim( $module.find( 'option:selected' ).text() ); … … 885 1704 // older feed, the pre-filled option's text is the raw value 886 1705 // 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 ) ) { 888 1709 $( '#f2z_module_label_value' ).val( text ); 889 1710 } 890 1711 } 891 1712 $module.on( 'change', function () { 892 if ( targetChanged( 'module', $module ) ) { 1713 var reset = targetChanged( 'module', $module ); 1714 var asked = fieldsKey; 1715 1716 if ( reset ) { 893 1717 resetMapping(); 894 1718 } 895 syncModuleLabel( );1719 syncModuleLabel( true ); 896 1720 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 } 897 1731 } ); 898 1732 $module.closest( 'form' ).on( 'submit', syncModuleLabel ); … … 900 1734 e.preventDefault(); 901 1735 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 () { 903 1746 $btn.removeClass( 'is-spinning' ).prop( 'disabled', false ); 904 1747 } ); … … 907 1750 e.preventDefault(); 908 1751 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 () { 913 1753 $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(); 915 1768 } ); 916 1769 $( document ).on( 'click.f2zNav', '.form2zoho-reload-topics', function ( e ) { 917 1770 e.preventDefault(); 918 1771 var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true ); 919 loadTopics( ).always( function () {1772 loadTopics( true ).always( function () { 920 1773 $btn.removeClass( 'is-spinning' ).prop( 'disabled', false ); 921 1774 } ); … … 924 1777 e.preventDefault(); 925 1778 var $btn = $( this ).addClass( 'is-spinning' ).prop( 'disabled', true ); 926 loadTags( ).always( function () {1779 loadTags( true ).always( function () { 927 1780 $btn.removeClass( 'is-spinning' ).prop( 'disabled', false ); 928 1781 } ); … … 942 1795 // need to know the picker is tiles rather than a plain dropdown. 943 1796 $( 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 944 1814 var key = $( this ).data( 'source' ); 945 1815 $( '.form2zoho-source-tile' ).removeClass( 'is-active' ).attr( 'aria-checked', 'false' ); 946 1816 $( this ).addClass( 'is-active' ).attr( 'aria-checked', 'true' ); 947 1817 $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 } 948 1836 } ); 949 1837 … … 968 1856 969 1857 // Initial load. 1858 renderAllTemplateTokens(); 1859 filterApps( true ); 1860 toggleConnection(); 970 1861 toggleOrg(); 971 1862 toggleDept(); 972 1863 toggleCampaignsUi(); 973 1864 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() ) ) { 975 1868 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(); 976 1873 // loadModules() only chains loadFields() (via its own done 977 1874 // callback) — Topics/Tags were never being fetched on the … … 987 1884 } 988 1885 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. 990 2380 $( document ).on( 'click.f2zNav', '.form2zoho-add-row', function () { 991 2381 var tpl = $( '#form2zoho-row-template' ).html(); … … 993 2383 return; 994 2384 } 995 $( tpl ).appendTo( '.form2zoho-map-rows' ); 2385 syncRowSource( $( tpl ).appendTo( '.form2zoho-map-rows' ) ); 2386 enhanceMappingSelects(); 996 2387 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' ); 1000 2389 } 1001 2390 } ); … … 1003 2392 // Remove a mapping row (keep at least one). 1004 2393 $( 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 1005 2400 var $rows = $( '.form2zoho-map-rows .form2zoho-map-row' ); 1006 2401 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(); 1008 2408 } 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(); 1010 2423 } 1011 2424 } ); … … 1016 2429 // contradictory sources at once, so clear it when the source flips. 1017 2430 $( 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() ) { 1019 2459 return; 1020 2460 } 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 × 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' ); 1025 2605 } ); 1026 2606 … … 1107 2687 $( document ).on( 'change.f2zNav', '.form2zoho-log-check', refreshToolbar ); 1108 2688 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 1109 2694 $selectFailedBtn.on( 'click', function () { 1110 2695 var turnOn = ! allFailedSelected(); … … 1130 2715 setLogsEmptyState( true ); 1131 2716 } 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' ); 1132 2721 } else { 1133 2722 showToast( ( res && res.data && res.data.message ) || i18n.logsDeleteFailed, true ); … … 1206 2795 var $logsTable = $( '#form2zoho-logs-table' ); 1207 2796 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' }; 1209 2798 // Translated, via shared_i18n(). These were English literals, so a 1210 2799 // translated site showed the polled rows in English right underneath … … 1216 2805 retry: i18n.statusRetry, 1217 2806 info: i18n.statusInfo, 1218 suppressed: i18n.statusSuppressed 2807 suppressed: i18n.statusSuppressed, 2808 skipped: i18n.statusSkipped 1219 2809 }; 1220 2810 … … 1228 2818 html += '<td><span class="form2zoho-pill ' + cls + '">' + esc( label ) + '</span></td>'; 1229 2819 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>'; 1233 2823 html += '<td>' + esc( row.message ) + '</td>'; 1234 2824 html += '<td><button type="button" class="button-link form2zoho-log-toggle">' + esc( i18n.details ) + '</button></td>'; … … 1242 2832 } 1243 2833 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 } ); 1245 2956 1246 2957 function pollLogs() { 1247 2958 if ( ! $( '#form2zoho-live-switch' ).is( ':checked' ) ) { 1248 return ;2959 return $.Deferred().resolve().promise(); 1249 2960 } 1250 2961 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 ) { 1252 2963 if ( ! res || ! res.success || ! res.data.rows || ! res.data.rows.length ) { 1253 2964 return; … … 1255 2966 var $tbody = $( '#form2zoho-logs-tbody' ); 1256 2967 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. 1257 2973 res.data.rows.forEach( function ( row ) { 1258 $tbody.prepend( logRowHtml( row ) );1259 2974 maxId = Math.max( maxId, row.id ); 1260 2975 } ); 1261 2976 $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 1262 3009 setLogsEmptyState( false ); 3010 $paging.prop( 'hidden', false ); 1263 3011 setTimeout( function () { 1264 3012 $tbody.find( '.form2zoho-log-row-new' ).removeClass( 'form2zoho-log-row-new' ); … … 1269 3017 } 1270 3018 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. 1271 3108 if ( window.form2zohoPollTimer ) { 1272 clearInterval( window.form2zohoPollTimer ); 1273 } 1274 window.form2zohoPollTimer = setInterval( pollLogs, 4000 ); 3109 clearTimeout( window.form2zohoPollTimer ); 3110 } 3111 schedulePoll( POLL_MIN ); 3112 1275 3113 $( '#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 } 1277 3132 } ); 1278 3133 } else if ( window.form2zohoPollTimer ) { 1279 3134 // Navigated away from the Logs tab — stop polling in the background. 1280 clear Interval( window.form2zohoPollTimer );3135 clearTimeout( window.form2zohoPollTimer ); 1281 3136 window.form2zohoPollTimer = null; 1282 3137 } … … 1290 3145 * @return {void} 1291 3146 */ 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 1292 3161 function showToast( message, isError ) { 1293 3162 var $stack = $( '.form2zoho-toast-stack' ); … … 1319 3188 setTimeout( function () { $toast.remove(); }, 200 ); 1320 3189 }, 1321 26003190 NOTICE_LINGER 1322 3191 ); 1323 3192 } … … 1382 3251 } ); 1383 3252 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 1384 3277 $( document ).on( 'change.f2zNav', '.form2zoho-feed-toggle', function () { 1385 3278 var $cb = $( this ); 1386 3279 var id = $cb.data( 'feed-id' ); 3280 actionBusy( $cb, true ); 1387 3281 post( 'form2zoho_toggle_feed', { feed_id: id } ) 3282 .always( function () { 3283 actionBusy( $cb, false ); 3284 } ) 1388 3285 .done( function () { 1389 3286 showToast( i18n.statusChanged ); … … 1395 3292 } ); 1396 3293 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 1397 3318 $( 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 } 1405 3371 1406 3372 function openModal( $modal ) { … … 1409 3375 $modal.find( '.form2zoho-modal' ).attr( 'tabindex', '-1' ).trigger( 'focus' ); 1410 3376 } 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 ) { 1412 3378 if ( e.target === this ) { 1413 3379 closeModal( $( this ).closest( '.form2zoho-modal-overlay' ) ); … … 1427 3393 } 1428 3394 var d = res.data; 1429 var dedupe = ( d.dedupe || [] ).join( ', ' ) || ' \u2014';3395 var dedupe = ( d.dedupe || [] ).join( ', ' ) || '-'; 1430 3396 // Same title class as the other three dialogs — a bare <h2> picks 1431 3397 // up the generic `.form2zoho h2` margins instead, which put an … … 1435 3401 html += '<tr><th scope="row">' + esc( i18n.colForm ) + '</th><td><code>' + esc( d.form_name ) + '</code></td></tr>'; 1436 3402 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>'; 1438 3404 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>'; 1439 3405 html += '<tr><th scope="row">' + esc( i18n.colMapped ) + '</th><td>' + esc( d.mapping_count ) + '</td></tr>'; … … 1452 3418 var $modal = $( '#form2zoho-timeline-modal' ); 1453 3419 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 ); 1455 3421 openModal( $modal ); 1456 3422 post( 'form2zoho_feed_timeline', { feed_id: id } ) … … 1471 3437 html += '<span class="form2zoho-timeline-dot"></span>'; 1472 3438 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>'; 1474 3440 html += '<div>' + esc( row.message ) + '</div>'; 1475 3441 // No deep link for Campaigns / Marketing Automation — Zoho … … 1502 3468 html += '</div>'; 1503 3469 } 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 1504 3494 html += '</div></li>'; 1505 3495 } ); … … 1673 3663 return 'is-success'; 1674 3664 } 1675 if ( 'suppressed' === status ) {3665 if ( 'suppressed' === status || 'skipped' === status ) { 1676 3666 return 'is-warning'; 1677 3667 } … … 1698 3688 var show = $( this ).is( ':checked' ); 1699 3689 $( '.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 : [ '' ] } ); 1700 3702 } ); 1701 3703 … … 1770 3772 }, 1500 ); 1771 3773 } 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 ) ); 1777 3786 } 1778 3787 }; … … 1841 3850 } 1842 3851 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; 1863 4331 return false; 1864 4332 } 1865 4333 } ); 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 ) { 1867 4376 return; 1868 4377 } 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 } 1876 4464 } ); 1877 4465 … … 1894 4482 } 1895 4483 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 1896 4521 // Whether a mapping row actually carries something to send: a form field 1897 4522 // picked, or a static value typed. Mirrors collect_mapping() in 1898 4523 // FeedsPage.php, which drops rows that fail this on save. 1899 4524 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(); 1903 4530 } 1904 4531 … … 1917 4544 return '' !== ( $row.find( '.f2z-map-zoho' ).val() || '' ) && rowIsFilled( $row ); 1918 4545 } 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 } ); 1919 4675 1920 4676 // Block "Save feed" on three mapping problems, and point the person at … … 1929 4685 // silent disappearance, and it is the easier mistake to make — 1930 4686 // "+ 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. 1931 4689 $( document ).on( 'submit.f2zNav', '#f2z-feed-editor', function ( e ) { 1932 4690 var required = {}; … … 1939 4697 var satisfied = {}; 1940 4698 var incomplete = []; 4699 var invalid = []; 1941 4700 var blank = 0; 1942 4701 var $rows = $( '.form2zoho-map-row' ); … … 1959 4718 if ( rowIsFilled( $row ) ) { 1960 4719 satisfied[ zoho ] = true; 4720 var problem = showStaticProblem( $row ); 4721 if ( problem ) { 4722 invalid.push( problem ); 4723 } 1961 4724 return; 1962 4725 } … … 1972 4735 1973 4736 var $alert = $( '#f2z-mapping-alert' ); 1974 if ( ! missing.length && ! incomplete.length && ! blank ) {4737 if ( ! missing.length && ! incomplete.length && ! blank && ! invalid.length ) { 1975 4738 $alert.prop( 'hidden', true ); 1976 4739 return; … … 1988 4751 parts.push( i18n.blankRows ); 1989 4752 } 4753 if ( invalid.length ) { 4754 parts.push( invalid.join( ' ' ) ); 4755 } 1990 4756 if ( missing.length ) { 1991 4757 parts.push( ( i18n.missingRequired ).replace( '%s', missing.join( ', ' ) ) ); … … 1993 4759 1994 4760 $alert.find( 'strong' ).text( 1995 ( incomplete.length || blank ) ? i18n.incompleteRowsTitle : i18n.missingRequiredTitle4761 ( incomplete.length || blank ) ? i18n.incompleteRowsTitle : ( invalid.length ? i18n.staticInvalidTitle : i18n.missingRequiredTitle ) 1996 4762 ); 1997 4763 $alert.find( 'p' ).text( parts.join( ' ' ) ); 1998 4764 $alert.prop( 'hidden', false ); 1999 4765 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' ); 2003 4772 $( 'html, body' ).animate( { scrollTop: $alert.offset().top - 40 }, 200 ); 2004 4773 } ); … … 2019 4788 ); 2020 4789 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 ); 2027 4853 } 2028 4854 } ); … … 2093 4919 } ); 2094 4920 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 2099 5000 $( 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 2107 5007 $( 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' ) ); 2111 5009 } ); 2112 5010 … … 2133 5031 // always come back with no fields/tags, because the access token 2134 5032 // 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 2135 5043 $( document ).on( 'click.f2zNav', '.form2zoho-toggle-edit-apps', function () { 2136 5044 var connId = $( this ).data( 'conn' ); … … 2139 5047 } ); 2140 5048 } ); 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 } ); 2141 5118 } )( jQuery ); -
techwave-form-sync-for-zoho/trunk/includes/Admin/Ajax.php
r3649280 r3748247 18 18 use Form2Zoho\Support\DataCenters; 19 19 use Form2Zoho\Support\FormSources; 20 use Form2Zoho\Support\ErrorTranslator; 21 use Form2Zoho\Support\SiteTime; 22 use Form2Zoho\Support\PhpMail; 20 23 21 24 defined( 'ABSPATH' ) || exit; … … 108 111 add_action( 'wp_ajax_form2zoho_browse_tags', array( $this, 'browse_tags' ) ); 109 112 add_action( 'wp_ajax_form2zoho_list_forms', array( $this, 'list_forms' ) ); 113 add_action( 'wp_ajax_form2zoho_list_connections', array( $this, 'list_connections' ) ); 110 114 add_action( 'wp_ajax_form2zoho_recent_logs', array( $this, 'recent_logs' ) ); 111 115 add_action( 'wp_ajax_form2zoho_feed_timeline', array( $this, 'feed_timeline' ) ); 116 add_action( 'wp_ajax_form2zoho_save_columns', array( $this, 'save_columns' ) ); 112 117 add_action( 'wp_ajax_form2zoho_toggle_feed', array( $this, 'toggle_feed' ) ); 113 118 add_action( 'wp_ajax_form2zoho_clone_feed', array( $this, 'clone_feed' ) ); … … 128 133 * correct the whole time. This answers the question on the spot. 129 134 * 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. 133 142 * 134 143 * @return void … … 152 161 "\n", 153 162 array( 154 __( 'This is a test message from Techwave Form Syncfor Zoho.', 'techwave-form-sync-for-zoho' ),163 __( 'This is a test message from FormBridge for Zoho.', 'techwave-form-sync-for-zoho' ), 155 164 '', 156 165 __( 'If you are reading it, this site can send the notification emails the plugin offers under Settings → Notifications.', 'techwave-form-sync-for-zoho' ), … … 158 167 ); 159 168 160 $sent = wp_mail(169 $sent = PhpMail::send( 161 170 $to, 162 171 /* translators: %s: site name. */ 163 sprintf( __( '[%s] Techwave Form Synctest email', 'techwave-form-sync-for-zoho' ), $site ),172 sprintf( __( '[%s] FormBridge test email', 'techwave-form-sync-for-zoho' ), $site ), 164 173 $body 165 174 ); 166 175 167 if ( ! $sent) {176 if ( is_wp_error( $sent ) ) { 168 177 wp_send_json_error( 169 178 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 ), 171 184 ) 172 185 ); … … 177 190 'message' => sprintf( 178 191 /* translators: %s: administration email address. */ 179 __( 'Test email sent to %s. Check that inbox — if it never arrives, the message left WordPressbut 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' ), 180 193 $to 181 194 ), … … 218 231 219 232 $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 ) ); 221 281 } 222 282 … … 276 336 $feed['title'] = $title; 277 337 $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 ); 279 343 280 344 wp_send_json_success( array( 'id' => $new_id ) ); … … 501 565 if ( ! empty( $feed['dedupe_enabled'] ) && ! empty( $feed['dedupe_fields'] ) ) { 502 566 $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' ) ); 503 571 } 504 572 … … 559 627 560 628 $status = isset( $context['status'] ) ? (string) $context['status'] : ''; 561 if ( 'success' !== $status && 'suppressed' !== $status ) {629 if ( 'success' !== $status && 'suppressed' !== $status && 'skipped' !== $status ) { 562 630 $status = 'error'; 563 631 } … … 608 676 // page-loaded one, with the form name, timestamp, and a readable 609 677 // 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; 611 679 $context_out['app'] = $app_label; 612 680 if ( '' !== (string) $row->form_id ) { … … 616 684 return array( 617 685 '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 ), 619 689 'level' => (string) $row->level, 620 690 'status' => $status, … … 626 696 'record_id' => $record_id, 627 697 '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 ), 629 699 'context' => wp_json_encode( $context_out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), 630 700 ); … … 663 733 } 664 734 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 ); 666 746 $this->bail_on_error( $topics ); 667 747 … … 682 762 } 683 763 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 ); 685 775 $this->bail_on_error( $tags ); 686 776 687 777 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 ) ); 688 825 } 689 826 … … 709 846 'available' => false, 710 847 /* 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 ) ), 712 849 ) 713 850 ); … … 744 881 ); 745 882 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 ); 748 901 $this->bail_on_error( $orgs ); 749 902 $payload['organizations'] = $this->flatten( $orgs ); 750 903 } 751 904 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 } 757 918 ); 758 919 $this->bail_on_error( $modules ); 759 920 $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 }769 921 770 922 wp_send_json_success( $payload ); … … 797 949 } 798 950 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 } 805 968 ); 806 969 $this->bail_on_error( $fields ); 807 970 808 971 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(); 809 1030 } 810 1031 … … 827 1048 $org_id = isset( $_POST['org_id'] ) ? sanitize_text_field( wp_unslash( $_POST['org_id'] ) ) : ''; 828 1049 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 833 1050 if ( '' === $integration ) { 834 1051 wp_send_json_error( array( 'message' => __( 'No integration selected.', 'techwave-form-sync-for-zoho' ) ) ); 835 1052 } 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 ); 836 1064 837 1065 return array( … … 843 1071 844 1072 /** 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 /** 845 1123 * 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". 846 1128 * 847 1129 * @param mixed $maybe_error Possible WP_Error. … … 850 1132 private function bail_on_error( $maybe_error ) { 851 1133 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() ) ); 853 1139 } 854 1140 } … … 892 1178 893 1179 $required = ! empty( $value['required'] ); 1180 $type = isset( $value['type'] ) ? (string) $value['type'] : ''; 1181 $unique = ! empty( $value['unique'] ); 894 1182 } else { 895 1183 $val = is_int( $key ) ? $value : $key; 896 1184 $label = $value; 897 1185 $required = false; 1186 $type = ''; 1187 $unique = false; 898 1188 } 899 1189 … … 902 1192 'label' => (string) $label, 903 1193 '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, 904 1200 ); 905 1201 } -
techwave-form-sync-for-zoho/trunk/includes/Admin/DeactivationFeedback.php
r3649280 r3748247 13 13 14 14 /** 15 * Asks why, once, on the Plugins screen, when someone deactivates Techwave Form Syncfor Zoho.15 * Asks why, once, on the Plugins screen, when someone deactivates FormBridge for Zoho. 16 16 * 17 17 * Entirely voluntary and entirely skippable. The modal is an overlay on the … … 180 180 181 181 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>'; 183 183 184 184 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 17 17 use Form2Zoho\Logging\Logger; 18 18 use Form2Zoho\Support\Assets; 19 use Form2Zoho\Support\Branding; 19 20 use Form2Zoho\Support\ErrorTranslator; 21 use Form2Zoho\Support\FieldMapper; 20 22 use Form2Zoho\Support\FormSources; 23 use Form2Zoho\Support\VideoGuide; 21 24 use Form2Zoho\Support\Markup; 25 use Form2Zoho\Support\SiteTime; 26 use Form2Zoho\Support\StaticValue; 22 27 23 28 defined( 'ABSPATH' ) || exit; … … 34 39 * plugin's whole top-level menu slug. SettingsPage registers itself 35 40 * 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 } 38 110 39 111 private const SLUG = self::MENU_SLUG; … … 121 193 public function add_menu() { 122 194 $this->hook_suffix = add_menu_page( 123 __( ' Techwave Form Syncfor Zoho', 'techwave-form-sync-for-zoho' ),195 __( 'FormBridge for Zoho', 'techwave-form-sync-for-zoho' ), 124 196 // Shortened for the sidebar only. The full name is the page title 125 197 // above; once the icon and padding are taken out, a WordPress menu 126 198 // label has room for about fourteen characters before it wraps onto 127 // a second line — " Techwave Form Sync" did, and sat two lines tall199 // a second line — "FormBridge for Zoho" did, and sat two lines tall 128 200 // next to single-line neighbours like "WPForms". Every in-product 129 // reference to the menu (" Techwave Sync → Forms") uses this same201 // reference to the menu ("FormBridge → All Feeds") uses this same 130 202 // shortened form, so what the docs say to click matches what is on 131 203 // screen. 132 __( 'Techwave Sync', 'techwave-form-sync-for-zoho'),204 self::menu_title(), 133 205 self::CAP, 134 206 self::SLUG, … … 143 215 // WordPress auto-creates a first submenu duplicating the parent as soon 144 216 // as any submenu is added, and labels it with the parent's menu title — 145 // leaving a meaningless " Techwave Sync > Techwave Sync" entry in the217 // leaving a meaningless "FormBridge > FormBridge" entry in the 146 218 // sidebar. 147 219 // Registering it explicitly against the same slug replaces that … … 149 221 add_submenu_page( 150 222 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' ), 153 225 self::CAP, 154 226 self::SLUG, … … 158 230 159 231 /** 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 /** 160 264 * Menu icon as an inline SVG data URI. 161 265 * 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. 170 282 * 171 283 * @return string 172 284 */ 173 285 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() 179 290 . '</svg>'; 180 291 … … 205 316 $needs_org = array(); 206 317 $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(); 207 322 foreach ( $this->registry->all() as $id => $integration ) { 208 323 $needs_org[ $id ] = (bool) $integration->needs_organization(); 209 324 if ( method_exists( $integration, 'get_departments' ) ) { 210 325 $dept_apps[] = $id; 326 } 327 if ( method_exists( $integration, 'needs_connection' ) && ! $integration->needs_connection() ) { 328 $no_conn_apps[] = $id; 211 329 } 212 330 } … … 230 348 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 231 349 '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(), 232 353 'needsOrg' => $needs_org, 233 354 'deptApps' => $dept_apps, 355 'noConnApps' => $no_conn_apps, 234 356 'connectionApps' => $conn_apps, 235 357 '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(), 237 364 'i18n' => array( 238 365 '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' ), 239 368 'viewInZoho' => __( 'View Record in Zoho', 'techwave-form-sync-for-zoho' ), 240 369 'recordId' => __( 'Record ID', 'techwave-form-sync-for-zoho' ), 241 370 'error' => __( 'Request failed.', 'techwave-form-sync-for-zoho' ), 242 371 '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' ), 249 394 'mailingList' => __( 'Mailing List', 'techwave-form-sync-for-zoho' ), 250 395 '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' ), 252 397 'moduleHelp' => __( 'Loaded from Zoho once the app and account are chosen.', 'techwave-form-sync-for-zoho' ), 253 398 'noMailingListsCampaigns' => __( 'No Mailing Lists found in Zoho Campaigns.', 'techwave-form-sync-for-zoho' ), … … 257 402 'noMatches' => __( 'No matches', 'techwave-form-sync-for-zoho' ), 258 403 /* 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' ), 260 407 'testConfirm' => __( 'Send one real test record to Zoho now?', 'techwave-form-sync-for-zoho' ), 261 408 'testSuccess' => __( 'Test successful', 'techwave-form-sync-for-zoho' ), … … 264 411 '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' ), 265 412 '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' ), 266 415 /* translators: %s: comma-separated list of required Zoho field names that are still unmapped. */ 267 416 'missingRequired' => __( 'These required Zoho fields are not mapped yet: %s. Map them (or set a static value) before saving.', 'techwave-form-sync-for-zoho' ), 268 417 'missingRequiredTitle' => __( 'Missing required fields', 'techwave-form-sync-for-zoho' ), 269 418 /* 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' ), 271 420 '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' ), 274 449 /* translators: 1: currently authorized app(s), 2: app the person just picked. */ 275 450 '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' ), … … 338 513 'statusInfo' => __( 'Info', 'techwave-form-sync-for-zoho' ), 339 514 'statusSuppressed' => __( 'Suppressed', 'techwave-form-sync-for-zoho' ), 515 'statusSkipped' => __( 'Skipped', 'techwave-form-sync-for-zoho' ), 340 516 'details' => __( 'Details', 'techwave-form-sync-for-zoho' ), 341 517 'copy' => __( 'Copy', 'techwave-form-sync-for-zoho' ), … … 354 530 /* translators: %d: number of log entries. */ 355 531 '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' ), 357 533 /* 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' ), 359 535 'logsNothingToResubmit' => __( 'Nothing to resubmit.', 'techwave-form-sync-for-zoho' ), 360 536 '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' ), 361 539 ); 362 540 } … … 380 558 381 559 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' ); 387 563 echo '<hr class="wp-header-end" />'; 388 564 $this->render_editor(); 389 565 } 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' ); 397 569 echo '<hr class="wp-header-end" />'; 398 570 $this->render_list(); … … 408 580 */ 409 581 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 410 586 $new_url = add_query_arg( 411 587 array( … … 431 607 $this->maybe_notice(); 432 608 433 // Newest feeds first . Sort by created_at when present (falls back to434 // reversing insertion order for legacy feeds saved before created_at435 // existed), so a freshly added feedalways 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. 436 612 $feeds = $this->feeds->all(); 437 613 uasort( 438 614 $feeds, 439 615 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 ); 442 618 if ( $at === $bt ) { 443 619 return 0; … … 459 635 echo '<span>' . esc_html__( 'Add New Feed', 'techwave-form-sync-for-zoho' ) . '</span>'; 460 636 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' ) ); 461 643 echo '</div>'; 462 644 echo '</div>'; // Close .form2zoho-card-pop. … … 512 694 513 695 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>'; 515 697 foreach ( $labels as $key => $label ) { 516 698 echo '<option value="' . esc_attr( $key ) . '" ' . selected( $app_filter, $key, false ) . '>' . esc_html( $label ) . '</option>'; … … 523 705 echo '</button>'; 524 706 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>'; 534 709 } 535 710 echo '</div>'; … … 571 746 echo '<thead><tr>'; 572 747 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>'; 578 753 echo '<th scope="col" class="form2zoho-actions-col">' . esc_html__( 'Actions', 'techwave-form-sync-for-zoho' ) . '</th>'; 579 754 echo '</tr></thead><tbody>'; … … 605 780 606 781 $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 : '-'; 608 785 $is_on = ! empty( $feed['enabled'] ); 609 786 // Prefer the saved human-readable module label (e.g. the mailing … … 617 794 $module_disp = substr( $module_disp, 0, 10 ) . '…'; 618 795 } 619 $action_nm = trim( $app . ( '' !== $module_disp ? ' —' . $module_disp : '' ) );796 $action_nm = trim( $app . ( '' !== $module_disp ? ' - ' . $module_disp : '' ) ); 620 797 621 798 echo '<tr data-feed-id="' . esc_attr( $feed['id'] ) . '" data-integration="' . esc_attr( $feed['integration'] ) . '">'; … … 625 802 $tile = FormSources::tile_for( isset( $feed['form_type'] ) ? (string) $feed['form_type'] : 'elementor' ); 626 803 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">'; 629 808 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 ) ) . '">'; 635 814 echo '<label class="form2zoho-switch">'; 636 815 printf( … … 663 842 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>'; 664 843 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>'; 665 849 666 850 echo '<button type="button" class="form2zoho-menu-item form2zoho-action-clone" data-feed-id="' . esc_attr( $feed['id'] ) . '">'; … … 770 954 $feed = is_array( $feed ) ? $feed : array(); 771 955 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' : '' ); 776 966 $intgr = isset( $feed['integration'] ) ? $feed['integration'] : 'crm'; 777 967 $conn = isset( $feed['connection_id'] ) ? $feed['connection_id'] : ''; … … 783 973 $mapping = isset( $feed['mapping'] ) && is_array( $feed['mapping'] ) ? $feed['mapping'] : array(); 784 974 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 785 980 $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">'; 787 982 echo '<div class="form2zoho-editor-wrap">'; 788 983 echo '<a href="' . esc_url( $list_url ) . '" class="form2zoho-back-link">'; … … 791 986 echo '</a>'; 792 987 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(); 796 993 if ( empty( $conn_choices ) ) { 797 994 $this->render_alert( … … 800 997 /* translators: %s: link to the Connections tab. */ 801 998 __( '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>' 803 1000 ) 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 804 1013 ); 805 1014 } … … 817 1026 ); 818 1027 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"' : '' ) . '>'; 820 1029 wp_nonce_field( 'form2zoho_save_feed' ); 821 1030 echo '<input type="hidden" name="action" value="form2zoho_save_feed" />'; 822 1031 echo '<input type="hidden" name="feed_id" value="' . esc_attr( $id ) . '" />'; 823 1032 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 same841 // property (see .form2zoho-switch in admin.css). It was a bare842 // checkbox here, so the one setting a person flips in two places843 // looked like two different controls. Wrapped in a <label> so the844 // sentence beside it names the input — the row's "Enabled" heading845 // 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. 848 1057 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' 851 1060 ); 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' ) . '">'; 861 1094 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 864 1142 if ( ! empty( $tile['icon'] ) ) { 865 1143 echo '<span class="form2zoho-source-icon form2zoho-source-icon-img"><img src="' . esc_url( $tile['icon'] ) . '" alt="" loading="lazy" /></span>'; … … 868 1146 } 869 1147 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 870 1160 echo '</button>'; 871 1161 } 872 1162 echo '</div>'; 873 1163 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 } 874 1170 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 } 875 1177 echo '<option value="' . esc_attr( $key ) . '" ' . selected( $form_type, $key, false ) . '>' . esc_html( $label ) . '</option>'; 876 1178 } 877 1179 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>'; 880 1293 881 1294 // 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>'; 883 1297 // .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. 885 1300 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>'; 887 1302 if ( '' !== $form ) { 888 1303 echo '<option value="' . esc_attr( $form ) . '" selected>' . esc_html( $form ) . '</option>'; 889 1304 } 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>'; 891 1306 } 892 1307 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' ) . ' ›</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>'; 922 1423 foreach ( $conn_choices as $cid => $clabel ) { 923 1424 $conn_row = $this->connections->get( $cid ); … … 926 1427 } 927 1428 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>'; 929 1447 930 1448 // Organization — used by Books/Desk (required) and by Bigin … … 937 1455 // instead (see DataCenters::record_url()'s 'campaigns'/'automation' 938 1456 // 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>'; 942 1466 if ( '' !== $org ) { 943 1467 echo '<option value="' . esc_attr( $org ) . '" selected>' . esc_html( $org ) . '</option>'; 944 1468 } 945 1469 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>'; 948 1478 949 1479 // 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>'; 953 1485 if ( '' !== $dept ) { 954 1486 echo '<option value="' . esc_attr( $dept ) . '" selected>' . esc_html( $dept ) . '</option>'; 955 1487 } 956 1488 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>'; 957 1491 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>'; 959 1493 960 1494 // Module (dropdown loaded from Zoho for the chosen app + account). 961 1495 // Relabelled to "Mailing List" by JS, and hidden entirely in favour 962 // of the Campaigns/Marketing Automation sectionbelow, when one of1496 // of the Campaigns/Marketing Automation group below, when one of 963 1497 // those two apps is selected — both reuse this same field to store 964 1498 // 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>'; 966 1501 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>'; 968 1503 if ( '' !== $module ) { 969 1504 echo '<option value="' . esc_attr( $module ) . '" selected>' . esc_html( $module ) . '</option>'; 970 1505 } 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>'; 972 1507 } 973 1508 echo '</select>'; … … 975 1510 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>'; 976 1511 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>'; 978 1513 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. 983 1559 $camp_sub = isset( $feed['campaigns_subscription_type'] ) ? (string) $feed['campaigns_subscription_type'] : ''; 984 1560 $camp_tag_raw = isset( $feed['campaigns_tag'] ) ? $feed['campaigns_tag'] : array(); … … 986 1562 $camp_topics = isset( $feed['campaigns_topics'] ) && is_array( $feed['campaigns_topics'] ) ? $feed['campaigns_topics'] : array(); 987 1563 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">'; 992 1576 foreach ( array( 993 1577 'Marketing' => __( 'Marketing', 'techwave-form-sync-for-zoho' ), … … 998 1582 } 999 1583 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>'; 1003 1606 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>'; 1005 1622 foreach ( $camp_topics as $tid ) { 1006 1623 echo '<option value="' . esc_attr( $tid ) . '" selected>' . esc_html( $tid ) . '</option>'; … … 1009 1626 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>'; 1010 1627 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>'; 1012 1629 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">‹ ' . 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' ) . ' ›</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 1064 1717 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>'; 1065 1737 // Filled from JS (i18n.mappingReset) when switching the Zoho app, 1066 1738 // account or module clears the rows below. … … 1073 1745 echo '<div class="form2zoho-table-scroll">'; 1074 1746 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. 1076 1751 echo '<th scope="col">' . esc_html__( 'Source', 'techwave-form-sync-for-zoho' ) . '</th>'; 1077 1752 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>'; 1078 1754 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>'; 1080 1756 echo '</tr></thead><tbody class="form2zoho-map-rows">'; 1081 1757 … … 1086 1762 'source' => isset( $rule['type'] ) ? (string) $rule['type'] : 'field', 1087 1763 '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'] : '', 1089 1766 ); 1090 1767 } … … 1104 1781 echo '</tbody></table>'; 1105 1782 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>'; 1108 1786 1109 1787 // Row template for JS cloning. … … 1121 1799 ); 1122 1800 echo '</script>'; 1123 echo '<div class="form2zoho-tab-footer">'; 1124 echo '<button type="button" class="button form2zoho-tab-back" data-back="connection">‹ ' . 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. 1132 1824 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">'; 1136 1833 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>'; 1137 1834 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' ); 1141 2259 } 1142 2260 … … 1152 2270 $form = isset( $row['form'] ) ? (string) $row['form'] : ''; 1153 2271 $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"'; 1154 2296 1155 2297 $html = '<tr class="form2zoho-map-row">'; 1156 1157 // Zoho field select — JS fills options from the module's fields; the1158 // 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>';1165 2298 1166 2299 // Source. … … 1168 2301 $html .= '<option value="field" ' . selected( $source, 'field', false ) . '>' . esc_html__( 'Form field', 'techwave-form-sync-for-zoho' ) . '</option>'; 1169 2302 $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>'; 1170 2308 $html .= '</select></td>'; 1171 2309 1172 2310 // 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>'; 1175 2316 if ( '' !== $form ) { 1176 2317 $html .= '<option value="' . esc_attr( $form ) . '" selected>' . esc_html( $form ) . '</option>'; 1177 2318 } 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>'; 1179 2343 1180 2344 // 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>'; 1182 2347 1183 2348 $html .= '<td><button type="button" class="button-link form2zoho-remove-row">' . esc_html__( 'Remove', 'techwave-form-sync-for-zoho' ) . '</button></td>'; … … 1228 2393 // exists in Zoho) instead of silently defaulting. 1229 2394 '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'] : '', 1230 2396 'campaigns_tag' => isset( $feed['campaigns_tag'] ) ? array_values( array_filter( array_map( 'trim', (array) $feed['campaigns_tag'] ) ) ) : array(), 1231 2397 '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'] : '', 1232 2403 ); 1233 2404 … … 1240 2411 $values[ (string) $rule['value'] ] = $this->sample_value( (string) $zoho_field ); 1241 2412 } 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']; 1242 2423 } 1243 2424 … … 1287 2468 $message = '' !== $record_id 1288 2469 /* 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' ); 1291 2472 } else { 1292 2473 $message = '' !== $record_id 1293 2474 /* 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' ); 1296 2477 } 1297 2478 … … 1352 2533 } 1353 2534 if ( false !== strpos( $name, 'name' ) ) { 1354 return ' Techwave Form SyncTest';1355 } 1356 1357 return ' Techwave Form Synctest';2535 return 'FormBridge Test'; 2536 } 2537 2538 return 'FormBridge test'; 1358 2539 } 1359 2540 … … 1390 2571 1391 2572 /** 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. 1396 2582 * 1397 2583 * @return string 1398 2584 */ 1399 2585 private function collect_dedupe_fields() { 1400 // Nonce already verified in handle_save().1401 2586 // 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 1402 2595 $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 1414 2608 return implode( ',', array_unique( $fields ) ); 1415 2609 } … … 1419 2613 * to Elementor for anything unrecognised. 1420 2614 * 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 * 1421 2622 * @param string $raw Posted value. 1422 2623 * @return string … … 1424 2625 private function sanitize_form_type( $raw ) { 1425 2626 $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; 1427 2633 } 1428 2634 … … 1440 2646 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above. 1441 2647 $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; 1443 2661 1444 2662 // 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; 1446 2666 1447 2667 $feed = array( 1448 'id' => isset( $_POST['feed_id'] ) ? sanitize_text_field( wp_unslash( $_POST['feed_id'] ) ) : '',2668 'id' => $existing_id, 1449 2669 '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, 1451 2676 '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'] ) : '' ), 1454 2686 'integration' => isset( $_POST['integration'] ) ? sanitize_key( wp_unslash( $_POST['integration'] ) ) : 'crm', 1455 2687 'connection_id' => isset( $_POST['connection_id'] ) ? sanitize_text_field( wp_unslash( $_POST['connection_id'] ) ) : '', … … 1459 2691 'module_label' => isset( $_POST['module_label'] ) ? sanitize_text_field( wp_unslash( $_POST['module_label'] ) ) : '', 1460 2692 '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', 1461 2694 '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(), 1462 2695 'campaigns_topics' => isset( $_POST['campaigns_topics'] ) && is_array( $_POST['campaigns_topics'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['campaigns_topics'] ) ) : array(), 1463 2696 'dedupe_enabled' => ! empty( $_POST['dedupe_enabled'] ), 1464 2697 'dedupe_fields' => $this->collect_dedupe_fields(), 1465 'mapping' => $this->collect_mapping( $mapping_skipped ),2698 'mapping' => $this->collect_mapping( $mapping_skipped, $mapping_duplicates, $mapping_invalid ), 1466 2699 '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 ), 1467 2704 ); 1468 2705 1469 2706 $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 } 1470 2729 1471 2730 // Guard against an unknown integration id. … … 1493 2752 // Carried separately from $message so it can be reported alongside 1494 2753 // whichever save outcome happened, rather than replacing it. 2754 if ( $mapping_duplicates > 0 ) { 2755 $args['duplicates'] = $mapping_duplicates; 2756 } 1495 2757 if ( $mapping_skipped > 0 ) { 1496 2758 $args['skipped'] = $mapping_skipped; 2759 } 2760 if ( $mapping_invalid > 0 ) { 2761 $args['invalid'] = $mapping_invalid; 1497 2762 } 1498 2763 … … 1533 2798 * Turn the parallel mapping arrays from the form into the stored shape. 1534 2799 * 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. 1537 2806 * @return array<string,array{type:string,value:string}> 1538 2807 */ 1539 private function collect_mapping( &$skipped = 0 ) {2808 private function collect_mapping( &$skipped = 0, &$duplicates = 0, &$invalid = 0 ) { 1540 2809 // Nonce already verified in handle_save(); every element of these four 1541 2810 // parallel arrays is passed through sanitize_text_field() in the loop below. … … 1545 2814 $form = isset( $_POST['map_form'] ) ? (array) wp_unslash( $_POST['map_form'] ) : array(); 1546 2815 $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(); 1547 2817 // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 1548 2818 1549 $mapping = array(); 1550 $skipped = 0; 2819 $mapping = array(); 2820 $skipped = 0; 2821 $duplicates = 0; 2822 $invalid = 0; 1551 2823 1552 2824 foreach ( $zoho as $i => $field ) { … … 1558 2830 } 1559 2831 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 ) { 1562 2835 $value = isset( $stat[ $i ] ) ? sanitize_text_field( $stat[ $i ] ) : ''; 1563 2836 } else { … … 1576 2849 } 1577 2850 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 1578 2878 $mapping[ $field ] = array( 1579 2879 'type' => $type, 1580 2880 'value' => $value, 1581 2881 ); 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 } 1582 2892 } 1583 2893 … … 1629 2939 $this->render_alert( 1630 2940 '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' ) 1632 2942 ); 1633 2943 } elseif ( 'deleted' === $message ) { … … 1637 2947 // Reported on top of the save outcome above, not instead of it: a feed 1638 2948 // 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 1639 2967 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only count. 1640 2968 $skipped = isset( $_GET['skipped'] ) ? absint( wp_unslash( $_GET['skipped'] ) ) : 0; … … 1654 2982 ); 1655 2983 } 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 } 1656 3002 } 1657 3003 } -
techwave-form-sync-for-zoho/trunk/includes/Admin/FeedsRepository.php
r3649280 r3748247 9 9 * 10 10 * 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. 12 12 * 13 13 * @package TechwaveFormSyncForZoho … … 95 95 * @param string $form_id Form identifier to record on the job. 96 96 * @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. 97 101 * @return array 98 102 */ 99 public static function build_job( array $feed, $form_id, array $form_values ) {100 returnarray(103 public static function build_job( array $feed, $form_id, array $form_values, array $files = array() ) { 104 $job = array( 101 105 'integration' => isset( $feed['integration'] ) ? sanitize_key( $feed['integration'] ) : '', 102 106 'form_id' => (string) $form_id, … … 112 116 'mapping' => isset( $feed['mapping'] ) && is_array( $feed['mapping'] ) ? $feed['mapping'] : array(), 113 117 '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'] : '', 114 119 'campaigns_tag' => isset( $feed['campaigns_tag'] ) ? array_values( array_filter( array_map( 'trim', (array) $feed['campaigns_tag'] ) ) ) : array(), 115 120 'campaigns_topics' => isset( $feed['campaigns_topics'] ) && is_array( $feed['campaigns_topics'] ) ? $feed['campaigns_topics'] : array(), 116 121 ), 117 122 '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 ), 118 150 ); 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; 119 178 } 120 179 -
techwave-form-sync-for-zoho/trunk/includes/Admin/SettingsPage.php
r3649280 r3748247 11 11 use Form2Zoho\Logging\Logger; 12 12 use Form2Zoho\Support\Assets; 13 use Form2Zoho\Support\Branding; 13 14 use Form2Zoho\Support\DataCenters; 14 15 use Form2Zoho\Support\Encryption; 15 16 use Form2Zoho\Support\Markup; 16 17 use Form2Zoho\Support\Settings; 18 use Form2Zoho\Support\VideoGuide; 19 use Form2Zoho\Integrations\IntegrationRegistry; 20 use Form2Zoho\Core\Plugin; 17 21 18 22 defined( 'ABSPATH' ) || exit; … … 25 29 final class SettingsPage { 26 30 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'; 29 40 30 41 /** … … 76 87 add_action( 'admin_post_form2zoho_clear_logs', array( $this, 'handle_clear_logs' ) ); 77 88 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' ) ); 78 91 } 79 92 … … 84 97 */ 85 98 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; 98 176 } 99 177 … … 144 222 'nonce' => wp_create_nonce( 'form2zoho_ajax' ), 145 223 'integrations' => $integrations, 224 'consoleUrls' => DataCenters::console_urls(), 146 225 'i18n' => array( 147 226 'pickInt' => __( 'Select a Zoho service', 'techwave-form-sync-for-zoho' ), … … 153 232 'label' => __( 'Label', 'techwave-form-sync-for-zoho' ), 154 233 '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' ), 156 235 'noResults' => __( 'Nothing returned.', 'techwave-form-sync-for-zoho' ), 157 236 'sendingTest' => __( 'Sending…', 'techwave-form-sync-for-zoho' ), … … 173 252 } 174 253 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 } 176 259 177 260 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 ); 181 262 echo '<div class="form2zoho-settings-pop" id="form2zoho-tab-body">'; 182 263 $this->render_tab_body( $tab ); … … 206 287 $this->oauth_notice(); 207 288 $this->action_notice(); 208 $this->tabs( $tab );209 289 210 290 if ( 'logs' === $tab ) { … … 212 292 } elseif ( 'settings' === $tab ) { 213 293 $this->render_settings(); 294 } elseif ( 'connections' === $tab ) { 295 $this->render_connections(); 296 } elseif ( 'support' === $tab ) { 297 $this->render_support(); 214 298 } 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 ); 216 312 } 217 313 } … … 234 330 check_ajax_referer( 'form2zoho_ajax', 'nonce' ); 235 331 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 ] ) ) { 238 335 $tab = 'connections'; 239 336 } … … 262 359 263 360 /** 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() { 270 381 $tabs = array( 271 382 'connections' => __( 'Connections', 'techwave-form-sync-for-zoho' ), 272 383 'logs' => __( 'Logs', 'techwave-form-sync-for-zoho' ), 273 384 '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; 292 404 } 293 405 … … 418 530 $this->render_alert( 419 531 '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' ), 421 533 __( 'Cannot store credentials securely', 'techwave-form-sync-for-zoho' ) 422 534 ); … … 495 607 echo '<div class="form2zoho-modal-head">'; 496 608 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>'; 498 626 echo '</div>'; 499 627 … … 514 642 echo '<code title="' . esc_attr( $redirect ) . '">' . esc_html( $redirect ) . '</code>'; 515 643 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>'; 517 649 echo '</button>'; 518 650 echo '</span>'; … … 525 657 echo '<tr><th scope="row">' . esc_html__( 'Authorize for', 'techwave-form-sync-for-zoho' ) . '</th><td>'; 526 658 // Same grid as the Edit access drawer, so the two app pickers match. 527 // Still a <p>: admin.js scopes the checkbox interlock with528 // 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. 529 661 echo '<p class="form2zoho-apps-grid">'; 530 662 // Nothing is pre-selected, and "All applications" in particular is not … … 542 674 } 543 675 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>'; 545 677 echo '</td></tr>'; 546 678 … … 555 687 submit_button( __( 'Connect with Zoho', 'techwave-form-sync-for-zoho' ), 'primary', 'submit', false ); 556 688 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 ); 557 715 echo '</div>'; 558 716 echo '</form>'; … … 562 720 563 721 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 ); 564 746 } 565 747 … … 627 809 echo '</button>'; 628 810 } 811 /* This link is documented in FeedsPage's own zero state. */ 812 VideoGuide::render( 'setup', __( 'Watch the full setup', 'techwave-form-sync-for-zoho' ) ); 629 813 echo '</div>'; 630 814 return; … … 634 818 echo '<table class="form2zoho-conn-table"><thead><tr>'; 635 819 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>'; 638 822 echo '<th scope="col" class="form2zoho-col-actions">' . esc_html__( 'Actions', 'techwave-form-sync-for-zoho' ) . '</th>'; 639 823 echo '</tr></thead><tbody>'; … … 693 877 echo '</td>'; 694 878 695 echo '<td >';879 echo '<td class="form2zoho-col-center">'; 696 880 echo '<div class="form2zoho-authorized-cell">'; 697 881 echo '<div class="form2zoho-authorized-pills">'; … … 701 885 echo '<span class="form2zoho-pill form2zoho-pill-all">' . esc_html__( 'All applications', 'techwave-form-sync-for-zoho' ) . '</span>'; 702 886 } else { 887 $names = array(); 703 888 foreach ( $conn_apps as $app ) { 704 889 if ( isset( $app_labels[ $app ] ) ) { 705 echo '<span class="form2zoho-pill">' . esc_html( $app_labels[ $app ] ) . '</span>';890 $names[] = $app_labels[ $app ]; 706 891 } 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>'; 707 916 } 708 917 } … … 711 920 echo '</div>'; 712 921 echo '</td>'; 713 echo '<td >';922 echo '<td class="form2zoho-col-center">'; 714 923 echo '<div class="form2zoho-conn-status-cell">'; 715 924 if ( $connected ) { … … 725 934 echo '</a>'; 726 935 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'] ) ) { 728 941 echo '<div class="form2zoho-conn-warn">'; 729 942 echo '<span class="form2zoho-pill form2zoho-pill-warn">' . esc_html__( 'Bigin org lookup failed', 'techwave-form-sync-for-zoho' ) . '</span>'; … … 756 969 echo '<input type="hidden" name="conn" value="' . esc_attr( $id ) . '" />'; 757 970 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. 761 973 echo '<p class="form2zoho-apps-grid">'; 762 974 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. 763 980 foreach ( $app_labels as $app_key => $app_label ) { 764 981 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>'; … … 796 1013 echo '<div class="form2zoho-modal-head">'; 797 1014 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>'; 799 1016 echo '</div>'; 800 1017 echo '<div class="form2zoho-modal-scroll"><div class="form2zoho-fields-out"></div></div>'; … … 809 1026 */ 810 1027 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(); 813 1036 814 1037 $has_rows = ! empty( $rows ); … … 856 1079 echo '</span>'; 857 1080 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>'; 859 1082 echo '</div>'; 860 1083 … … 865 1088 echo '<table class="widefat striped form2zoho-logs" id="form2zoho-logs-table" data-after-id="' . esc_attr( (string) $last_id ) . '" ' . ( empty( $rows ) ? 'hidden' : '' ) . '><thead><tr>'; 866 1089 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>'; 868 1091 echo '<th scope="col">' . esc_html__( 'Status', 'techwave-form-sync-for-zoho' ) . '</th>'; 869 1092 echo '<th scope="col">' . esc_html__( 'App', 'techwave-form-sync-for-zoho' ) . '</th>'; … … 874 1097 echo '<th scope="col"><span class="screen-reader-text">' . esc_html__( 'Details', 'techwave-form-sync-for-zoho' ) . '</span></th>'; 875 1098 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 ) { 877 1118 // Human-readable app names for the "App" column and the copyable 878 1119 // JSON detail — the raw integration id (e.g. "automation") is … … 886 1127 'automation' => __( 'Zoho Marketing Automation', 'techwave-form-sync-for-zoho' ), 887 1128 ); 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 } 888 1149 889 1150 // Map feed id => [module, module_label] once, so each row below can … … 923 1184 } elseif ( 'suppressed' === $status ) { 924 1185 $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>'; 925 1189 } else { 926 1190 $pill = '<span class="form2zoho-pill form2zoho-pill-bad">' . esc_html__( 'Error', 'techwave-form-sync-for-zoho' ) . '</span>'; 927 1191 } 928 1192 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; 930 1194 $pretty_data['app'] = isset( $app_labels[ $row->integration ] ) ? $app_labels[ $row->integration ] : $row->integration; 931 1195 if ( '' !== (string) $row->form_id ) { … … 938 1202 echo '<tr class="form2zoho-log-row" data-row-id="' . esc_attr( (string) $row->id ) . '">'; 939 1203 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>'; 941 1206 echo '<td>' . wp_kses( $pill, Markup::pill() ) . '</td>'; 942 1207 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>'; 947 1212 echo '<td><button type="button" class="button-link form2zoho-log-toggle">' . esc_html__( 'Details', 'techwave-form-sync-for-zoho' ) . '</button></td>'; 948 1213 echo '</tr>'; … … 958 1223 echo '</td></tr>'; 959 1224 } 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>‹ %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 ›</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 961 1500 echo '</div>'; 962 1501 } … … 992 1531 'shield', 993 1532 __( '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' ) 995 1534 ); 996 1535 … … 999 1538 'trash', 1000 1539 __( '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' ), 1002 1541 '<label class="screen-reader-text" for="form2zoho_log_retention_days">' . esc_html__( 'Days to keep log entries', 'techwave-form-sync-for-zoho' ) . '</label>' 1003 1542 . '<span class="form2zoho-unitfield">' … … 1039 1578 printf( 1040 1579 /* 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' ), 1042 1581 esc_html( (string) get_option( 'admin_email' ) ) 1043 1582 ); … … 1061 1600 'trash', 1062 1601 __( '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' ) 1064 1603 ); 1065 1604 $this->settings_section_close(); … … 1078 1617 printf( 1079 1618 /* translators: %s: version number, e.g. 1.0.0. */ 1080 esc_html__( ' Techwave Form Syncfor Zoho version %s', 'techwave-form-sync-for-zoho' ),1619 esc_html__( 'FormBridge for Zoho version %s', 'techwave-form-sync-for-zoho' ), 1081 1620 esc_html( defined( 'FORM2ZOHO_VERSION' ) ? FORM2ZOHO_VERSION : '' ) 1082 1621 ); -
techwave-form-sync-for-zoho/trunk/includes/Api/AbstractClient.php
r3649280 r3748247 43 43 */ 44 44 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 = ''; 45 53 46 54 /** … … 93 101 94 102 /** 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 /** 95 337 * Perform an authenticated JSON request with one automatic retry on 401. 96 338 * … … 105 347 // Retry once if the token was rejected — covers tokens that expired mid-flight. 106 348 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 ); 108 350 $result = $this->dispatch( $method, $path, $args ); 109 351 } 110 352 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 111 367 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_' ); 112 483 } 113 484 … … 128 499 return $token; 129 500 } 501 $this->last_token = $token; 130 502 131 503 $url = $this->connections->api_base( $this->connection_id, $this->service() ) . $path; … … 144 516 $request = array( 145 517 '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(), 163 519 'headers' => $headers, 164 520 ); … … 179 535 180 536 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 ) ) ); 181 541 return $response; 182 542 } 183 543 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(); 188 564 189 565 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 ); 191 576 } 192 577 -
techwave-form-sync-for-zoho/trunk/includes/Api/BiginClient.php
r3649280 r3748247 68 68 $out = array(); 69 69 foreach ( (array) ( $res['fields'] ?? array() ) as $field ) { 70 if ( ! empty( $field['read_only'] ) ) {70 if ( ! empty( $field['read_only'] ) || self::is_unfillable_field( $field ) ) { 71 71 continue; 72 72 } … … 75 75 'label' => (string) $field['field_label'], 76 76 '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'] ), 77 89 ); 78 90 } … … 114 126 115 127 /** 116 * Create a record .128 * Create a record, or update the one it duplicates. 117 129 * 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. 121 135 * @return array|WP_Error 122 136 */ 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 125 147 return $this->request( 126 148 'POST', 127 149 '/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 ) 134 151 ); 135 152 } … … 138 155 'POST', 139 156 '/bigin/v2/' . rawurlencode( $module ), 140 array( 'body' => array( 'data' => array( $record )) )157 array( 'body' => self::with_extras( array( 'data' => array( $record ) ), $extras ) ) 141 158 ); 142 159 } 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 } 143 178 } -
techwave-form-sync-for-zoho/trunk/includes/Api/BooksClient.php
r3649280 r3748247 23 23 24 24 /** 25 * Whether this lookup must skip the cached settings (a reload button). 26 * 27 * @var bool 28 */ 29 private $skip_cache = false; 30 31 /** 25 32 * {@inheritDoc} 26 33 */ … … 53 60 } 54 61 if ( '' !== $plan ) { 55 $name .= ' — ' . $plan;62 $name .= ' (' . $plan . ')'; 56 63 } 57 64 … … 72 79 * form usually cannot supply. 73 80 * 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. 74 85 * @return array<int,array> 75 86 */ 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() { 77 278 return array( 78 279 array( … … 115 316 * 116 317 * @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. 117 321 * @return array<int,array> 118 322 */ 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( 121 481 'contacts' => array( 122 482 'contact_name' => true, … … 172 532 ), 173 533 ); 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 ) { 175 544 $fields = isset( $map[ $resource_key ] ) ? $map[ $resource_key ] : array(); 176 545 … … 203 572 } 204 573 574 $record = $this->pack_custom_fields( $record, $org_id ); 575 205 576 return $this->request( 206 577 'POST', … … 211 582 ) 212 583 ); 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; 213 683 } 214 684 -
techwave-form-sync-for-zoho/trunk/includes/Api/CampaignsClient.php
r3649280 r3748247 24 24 final class CampaignsClient extends AbstractClient { 25 25 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 26 35 /** 27 36 * {@inheritDoc} … … 44 53 */ 45 54 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 62 55 $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 ) 71 73 ); 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 ); 74 99 } 75 100 … … 86 111 */ 87 112 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 ), 98 129 ), 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 ) 116 131 ); 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 118 160 return $out; 119 161 } … … 449 491 'resfmt' => 'JSON', 450 492 'listkey' => $listkey, 451 'contactinfo' => $this->pseudo_json( $contactinfo ),452 'source' => ' Techwave Form Syncfor Zoho',493 'contactinfo' => '', 494 'source' => 'FormBridge for Zoho', 453 495 ); 454 496 if ( '' !== $topic_id ) { 455 497 $query['topic_id'] = $topic_id; 456 498 } 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 ); 458 526 } 459 527 … … 471 539 'resfmt' => 'JSON', 472 540 'listkey' => $listkey, 473 'contactinfo' => $this->pseudo_json( $contactinfo ),541 'contactinfo' => '', 474 542 ); 475 543 if ( '' !== $topic_id ) { 476 544 $query['topic_id'] = $topic_id; 477 545 } 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 ) ); 479 640 } 480 641 } -
techwave-form-sync-for-zoho/trunk/includes/Api/CrmClient.php
r3649280 r3748247 50 50 51 51 /** 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 /** 52 92 * List writable fields for a module. 53 93 * … … 63 103 $out = array(); 64 104 foreach ( (array) ( $res['fields'] ?? array() ) as $field ) { 65 if ( ! empty( $field['read_only'] ) ) {105 if ( ! empty( $field['read_only'] ) || self::is_unfillable_field( $field ) ) { 66 106 continue; 67 107 } … … 70 110 'label' => (string) $field['field_label'], 71 111 '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'] ), 72 124 ); 73 125 } … … 76 128 77 129 /** 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. 83 137 * @return array|WP_Error 84 138 */ 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 87 150 // Upsert merges into an existing record when a duplicate is found. 88 151 return $this->request( 89 152 'POST', 90 153 '/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 ) 97 155 ); 98 156 } … … 101 159 'POST', 102 160 '/crm/v6/' . rawurlencode( $module ), 103 array( 'body' => array( 'data' => array( $record )) )161 array( 'body' => self::with_extras( array( 'data' => array( $record ) ), $extras, false ) ) 104 162 ); 105 163 } 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 } 106 212 } -
techwave-form-sync-for-zoho/trunk/includes/Api/DeskClient.php
r3649280 r3748247 122 122 * @return array<int,array> 123 123 */ 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 ) { 125 192 switch ( $module ) { 126 193 case 'tickets': … … 244 311 * @return array|WP_Error 245 312 */ 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() ) { 247 314 if ( '' === (string) $org_id ) { 248 315 return new WP_Error( 'form2zoho_desk_no_org', __( 'Select a Zoho Desk organization for this feed.', 'techwave-form-sync-for-zoho' ) ); … … 254 321 return $record; 255 322 } 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 ); 257 336 258 337 $result = $this->request( … … 285 364 * @return array|WP_Error 286 365 */ 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 287 398 private function prepare_ticket( array $record, $department_id ) { 288 399 if ( '' === $department_id ) { -
techwave-form-sync-for-zoho/trunk/includes/Api/MarketingAutomationClient.php
r3649280 r3748247 21 21 final class MarketingAutomationClient extends AbstractClient { 22 22 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 23 32 /** 24 33 * {@inheritDoc} … … 37 46 */ 38 47 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 55 48 $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 ) 64 66 ); 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 ); 67 92 } 68 93 … … 76 101 */ 77 102 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 ), 88 119 ), 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 ) 106 121 ); 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 108 150 return $out; 109 151 } … … 328 370 'resfmt' => 'JSON', 329 371 'listkey' => $listkey, 330 'leadinfo' => $this->pseudo_json( $leadinfo ),331 'sources' => ' Techwave Form Syncfor Zoho',372 'leadinfo' => '', 373 'sources' => 'FormBridge for Zoho', 332 374 ); 333 375 if ( '' !== $topic_id ) { 334 376 $query['topic_id'] = $topic_id; 335 377 } 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 ) ); 337 404 } 338 405 … … 349 416 'resfmt' => 'JSON', 350 417 'listkey' => $listkey, 351 'leadinfo' => $this->pseudo_json( $leadinfo ),418 'leadinfo' => '', 352 419 ); 353 420 if ( '' !== $topic_id ) { 354 421 $query['topic_id'] = $topic_id; 355 422 } 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 ) ); 357 517 } 358 518 } -
techwave-form-sync-for-zoho/trunk/includes/Auth/ConnectionManager.php
r3649280 r3748247 12 12 use Form2Zoho\Api\BiginClient; 13 13 use Form2Zoho\Logging\Logger; 14 use Form2Zoho\Admin\SettingsPage; 14 15 use WP_Error; 15 16 … … 50 51 // fields. settings.ALL would also grant write access to the account's 51 52 // 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', 53 64 // One CREATE scope per resource BooksClient::modules() offers, plus 54 65 // settings for Items (a Books "settings" resource) and the … … 82 93 83 94 /** 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 /** 84 149 * Encryption helper. 85 150 * … … 96 161 97 162 /** 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 /** 98 182 * The fixed redirect URI registered in the Zoho console. 99 183 * … … 101 185 */ 102 186 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 ); 104 188 } 105 189 … … 110 194 * a connection is expected to name the app(s) it will actually send to. 111 195 * 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 * 112 199 * @return array<string,string> 113 200 */ 114 201 public function app_choices() { 115 returnarray(202 $choices = array( 116 203 'all' => __( 'All applications', 'techwave-form-sync-for-zoho' ), 117 204 'crm' => __( 'Zoho CRM', 'techwave-form-sync-for-zoho' ), … … 122 209 'automation' => __( 'Zoho Marketing Automation', 'techwave-form-sync-for-zoho' ), 123 210 ); 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; 124 230 } 125 231 … … 132 238 private function scopes_for( array $apps ) { 133 239 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(); 137 244 $scopes = array( self::BASE_SCOPE ); 138 245 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 ]; 141 248 } 142 249 } … … 165 272 } 166 273 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(); 167 308 $clean = array(); 309 168 310 foreach ( $apps as $app ) { 169 311 $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 ] ) ) { 171 318 $clean[] = $app; 172 319 } 173 320 } 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 ) ); 181 323 } 182 324 … … 216 358 217 359 /** 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 /** 218 389 * Persist a new draft connection before the OAuth dance starts. 219 390 * … … 225 396 return new WP_Error( 226 397 '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' ) 228 399 ); 229 400 } … … 253 424 } 254 425 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 ); 264 429 // Nothing picked is an error, not a licence to request everything. 265 430 // This used to fall back to "all", which meant an untouched picker … … 268 433 return new WP_Error( 'form2zoho_no_apps', __( 'Choose at least one Zoho app for this connection.', 'techwave-form-sync-for-zoho' ) ); 269 434 } 270 $apps = array_values( array_unique( $apps ) );271 272 435 $id = 'conn_' . wp_generate_password( 12, false, false ); 273 436 $all = $this->all(); … … 301 464 } 302 465 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 ); 305 481 306 482 // Older connections saved before the app picker existed have no … … 331 507 $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; 332 508 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 ) { 335 511 $this->redirect_back( 'state' ); 336 512 } 337 513 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 } 338 530 339 531 $conn = $this->get( $conn_id ); … … 384 576 $apps = isset( $conn['apps'] ) && is_array( $conn['apps'] ) ? $conn['apps'] : array(); 385 577 if ( ! in_array( 'bigin', $apps, true ) && ! in_array( 'all', $apps, true ) ) { 578 $this->forget_bigin_org( $id ); 386 579 return; 387 580 } 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 ); 388 585 389 586 $client = new BiginClient( $this, new Logger(), $id ); … … 408 605 409 606 /** 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 /** 410 624 * Store the resolved Bigin org domain segment on a connection. 411 625 * … … 487 701 } 488 702 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 ) { 489 843 $conn = $this->get( $id ); 490 844 if ( null === $conn || empty( $conn['refresh_token'] ) ) { … … 559 913 update_option( self::OPTION, $all, false ); 560 914 delete_transient( 'form2zoho_at_' . $id ); 915 $this->unlock_refresh( $id ); 561 916 562 917 if ( null !== $conn ) { … … 586 941 add_query_arg( 587 942 array( 588 'page' => 'form2zoho',943 'page' => SettingsPage::SLUG, 589 944 'f2z_oauth' => rawurlencode( $status ), 590 945 ), -
techwave-form-sync-for-zoho/trunk/includes/Auth/OAuthClient.php
r3649280 r3748247 126 126 127 127 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 ) ) ); 128 130 return $response; 129 131 } -
techwave-form-sync-for-zoho/trunk/includes/ContactForm7/SubmissionHandler.php
r3649280 r3748247 87 87 $values = $this->collect_values(); 88 88 89 $this->deliver_to_feeds( $form_name, $values );89 $this->deliver_to_feeds( $form_name, $values, $this->collect_files() ); 90 90 } 91 91 … … 108 108 return is_array( $data ) ? $data : array(); 109 109 } 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 } 110 155 } -
techwave-form-sync-for-zoho/trunk/includes/Core/Activator.php
r3649280 r3748247 27 27 */ 28 28 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'; 29 35 30 36 /** … … 84 90 */ 85 91 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 86 96 self::create_log_table(); 87 97 … … 156 166 } 157 167 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 158 178 self::create_log_table(); 159 179 self::ensure_secret(); -
techwave-form-sync-for-zoho/trunk/includes/Core/Deactivator.php
r3649280 r3748247 23 23 */ 24 24 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 ); 31 33 32 34 // Cancel Action Scheduler jobs if that library is active. … … 34 36 as_unschedule_all_actions( Dispatcher::HOOK_PROCESS, array(), 'form2zoho' ); 35 37 } 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 }43 38 } 44 39 } -
techwave-form-sync-for-zoho/trunk/includes/Core/Plugin.php
r3649280 r3748247 9 9 10 10 use Form2Zoho\Admin\Ajax; 11 use Form2Zoho\Admin\DashboardPage; 11 12 use Form2Zoho\Admin\FeedsPage; 13 use Form2Zoho\Admin\NoticeGuard; 14 use Form2Zoho\Admin\LegacyPageSlugs; 12 15 use Form2Zoho\Admin\FeedsRepository; 16 use Form2Zoho\Admin\ProUpsell; 13 17 use Form2Zoho\Admin\SettingsPage; 14 18 use Form2Zoho\Admin\DeactivationFeedback; … … 184 188 $ajax->register(); 185 189 $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(); 186 192 // Optional "why are you deactivating?" prompt on the Plugins 187 193 // screen. Hooks nothing outside plugins.php and its own endpoint, 188 194 // and sends nothing unless the person presses the send button. 189 195 ( 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(); 190 206 $this->services['settings'] = $settings; 191 207 $this->services['ajax'] = $ajax; … … 194 210 195 211 // 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' ) ); 197 226 198 227 // Privacy exporter / eraser registration. … … 237 266 $this->services['sureforms_submissions'] = new SureFormsSubmissionHandler( $this->services['feeds'], $this->services['dispatcher'], $this->services['logger'] ); 238 267 $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 ); 239 292 } 240 293 … … 245 298 */ 246 299 public function prune_logs() { 247 // Gated by the "Delete log entries automatically" toggle, which is o n248 // by default : log rows can carry submitted field values, so keeping249 // 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. 250 303 if ( ! Settings::is_on( 'form2zoho_log_retention_enabled' ) ) { 251 304 return; … … 295 348 $lines[] = ''; 296 349 /* 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' ) ); 298 351 299 352 // Drop the lines that were conditionally left empty above. 'strlen' … … 358 411 $lines[] = ''; 359 412 /* 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' ) ); 361 414 362 415 // Drop the lines that were conditionally left empty above. 'strlen' … … 404 457 $lines[] = ''; 405 458 /* 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' ) ); 407 460 408 461 // Drop the lines that were conditionally left empty above. 'strlen' … … 457 510 * @param string $subject Subject line. 458 511 * @param string $body Plain-text body. 459 * @return bool Whether wp_mail()accepted the message.512 * @return bool Whether the mailer accepted the message. 460 513 */ 461 514 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 ) ) { 464 519 $this->services['logger']->warning( 465 520 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() 469 525 ), 470 526 array( 'status' => 'suppressed' ) 471 527 ); 472 } 473 return (bool) $sent; 528 529 return false; 530 } 531 532 return true; 474 533 } 475 534 } -
techwave-form-sync-for-zoho/trunk/includes/Elementor/SubmissionHandler.php
r3649280 r3748247 88 88 $values = $this->collect_values( $record ); 89 89 90 $this->deliver_to_feeds( $form_name, $values );90 $this->deliver_to_feeds( $form_name, $values, $this->collect_files( $record ) ); 91 91 } 92 92 … … 105 105 return $out; 106 106 } 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 } 107 131 } -
techwave-form-sync-for-zoho/trunk/includes/Forminator/SubmissionHandler.php
r3649280 r3748247 20 20 21 21 use Form2Zoho\Support\BaseSubmissionHandler; 22 use Form2Zoho\Support\UploadedFiles; 22 23 23 24 defined( 'ABSPATH' ) || exit; … … 47 48 */ 48 49 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(); 49 58 50 59 /** … … 61 70 */ 62 71 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 63 78 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 );65 79 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 } 66 143 } 67 144 … … 115 192 116 193 $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 ); 118 203 119 204 $this->deliver_to_feeds( $form_name, $values ); … … 159 244 160 245 /** 246 * Whether a posted key is shaped like one of Forminator's own field ids. 247 * 248 * Forminator names every field "<type>-<n>" — 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 /** 161 275 * Read back the field values for the entry that was just saved, straight 162 276 * from Forminator's own entry meta. … … 177 291 } else { 178 292 // 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 ); 191 298 } 192 299 193 300 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; 199 310 } 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 ); 200 322 } 201 323 } … … 232 354 233 355 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 ) ) { 235 357 $out[ $key ] = sanitize_text_field( $value ); 236 358 } … … 239 361 240 362 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; 241 392 } 242 393 -
techwave-form-sync-for-zoho/trunk/includes/Integrations/AbstractIntegration.php
r3649280 r3748247 11 11 use Form2Zoho\Logging\Logger; 12 12 use Form2Zoho\Support\FieldMapper; 13 use Form2Zoho\Support\Settings; 13 14 14 15 defined( 'ABSPATH' ) || exit; … … 47 48 /** 48 49 * 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). 49 76 * 50 77 * @return bool … … 104 131 } 105 132 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']; 106 204 } 107 205 -
techwave-form-sync-for-zoho/trunk/includes/Integrations/BiginIntegration.php
r3649280 r3748247 98 98 } 99 99 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'] ) ) ) ); 103 106 } 104 107 105 108 $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 ) ); 107 110 108 111 $outcome = $this->interpret( $result ); -
techwave-form-sync-for-zoho/trunk/includes/Integrations/BooksIntegration.php
r3649280 r3748247 61 61 * fetched, so it cannot fail — hence no WP_Error in the return type. 62 62 * 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. 64 65 * @return array<int,array> 65 66 */ 66 67 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'] ) ); 68 74 } 69 75 … … 79 85 return new WP_Error( 'form2zoho_no_module', __( 'Choose a Books resource first.', 'techwave-form-sync-for-zoho' ) ); 80 86 } 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'] ) ); 82 91 } 83 92 -
techwave-form-sync-for-zoho/trunk/includes/Integrations/CampaignsIntegration.php
r3649280 r3748247 163 163 $subscription = isset( $settings['campaigns_subscription_type'] ) ? strtolower( trim( (string) $settings['campaigns_subscription_type'] ) ) : ''; 164 164 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 165 195 $client = $this->client( $settings ); 166 196 … … 170 200 $result = $client->unsubscribe( $listkey, $record, $topic_id ); 171 201 } 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 172 237 $result = $client->subscribe( $listkey, $record, $topic_id ); 173 238 } … … 184 249 $email = $this->find_email( $record ); 185 250 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' ); 187 278 } 188 279 … … 191 282 // exists. Best-effort: a tag that fails to associate (e.g. it was 192 283 // 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. 194 291 $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 ) { 196 293 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 } 198 310 } 199 311 } … … 223 335 return $result; 224 336 } 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 ) ) { 227 348 // No id here on purpose: the subscribe response has no record 228 349 // id, and the listkey it echoes back is the mailing list's … … 237 358 'id' => '', 238 359 'action' => 'created', 239 'zoho_message' => isset( $ result['message'] ) ? (string) $result['message'] : '',360 'zoho_message' => isset( $body['message'] ) ? (string) $body['message'] : '', 240 361 ); 241 362 } 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 243 379 return new WP_Error( 'form2zoho_campaigns_rejected', $message, $result ); 244 380 } … … 256 392 */ 257 393 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 258 419 foreach ( $record as $key => $value ) { 259 if ( false !== stripos( (string) $key, 'email' ) && '' !== trim( (string) $value) ) {260 returntrim( (string) $value );420 if ( is_string( $key ) && ! is_array( $value ) && false !== stripos( $key, 'email' ) ) { 421 $candidates[ $key ] = trim( (string) $value ); 261 422 } 262 423 } 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' => '' ); 264 443 } 265 444 } -
techwave-form-sync-for-zoho/trunk/includes/Integrations/CrmIntegration.php
r3649280 r3748247 89 89 } 90 90 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'] ) ) ) ); 94 97 } 95 98 96 99 $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 ) ); 98 104 99 105 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; 100 211 } 101 212 -
techwave-form-sync-for-zoho/trunk/includes/Integrations/DeskIntegration.php
r3649280 r3748247 90 90 return new WP_Error( 'form2zoho_no_module', __( 'Choose a Desk module first.', 'techwave-form-sync-for-zoho' ) ); 91 91 } 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 ); 93 99 } 94 100 … … 125 131 } 126 132 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 ) ); 128 134 return $this->interpret( $result, $module ); 129 135 } -
techwave-form-sync-for-zoho/trunk/includes/Integrations/MarketingAutomationIntegration.php
r3649280 r3748247 139 139 $subscription = isset( $settings['campaigns_subscription_type'] ) ? strtolower( trim( (string) $settings['campaigns_subscription_type'] ) ) : ''; 140 140 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 141 171 $client = $this->client( $settings ); 142 172 … … 144 174 $result = $client->unsubscribe( $listkey, $record, $topic_id ); 145 175 } 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 146 211 $result = $client->subscribe( $listkey, $record, $topic_id ); 147 212 } … … 157 222 $email = $this->find_email( $record ); 158 223 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' ); 160 251 } 161 252 … … 193 284 return $result; 194 285 } 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 ) ) { 197 297 // No id here on purpose — see CampaignsIntegration::interpret(): 198 298 // send() fills in the lead's email as the record identity. … … 206 306 'id' => '', 207 307 'action' => 'created', 208 'zoho_message' => isset( $ result['message'] ) ? (string) $result['message'] : '',308 'zoho_message' => isset( $body['message'] ) ? (string) $body['message'] : '', 209 309 ); 210 310 } 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 212 327 return new WP_Error( 'form2zoho_automation_rejected', $message, $result ); 213 328 } … … 223 338 */ 224 339 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 225 365 foreach ( $record as $key => $value ) { 226 if ( false !== stripos( (string) $key, 'email' ) && '' !== trim( (string) $value) ) {227 returntrim( (string) $value );366 if ( is_string( $key ) && ! is_array( $value ) && false !== stripos( $key, 'email' ) ) { 367 $candidates[ $key ] = trim( (string) $value ); 228 368 } 229 369 } 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' => '' ); 231 389 } 232 390 } -
techwave-form-sync-for-zoho/trunk/includes/Logging/Logger.php
r3649280 r3748247 43 43 * list, because redact() walks the whole context — including form_values. 44 44 */ 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 45 57 private const REDACT_KEYS = array( 46 58 'access_token', … … 143 155 } 144 156 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 145 164 $row = array( 146 165 'created_at' => current_time( 'mysql', true ), … … 149 168 'form_id' => substr( (string) $form_id, 0, 64 ), 150 169 '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 ), 153 172 ); 154 173 155 174 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom log table. 156 175 $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; 157 194 } 158 195 … … 200 237 */ 201 238 public static function redact( $data ) { 239 if ( is_string( $data ) ) { 240 return self::scrub( $data ); 241 } 242 202 243 if ( ! is_array( $data ) ) { 203 244 return $data; … … 209 250 continue; 210 251 } 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; 212 257 } 213 258 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 ); 214 287 } 215 288 … … 240 313 241 314 /** 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 /** 242 394 * Fetch log rows newer than a given id, oldest first — used to poll for 243 395 * "live" updates on the admin Logs screen without a full page reload. … … 275 427 $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. 276 428 ); 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 277 503 } 278 504 -
techwave-form-sync-for-zoho/trunk/includes/NinjaForms/SubmissionHandler.php
r3649280 r3748247 49 49 */ 50 50 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 ) { 51 73 if ( ! is_array( $form_data ) ) { 52 74 return; -
techwave-form-sync-for-zoho/trunk/includes/Privacy/PrivacyProvider.php
r3649280 r3748247 59 59 60 60 $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>'; 62 62 $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>'; 63 63 64 64 wp_add_privacy_policy_content( 65 __( ' Techwave Form Syncfor Zoho', 'techwave-form-sync-for-zoho' ),65 __( 'FormBridge for Zoho', 'techwave-form-sync-for-zoho' ), 66 66 wp_kses_post( $content ) 67 67 ); … … 76 76 public function register_exporter( $exporters ) { 77 77 $exporters[ self::SLUG ] = array( 78 'exporter_friendly_name' => __( ' Techwave Form Syncfor Zoho delivery logs', 'techwave-form-sync-for-zoho' ),78 'exporter_friendly_name' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ), 79 79 'callback' => array( $this, 'export' ), 80 80 ); … … 90 90 public function register_eraser( $erasers ) { 91 91 $erasers[ self::SLUG ] = array( 92 'eraser_friendly_name' => __( ' Techwave Form Syncfor Zoho delivery logs', 'techwave-form-sync-for-zoho' ),92 'eraser_friendly_name' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ), 93 93 'callback' => array( $this, 'erase' ), 94 94 ); … … 181 181 $items[] = array( 182 182 'group_id' => 'form2zoho-logs', 183 'group_label' => __( ' Techwave Form Syncfor Zoho delivery logs', 'techwave-form-sync-for-zoho' ),183 'group_label' => __( 'FormBridge for Zoho delivery logs', 'techwave-form-sync-for-zoho' ), 184 184 'item_id' => 'log-' . (int) $row['id'], 185 185 'data' => $data, -
techwave-form-sync-for-zoho/trunk/includes/Queue/Dispatcher.php
r3649280 r3748247 78 78 79 79 /** 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 /** 80 100 * Deliver a job to Zoho now. 81 101 * … … 114 134 'Unknown integration in queue.', 115 135 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() ), 118 142 ), 119 143 (string) $job['integration'], … … 127 151 $attempt = isset( $job['attempt'] ) ? (int) $job['attempt'] : 1; 128 152 $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'] ?? '' ); 129 200 130 201 $result = $integration->send( $settings, $form_values ); … … 156 227 } 157 228 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 ) { 159 241 $message = '' !== $record_id 160 242 /* translators: %s: Zoho record id. */ … … 172 254 // Marketing Automation pending-confirmation subscribe visible in 173 255 // 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 ) ) ) { 175 261 $message .= ' ' . $zoho_message; 176 262 } … … 213 299 $feed_id 214 300 ); 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 ); 215 319 return; 216 320 } … … 257 361 */ 258 362 do_action( 'form2zoho_submission_failed', $job, $raw_message ); 363 self::drop_kept_files( $job ); 259 364 return; 260 365 } … … 270 375 // still shows the attempt count it took. 271 376 $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 272 452 } 273 453 … … 345 525 private function schedule( array $job, $delay ) { 346 526 $when = time() + max( 0, (int) $delay ); 527 $job = self::keep_files( $job ); 347 528 $handle = $this->stash( $job ); 348 529 -
techwave-form-sync-for-zoho/trunk/includes/Support/BaseSubmissionHandler.php
r3649280 r3748247 89 89 * @return int Number of feeds delivered to. 90 90 */ 91 protected function deliver_to_feeds( $form_name, array $values ) {91 protected function deliver_to_feeds( $form_name, array $values, array $files = array() ) { 92 92 $form_name = (string) $form_name; 93 93 $queued = 0; … … 97 97 $connection_id = isset( $feed['connection_id'] ) ? (string) $feed['connection_id'] : ''; 98 98 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 ) ) ) { 100 105 $this->logger->error( 101 106 __( 'Feed is enabled but has no Zoho app or connected account selected.', 'techwave-form-sync-for-zoho' ), … … 108 113 isset( $feed['id'] ) ? (string) $feed['id'] : '' 109 114 ); 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 ); 110 128 continue; 111 129 } 112 130 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 ) ); 114 151 ++$queued; 115 152 } -
techwave-form-sync-for-zoho/trunk/includes/Support/ContactForm7Forms.php
r3649280 r3748247 100 100 'label' => self::humanize( $field_name ), 101 101 ); 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 } 102 108 } 103 109 -
techwave-form-sync-for-zoho/trunk/includes/Support/DataCenters.php
r3649280 r3748247 31 31 'label' => 'United States (.com)', 32 32 'accounts' => 'https://accounts.zoho.com', 33 'console' => 'https://api-console.zoho.com', 33 34 'crm' => 'https://www.zohoapis.com', 34 35 'books' => 'https://www.zohoapis.com', … … 41 42 'label' => 'Europe (.eu)', 42 43 'accounts' => 'https://accounts.zoho.eu', 44 'console' => 'https://api-console.zoho.eu', 43 45 'crm' => 'https://www.zohoapis.eu', 44 46 'books' => 'https://www.zohoapis.eu', … … 51 53 'label' => 'India (.in)', 52 54 'accounts' => 'https://accounts.zoho.in', 55 'console' => 'https://api-console.zoho.in', 53 56 'crm' => 'https://www.zohoapis.in', 54 57 'books' => 'https://www.zohoapis.in', … … 61 64 'label' => 'Australia (.com.au)', 62 65 'accounts' => 'https://accounts.zoho.com.au', 66 'console' => 'https://api-console.zoho.com.au', 63 67 'crm' => 'https://www.zohoapis.com.au', 64 68 'books' => 'https://www.zohoapis.com.au', … … 71 75 'label' => 'Japan (.jp)', 72 76 'accounts' => 'https://accounts.zoho.jp', 77 'console' => 'https://api-console.zoho.jp', 73 78 'crm' => 'https://www.zohoapis.jp', 74 79 'books' => 'https://www.zohoapis.jp', … … 81 86 'label' => 'Canada (.ca)', 82 87 'accounts' => 'https://accounts.zohocloud.ca', 88 'console' => 'https://api-console.zohocloud.ca', 83 89 'crm' => 'https://www.zohoapis.ca', 84 90 'books' => 'https://www.zohoapis.ca', … … 91 97 'label' => 'Saudi Arabia (.sa)', 92 98 'accounts' => 'https://accounts.zoho.sa', 99 'console' => 'https://api-console.zoho.sa', 93 100 'crm' => 'https://www.zohoapis.sa', 94 101 'books' => 'https://www.zohoapis.sa', … … 101 108 'label' => 'China (.com.cn)', 102 109 'accounts' => 'https://accounts.zoho.com.cn', 110 'console' => 'https://api-console.zoho.com.cn', 103 111 'crm' => 'https://www.zohoapis.com.cn', 104 112 'books' => 'https://www.zohoapis.com.cn', … … 121 129 $key = is_string( $key ) ? strtolower( $key ) : ''; 122 130 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; 123 167 } 124 168 … … 264 308 265 309 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. 269 325 $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 ); 271 327 272 328 case 'desk': -
techwave-form-sync-for-zoho/trunk/includes/Support/ElementorForms.php
r3649280 r3748247 139 139 'label' => $label, 140 140 ); 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 } 141 147 } 142 148 } -
techwave-form-sync-for-zoho/trunk/includes/Support/Encryption.php
r3649280 r3748247 53 53 return new WP_Error( 54 54 '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' ) 56 56 ); 57 57 } … … 67 67 return new WP_Error( 68 68 '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' ) 70 70 ); 71 71 } -
techwave-form-sync-for-zoho/trunk/includes/Support/ErrorTranslator.php
r3649280 r3748247 30 30 if ( '' === $raw ) { 31 31 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; 32 38 } 33 39 … … 77 83 78 84 /** 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 /** 79 141 * Pull a Zoho field's API name out of the parenthesised part of an error 80 142 * string (e.g. "...(/lastName missing)" or "(Last_Name)") and turn it -
techwave-form-sync-for-zoho/trunk/includes/Support/FieldMapper.php
r3649280 r3748247 36 36 $type = isset( $row['type'] ) ? $row['type'] : 'field'; 37 37 $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 ) { 40 43 $resolved = (string) $value; 41 44 } else { … … 43 46 } 44 47 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 45 61 if ( is_array( $resolved ) ) { 46 62 $resolved = implode( ', ', array_map( 'strval', $resolved ) ); … … 52 68 } 53 69 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 ); 61 81 } 62 82 63 83 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 ) ); 64 244 } 65 245 -
techwave-form-sync-for-zoho/trunk/includes/Support/FluentFormsForms.php
r3649280 r3748247 143 143 'label' => $label, 144 144 ); 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 } 145 151 } 146 152 -
techwave-form-sync-for-zoho/trunk/includes/Support/FormSources.php
r3649280 r3748247 24 24 * Source key => [label, discovery class, tile initials, tile colour]. 25 25 * 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}> 27 31 */ 28 32 private static function map() { 29 returnarray(30 'elementor' => array(33 $map = array( 34 'elementor' => array( 31 35 'label' => __( 'Elementor Pro Forms', 'techwave-form-sync-for-zoho' ), 32 36 'class' => ElementorForms::class, … … 34 38 'color' => '#92003b', 35 39 ), 36 'cf7' => array(40 'cf7' => array( 37 41 'label' => __( 'Contact Form 7', 'techwave-form-sync-for-zoho' ), 38 42 'class' => ContactForm7Forms::class, … … 40 44 'color' => '#1e3a5f', 41 45 ), 42 'wpforms' => array(46 'wpforms' => array( 43 47 'label' => __( 'WPForms', 'techwave-form-sync-for-zoho' ), 44 48 'class' => WPFormsForms::class, … … 46 50 'color' => '#e27730', 47 51 ), 48 'fluent' => array(52 'fluent' => array( 49 53 'label' => __( 'Fluent Forms', 'techwave-form-sync-for-zoho' ), 50 54 'class' => FluentFormsForms::class, … … 52 56 'color' => '#0967d2', 53 57 ), 54 'forminator' => array(58 'forminator' => array( 55 59 'label' => __( 'Forminator', 'techwave-form-sync-for-zoho' ), 56 60 'class' => ForminatorForms::class, … … 58 62 'color' => '#0f2540', 59 63 ), 60 'ninja' => array(64 'ninja' => array( 61 65 'label' => __( 'Ninja Forms', 'techwave-form-sync-for-zoho' ), 62 66 'class' => NinjaFormsForms::class, … … 64 68 'color' => '#e0332f', 65 69 ), 66 'gravity' => array(70 'gravity' => array( 67 71 'label' => __( 'Gravity Forms', 'techwave-form-sync-for-zoho' ), 68 72 'class' => GravityFormsForms::class, … … 70 74 'color' => '#f15a29', 71 75 ), 72 'formidable' => array(76 'formidable' => array( 73 77 'label' => __( 'Formidable Forms', 'techwave-form-sync-for-zoho' ), 74 78 'class' => FormidableFormsForms::class, … … 76 80 'color' => '#4a4a4a', 77 81 ), 78 'happyforms' => array(82 'happyforms' => array( 79 83 'label' => __( 'Happy Forms', 'techwave-form-sync-for-zoho' ), 80 84 'class' => HappyFormsForms::class, … … 82 86 'color' => '#222222', 83 87 ), 84 'sureforms' => array(88 'sureforms' => array( 85 89 'label' => __( 'SureForms', 'techwave-form-sync-for-zoho' ), 86 90 'class' => SureFormsForms::class, … … 88 92 'color' => '#0a4dd6', 89 93 ), 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 ), 90 246 ); 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 ); 91 362 } 92 363 … … 128 399 'initials' => $row['initials'], 129 400 'color' => $row['color'], 130 'icon' => self::icon_url( $row ),401 'icon' => self::icon_url( isset( $map[ $key ] ) ? $key : 'elementor', $row ), 131 402 ); 132 403 } … … 135 406 * Everything the icon-tile picker UI needs, in display order. 136 407 * 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}> 138 412 */ 139 413 public static function tiles() { … … 145 419 'initials' => $row['initials'], 146 420 'color' => $row['color'], 147 'icon' => self::icon_url( $row ), 421 'icon' => self::icon_url( $key, $row ), 422 'locked' => ! empty( $row['locked'] ), 148 423 ); 149 424 } … … 151 426 } 152 427 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. 163 450 * @return string 164 451 */ 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 166 455 /** 167 456 * Filter the logo URL used for one form-source tile. 168 457 * 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. 170 461 * @param array $row The source's map() row. 171 462 */ 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 ]; 173 485 } 174 486 … … 189 501 190 502 /** 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 /** 191 518 * Whether the form plugin behind a source key is actually active. 192 519 * … … 199 526 return false; 200 527 } 528 if ( empty( $map[ $key ]['class'] ) ) { 529 return false; 530 } 531 201 532 $class = $map[ $key ]['class']; 202 533 return $class::is_available(); … … 215 546 return array(); 216 547 } 548 if ( empty( $map[ $key ]['class'] ) ) { 549 return array(); 550 } 551 217 552 $class = $map[ $key ]['class']; 218 553 return $class::all( $fresh ); -
techwave-form-sync-for-zoho/trunk/includes/Support/FormidableFormsForms.php
r3649280 r3748247 108 108 ); 109 109 $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 } 110 116 } 111 117 -
techwave-form-sync-for-zoho/trunk/includes/Support/ForminatorForms.php
r3649280 r3748247 55 55 56 56 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 ); 58 60 59 61 foreach ( (array) $forms as $form ) { … … 92 94 'label' => '' !== $label ? $label : $slug, 93 95 ); 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 } 94 103 } 95 104 -
techwave-form-sync-for-zoho/trunk/includes/Support/GravityFormsForms.php
r3649280 r3748247 102 102 $fields[ $input_id ] = array( 103 103 'id' => $input_id, 104 'label' => ( isset( $field['label'] ) ? $field['label'] . ' — ' : '' ) .$sub_label,104 'label' => isset( $field['label'] ) ? $field['label'] . ' (' . $sub_label . ')' : $sub_label, 105 105 ); 106 106 } … … 113 113 'label' => $label, 114 114 ); 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 } 115 121 } 116 122 -
techwave-form-sync-for-zoho/trunk/includes/Support/HappyFormsForms.php
r3649280 r3748247 275 275 'label' => $label, 276 276 ); 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 } 277 283 } 278 284 -
techwave-form-sync-for-zoho/trunk/includes/Support/Markup.php
r3649280 r3748247 90 90 91 91 /** 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 /** 92 154 * A status pill: one <span> carrying its colour class. 93 155 * … … 139 201 * saved value once the dropdown has been filled from Zoho. 140 202 * 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 * 141 207 * @return array<string,array<string,bool>> 142 208 */ … … 145 211 'tr' => array( 'class' => true ), 146 212 'td' => array( 'class' => true ), 213 'div' => array( 214 'class' => true, 215 'hidden' => true, 216 ), 147 217 'select' => array( 148 218 'name' => true, 149 219 'class' => true, 150 220 '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, 151 225 ), 152 226 'option' => array( … … 160 234 'value' => true, 161 235 'placeholder' => true, 236 'disabled' => true, 162 237 ), 163 238 'button' => array( -
techwave-form-sync-for-zoho/trunk/includes/Support/NinjaFormsForms.php
r3649280 r3748247 98 98 'label' => $label, 99 99 ); 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 } 100 106 } 101 107 -
techwave-form-sync-for-zoho/trunk/includes/Support/Settings.php
r3649280 r3748247 44 44 // Logs screen's one-click Resubmit, and the Settings screen says so. 45 45 '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', 47 50 'form2zoho_email_notify_failures' => '0', 48 51 'form2zoho_email_notify_connections' => '0', -
techwave-form-sync-for-zoho/trunk/includes/Support/SureFormsForms.php
r3649280 r3748247 225 225 'label' => '' !== $label ? $label : ( 'Field ' . $field_id ), 226 226 ); 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 } 227 233 } 228 234 } -
techwave-form-sync-for-zoho/trunk/includes/Support/WPFormsForms.php
r3649280 r3748247 99 99 $fields[ $sub_id ] = array( 100 100 'id' => $sub_id, 101 'label' => $label . ' — ' . ucfirst( $part ),101 'label' => $label . ' (' . ucfirst( $part ) . ')', 102 102 ); 103 103 } … … 109 109 'label' => $label, 110 110 ); 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 } 111 117 } 112 118 -
techwave-form-sync-for-zoho/trunk/languages/techwave-form-sync-for-zoho.pot
r3649280 r3748247 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: Techwave Form Sync for Zoho 1.0.0\n"5 "Project-Id-Version: FormBridge for Zoho 1.1.0\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/techwave-form-sync-for-zoho\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026-0 8-15T04:32:15+00:00\n"12 "POT-Creation-Date: 2026-09-22T06:33:18+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 17 17 #. Plugin Name of the plugin 18 18 #: 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 23 20 #: includes/Privacy/PrivacyProvider.php:65 24 msgid " Techwave Form Syncfor Zoho"21 msgid "FormBridge for Zoho" 25 22 msgstr "" 26 23 27 24 #. Plugin URI of the plugin 25 #: techwave-form-sync-for-zoho.php 26 msgid "https://techwavesolutions.net/techwave-form-sync-for-zoho" 27 msgstr "" 28 29 #. Description of the plugin 30 #: techwave-form-sync-for-zoho.php 31 msgid "Send WordPress form submissions to Zoho CRM, Bigin, Books, Desk, Campaigns and Marketing Automation." 32 msgstr "" 33 34 #. Author of the plugin 35 #: techwave-form-sync-for-zoho.php 36 msgid "Techwave Solutions" 37 msgstr "" 38 28 39 #. Author URI of the plugin 29 40 #: techwave-form-sync-for-zoho.php … … 31 42 msgstr "" 32 43 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 54 57 #: 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 60 64 msgid "Permission denied." 61 65 msgstr "" 62 66 63 #: includes/Admin/Ajax.php:1 4567 #: includes/Admin/Ajax.php:154 64 68 msgid "This site has no valid administration email address set, so there is nowhere to send notifications. Set one under Settings → General." 65 69 msgstr "" 66 70 67 #: includes/Admin/Ajax.php:1 5468 msgid "This is a test message from Techwave Form Syncfor Zoho."69 msgstr "" 70 71 #: includes/Admin/Ajax.php:1 5671 #: includes/Admin/Ajax.php:163 72 msgid "This is a test message from FormBridge for Zoho." 73 msgstr "" 74 75 #: includes/Admin/Ajax.php:165 72 76 msgid "If you are reading it, this site can send the notification emails the plugin offers under Settings → Notifications." 73 77 msgstr "" 74 78 75 79 #. 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 82 msgid "[%s] FormBridge test email" 83 msgstr "" 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 88 msgid "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." 83 89 msgstr "" 84 90 85 91 #. translators: %s: administration email address. 86 #: includes/Admin/Ajax.php:1 7987 #, php-format 88 msgid "Test email sent to %s . Check that inbox — if it never arrives, the message left WordPressbut was rejected or filtered further down the line."89 msgstr "" 90 91 #: includes/Admin/Ajax.php:2 1692 #: includes/Admin/Ajax.php:192 93 #, php-format 94 msgid "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." 95 msgstr "" 96 97 #: includes/Admin/Ajax.php:229 92 98 msgid "Missing feed id." 93 99 msgstr "" 94 100 95 #: includes/Admin/Ajax.php:2 3796 #: includes/Admin/Ajax.php: 26197 #: includes/Admin/Ajax.php: 495101 #: includes/Admin/Ajax.php:297 102 #: includes/Admin/Ajax.php:321 103 #: includes/Admin/Ajax.php:559 98 104 msgid "Feed not found." 99 105 msgstr "" 100 106 101 #: includes/Admin/Ajax.php: 265107 #: includes/Admin/Ajax.php:325 102 108 msgid "(copy)" 103 109 msgstr "" 104 110 105 #: includes/Admin/Ajax.php:3 01111 #: includes/Admin/Ajax.php:365 106 112 msgid "Not available for this Zoho app." 107 113 msgstr "" 108 114 109 #: includes/Admin/Ajax.php: 387110 #: includes/Admin/Ajax.php:4 14115 #: includes/Admin/Ajax.php:451 116 #: includes/Admin/Ajax.php:478 111 117 msgid "No rows selected." 112 118 msgstr "" 113 119 114 120 #. translators: %d: log row id. 115 #: includes/Admin/Ajax.php: 441121 #: includes/Admin/Ajax.php:505 116 122 #, php-format 117 123 msgid "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." … … 119 125 120 126 #. translators: %d: log row id. 121 #: includes/Admin/Ajax.php: 453127 #: includes/Admin/Ajax.php:517 122 128 #, php-format 123 129 msgid "Row #%d: its feed no longer exists, so it cannot be resubmitted." … … 125 131 126 132 #. translators: %d: log row id. 127 #: includes/Admin/Ajax.php: 462133 #: includes/Admin/Ajax.php:526 128 134 #, php-format 129 135 msgid "Row #%d: its feed is currently disabled." 130 136 msgstr "" 131 137 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 139 msgid "Zoho's own duplicate check" 140 msgstr "" 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 136 146 #: includes/Integrations/CrmIntegration.php:35 137 147 msgid "Zoho CRM" 138 148 msgstr "" 139 149 140 #: includes/Admin/Ajax.php: 598141 #: includes/Admin/SettingsPage.php:1 30142 #: includes/Admin/SettingsPage.php: 881143 #: includes/Auth/ConnectionManager.php: 118150 #: includes/Admin/Ajax.php:666 151 #: includes/Admin/SettingsPage.php:143 152 #: includes/Admin/SettingsPage.php:1055 153 #: includes/Auth/ConnectionManager.php:202 144 154 #: includes/Integrations/BooksIntegration.php:35 145 155 msgid "Zoho Books" 146 156 msgstr "" 147 157 148 #: includes/Admin/Ajax.php: 599149 #: includes/Admin/SettingsPage.php:1 31150 #: includes/Admin/SettingsPage.php: 882151 #: includes/Auth/ConnectionManager.php: 119158 #: includes/Admin/Ajax.php:667 159 #: includes/Admin/SettingsPage.php:144 160 #: includes/Admin/SettingsPage.php:1056 161 #: includes/Auth/ConnectionManager.php:203 152 162 #: includes/Integrations/DeskIntegration.php:35 153 163 msgid "Zoho Desk" 154 164 msgstr "" 155 165 156 #: includes/Admin/Ajax.php:6 00157 #: includes/Admin/SettingsPage.php:1 32158 #: includes/Admin/SettingsPage.php: 883159 #: includes/Auth/ConnectionManager.php: 120166 #: includes/Admin/Ajax.php:668 167 #: includes/Admin/SettingsPage.php:145 168 #: includes/Admin/SettingsPage.php:1057 169 #: includes/Auth/ConnectionManager.php:204 160 170 #: includes/Integrations/BiginIntegration.php:35 161 171 msgid "Zoho Bigin" 162 172 msgstr "" 163 173 164 #: includes/Admin/Ajax.php:6 01165 #: includes/Admin/SettingsPage.php:1 33166 #: includes/Admin/SettingsPage.php: 884167 #: includes/Auth/ConnectionManager.php: 121174 #: includes/Admin/Ajax.php:669 175 #: includes/Admin/SettingsPage.php:146 176 #: includes/Admin/SettingsPage.php:1058 177 #: includes/Auth/ConnectionManager.php:205 168 178 #: includes/Integrations/CampaignsIntegration.php:53 169 179 msgid "Zoho Campaigns" 170 180 msgstr "" 171 181 172 #: includes/Admin/Ajax.php:6 02173 #: includes/Admin/SettingsPage.php:1 34174 #: includes/Admin/SettingsPage.php: 885175 #: includes/Auth/ConnectionManager.php: 122182 #: includes/Admin/Ajax.php:670 183 #: includes/Admin/SettingsPage.php:147 184 #: includes/Admin/SettingsPage.php:1059 185 #: includes/Auth/ConnectionManager.php:206 176 186 #: includes/Integrations/MarketingAutomationIntegration.php:41 177 187 msgid "Zoho Marketing Automation" … … 179 189 180 190 #. 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 194 msgid "%s is not installed or activated." 195 msgstr "" 196 197 #: includes/Admin/Ajax.php:874 198 #: includes/Admin/Ajax.php:948 199 #: includes/Admin/FeedsPage.php:2340 189 200 msgid "Unknown integration." 190 201 msgstr "" 191 202 192 #: includes/Admin/Ajax.php: 791203 #: includes/Admin/Ajax.php:943 193 204 #: includes/Integrations/BiginIntegration.php:66 194 205 #: includes/Integrations/CrmIntegration.php:57 … … 196 207 msgstr "" 197 208 198 #: includes/Admin/Ajax.php:830 209 #: includes/Admin/Ajax.php:1051 210 msgid "No integration selected." 211 msgstr "" 212 213 #: includes/Admin/Ajax.php:1060 199 214 msgid "Unknown or disconnected account." 200 215 msgstr "" 201 216 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 220 msgid "This connection is not authorized for %1$s. Use \"Edit access\" on the connection, tick %2$s, then press Update & Reauthorize." 204 221 msgstr "" 205 222 … … 229 246 230 247 #: 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 236 254 msgid "Close" 237 255 msgstr "" … … 242 260 243 261 #: 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."262 msgid "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." 245 263 msgstr "" 246 264 … … 265 283 msgstr "" 266 284 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 287 msgid "Feed Name" 288 msgstr "" 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 295 msgid "Trigger" 296 msgstr "" 297 298 #: includes/Admin/FeedsPage.php:71 299 #: includes/Admin/FeedsPage.php:737 300 msgid "Action Name" 301 msgstr "" 302 303 #: includes/Admin/FeedsPage.php:72 304 #: includes/Admin/FeedsPage.php:738 305 msgid "Created At" 306 msgstr "" 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 315 msgid "Status" 316 msgstr "" 317 318 #: includes/Admin/FeedsPage.php:223 319 #: includes/Admin/FeedsPage.php:224 320 msgid "All Feeds" 321 msgstr "" 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 326 msgid "FormBridge" 327 msgstr "" 328 329 #: includes/Admin/FeedsPage.php:362 330 #: includes/Admin/FeedsPage.php:457 278 331 msgid "Loading…" 279 332 msgstr "" 280 333 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 341 msgid "%s (opens in a new tab)" 342 msgstr "" 343 344 #: includes/Admin/FeedsPage.php:365 345 #: includes/Admin/FeedsPage.php:477 283 346 msgid "View Record in Zoho" 284 347 msgstr "" 285 348 286 #: includes/Admin/FeedsPage.php: 239287 #: includes/Admin/FeedsPage.php: 311288 #: includes/Admin/SettingsPage.php: 871349 #: includes/Admin/FeedsPage.php:366 350 #: includes/Admin/FeedsPage.php:475 351 #: includes/Admin/SettingsPage.php:1027 289 352 msgid "Record ID" 290 353 msgstr "" 291 354 292 #: includes/Admin/FeedsPage.php: 240293 #: includes/Admin/FeedsPage.php: 294355 #: includes/Admin/FeedsPage.php:367 356 #: includes/Admin/FeedsPage.php:458 294 357 msgid "Request failed." 295 358 msgstr "" 296 359 297 #: includes/Admin/FeedsPage.php: 241360 #: includes/Admin/FeedsPage.php:368 298 361 msgid "Status changed successfully" 299 362 msgstr "" 300 363 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 365 msgid "Select a form" 366 msgstr "" 367 368 #: includes/Admin/FeedsPage.php:370 369 msgid "Select organization" 370 msgstr "" 371 372 #: includes/Admin/FeedsPage.php:371 373 msgid "Select module" 374 msgstr "" 375 376 #: includes/Admin/FeedsPage.php:372 377 msgid "Select department" 378 msgstr "" 379 380 #: includes/Admin/FeedsPage.php:373 381 #: includes/Admin/FeedsPage.php:2289 382 msgid "Select Zoho field" 383 msgstr "" 384 385 #: includes/Admin/FeedsPage.php:374 386 #: includes/Admin/FeedsPage.php:2279 387 msgid "Select form field" 388 msgstr "" 389 390 #: includes/Admin/FeedsPage.php:376 391 msgid "Type to search" 392 msgstr "" 393 394 #: includes/Admin/FeedsPage.php:377 395 msgid "No field matches" 396 msgstr "" 397 398 #. translators: shown in the mapping row's text box when the row combines several form fields. 399 #: includes/Admin/FeedsPage.php:379 400 msgid "e.g. {first-name} {last-name}. Pick a form field to insert one" 401 msgstr "" 402 403 #. translators: %d: number of matching fields. 404 #: includes/Admin/FeedsPage.php:381 405 #, php-format 406 msgid "%d fields match" 407 msgstr "" 408 409 #: includes/Admin/FeedsPage.php:382 328 410 msgid "Mailing List" 329 411 msgstr "" 330 412 331 #: includes/Admin/FeedsPage.php: 249413 #: includes/Admin/FeedsPage.php:383 332 414 msgid "Loaded from Zoho once the account is chosen." 333 415 msgstr "" 334 416 335 #: includes/Admin/FeedsPage.php: 250336 #: includes/Admin/FeedsPage.php: 964417 #: includes/Admin/FeedsPage.php:384 418 #: includes/Admin/FeedsPage.php:1468 337 419 msgid "Module / resource" 338 420 msgstr "" 339 421 340 #: includes/Admin/FeedsPage.php: 251341 #: includes/Admin/FeedsPage.php: 976422 #: includes/Admin/FeedsPage.php:385 423 #: includes/Admin/FeedsPage.php:1480 342 424 msgid "Loaded from Zoho once the app and account are chosen." 343 425 msgstr "" 344 426 345 #: includes/Admin/FeedsPage.php: 252346 #: includes/Admin/FeedsPage.php: 977427 #: includes/Admin/FeedsPage.php:386 428 #: includes/Admin/FeedsPage.php:1481 347 429 msgid "No Mailing Lists found in Zoho Campaigns." 348 430 msgstr "" 349 431 350 #: includes/Admin/FeedsPage.php: 253432 #: includes/Admin/FeedsPage.php:387 351 433 msgid "No Mailing Lists found in Zoho Marketing Automation." 352 434 msgstr "" 353 435 354 #: includes/Admin/FeedsPage.php: 254436 #: includes/Admin/FeedsPage.php:388 355 437 msgid "Search for topic" 356 438 msgstr "" 357 439 358 #: includes/Admin/FeedsPage.php: 255440 #: includes/Admin/FeedsPage.php:389 359 441 msgid "Search for tag" 360 442 msgstr "" 361 443 362 #: includes/Admin/FeedsPage.php: 256363 #: includes/Admin/FeedsPage.php: 310444 #: includes/Admin/FeedsPage.php:390 445 #: includes/Admin/FeedsPage.php:474 364 446 msgid "No matches" 365 447 msgstr "" 366 448 367 449 #. 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 452 msgid "%s (not found, please reselect)" 453 msgstr "" 454 455 #. translators: %s: Zoho app name. 456 #: includes/Admin/FeedsPage.php:394 457 #, php-format 458 msgid "%s (not authorised for this account)" 459 msgstr "" 460 461 #: includes/Admin/FeedsPage.php:395 374 462 msgid "Send one real test record to Zoho now?" 375 463 msgstr "" 376 464 377 #: includes/Admin/FeedsPage.php: 260465 #: includes/Admin/FeedsPage.php:396 378 466 msgid "Test successful" 379 467 msgstr "" 380 468 381 #: includes/Admin/FeedsPage.php: 261469 #: includes/Admin/FeedsPage.php:397 382 470 msgid "Test failed" 383 471 msgstr "" 384 472 385 #: includes/Admin/FeedsPage.php: 262386 #: includes/Admin/FeedsPage.php: 894473 #: includes/Admin/FeedsPage.php:398 474 #: includes/Admin/FeedsPage.php:1286 387 475 msgid "Pick the form to connect. The list is read from your saved forms." 388 476 msgstr "" 389 477 390 #: includes/Admin/FeedsPage.php: 263478 #: includes/Admin/FeedsPage.php:399 391 479 msgid "This form could not be found in the current list. Please pick the form again and save, or submissions will never match." 392 480 msgstr "" 393 481 394 #: includes/Admin/FeedsPage.php: 264482 #: includes/Admin/FeedsPage.php:400 395 483 msgid "Contact Form 7 is not active on this site." 396 484 msgstr "" 397 485 398 486 #. translators: %s: comma-separated list of required Zoho field names that are still unmapped. 399 #: includes/Admin/FeedsPage.php: 266487 #: includes/Admin/FeedsPage.php:404 400 488 #, php-format 401 489 msgid "These required Zoho fields are not mapped yet: %s. Map them (or set a static value) before saving." 402 490 msgstr "" 403 491 404 #: includes/Admin/FeedsPage.php: 267405 #: includes/Admin/FeedsPage.php:1 069492 #: includes/Admin/FeedsPage.php:405 493 #: includes/Admin/FeedsPage.php:1709 406 494 msgid "Missing required fields" 407 495 msgstr "" 408 496 409 497 #. translators: %s: comma-separated list of Zoho field names whose mapping row is unfinished. 410 #: includes/Admin/FeedsPage.php: 269411 #, 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: 270498 #: includes/Admin/FeedsPage.php:407 499 #, php-format 500 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." 501 msgstr "" 502 503 #: includes/Admin/FeedsPage.php:408 416 504 msgid "Unfinished mapping rows" 417 505 msgstr "" 418 506 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 508 msgid "Every mapping row needs a Zoho field. The highlighted rows have none. Pick a Zoho field for each, or remove the row." 509 msgstr "" 510 511 #: includes/Admin/FeedsPage.php:410 512 msgid "Creating feed…" 513 msgstr "" 514 515 #: includes/Admin/FeedsPage.php:411 516 msgid "Saving feed…" 517 msgstr "" 518 519 #. translators: %s: a form field's label. 520 #: includes/Admin/FeedsPage.php:413 521 #, php-format 522 msgid "Remove %s" 523 msgstr "" 524 525 #: includes/Admin/FeedsPage.php:414 526 msgid "Try again" 527 msgstr "" 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 532 msgid "Fetching %1$s fields from %2$s…" 533 msgstr "" 534 535 #. translators: %s: Zoho app name, e.g. "Zoho Campaigns". 536 #: includes/Admin/FeedsPage.php:418 537 #, php-format 538 msgid "Fetching fields from %s…" 539 msgstr "" 540 541 #: includes/Admin/FeedsPage.php:419 542 msgid "Static values Zoho will not accept" 543 msgstr "" 544 545 #: includes/Admin/FeedsPage.php:420 546 #: includes/Admin/SettingsPage.php:167 547 msgid "Required" 548 msgstr "" 549 550 #: includes/Admin/FeedsPage.php:421 551 msgid "Zoho needs this field on every record, so this row cannot be removed or pointed at another field." 552 msgstr "" 553 554 #. translators: %s: Zoho field name, e.g. "Phone". 555 #: includes/Admin/FeedsPage.php:423 556 #, php-format 557 msgid "%s needs a phone number: digits, spaces and + - ( ) only." 558 msgstr "" 559 560 #. translators: %s: Zoho field name, e.g. "Email". 561 #: includes/Admin/FeedsPage.php:425 562 #, php-format 563 msgid "%s needs an email address, such as name@example.com." 564 msgstr "" 565 566 #. translators: %s: Zoho field name, e.g. "No. of Employees". 567 #: includes/Admin/FeedsPage.php:427 568 #, php-format 569 msgid "%s needs a whole number." 570 msgstr "" 571 572 #. translators: %s: Zoho field name, e.g. "Annual Revenue". 573 #: includes/Admin/FeedsPage.php:429 574 #, php-format 575 msgid "%s needs a number, such as 12.50." 576 msgstr "" 577 578 #. translators: %s: Zoho field name, e.g. "Date of Birth". 579 #: includes/Admin/FeedsPage.php:431 580 #, php-format 581 msgid "%s needs a date written as YYYY-MM-DD." 582 msgstr "" 583 584 #. translators: %s: Zoho field name, e.g. "Follow-up Time". 585 #: includes/Admin/FeedsPage.php:433 586 #, php-format 587 msgid "%s needs a date and time written as YYYY-MM-DDTHH:MM:SS." 588 msgstr "" 589 590 #. translators: %s: Zoho field name, e.g. "Website". 591 #: includes/Admin/FeedsPage.php:435 592 #, php-format 593 msgid "%s needs a web address, such as https://example.com." 594 msgstr "" 595 596 #: includes/Admin/FeedsPage.php:436 597 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." 425 598 msgstr "" 426 599 427 600 #. translators: 1: currently authorized app(s), 2: app the person just picked. 428 #: includes/Admin/FeedsPage.php: 274601 #: includes/Admin/FeedsPage.php:438 429 602 #, php-format 430 603 msgid "This account is only authorized for %1$s. You need to re-authorize it for %2$s before you can fetch this data." 431 604 msgstr "" 432 605 433 #: includes/Admin/FeedsPage.php: 275606 #: includes/Admin/FeedsPage.php:439 434 607 msgid "Fix this under Connections → Edit access." 435 608 msgstr "" 436 609 437 #: includes/Admin/FeedsPage.php: 296610 #: includes/Admin/FeedsPage.php:460 438 611 msgid "Please confirm" 439 612 msgstr "" 440 613 441 #: includes/Admin/FeedsPage.php: 297442 #: includes/Admin/SettingsPage.php: 555614 #: includes/Admin/FeedsPage.php:461 615 #: includes/Admin/SettingsPage.php:646 443 616 msgid "Cancel" 444 617 msgstr "" 445 618 446 #: includes/Admin/FeedsPage.php: 301447 #: includes/Admin/FeedsPage.php: 671448 #: includes/Admin/SettingsPage.php: 740619 #: includes/Admin/FeedsPage.php:465 620 #: includes/Admin/FeedsPage.php:836 621 #: includes/Admin/SettingsPage.php:886 449 622 msgid "Delete" 450 623 msgstr "" 451 624 452 #: includes/Admin/FeedsPage.php: 302625 #: includes/Admin/FeedsPage.php:466 453 626 msgid "Send" 454 627 msgstr "" 455 628 456 #: includes/Admin/FeedsPage.php: 303629 #: includes/Admin/FeedsPage.php:467 457 630 msgid "Resubmit" 458 631 msgstr "" 459 632 460 #: includes/Admin/FeedsPage.php: 304633 #: includes/Admin/FeedsPage.php:468 461 634 msgid "Copied" 462 635 msgstr "" 463 636 464 #: includes/Admin/FeedsPage.php: 305637 #: includes/Admin/FeedsPage.php:469 465 638 msgid "Feed" 466 639 msgstr "" 467 640 468 #: includes/Admin/FeedsPage.php: 306469 #: includes/Admin/FeedsPage.php: 659641 #: includes/Admin/FeedsPage.php:470 642 #: includes/Admin/FeedsPage.php:824 470 643 msgid "Timeline" 471 644 msgstr "" 472 645 473 #: includes/Admin/FeedsPage.php:307 474 #: includes/Admin/FeedsPage.php:838 646 #: includes/Admin/FeedsPage.php:471 475 647 msgid "Enabled" 476 648 msgstr "" 477 649 478 #: includes/Admin/FeedsPage.php: 308650 #: includes/Admin/FeedsPage.php:472 479 651 msgid "Disabled" 480 652 msgstr "" 481 653 482 #: includes/Admin/FeedsPage.php: 309654 #: includes/Admin/FeedsPage.php:473 483 655 msgid "No activity logged for this feed yet." 484 656 msgstr "" 485 657 486 #: includes/Admin/FeedsPage.php: 312487 #: includes/Admin/SettingsPage.php: 869658 #: includes/Admin/FeedsPage.php:476 659 #: includes/Admin/SettingsPage.php:1025 488 660 msgid "Module" 489 661 msgstr "" 490 662 491 #: includes/Admin/FeedsPage.php: 314492 #: includes/Admin/FeedsPage.php: 881493 #: includes/Admin/SettingsPage.php: 870663 #: includes/Admin/FeedsPage.php:478 664 #: includes/Admin/FeedsPage.php:1272 665 #: includes/Admin/SettingsPage.php:1026 494 666 #: includes/Privacy/PrivacyProvider.php:164 495 667 msgid "Form" 496 668 msgstr "" 497 669 498 #: includes/Admin/FeedsPage.php:315 499 #: includes/Admin/FeedsPage.php:858 500 #: includes/Admin/FeedsPage.php:859 670 #: includes/Admin/FeedsPage.php:479 501 671 msgid "Form plugin" 502 672 msgstr "" 503 673 504 #: includes/Admin/FeedsPage.php:316 674 #: includes/Admin/FeedsPage.php:480 675 #: includes/Admin/FeedsPage.php:2150 505 676 msgid "Destination" 506 677 msgstr "" 507 678 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 518 680 msgid "Mapped fields" 519 681 msgstr "" 520 682 521 #: includes/Admin/FeedsPage.php:319 522 #: includes/Admin/FeedsPage.php:1040 683 #: includes/Admin/FeedsPage.php:483 523 684 msgid "Duplicate check" 524 685 msgstr "" 525 686 526 687 #. translators: %d: number of selected rows. Always 1 here. 527 #: includes/Admin/FeedsPage.php: 321688 #: includes/Admin/FeedsPage.php:485 528 689 #, php-format 529 690 msgid "%d row selected" … … 531 692 532 693 #. translators: %d: number of selected rows. 533 #: includes/Admin/FeedsPage.php: 323694 #: includes/Admin/FeedsPage.php:487 534 695 #, php-format 535 696 msgid "%d rows selected" 536 697 msgstr "" 537 698 538 #: includes/Admin/FeedsPage.php: 324699 #: includes/Admin/FeedsPage.php:488 539 700 msgid "Delete this feed?" 540 701 msgstr "" 541 702 542 703 #. translators: %d: number of feeds. 543 #: includes/Admin/FeedsPage.php: 326704 #: includes/Admin/FeedsPage.php:490 544 705 #, php-format 545 706 msgid "Delete %d feeds?" 546 707 msgstr "" 547 708 548 #: includes/Admin/FeedsPage.php: 327549 #: includes/Admin/FeedsPage.php: 507709 #: includes/Admin/FeedsPage.php:491 710 #: includes/Admin/FeedsPage.php:677 550 711 msgid "Select all" 551 712 msgstr "" 552 713 553 #: includes/Admin/FeedsPage.php: 333554 #: includes/Admin/SettingsPage.php: 921714 #: includes/Admin/FeedsPage.php:497 715 #: includes/Admin/SettingsPage.php:1115 555 716 msgid "Success" 556 717 msgstr "" 557 718 558 #: includes/Admin/FeedsPage.php: 334719 #: includes/Admin/FeedsPage.php:498 559 720 msgid "Failed" 560 721 msgstr "" 561 722 562 #: includes/Admin/FeedsPage.php: 335563 #: includes/Admin/SettingsPage.php: 925723 #: includes/Admin/FeedsPage.php:499 724 #: includes/Admin/SettingsPage.php:1119 564 725 msgid "Error" 565 726 msgstr "" 566 727 567 #: includes/Admin/FeedsPage.php: 336728 #: includes/Admin/FeedsPage.php:500 568 729 msgid "Retry" 569 730 msgstr "" 570 731 571 #: includes/Admin/FeedsPage.php: 337572 #: includes/Admin/FeedsPage.php: 655732 #: includes/Admin/FeedsPage.php:501 733 #: includes/Admin/FeedsPage.php:820 573 734 msgid "Info" 574 735 msgstr "" 575 736 576 #: includes/Admin/FeedsPage.php: 338577 #: includes/Admin/SettingsPage.php: 923737 #: includes/Admin/FeedsPage.php:502 738 #: includes/Admin/SettingsPage.php:1117 578 739 msgid "Suppressed" 579 740 msgstr "" 580 741 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 584 746 msgid "Details" 585 747 msgstr "" 586 748 587 #: includes/Admin/FeedsPage.php: 340588 #: includes/Admin/SettingsPage.php: 953749 #: includes/Admin/FeedsPage.php:504 750 #: includes/Admin/SettingsPage.php:1148 589 751 msgid "Copy" 590 752 msgstr "" 591 753 592 #: includes/Admin/FeedsPage.php: 341593 #: includes/Admin/SettingsPage.php: 514594 #: includes/Admin/SettingsPage.php: 951754 #: includes/Admin/FeedsPage.php:505 755 #: includes/Admin/SettingsPage.php:601 756 #: includes/Admin/SettingsPage.php:1146 595 757 msgid "Copy to clipboard" 596 758 msgstr "" 597 759 598 #: includes/Admin/FeedsPage.php: 344599 #: includes/Admin/SettingsPage.php: 826760 #: includes/Admin/FeedsPage.php:508 761 #: includes/Admin/SettingsPage.php:982 600 762 msgid "Select failed" 601 763 msgstr "" 602 764 603 #: includes/Admin/FeedsPage.php: 345765 #: includes/Admin/FeedsPage.php:509 604 766 msgid "Unselect failed" 605 767 msgstr "" 606 768 607 #: includes/Admin/FeedsPage.php: 346769 #: includes/Admin/FeedsPage.php:510 608 770 msgid "This log entry will be permanently deleted. Continue?" 609 771 msgstr "" 610 772 611 773 #. translators: %d: number of log entries. 612 #: includes/Admin/FeedsPage.php: 348774 #: includes/Admin/FeedsPage.php:512 613 775 #, php-format 614 776 msgid "%d log entries will be permanently deleted. Continue?" … … 616 778 617 779 #. translators: %d: number of log entries. 618 #: includes/Admin/FeedsPage.php: 350780 #: includes/Admin/FeedsPage.php:514 619 781 #, php-format 620 782 msgid "%d log entries deleted." 621 783 msgstr "" 622 784 623 #: includes/Admin/FeedsPage.php: 351785 #: includes/Admin/FeedsPage.php:515 624 786 msgid "Could not delete the selected entries." 625 787 msgstr "" 626 788 627 #: includes/Admin/FeedsPage.php: 352789 #: includes/Admin/FeedsPage.php:516 628 790 msgid "Resend the selected entry to Zoho now?" 629 791 msgstr "" 630 792 631 793 #. translators: %d: number of log entries. 632 #: includes/Admin/FeedsPage.php: 354794 #: includes/Admin/FeedsPage.php:518 633 795 #, php-format 634 796 msgid "Resend %d selected entries to Zoho now?" 635 797 msgstr "" 636 798 637 #: includes/Admin/FeedsPage.php: 355638 msgid "Entry resubmitted — check the log for the result."799 #: includes/Admin/FeedsPage.php:519 800 msgid "Entry resubmitted. Check the log for the result." 639 801 msgstr "" 640 802 641 803 #. translators: %d: number of log entries. 642 #: includes/Admin/FeedsPage.php: 357643 #, php-format 644 msgid "%d entries resubmitted — check the log for the result."645 msgstr "" 646 647 #: includes/Admin/FeedsPage.php: 358804 #: includes/Admin/FeedsPage.php:521 805 #, php-format 806 msgid "%d entries resubmitted. Check the log for the result." 807 msgstr "" 808 809 #: includes/Admin/FeedsPage.php:522 648 810 msgid "Nothing to resubmit." 649 811 msgstr "" 650 812 651 #: includes/Admin/FeedsPage.php: 359813 #: includes/Admin/FeedsPage.php:523 652 814 msgid "Could not resubmit the selected entries." 653 815 msgstr "" 654 816 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 820 msgid "Page %1$s of %2$s · %3$s entries" 821 msgstr "" 822 823 #: includes/Admin/FeedsPage.php:536 824 #: includes/Admin/SettingsPage.php:185 657 825 msgid "You do not have permission to view this page." 658 826 msgstr "" 659 827 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 667 829 msgid "Feeds" 668 830 msgstr "" 669 831 670 #: includes/Admin/FeedsPage.php: 422832 #: includes/Admin/FeedsPage.php:586 671 833 msgid "Each feed sends a matching Elementor or Contact Form 7 form to one Zoho destination. A form can have more than one feed." 672 834 msgstr "" 673 835 674 #: includes/Admin/FeedsPage.php: 426675 #: includes/Admin/FeedsPage.php: 458836 #: includes/Admin/FeedsPage.php:590 837 #: includes/Admin/FeedsPage.php:622 676 838 msgid "Add New Feed" 677 839 msgstr "" 678 840 679 #: includes/Admin/FeedsPage.php: 454841 #: includes/Admin/FeedsPage.php:618 680 842 msgid "No feeds yet" 681 843 msgstr "" 682 844 683 #: includes/Admin/FeedsPage.php: 455845 #: includes/Admin/FeedsPage.php:619 684 846 msgid "A feed connects one of your forms to one Zoho destination. Create your first one to start sending submissions." 685 847 msgstr "" 686 848 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 851 msgid "Watch the full setup" 852 msgstr "" 853 854 #: includes/Admin/FeedsPage.php:683 855 msgid "All Apps" 856 msgstr "" 857 858 #: includes/Admin/FeedsPage.php:690 692 859 msgid "Show/hide columns" 693 860 msgstr "" 694 861 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 717 864 msgid "Delete selected" 718 865 msgstr "" 719 866 720 #: includes/Admin/FeedsPage.php: 550867 #: includes/Admin/FeedsPage.php:713 721 868 msgid "Search feeds by name" 722 869 msgstr "" 723 870 724 #: includes/Admin/FeedsPage.php: 551871 #: includes/Admin/FeedsPage.php:714 725 872 msgid "Search feed name…" 726 873 msgstr "" 727 874 728 875 #. translators: %d: number of feeds. 729 #: includes/Admin/FeedsPage.php: 557876 #: includes/Admin/FeedsPage.php:720 730 877 #, php-format 731 878 msgid "%d feed" … … 734 881 msgstr[1] "" 735 882 736 #: includes/Admin/FeedsPage.php: 571883 #: includes/Admin/FeedsPage.php:734 737 884 msgid "Select all feeds" 738 885 msgstr "" 739 886 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 742 890 msgid "Actions" 743 891 msgstr "" 744 892 745 #: includes/Admin/FeedsPage.php: 581893 #: includes/Admin/FeedsPage.php:744 746 894 msgid "No feeds match your search/filter." 747 895 msgstr "" 748 896 749 #: includes/Admin/FeedsPage.php: 626750 #: includes/Admin/FeedsPage.php: 1367897 #: includes/Admin/FeedsPage.php:791 898 #: includes/Admin/FeedsPage.php:2505 751 899 msgid "Untitled feed" 752 900 msgstr "" 753 901 754 902 #. translators: %s: feed title. 755 #: includes/Admin/FeedsPage.php: 639903 #: includes/Admin/FeedsPage.php:804 756 904 #, php-format 757 905 msgid "Enable the feed \"%s\"" 758 906 msgstr "" 759 907 760 #: includes/Admin/FeedsPage.php: 663908 #: includes/Admin/FeedsPage.php:828 761 909 msgid "Edit" 762 910 msgstr "" 763 911 764 #: includes/Admin/FeedsPage.php: 667912 #: includes/Admin/FeedsPage.php:832 765 913 msgid "Clone" 766 914 msgstr "" 767 915 768 #: includes/Admin/FeedsPage.php: 669916 #: includes/Admin/FeedsPage.php:834 769 917 msgid "Delete this feed? Submissions for this form will stop being sent to Zoho." 770 918 msgstr "" 771 919 772 #: includes/Admin/FeedsPage.php:696 920 #: includes/Admin/FeedsPage.php:861 921 #: includes/Admin/SettingsPage.php:1221 773 922 msgid "Show" 774 923 msgstr "" 775 924 776 #: includes/Admin/FeedsPage.php:701 925 #: includes/Admin/FeedsPage.php:866 926 #: includes/Admin/SettingsPage.php:1229 777 927 msgid "per page" 778 928 msgstr "" 779 929 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 782 933 msgid "Prev" 783 934 msgstr "" 784 935 785 936 #. translators: 1: current page, 2: total pages. 786 #: includes/Admin/FeedsPage.php: 728937 #: includes/Admin/FeedsPage.php:893 787 938 #, php-format 788 939 msgid "Page %1$d of %2$d" 789 940 msgstr "" 790 941 791 #: includes/Admin/FeedsPage.php:735792 #: includes/Admin/FeedsPage.php:737793 942 #: includes/Admin/FeedsPage.php:900 794 #: includes/Admin/FeedsPage.php:1054 943 #: includes/Admin/FeedsPage.php:902 944 #: includes/Admin/SettingsPage.php:1241 795 945 msgid "Next" 796 946 msgstr "" 797 947 798 #: includes/Admin/FeedsPage.php: 744948 #: includes/Admin/FeedsPage.php:909 799 949 msgid "Feed details" 800 950 msgstr "" 801 951 802 #: includes/Admin/FeedsPage.php: 789952 #: includes/Admin/FeedsPage.php:965 803 953 msgid "Back to all feeds" 804 954 msgstr "" 805 955 806 #: includes/Admin/FeedsPage.php:792807 msgid "Edit feed"808 msgstr ""809 810 #: includes/Admin/FeedsPage.php:792811 msgid "Add feed"812 msgstr ""813 814 956 #. translators: %s: link to the Connections tab. 815 #: includes/Admin/FeedsPage.php: 800957 #: includes/Admin/FeedsPage.php:978 816 958 #, php-format 817 959 msgid "No Zoho accounts connected yet. Connect one under %s first." 818 960 msgstr "" 819 961 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 963 msgid "FormBridge → Connections" 964 msgstr "" 965 966 #. translators: %s: connected account name. 967 #: includes/Admin/FeedsPage.php:991 968 #, php-format 969 msgid "%s (not authorised)" 970 msgstr "" 971 972 #: includes/Admin/FeedsPage.php:1043 973 msgid "Choose a trigger" 974 msgstr "" 975 976 #: includes/Admin/FeedsPage.php:1044 977 msgid "Which form or event should send records to Zoho? Picking one moves you straight on." 978 msgstr "" 979 980 #: includes/Admin/FeedsPage.php:1049 981 msgid "Trigger guide" 982 msgstr "" 983 984 #: includes/Admin/FeedsPage.php:1053 985 msgid "Watch: every trigger" 986 msgstr "" 987 988 #: includes/Admin/FeedsPage.php:1066 989 msgid "Search triggers" 990 msgstr "" 991 992 #: includes/Admin/FeedsPage.php:1068 993 msgid "Search triggers…" 994 msgstr "" 995 996 #: includes/Admin/FeedsPage.php:1132 997 #: includes/Admin/ProUpsell.php:402 998 msgid "Pro" 999 msgstr "" 1000 1001 #: includes/Admin/FeedsPage.php:1137 1002 msgid "Available in FormBridge Pro. Opens a dialog." 1003 msgstr "" 1004 1005 #: includes/Admin/FeedsPage.php:1163 1006 msgid "No trigger matches that search." 1007 msgstr "" 1008 1009 #: includes/Admin/FeedsPage.php:1203 1010 msgid "Name this feed" 1011 msgstr "" 1012 1013 #: includes/Admin/FeedsPage.php:1204 1014 msgid "Give the feed a name you will recognise later, and pick the form it listens to." 1015 msgstr "" 1016 1017 #: includes/Admin/FeedsPage.php:1228 1018 msgid "Locked" 1019 msgstr "" 1020 1021 #: includes/Admin/FeedsPage.php:1236 1022 msgid "Change trigger" 1023 msgstr "" 1024 1025 #: includes/Admin/FeedsPage.php:1239 1026 msgid "Change" 1027 msgstr "" 1028 1029 #: includes/Admin/FeedsPage.php:1265 837 1030 msgid "Feed title" 838 1031 msgstr "" 839 1032 840 #: includes/Admin/FeedsPage.php: 853841 msgid " Send submissions for this feed."842 msgstr "" 843 844 #: includes/Admin/FeedsPage.php: 877845 msgid " Choose which form plugin this feed listens to."846 msgstr "" 847 848 #: includes/Admin/FeedsPage.php: 8891033 #: includes/Admin/FeedsPage.php:1266 1034 msgid "Contact form → Zoho CRM leads" 1035 msgstr "" 1036 1037 #: includes/Admin/FeedsPage.php:1267 1038 msgid "Only used inside WordPress. It is how this feed is listed here and named in the delivery log." 1039 msgstr "" 1040 1041 #: includes/Admin/FeedsPage.php:1281 849 1042 msgid "Loading forms…" 850 1043 msgstr "" 851 1044 852 #: includes/Admin/FeedsPage.php: 8921045 #: includes/Admin/FeedsPage.php:1284 853 1046 msgid "Reload forms" 854 1047 msgstr "" 855 1048 856 #: includes/Admin/FeedsPage.php:908 1049 #: includes/Admin/FeedsPage.php:1320 1050 #: includes/Admin/FeedsPage.php:1664 1051 #: includes/Admin/FeedsPage.php:1809 1052 msgid "Back" 1053 msgstr "" 1054 1055 #: includes/Admin/FeedsPage.php:1325 1056 #: includes/Admin/FeedsPage.php:1665 1057 msgid "Continue" 1058 msgstr "" 1059 1060 #: includes/Admin/FeedsPage.php:1339 1061 msgid "Where should records go?" 1062 msgstr "" 1063 1064 #: includes/Admin/FeedsPage.php:1340 1065 msgid "Pick the account, then the Zoho app it is authorised for, and the kind of record to create." 1066 msgstr "" 1067 1068 #: includes/Admin/FeedsPage.php:1349 1069 msgid "Zoho destination" 1070 msgstr "" 1071 1072 #: includes/Admin/FeedsPage.php:1363 1073 msgid "Connect a new account (opens in a new tab)" 1074 msgstr "" 1075 1076 #: includes/Admin/FeedsPage.php:1364 1077 msgid "Connect a new account" 1078 msgstr "" 1079 1080 #: includes/Admin/FeedsPage.php:1380 1081 msgid "Connected account" 1082 msgstr "" 1083 1084 #: includes/Admin/FeedsPage.php:1383 1085 msgid "Select" 1086 msgstr "" 1087 1088 #: includes/Admin/FeedsPage.php:1390 1089 msgid "Reload connected accounts" 1090 msgstr "" 1091 1092 #: includes/Admin/FeedsPage.php:1398 857 1093 #: includes/Privacy/PrivacyProvider.php:160 858 1094 msgid "Zoho app" 859 1095 msgstr "" 860 1096 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 1098 msgid "Watch: every Zoho app" 1099 msgstr "" 1100 1101 #: includes/Admin/FeedsPage.php:1430 870 1102 msgid "Organization" 871 1103 msgstr "" 872 1104 873 #: includes/Admin/FeedsPage.php: 940874 #: includes/Admin/FeedsPage.php: 951875 msgid " — None —"876 msgstr "" 877 878 #: includes/Admin/FeedsPage.php: 945879 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: 9491105 #: includes/Admin/FeedsPage.php:1433 1106 #: includes/Admin/FeedsPage.php:1452 1107 msgid "None" 1108 msgstr "" 1109 1110 #: includes/Admin/FeedsPage.php:1444 1111 msgid "Required for Zoho Books and Zoho Desk. Bigin captures its own organization when the connection is authorized." 1112 msgstr "" 1113 1114 #: includes/Admin/FeedsPage.php:1449 883 1115 msgid "Department" 884 1116 msgstr "" 885 1117 886 #: includes/Admin/FeedsPage.php: 9561118 #: includes/Admin/FeedsPage.php:1459 887 1119 msgid "Required for Zoho Desk tickets. Loads after you pick the organization." 888 1120 msgstr "" 889 1121 890 #: includes/Admin/FeedsPage.php: 970891 msgid " — Select an account first —"892 msgstr "" 893 894 #: includes/Admin/FeedsPage.php: 9741122 #: includes/Admin/FeedsPage.php:1474 1123 msgid "Select an account first" 1124 msgstr "" 1125 1126 #: includes/Admin/FeedsPage.php:1478 895 1127 msgid "Reload mailing lists" 896 1128 msgstr "" 897 1129 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 1131 msgid "Contact options" 1132 msgstr "" 1133 1134 #: includes/Admin/FeedsPage.php:1536 1135 msgid "How the contact is subscribed, and what it is filed under." 1136 msgstr "" 1137 1138 #: includes/Admin/FeedsPage.php:1542 1139 msgid "Subscription type" 1140 msgstr "" 1141 1142 #: includes/Admin/FeedsPage.php:1545 903 1143 msgid "Marketing" 904 1144 msgstr "" 905 1145 906 #: includes/Admin/FeedsPage.php: 9931146 #: includes/Admin/FeedsPage.php:1546 907 1147 msgid "Non-marketing" 908 1148 msgstr "" 909 1149 910 #: includes/Admin/FeedsPage.php: 9941150 #: includes/Admin/FeedsPage.php:1547 911 1151 msgid "Unsubscribed" 912 1152 msgstr "" 913 1153 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 1155 msgid "Add to list" 1156 msgstr "" 1157 1158 #: includes/Admin/FeedsPage.php:1563 1159 msgid "Right away" 1160 msgstr "" 1161 1162 #: includes/Admin/FeedsPage.php:1564 1163 msgid "After they confirm by email" 1164 msgstr "" 1165 1166 #: includes/Admin/FeedsPage.php:1569 1167 msgid "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." 1168 msgstr "" 1169 1170 #: includes/Admin/FeedsPage.php:1573 1171 msgid "Contact tags" 1172 msgstr "" 1173 1174 #: includes/Admin/FeedsPage.php:1580 1175 msgid "Reload tags" 1176 msgstr "" 1177 1178 #: includes/Admin/FeedsPage.php:1582 1179 msgid "Optional. Pick one or more existing tags. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one." 1180 msgstr "" 1181 1182 #: includes/Admin/FeedsPage.php:1587 1183 msgid "Associate contacts with these topics" 1184 msgstr "" 1185 1186 #: includes/Admin/FeedsPage.php:1594 919 1187 msgid "Reload topics" 920 1188 msgstr "" 921 1189 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 1191 msgid "Optional. Leave empty to skip topic association entirely. Loads after you pick an account. Hold Ctrl/Cmd to pick more than one." 1192 msgstr "" 1193 1194 #: includes/Admin/FeedsPage.php:1618 1195 msgid "Duplicate handling" 1196 msgstr "" 1197 1198 #: includes/Admin/FeedsPage.php:1619 1199 msgid "What to do when Zoho already holds a record for the same person." 1200 msgstr "" 1201 1202 #: includes/Admin/FeedsPage.php:1629 939 1203 msgid "Update the matching Zoho record instead of creating a duplicate." 940 1204 msgstr "" 941 1205 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 1207 msgid "Fields Zoho marks unique" 1208 msgstr "" 1209 1210 #: includes/Admin/FeedsPage.php:1649 1211 msgid "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." 1212 msgstr "" 1213 1214 #: includes/Admin/FeedsPage.php:1655 1215 msgid "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." 1216 msgstr "" 1217 1218 #: includes/Admin/FeedsPage.php:1672 1219 #: includes/Admin/FeedsPage.php:2155 1220 msgid "Match the fields" 1221 msgstr "" 1222 1223 #: includes/Admin/FeedsPage.php:1673 1224 msgid "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." 1225 msgstr "" 1226 1227 #: includes/Admin/FeedsPage.php:1680 968 1228 msgid "Field mapping" 969 1229 msgstr "" 970 1230 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 1232 msgid "Loading Zoho fields" 1233 msgstr "" 1234 1235 #: includes/Admin/FeedsPage.php:1700 1236 msgid "Fetching the field list from Zoho…" 1237 msgstr "" 1238 1239 #: includes/Admin/FeedsPage.php:1701 1240 msgid "This is taking longer than usual. Zoho can be slow to answer, and the editor will carry on as soon as it does." 1241 msgstr "" 1242 1243 #: includes/Admin/FeedsPage.php:1718 1244 msgid "Source" 1245 msgstr "" 1246 1247 #: includes/Admin/FeedsPage.php:1719 1248 #: includes/Admin/FeedsPage.php:2265 1249 msgid "Form field" 1250 msgstr "" 1251 1252 #: includes/Admin/FeedsPage.php:1720 976 1253 msgid "Zoho field" 977 1254 msgstr "" 978 1255 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 990 1258 msgid "Static value" 991 1259 msgstr "" 992 1260 993 #: includes/Admin/FeedsPage.php:1 1051261 #: includes/Admin/FeedsPage.php:1750 994 1262 msgid "+ Add mapping row" 995 1263 msgstr "" 996 1264 997 #: includes/Admin/FeedsPage.php:1122 1265 #: includes/Admin/FeedsPage.php:1795 1266 msgid "Test this feed" 1267 msgstr "" 1268 1269 #: includes/Admin/FeedsPage.php:1796 1270 msgid "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)." 1271 msgstr "" 1272 1273 #: includes/Admin/FeedsPage.php:1800 1274 msgid "Send test record" 1275 msgstr "" 1276 1277 #: includes/Admin/FeedsPage.php:1810 998 1278 msgid "Save feed" 999 1279 msgstr "" 1000 1280 1001 #: includes/Admin/FeedsPage.php:1 1221281 #: includes/Admin/FeedsPage.php:1810 1002 1282 msgid "Create feed" 1003 1283 msgstr "" 1004 1284 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 1290 msgid "%s guide" 1291 msgstr "" 1292 1293 #: includes/Admin/FeedsPage.php:2143 1294 msgid "Where records come from" 1295 msgstr "" 1296 1297 #: includes/Admin/FeedsPage.php:2147 1298 msgid "Name and form" 1299 msgstr "" 1300 1301 #: includes/Admin/FeedsPage.php:2151 1302 msgid "Zoho app and account" 1303 msgstr "" 1304 1305 #: includes/Admin/FeedsPage.php:2154 1306 msgid "Mapping" 1307 msgstr "" 1308 1309 #: includes/Admin/FeedsPage.php:2162 1310 msgid "Feed setup" 1311 msgstr "" 1312 1313 #. translators: 1: step number, 2: total number of steps. 1314 #: includes/Admin/FeedsPage.php:2194 1315 #, php-format 1316 msgid "Step %1$s of %2$s:" 1317 msgstr "" 1318 1319 #: includes/Admin/FeedsPage.php:2200 1320 msgid "(locked)" 1321 msgstr "" 1322 1323 #: includes/Admin/FeedsPage.php:2222 1324 msgid "A saved feed keeps its trigger. To send a different form or event to Zoho, create a new feed." 1325 msgstr "" 1326 1327 #: includes/Admin/FeedsPage.php:2271 1328 msgid "Combine fields" 1329 msgstr "" 1330 1331 #: includes/Admin/FeedsPage.php:2312 1018 1332 msgid "Remove" 1019 1333 msgstr "" 1020 1334 1021 #: includes/Admin/FeedsPage.php: 12031335 #: includes/Admin/FeedsPage.php:2335 1022 1336 msgid "Save the feed first, then test it." 1023 1337 msgstr "" 1024 1338 1025 1339 #. translators: %s: Zoho record id. 1026 #: includes/Admin/FeedsPage.php: 12861027 #, php-format 1028 msgid "Success —record updated in Zoho (id %s)."1029 msgstr "" 1030 1031 #: includes/Admin/FeedsPage.php: 12871032 msgid "Success —a record was updated in Zoho."1340 #: includes/Admin/FeedsPage.php:2424 1341 #, php-format 1342 msgid "Success: record updated in Zoho (id %s)." 1343 msgstr "" 1344 1345 #: includes/Admin/FeedsPage.php:2425 1346 msgid "Success: a record was updated in Zoho." 1033 1347 msgstr "" 1034 1348 1035 1349 #. translators: %s: Zoho record id. 1036 #: includes/Admin/FeedsPage.php: 12911037 #, php-format 1038 msgid "Success —record created in Zoho (id %s)."1039 msgstr "" 1040 1041 #: includes/Admin/FeedsPage.php: 12921042 msgid "Success —a test record was created in Zoho."1043 msgstr "" 1044 1045 #: includes/Admin/FeedsPage.php: 16111046 #: includes/Admin/SettingsPage.php: 3931350 #: includes/Admin/FeedsPage.php:2429 1351 #, php-format 1352 msgid "Success: record created in Zoho (id %s)." 1353 msgstr "" 1354 1355 #: includes/Admin/FeedsPage.php:2430 1356 msgid "Success: a test record was created in Zoho." 1357 msgstr "" 1358 1359 #: includes/Admin/FeedsPage.php:2878 1360 #: includes/Admin/SettingsPage.php:464 1047 1361 msgid "Dismiss" 1048 1362 msgstr "" 1049 1363 1050 #: includes/Admin/FeedsPage.php: 16241364 #: includes/Admin/FeedsPage.php:2891 1051 1365 msgid "Feed saved." 1052 1366 msgstr "" 1053 1367 1054 #: includes/Admin/FeedsPage.php: 16281055 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: 16311368 #: includes/Admin/FeedsPage.php:2895 1369 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." 1370 msgstr "" 1371 1372 #: includes/Admin/FeedsPage.php:2898 1059 1373 msgid "Feed deleted." 1060 1374 msgstr "" 1061 1375 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 1379 msgid "%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." 1380 msgid_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." 1381 msgstr[0] "" 1382 msgstr[1] "" 1383 1062 1384 #. translators: %d: number of mapping rows that were left out. 1063 #: includes/Admin/FeedsPage.php: 16431385 #: includes/Admin/FeedsPage.php:2928 1064 1386 #, php-format 1065 1387 msgid "%d mapping row was left out because it had no form field chosen and no static value typed. Finish the row and save again." … … 1068 1390 msgstr[1] "" 1069 1391 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 1395 msgid "%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." 1396 msgid_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." 1397 msgstr[0] "" 1398 msgstr[1] "" 1399 1400 #: includes/Admin/ProUpsell.php:92 1401 msgid "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." 1402 msgstr "" 1403 1404 #: includes/Admin/ProUpsell.php:93 1405 msgid "Members reach Zoho when a membership starts, renews, lapses or is cancelled, carrying the plan name and the expiry date with them." 1406 msgstr "" 1407 1408 #: includes/Admin/ProUpsell.php:94 1409 msgid "Learners reach Zoho when they are given access to a course, and again when they finish it, with the course name and their progress." 1410 msgstr "" 1411 1412 #: includes/Admin/ProUpsell.php:95 1413 msgid "Students reach Zoho when they enrol, when they complete a course, and when an enrolment is cancelled." 1414 msgstr "" 1415 1416 #: includes/Admin/ProUpsell.php:96 1417 msgid "Donors reach Zoho the moment a donation completes, with the amount, the currency and the campaign they gave to." 1418 msgstr "" 1419 1420 #: includes/Admin/ProUpsell.php:97 1421 msgid "Buyers reach Zoho when a purchase completes, with what they bought and what they have spent with you in total." 1422 msgstr "" 1423 1424 #: includes/Admin/ProUpsell.php:98 1425 msgid "Customers reach Zoho when an appointment is booked, rescheduled or cancelled, with the service and the appointment time." 1426 msgstr "" 1427 1428 #: includes/Admin/ProUpsell.php:99 1429 msgid "Vendors reach Zoho when a store is created, and again whenever the store profile changes, with shop name, address and payment contact included." 1430 msgstr "" 1431 1432 #: includes/Admin/ProUpsell.php:100 1433 msgid "Employers reach Zoho when they submit a job listing, with the company name, the listing title and where the role is based." 1434 msgstr "" 1435 1436 #: includes/Admin/ProUpsell.php:101 1437 msgid "Attendees reach Zoho as tickets are bought and RSVPs come in, with the event they are coming to and the ticket they hold." 1438 msgstr "" 1439 1440 #: includes/Admin/ProUpsell.php:102 1441 msgid "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." 1442 msgstr "" 1443 1444 #. translators: %s: source name, e.g. "WooCommerce Customer". 1445 #: includes/Admin/ProUpsell.php:119 1446 #, php-format 1447 msgid "%s is part of FormBridge Pro" 1448 msgstr "" 1449 1450 #: includes/Admin/ProUpsell.php:130 1451 msgid "Ten triggers that are people, not forms" 1452 msgstr "" 1453 1454 #: includes/Admin/ProUpsell.php:131 1455 msgid "Customers, members, learners, students, donors, vendors, employers and attendees, each one syncing on its own events with no form involved." 1456 msgstr "" 1457 1458 #: includes/Admin/ProUpsell.php:134 1459 msgid "Zoho Inventory as a destination" 1460 msgstr "" 1461 1462 #: includes/Admin/ProUpsell.php:135 1463 msgid "A seventh Zoho app alongside CRM, Bigin, Books, Desk, Campaigns and Marketing Automation." 1464 msgstr "" 1465 1466 #: includes/Admin/ProUpsell.php:138 1467 msgid "Nothing new to learn" 1468 msgstr "" 1469 1470 #: includes/Admin/ProUpsell.php:139 1471 msgid "A Pro trigger is an ordinary feed: the same field mapping, the same duplicate handling, the same delivery log and retries." 1472 msgstr "" 1473 1474 #: includes/Admin/ProUpsell.php:142 1475 msgid "Updates in your dashboard" 1476 msgstr "" 1477 1478 #: includes/Admin/ProUpsell.php:143 1479 msgid "Activate your licence once and Pro updates from the Plugins screen like anything else." 1480 msgstr "" 1481 1482 #: includes/Admin/ProUpsell.php:257 1483 #: includes/Admin/ProUpsell.php:294 1484 #: includes/Admin/ProUpsell.php:300 1485 msgid "Upgrade to Pro" 1486 msgstr "" 1487 1488 #: includes/Admin/ProUpsell.php:258 1489 #: includes/Admin/ProUpsell.php:449 1490 msgid "(opens in a new tab)" 1491 msgstr "" 1492 1493 #: includes/Admin/ProUpsell.php:359 1494 msgid "Upgrade to Pro (opens in a new tab)" 1495 msgstr "" 1496 1497 #: includes/Admin/ProUpsell.php:423 1498 msgid "Included with Pro" 1499 msgstr "" 1500 1501 #: includes/Admin/ProUpsell.php:448 1502 msgid "Get FormBridge Pro" 1503 msgstr "" 1504 1505 #: includes/Admin/ProUpsell.php:457 1506 msgid "Maybe later" 1507 msgstr "" 1508 1509 #: includes/Admin/SettingsPage.php:104 1510 #: includes/Admin/SettingsPage.php:105 1511 #: includes/Admin/SettingsPage.php:305 1073 1512 msgid "Connections" 1074 1513 msgstr "" 1075 1514 1076 #: includes/Admin/SettingsPage.php:1 461515 #: includes/Admin/SettingsPage.php:160 1077 1516 msgid "Select a Zoho service" 1078 1517 msgstr "" 1079 1518 1080 #: includes/Admin/SettingsPage.php:1 471519 #: includes/Admin/SettingsPage.php:161 1081 1520 msgid "Select an organization" 1082 1521 msgstr "" 1083 1522 1084 #: includes/Admin/SettingsPage.php:1 481523 #: includes/Admin/SettingsPage.php:162 1085 1524 msgid "Load modules" 1086 1525 msgstr "" 1087 1526 1088 #: includes/Admin/SettingsPage.php:1 491527 #: includes/Admin/SettingsPage.php:163 1089 1528 msgid "Modules" 1090 1529 msgstr "" 1091 1530 1092 #: includes/Admin/SettingsPage.php:1 501531 #: includes/Admin/SettingsPage.php:164 1093 1532 msgid "Fields" 1094 1533 msgstr "" 1095 1534 1096 #: includes/Admin/SettingsPage.php:1 511535 #: includes/Admin/SettingsPage.php:165 1097 1536 msgid "API name" 1098 1537 msgstr "" 1099 1538 1100 #: includes/Admin/SettingsPage.php:1 521101 #: includes/Admin/SettingsPage.php: 5201539 #: includes/Admin/SettingsPage.php:166 1540 #: includes/Admin/SettingsPage.php:611 1102 1541 msgid "Label" 1103 1542 msgstr "" 1104 1543 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 1545 msgid "Copy these API names into a feed under FormBridge → All Feeds." 1546 msgstr "" 1547 1548 #: includes/Admin/SettingsPage.php:169 1114 1549 msgid "Nothing returned." 1115 1550 msgstr "" 1116 1551 1117 #: includes/Admin/SettingsPage.php:1 561552 #: includes/Admin/SettingsPage.php:170 1118 1553 msgid "Sending…" 1119 1554 msgstr "" 1120 1555 1121 #: includes/Admin/SettingsPage.php:1 571122 #: includes/Admin/SettingsPage.php:1 0361556 #: includes/Admin/SettingsPage.php:171 1557 #: includes/Admin/SettingsPage.php:1377 1123 1558 msgid "Send test email" 1124 1559 msgstr "" 1125 1560 1126 #: includes/Admin/SettingsPage.php:1 581127 #: includes/Auth/ConnectionManager.php: 2681561 #: includes/Admin/SettingsPage.php:172 1562 #: includes/Auth/ConnectionManager.php:411 1128 1563 msgid "Choose at least one Zoho app for this connection." 1129 1564 msgstr "" 1130 1565 1131 #: includes/Admin/SettingsPage.php: 2711566 #: includes/Admin/SettingsPage.php:306 1132 1567 msgid "Logs" 1133 1568 msgstr "" 1134 1569 1135 #: includes/Admin/SettingsPage.php: 2721570 #: includes/Admin/SettingsPage.php:307 1136 1571 msgid "Settings" 1137 1572 msgstr "" 1138 1573 1139 #: includes/Admin/SettingsPage.php:305 1574 #: includes/Admin/SettingsPage.php:358 1575 msgid "Back to feeds" 1576 msgstr "" 1577 1578 #: includes/Admin/SettingsPage.php:376 1140 1579 msgid "Zoho account connected." 1141 1580 msgstr "" 1142 1581 1143 #: includes/Admin/SettingsPage.php:3 061582 #: includes/Admin/SettingsPage.php:377 1144 1583 msgid "Security check failed. Please try connecting again." 1145 1584 msgstr "" 1146 1585 1147 #: includes/Admin/SettingsPage.php:3 071586 #: includes/Admin/SettingsPage.php:378 1148 1587 msgid "Authorisation was cancelled or denied." 1149 1588 msgstr "" 1150 1589 1151 #: includes/Admin/SettingsPage.php:3 081590 #: includes/Admin/SettingsPage.php:379 1152 1591 msgid "Could not exchange the code for tokens. Check the client credentials and data centre." 1153 1592 msgstr "" 1154 1593 1155 #: includes/Admin/SettingsPage.php:3 091594 #: includes/Admin/SettingsPage.php:380 1156 1595 msgid "Zoho authorised the connection, but the refresh token could not be encrypted, so nothing was saved. Enable PHP's OpenSSL extension and try again." 1157 1596 msgstr "" 1158 1597 1159 #: includes/Admin/SettingsPage.php:3 281598 #: includes/Admin/SettingsPage.php:399 1160 1599 msgid "Settings saved." 1161 1600 msgstr "" 1162 1601 1163 #: includes/Admin/SettingsPage.php: 3291602 #: includes/Admin/SettingsPage.php:400 1164 1603 msgid "All log entries were deleted." 1165 1604 msgstr "" 1166 1605 1167 #: includes/Admin/SettingsPage.php: 3301606 #: includes/Admin/SettingsPage.php:401 1168 1607 msgid "Connection deleted." 1169 1608 msgstr "" 1170 1609 1171 #: includes/Admin/SettingsPage.php:4 191172 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:4 201610 #: includes/Admin/SettingsPage.php:490 1611 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." 1612 msgstr "" 1613 1614 #: includes/Admin/SettingsPage.php:491 1176 1615 msgid "Cannot store credentials securely" 1177 1616 msgstr "" 1178 1617 1179 #: includes/Admin/SettingsPage.php: 4341618 #: includes/Admin/SettingsPage.php:505 1180 1619 msgid "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." 1181 1620 msgstr "" 1182 1621 1183 #: includes/Admin/SettingsPage.php: 4351622 #: includes/Admin/SettingsPage.php:506 1184 1623 msgid "Stored credentials are weakly protected" 1185 1624 msgstr "" 1186 1625 1187 #: includes/Admin/SettingsPage.php: 4541188 #: includes/Admin/SettingsPage.php: 6191626 #: includes/Admin/SettingsPage.php:525 1627 #: includes/Admin/SettingsPage.php:759 1189 1628 msgid "Connect one or more Zoho accounts. Each feed can then pick which account, app and module to use." 1190 1629 msgstr "" 1191 1630 1192 #: includes/Admin/SettingsPage.php: 4581631 #: includes/Admin/SettingsPage.php:529 1193 1632 msgid "Connected accounts" 1194 1633 msgstr "" 1195 1634 1196 #: includes/Admin/SettingsPage.php: 4691197 #: includes/Admin/SettingsPage.php: 6251635 #: includes/Admin/SettingsPage.php:540 1636 #: includes/Admin/SettingsPage.php:765 1198 1637 msgid "Create Connection" 1199 1638 msgstr "" 1200 1639 1201 #: includes/Admin/SettingsPage.php: 4951640 #: includes/Admin/SettingsPage.php:566 1202 1641 msgid "Create a connection" 1203 1642 msgstr "" 1204 1643 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 1647 msgid "Register this site as a Server-based Application in the %s, then paste the credentials below." 1648 msgstr "" 1649 1650 #: includes/Admin/SettingsPage.php:580 1651 msgid "Zoho API console" 1652 msgstr "" 1653 1654 #: includes/Admin/SettingsPage.php:596 1210 1655 msgid "Redirect URI for the Zoho console" 1211 1656 msgstr "" 1212 1657 1213 #: includes/Admin/SettingsPage.php: 5201658 #: includes/Admin/SettingsPage.php:611 1214 1659 msgid "A name to recognise this account." 1215 1660 msgstr "" 1216 1661 1217 #: includes/Admin/SettingsPage.php: 5211662 #: includes/Admin/SettingsPage.php:612 1218 1663 msgid "Data centre" 1219 1664 msgstr "" 1220 1665 1221 #: includes/Admin/SettingsPage.php: 5241666 #: includes/Admin/SettingsPage.php:615 1222 1667 msgid "Authorize for" 1223 1668 msgstr "" 1224 1669 1225 #: includes/Admin/SettingsPage.php: 5431226 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: 5461670 #: includes/Admin/SettingsPage.php:634 1671 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." 1672 msgstr "" 1673 1674 #: includes/Admin/SettingsPage.php:637 1230 1675 msgid "Client ID" 1231 1676 msgstr "" 1232 1677 1233 #: includes/Admin/SettingsPage.php: 5471678 #: includes/Admin/SettingsPage.php:638 1234 1679 msgid "Client Secret" 1235 1680 msgstr "" 1236 1681 1237 #: includes/Admin/SettingsPage.php: 5541682 #: includes/Admin/SettingsPage.php:645 1238 1683 msgid "Connect with Zoho" 1239 1684 msgstr "" 1240 1685 1241 #: includes/Admin/SettingsPage.php:618 1686 #: includes/Admin/SettingsPage.php:662 1687 msgid "Setup guide" 1688 msgstr "" 1689 1690 #: includes/Admin/SettingsPage.php:669 1691 msgid "Watch: connect an account" 1692 msgstr "" 1693 1694 #: includes/Admin/SettingsPage.php:758 1242 1695 msgid "Connect your first Zoho account" 1243 1696 msgstr "" 1244 1697 1245 #: includes/Admin/SettingsPage.php: 6341698 #: includes/Admin/SettingsPage.php:776 1246 1699 msgid "Account" 1247 1700 msgstr "" 1248 1701 1249 #: includes/Admin/SettingsPage.php: 6351702 #: includes/Admin/SettingsPage.php:777 1250 1703 msgid "Authorized for" 1251 1704 msgstr "" 1252 1705 1253 #: includes/Admin/SettingsPage.php: 7001254 #: includes/Admin/SettingsPage.php: 7611255 #: includes/Auth/ConnectionManager.php: 1161706 #: includes/Admin/SettingsPage.php:842 1707 #: includes/Admin/SettingsPage.php:906 1708 #: includes/Auth/ConnectionManager.php:200 1256 1709 msgid "All applications" 1257 1710 msgstr "" 1258 1711 1259 #: includes/Admin/SettingsPage.php: 7091712 #: includes/Admin/SettingsPage.php:851 1260 1713 msgid "Edit access" 1261 1714 msgstr "" 1262 1715 1263 #: includes/Admin/SettingsPage.php: 7151716 #: includes/Admin/SettingsPage.php:857 1264 1717 msgid "Connected" 1265 1718 msgstr "" 1266 1719 1267 #: includes/Admin/SettingsPage.php: 7171720 #: includes/Admin/SettingsPage.php:859 1268 1721 msgid "Not authorised" 1269 1722 msgstr "" 1270 1723 1271 #: includes/Admin/SettingsPage.php: 7221724 #: includes/Admin/SettingsPage.php:864 1272 1725 msgid "Re-authorize" 1273 1726 msgstr "" 1274 1727 1275 #: includes/Admin/SettingsPage.php: 7221728 #: includes/Admin/SettingsPage.php:864 1276 1729 msgid "Re-authorize with the same access" 1277 1730 msgstr "" 1278 1731 1279 #: includes/Admin/SettingsPage.php: 7281732 #: includes/Admin/SettingsPage.php:874 1280 1733 msgid "Bigin org lookup failed" 1281 1734 msgstr "" 1282 1735 1283 #: includes/Admin/SettingsPage.php: 7291736 #: includes/Admin/SettingsPage.php:875 1284 1737 msgid "Reconnect to fix" 1285 1738 msgstr "" 1286 1739 1287 #: includes/Admin/SettingsPage.php: 7391740 #: includes/Admin/SettingsPage.php:885 1288 1741 msgid "Modules & fields" 1289 1742 msgstr "" 1290 1743 1291 #: includes/Admin/SettingsPage.php: 7401744 #: includes/Admin/SettingsPage.php:886 1292 1745 msgid "Delete this connection? Any feed using it will stop sending until a new connection is picked." 1293 1746 msgstr "" 1294 1747 1295 #: includes/Admin/SettingsPage.php: 7561748 #: includes/Admin/SettingsPage.php:902 1296 1749 msgid "Check every Zoho app this connection should have access to, then reauthorize. Zoho will ask you to approve the updated list." 1297 1750 msgstr "" 1298 1751 1299 #: includes/Admin/SettingsPage.php: 7661752 #: includes/Admin/SettingsPage.php:916 1300 1753 msgid "Update & Reauthorize" 1301 1754 msgstr "" 1302 1755 1303 #: includes/Admin/SettingsPage.php: 7971304 msgid "Copy an API name into a feed under Techwave Sync → Forms."1305 msgstr "" 1306 1307 #: includes/Admin/SettingsPage.php: 8271756 #: includes/Admin/SettingsPage.php:947 1757 msgid "Copy an API name into a feed under FormBridge → All Feeds." 1758 msgstr "" 1759 1760 #: includes/Admin/SettingsPage.php:983 1308 1761 msgid "Resubmit selected" 1309 1762 msgstr "" 1310 1763 1311 #: includes/Admin/SettingsPage.php: 8381764 #: includes/Admin/SettingsPage.php:994 1312 1765 msgid "Live" 1313 1766 msgstr "" 1314 1767 1315 #: includes/Admin/SettingsPage.php: 8431768 #: includes/Admin/SettingsPage.php:999 1316 1769 msgid "Clear logs" 1317 1770 msgstr "" 1318 1771 1319 #: includes/Admin/SettingsPage.php: 8561772 #: includes/Admin/SettingsPage.php:1012 1320 1773 msgid "No log entries yet" 1321 1774 msgstr "" 1322 1775 1323 #: includes/Admin/SettingsPage.php: 8571324 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: 8651776 #: includes/Admin/SettingsPage.php:1013 1777 msgid "Every delivery to Zoho is recorded here: successes, failures and the exact data sent. New submissions appear instantly while Live is on." 1778 msgstr "" 1779 1780 #: includes/Admin/SettingsPage.php:1021 1328 1781 msgid "Select all log entries" 1329 1782 msgstr "" 1330 1783 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 1787 msgid "Time (%s)" 1788 msgstr "" 1789 1790 #: includes/Admin/SettingsPage.php:1024 1336 1791 msgid "App" 1337 1792 msgstr "" 1338 1793 1339 #: includes/Admin/SettingsPage.php: 8721794 #: includes/Admin/SettingsPage.php:1028 1340 1795 #: includes/Privacy/PrivacyProvider.php:168 1341 1796 msgid "Message" 1342 1797 msgstr "" 1343 1798 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 1802 msgid "Page %1$s of %2$s · %3$s entry" 1803 msgid_plural "Page %1$s of %2$s · %3$s entries" 1804 msgstr[0] "" 1805 msgstr[1] "" 1806 1807 #: includes/Admin/SettingsPage.php:1320 1808 #: includes/Admin/SettingsPage.php:1326 1346 1809 msgid "Logging" 1347 1810 msgstr "" 1348 1811 1349 #: includes/Admin/SettingsPage.php: 9801812 #: includes/Admin/SettingsPage.php:1321 1350 1813 msgid "What the Logs tab records, and how long it keeps it." 1351 1814 msgstr "" 1352 1815 1353 #: includes/Admin/SettingsPage.php: 9861816 #: includes/Admin/SettingsPage.php:1327 1354 1817 msgid "Record the outcome of every delivery on the Logs tab." 1355 1818 msgstr "" 1356 1819 1357 #: includes/Admin/SettingsPage.php: 9921820 #: includes/Admin/SettingsPage.php:1333 1358 1821 msgid "Store submitted values in the log" 1359 1822 msgstr "" 1360 1823 1361 #: includes/Admin/SettingsPage.php: 9931362 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: 9991824 #: includes/Admin/SettingsPage.php:1334 1825 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 until the entry is deleted." 1826 msgstr "" 1827 1828 #: includes/Admin/SettingsPage.php:1340 1366 1829 msgid "Delete log entries automatically" 1367 1830 msgstr "" 1368 1831 1369 #: includes/Admin/SettingsPage.php:1 0001370 msgid " Deletes log entries older than this many days,once a day."1371 msgstr "" 1372 1373 #: includes/Admin/SettingsPage.php:1 0011832 #: includes/Admin/SettingsPage.php:1341 1833 msgid "Off by default, so log entries stay until you delete them. When on, entries older than this many days are deleted once a day." 1834 msgstr "" 1835 1836 #: includes/Admin/SettingsPage.php:1342 1374 1837 msgid "Days to keep log entries" 1375 1838 msgstr "" 1376 1839 1377 #: includes/Admin/SettingsPage.php:1 0031840 #: includes/Admin/SettingsPage.php:1344 1378 1841 msgid "Days" 1379 1842 msgstr "" 1380 1843 1381 #: includes/Admin/SettingsPage.php:1 0071844 #: includes/Admin/SettingsPage.php:1348 1382 1845 msgid "days" 1383 1846 msgstr "" 1384 1847 1385 #: includes/Admin/SettingsPage.php:1 0131848 #: includes/Admin/SettingsPage.php:1354 1386 1849 msgid "Notifications" 1387 1850 msgstr "" 1388 1851 1389 #: includes/Admin/SettingsPage.php:1 0141852 #: includes/Admin/SettingsPage.php:1355 1390 1853 msgid "Emails sent to the site admin address when something needs attention." 1391 1854 msgstr "" 1392 1855 1393 #: includes/Admin/SettingsPage.php:1 0191856 #: includes/Admin/SettingsPage.php:1360 1394 1857 msgid "Email me when an integration fails" 1395 1858 msgstr "" 1396 1859 1397 #: includes/Admin/SettingsPage.php:1 0201860 #: includes/Admin/SettingsPage.php:1361 1398 1861 msgid "Sends a notification to the site admin email whenever a submission permanently fails to reach Zoho." 1399 1862 msgstr "" 1400 1863 1401 #: includes/Admin/SettingsPage.php:1 0261864 #: includes/Admin/SettingsPage.php:1367 1402 1865 msgid "Email me when a connection fails or is deleted" 1403 1866 msgstr "" 1404 1867 1405 #: includes/Admin/SettingsPage.php:1 0271868 #: includes/Admin/SettingsPage.php:1368 1406 1869 msgid "Sends a notification to the site admin email whenever a Zoho connection stops working or is removed from the Connections tab." 1407 1870 msgstr "" 1408 1871 1409 1872 #. translators: %s: administration email address. 1410 #: includes/Admin/SettingsPage.php:1 0401411 #, 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:1 0541873 #: includes/Admin/SettingsPage.php:1381 1874 #, php-format 1875 msgid "Sends one message to %s with the PHP mail function, the same way the notifications are sent." 1876 msgstr "" 1877 1878 #: includes/Admin/SettingsPage.php:1395 1416 1879 msgid "Data & privacy" 1417 1880 msgstr "" 1418 1881 1419 #: includes/Admin/SettingsPage.php:1 0551882 #: includes/Admin/SettingsPage.php:1396 1420 1883 msgid "What happens to everything this plugin stored, if you ever delete it." 1421 1884 msgstr "" 1422 1885 1423 #: includes/Admin/SettingsPage.php:1 0611886 #: includes/Admin/SettingsPage.php:1402 1424 1887 msgid "Erase all plugin data when the plugin is deleted" 1425 1888 msgstr "" 1426 1889 1427 #: includes/Admin/SettingsPage.php:1 0621428 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 1891 msgid "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." 1429 1892 msgstr "" 1430 1893 1431 1894 #. 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 1897 msgid "FormBridge for Zoho version %s" 1898 msgstr "" 1899 1900 #. translators: %s: file path. 1901 #: includes/Api/AbstractClient.php:188 1902 #, php-format 1903 msgid "The uploaded file could not be read from %s." 1904 msgstr "" 1905 1906 #. translators: %d: the limit in megabytes. 1907 #: includes/Api/AbstractClient.php:203 1908 #, php-format 1909 msgid "This submission has already sent %dMB of files, which is all this request will carry." 1910 msgstr "" 1911 1912 #. translators: 1: file name, 2: Zoho's limit in megabytes. 1913 #: includes/Api/AbstractClient.php:214 1914 #, php-format 1915 msgid "%1$s is larger than the %2$dMB Zoho accepts for a file field." 1916 msgstr "" 1917 1918 #: includes/Api/AbstractClient.php:235 1919 msgid "The uploaded file could not be read." 1920 msgstr "" 1921 1922 #: includes/Api/AbstractClient.php:290 1923 msgid "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." 1439 1924 msgstr "" 1440 1925 1441 1926 #. translators: 1: HTTP status code, 2: detail from Zoho. 1442 #: includes/Api/AbstractClient.php:198 1927 #: includes/Api/AbstractClient.php:299 1928 #, php-format 1929 msgid "Zoho refused the file upload. HTTP %1$d. %2$s" 1930 msgstr "" 1931 1932 #: includes/Api/AbstractClient.php:570 1933 msgid "Zoho rejected the access token. The connection may need reauthorizing: open Connections, use \"Edit access\", and press Update & Reauthorize." 1934 msgstr "" 1935 1936 #. translators: 1: HTTP status code, 2: detail from Zoho. 1937 #: includes/Api/AbstractClient.php:580 1443 1938 #, php-format 1444 1939 msgid "Zoho API returned HTTP %1$d. %2$s" 1445 1940 msgstr "" 1446 1941 1447 #: includes/Api/AbstractClient.php: 2911942 #: includes/Api/AbstractClient.php:673 1448 1943 msgid "No detail provided." 1449 1944 msgstr "" 1450 1945 1451 #: includes/Api/BiginClient.php:1 081946 #: includes/Api/BiginClient.php:120 1452 1947 msgid "Zoho responded, but the organization info did not include a domain_name." 1453 1948 msgstr "" 1454 1949 1455 #: includes/Api/BooksClient.php: 801950 #: includes/Api/BooksClient.php:281 1456 1951 msgid "Customers / Contacts" 1457 1952 msgstr "" 1458 1953 1459 #: includes/Api/BooksClient.php: 841954 #: includes/Api/BooksClient.php:285 1460 1955 msgid "Items" 1461 1956 msgstr "" 1462 1957 1463 #: includes/Api/BooksClient.php: 881958 #: includes/Api/BooksClient.php:289 1464 1959 msgid "Invoices" 1465 1960 msgstr "" 1466 1961 1467 #: includes/Api/BooksClient.php: 921962 #: includes/Api/BooksClient.php:293 1468 1963 msgid "Estimates" 1469 1964 msgstr "" 1470 1965 1471 #: includes/Api/BooksClient.php: 961966 #: includes/Api/BooksClient.php:297 1472 1967 msgid "Sales Orders" 1473 1968 msgstr "" 1474 1969 1475 #: includes/Api/BooksClient.php: 1001970 #: includes/Api/BooksClient.php:301 1476 1971 msgid "Purchase Orders" 1477 1972 msgstr "" 1478 1973 1479 #: includes/Api/BooksClient.php: 1041974 #: includes/Api/BooksClient.php:305 1480 1975 msgid "Bills" 1481 1976 msgstr "" 1482 1977 1483 #: includes/Api/BooksClient.php: 1081978 #: includes/Api/BooksClient.php:309 1484 1979 msgid "Expenses" 1485 1980 msgstr "" 1486 1981 1487 #: includes/Api/BooksClient.php: 1981982 #: includes/Api/BooksClient.php:567 1488 1983 msgid "Select a Zoho Books organisation for this form." 1489 1984 msgstr "" 1490 1985 1491 #: includes/Api/BooksClient.php: 2261492 #: includes/Api/DeskClient.php: 3141986 #: includes/Api/BooksClient.php:696 1987 #: includes/Api/DeskClient.php:425 1493 1988 msgid "Web Lead" 1494 1989 msgstr "" 1495 1990 1496 #: includes/Api/CampaignsClient.php: 1581991 #: includes/Api/CampaignsClient.php:200 1497 1992 msgid "Zoho did not return any contact fields for this account." 1498 1993 msgstr "" 1499 1994 1500 1995 #. translators: %s: comma-separated list of keys Zoho actually sent for one field row. 1501 #: includes/Api/CampaignsClient.php: 1881996 #: includes/Api/CampaignsClient.php:230 1502 1997 #, php-format 1503 1998 msgid "Zoho returned %1$d contact field row(s), but none matched the expected format. Keys seen: %2$s" 1504 1999 msgstr "" 1505 2000 1506 #: includes/Api/CampaignsClient.php: 1901507 #: includes/Api/CampaignsClient.php: 3581508 #: includes/Api/MarketingAutomationClient.php: 1692001 #: includes/Api/CampaignsClient.php:232 2002 #: includes/Api/CampaignsClient.php:400 2003 #: includes/Api/MarketingAutomationClient.php:211 1509 2004 msgid "(row was not an object)" 1510 2005 msgstr "" 1511 2006 1512 #: includes/Api/CampaignsClient.php:3 022007 #: includes/Api/CampaignsClient.php:344 1513 2008 msgid "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." 1514 2009 msgstr "" 1515 2010 1516 #: includes/Api/CampaignsClient.php:3 112011 #: includes/Api/CampaignsClient.php:353 1517 2012 msgid "Zoho rejected the tags request." 1518 2013 msgstr "" 1519 2014 1520 2015 #. translators: %s: comma-separated list of keys Zoho actually sent for one tag row. 1521 #: includes/Api/CampaignsClient.php:3 562016 #: includes/Api/CampaignsClient.php:398 1522 2017 #, php-format 1523 2018 msgid "Zoho returned %1$d tag row(s), but none matched the expected format. Keys seen: %2$s" 2019 msgstr "" 2020 2021 #: includes/Api/CrmClient.php:74 2022 msgid "Zoho accepted the file but returned no id for it." 1524 2023 msgstr "" 1525 2024 … … 1536 2035 msgstr "" 1537 2036 1538 #: includes/Api/DeskClient.php:1 302037 #: includes/Api/DeskClient.php:197 1539 2038 msgid "Subject" 1540 2039 msgstr "" 1541 2040 1542 #: includes/Api/DeskClient.php: 1352041 #: includes/Api/DeskClient.php:202 1543 2042 msgid "Contact last name" 1544 2043 msgstr "" 1545 2044 1546 #: includes/Api/DeskClient.php: 1402045 #: includes/Api/DeskClient.php:207 1547 2046 msgid "Contact first name" 1548 2047 msgstr "" 1549 2048 1550 #: includes/Api/DeskClient.php: 1452049 #: includes/Api/DeskClient.php:212 1551 2050 msgid "Contact email" 1552 2051 msgstr "" 1553 2052 1554 #: includes/Api/DeskClient.php: 1502053 #: includes/Api/DeskClient.php:217 1555 2054 msgid "Contact phone" 1556 2055 msgstr "" 1557 2056 1558 #: includes/Api/DeskClient.php: 1551559 #: includes/Api/DeskClient.php: 1992057 #: includes/Api/DeskClient.php:222 2058 #: includes/Api/DeskClient.php:266 1560 2059 msgid "Description" 1561 2060 msgstr "" 1562 2061 1563 #: includes/Api/DeskClient.php: 1602062 #: includes/Api/DeskClient.php:227 1564 2063 msgid "Priority" 1565 2064 msgstr "" 1566 2065 1567 #: includes/Api/DeskClient.php: 1742066 #: includes/Api/DeskClient.php:241 1568 2067 msgid "Last name" 1569 2068 msgstr "" 1570 2069 1571 #: includes/Api/DeskClient.php: 1792070 #: includes/Api/DeskClient.php:246 1572 2071 msgid "First name" 1573 2072 msgstr "" 1574 2073 1575 #: includes/Api/DeskClient.php: 1842074 #: includes/Api/DeskClient.php:251 1576 2075 msgid "Email" 1577 2076 msgstr "" 1578 2077 1579 #: includes/Api/DeskClient.php: 1891580 #: includes/Api/DeskClient.php:2 182078 #: includes/Api/DeskClient.php:256 2079 #: includes/Api/DeskClient.php:285 1581 2080 msgid "Phone" 1582 2081 msgstr "" 1583 2082 1584 #: includes/Api/DeskClient.php: 1942083 #: includes/Api/DeskClient.php:261 1585 2084 msgid "Mobile" 1586 2085 msgstr "" 1587 2086 1588 #: includes/Api/DeskClient.php:2 082087 #: includes/Api/DeskClient.php:275 1589 2088 msgid "Account name" 1590 2089 msgstr "" 1591 2090 1592 #: includes/Api/DeskClient.php:2 132091 #: includes/Api/DeskClient.php:280 1593 2092 msgid "Website" 1594 2093 msgstr "" 1595 2094 1596 #: includes/Api/DeskClient.php:2 232095 #: includes/Api/DeskClient.php:290 1597 2096 msgid "City" 1598 2097 msgstr "" 1599 2098 1600 #: includes/Api/DeskClient.php:2 282099 #: includes/Api/DeskClient.php:295 1601 2100 msgid "Country" 1602 2101 msgstr "" 1603 2102 1604 #: includes/Api/DeskClient.php: 2482103 #: includes/Api/DeskClient.php:315 1605 2104 msgid "Select a Zoho Desk organization for this feed." 1606 2105 msgstr "" 1607 2106 1608 #: includes/Api/DeskClient.php: 2892107 #: includes/Api/DeskClient.php:400 1609 2108 msgid "Choose a Department for this Zoho Desk feed." 1610 2109 msgstr "" 1611 2110 1612 #: includes/Api/DeskClient.php: 3422111 #: includes/Api/DeskClient.php:453 1613 2112 msgid "New web form submission" 1614 2113 msgstr "" 1615 2114 1616 #: includes/Api/MarketingAutomationClient.php:1 412115 #: includes/Api/MarketingAutomationClient.php:183 1617 2116 msgid "Zoho did not return any lead fields for this account." 1618 2117 msgstr "" 1619 2118 1620 2119 #. translators: %s: comma-separated list of keys Zoho actually sent for one field row. 1621 #: includes/Api/MarketingAutomationClient.php: 1672120 #: includes/Api/MarketingAutomationClient.php:209 1622 2121 #, php-format 1623 2122 msgid "Zoho returned %1$d lead field row(s), but none matched the expected format. Keys seen: %2$s" 1624 2123 msgstr "" 1625 2124 1626 #: includes/Api/MarketingAutomationClient.php:2 512125 #: includes/Api/MarketingAutomationClient.php:293 1627 2126 msgid "Zoho Marketing Automation does not have a tags feature." 1628 2127 msgstr "" 1629 2128 1630 #: includes/Auth/ConnectionManager.php: 1641631 #: includes/Auth/ConnectionManager.php: 3002129 #: includes/Auth/ConnectionManager.php:249 2130 #: includes/Auth/ConnectionManager.php:441 1632 2131 msgid "Connection not found." 1633 2132 msgstr "" 1634 2133 1635 #: includes/Auth/ConnectionManager.php: 1752134 #: includes/Auth/ConnectionManager.php:254 1636 2135 msgid "Choose at least one Zoho app." 1637 2136 msgstr "" 1638 2137 1639 #: includes/Auth/ConnectionManager.php: 2271640 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: 2332138 #: includes/Auth/ConnectionManager.php:376 2139 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." 2140 msgstr "" 2141 2142 #: includes/Auth/ConnectionManager.php:382 1644 2143 msgid "Please choose a valid Zoho data center." 1645 2144 msgstr "" 1646 2145 1647 #: includes/Auth/ConnectionManager.php: 2442146 #: includes/Auth/ConnectionManager.php:393 1648 2147 msgid "Client ID and Client Secret are required." 1649 2148 msgstr "" 1650 2149 1651 #: includes/Auth/ConnectionManager.php: 2472150 #: includes/Auth/ConnectionManager.php:396 1652 2151 msgid "That does not look like a Zoho Client ID / Client Secret. Copy both values exactly as shown in the Zoho API console." 1653 2152 msgstr "" 1654 2153 1655 #: includes/Auth/ConnectionManager.php: 2772154 #: includes/Auth/ConnectionManager.php:418 1656 2155 msgid "Zoho account" 1657 2156 msgstr "" 1658 2157 1659 #: includes/Auth/ConnectionManager.php: 3212158 #: includes/Auth/ConnectionManager.php:475 1660 2159 msgid "You are not allowed to do this." 1661 2160 msgstr "" 1662 2161 1663 #: includes/Auth/ConnectionManager.php: 4912162 #: includes/Auth/ConnectionManager.php:823 1664 2163 msgid "This Zoho connection is not authorised." 1665 2164 msgstr "" 1666 2165 1667 #: includes/Auth/ConnectionManager.php: 5022166 #: includes/Auth/ConnectionManager.php:834 1668 2167 msgid "Could not refresh the Zoho access token." 1669 2168 msgstr "" 1670 2169 1671 #: includes/Auth/OAuthClient.php:13 52170 #: includes/Auth/OAuthClient.php:137 1672 2171 msgid "Unreadable response from Zoho accounts server." 1673 2172 msgstr "" 1674 2173 1675 2174 #. translators: %d: HTTP status code. 1676 #: includes/Auth/OAuthClient.php:14 32175 #: includes/Auth/OAuthClient.php:145 1677 2176 #, php-format 1678 2177 msgid "Zoho returned HTTP %d during token request." … … 1680 2179 1681 2180 #. translators: %s: site name. 1682 #: includes/Core/Plugin.php: 2842181 #: includes/Core/Plugin.php:334 1683 2182 #, php-format 1684 2183 msgid "[%s] A form submission failed to reach Zoho" 1685 2184 msgstr "" 1686 2185 1687 #: includes/Core/Plugin.php: 2872186 #: includes/Core/Plugin.php:337 1688 2187 msgid "A form submission could not be sent to Zoho and will not be retried again automatically." 1689 2188 msgstr "" 1690 2189 1691 2190 #. translators: %s: integration/app id, e.g. "crm". 1692 #: includes/Core/Plugin.php: 2902191 #: includes/Core/Plugin.php:340 1693 2192 #, php-format 1694 2193 msgid "App: %s" … … 1696 2195 1697 2196 #. translators: %s: form name. 1698 #: includes/Core/Plugin.php: 2922197 #: includes/Core/Plugin.php:342 1699 2198 #, php-format 1700 2199 msgid "Form: %s" … … 1702 2201 1703 2202 #. translators: %s: error message. 1704 #: includes/Core/Plugin.php: 2941705 #: includes/Core/Plugin.php: 3572203 #: includes/Core/Plugin.php:344 2204 #: includes/Core/Plugin.php:407 1706 2205 #, php-format 1707 2206 msgid "Reason: %s" … … 1709 2208 1710 2209 #. translators: %s: URL to the plugin's Logs screen. 1711 #: includes/Core/Plugin.php: 2972210 #: includes/Core/Plugin.php:347 1712 2211 #, php-format 1713 2212 msgid "View the full log entry: %s" … … 1715 2214 1716 2215 #. translators: %s: site name. 1717 #: includes/Core/Plugin.php: 3502216 #: includes/Core/Plugin.php:400 1718 2217 #, php-format 1719 2218 msgid "[%s] A Zoho connection has failed" … … 1721 2220 1722 2221 #. translators: %s: connection label. 1723 #: includes/Core/Plugin.php: 3542222 #: includes/Core/Plugin.php:404 1724 2223 #, php-format 1725 2224 msgid "The Zoho connection \"%s\" could not be authorised anymore. Form submissions using this connection will fail until it is re-authorized." … … 1727 2226 1728 2227 #. translators: %s: URL to the plugin's Connections screen. 1729 #: includes/Core/Plugin.php: 3602228 #: includes/Core/Plugin.php:410 1730 2229 #, php-format 1731 2230 msgid "Check the connection here: %s" … … 1733 2232 1734 2233 #. translators: %s: site name. 1735 #: includes/Core/Plugin.php: 3992234 #: includes/Core/Plugin.php:449 1736 2235 #, php-format 1737 2236 msgid "[%s] A Zoho connection was deleted" … … 1739 2238 1740 2239 #. translators: %s: connection label. 1741 #: includes/Core/Plugin.php:4 032240 #: includes/Core/Plugin.php:453 1742 2241 #, php-format 1743 2242 msgid "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." … … 1745 2244 1746 2245 #. translators: %s: URL to the plugin's Connections screen. 1747 #: includes/Core/Plugin.php:4 062246 #: includes/Core/Plugin.php:456 1748 2247 #, php-format 1749 2248 msgid "Manage connections here: %s" 1750 2249 msgstr "" 1751 2250 1752 #. translators: %s: recipient email address.1753 #: includes/Core/Plugin.php: 4671754 #, 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 2254 msgid "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." 1756 2255 msgstr "" 1757 2256 … … 1761 2260 1762 2261 #: includes/Integrations/BiginIntegration.php:95 1763 #: includes/Integrations/BooksIntegration.php:11 02262 #: includes/Integrations/BooksIntegration.php:119 1764 2263 #: includes/Integrations/CampaignsIntegration.php:149 1765 2264 #: includes/Integrations/CrmIntegration.php:86 1766 #: includes/Integrations/DeskIntegration.php:12 22265 #: includes/Integrations/DeskIntegration.php:128 1767 2266 #: includes/Integrations/MarketingAutomationIntegration.php:125 1768 2267 msgid "Mapping produced no data to send." … … 1770 2269 1771 2270 #. translators: %s: reason the organization lookup failed. 1772 #: includes/Integrations/BiginIntegration.php:15 31773 #: includes/Integrations/BiginIntegration.php:17 42271 #: includes/Integrations/BiginIntegration.php:156 2272 #: includes/Integrations/BiginIntegration.php:177 1774 2273 #, php-format 1775 2274 msgid "\"View Record in Zoho\" link will open the Bigin homepage instead of the exact record: %s Reconnect this Bigin connection to fix it." 1776 2275 msgstr "" 1777 2276 1778 #: includes/Integrations/BiginIntegration.php:2 192277 #: includes/Integrations/BiginIntegration.php:222 1779 2278 msgid "Unexpected Bigin response." 1780 2279 msgstr "" 1781 2280 1782 #: includes/Integrations/BooksIntegration.php: 792281 #: includes/Integrations/BooksIntegration.php:85 1783 2282 msgid "Choose a Books resource first." 1784 2283 msgstr "" 1785 2284 1786 #: includes/Integrations/BooksIntegration.php: 982285 #: includes/Integrations/BooksIntegration.php:107 1787 2286 msgid "No Books resource configured." 1788 2287 msgstr "" 1789 2288 1790 #: includes/Integrations/BooksIntegration.php:1 432289 #: includes/Integrations/BooksIntegration.php:152 1791 2290 msgid "Unexpected Books response." 1792 2291 msgstr "" … … 1797 2296 msgstr "" 1798 2297 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 2302 msgid "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." 2303 msgstr "" 2304 2305 #. translators: %s: Zoho field name. 2306 #: includes/Integrations/CampaignsIntegration.php:186 2307 #: includes/Integrations/MarketingAutomationIntegration.php:162 2308 #, php-format 2309 msgid "The value mapped to \"%s\" is not an email address, so Zoho refused it." 2310 msgstr "" 2311 2312 #: includes/Integrations/CampaignsIntegration.php:211 2313 msgid "Map a form field to the contact email. Zoho needs it to add the contact to the list." 2314 msgstr "" 2315 2316 #. translators: %s: reason Zoho gave. 2317 #: includes/Integrations/CampaignsIntegration.php:226 2318 #, php-format 2319 msgid "The submitted fields were sent, but the contact could not be added to the mailing list directly: %s" 2320 msgstr "" 2321 2322 #. translators: %s: the contact's email address. 2323 #: includes/Integrations/CampaignsIntegration.php:267 2324 #, php-format 2325 msgid "This contact was already in the mailing list, so Zoho updated it with the submitted fields (%s)." 2326 msgstr "" 2327 2328 #: includes/Integrations/CampaignsIntegration.php:268 2329 msgid "This contact was already in the mailing list, so Zoho updated it with the submitted fields." 2330 msgstr "" 2331 2332 #. translators: %s: the contact's email address. 2333 #: includes/Integrations/CampaignsIntegration.php:276 2334 #, php-format 2335 msgid "Contact unsubscribed in Zoho (%s)." 2336 msgstr "" 2337 2338 #: includes/Integrations/CampaignsIntegration.php:277 2339 msgid "Contact unsubscribed in Zoho." 2340 msgstr "" 2341 2342 #. translators: 1: tag name, 2: reason Zoho gave. 2343 #: includes/Integrations/CampaignsIntegration.php:300 2344 #, php-format 2345 msgid "The tag \"%1$s\" was not attached to the contact: %2$s" 2346 msgstr "" 2347 2348 #: includes/Integrations/CampaignsIntegration.php:366 1800 2349 msgid "Unexpected Campaigns response." 2350 msgstr "" 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 2356 msgid "%1$s (Zoho code %2$s)" 1801 2357 msgstr "" 1802 2358 … … 1805 2361 msgstr "" 1806 2362 1807 #: includes/Integrations/CrmIntegration.php: 1342363 #: includes/Integrations/CrmIntegration.php:240 1808 2364 msgid "Unexpected CRM response." 1809 2365 msgstr "" … … 1813 2369 msgstr "" 1814 2370 1815 #: includes/Integrations/DeskIntegration.php:11 02371 #: includes/Integrations/DeskIntegration.php:116 1816 2372 msgid "No Desk module configured." 1817 2373 msgstr "" 1818 2374 1819 #: includes/Integrations/DeskIntegration.php:16 32375 #: includes/Integrations/DeskIntegration.php:169 1820 2376 msgid "Unexpected Desk response." 1821 2377 msgstr "" 1822 2378 1823 #: includes/Integrations/MarketingAutomationIntegration.php:211 2379 #: includes/Integrations/MarketingAutomationIntegration.php:185 2380 msgid "Map a form field to the lead email. Zoho needs it to add the lead to the list." 2381 msgstr "" 2382 2383 #. translators: %s: reason Zoho gave. 2384 #: includes/Integrations/MarketingAutomationIntegration.php:200 2385 #, php-format 2386 msgid "The submitted fields were sent, but the lead could not be added to the mailing list directly: %s" 2387 msgstr "" 2388 2389 #. translators: %s: the lead's email address. 2390 #: includes/Integrations/MarketingAutomationIntegration.php:240 2391 #, php-format 2392 msgid "This lead was already in the mailing list, so Zoho updated it with the submitted fields (%s)." 2393 msgstr "" 2394 2395 #: includes/Integrations/MarketingAutomationIntegration.php:241 2396 msgid "This lead was already in the mailing list, so Zoho updated it with the submitted fields." 2397 msgstr "" 2398 2399 #. translators: %s: the lead's email address. 2400 #: includes/Integrations/MarketingAutomationIntegration.php:249 2401 #, php-format 2402 msgid "Lead unsubscribed in Zoho (%s)." 2403 msgstr "" 2404 2405 #: includes/Integrations/MarketingAutomationIntegration.php:250 2406 msgid "Lead unsubscribed in Zoho." 2407 msgstr "" 2408 2409 #: includes/Integrations/MarketingAutomationIntegration.php:314 1824 2410 msgid "Unexpected Marketing Automation response." 2411 msgstr "" 2412 2413 #. translators: marks a log entry that was too long to store in full. 2414 #: includes/Logging/Logger.php:191 2415 msgid "[cut: entry too long]" 1825 2416 msgstr "" 1826 2417 … … 1830 2421 1831 2422 #: 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."2423 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 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." 1833 2424 msgstr "" 1834 2425 … … 1840 2431 #: includes/Privacy/PrivacyProvider.php:92 1841 2432 #: includes/Privacy/PrivacyProvider.php:183 1842 msgid " Techwave Form Syncfor Zoho delivery logs"2433 msgid "FormBridge for Zoho delivery logs" 1843 2434 msgstr "" 1844 2435 … … 1856 2447 1857 2448 #. translators: %s: Zoho record id. 1858 #: includes/Queue/Dispatcher.php: 1612449 #: includes/Queue/Dispatcher.php:243 1859 2450 #, php-format 1860 2451 msgid "Record updated in Zoho (id %s)." 1861 2452 msgstr "" 1862 2453 1863 #: includes/Queue/Dispatcher.php: 1622454 #: includes/Queue/Dispatcher.php:244 1864 2455 msgid "Record updated in Zoho." 1865 2456 msgstr "" 1866 2457 1867 2458 #. translators: %s: Zoho record id. 1868 #: includes/Queue/Dispatcher.php: 1662459 #: includes/Queue/Dispatcher.php:248 1869 2460 #, php-format 1870 2461 msgid "Record created in Zoho (id %s)." 1871 2462 msgstr "" 1872 2463 1873 #: includes/Queue/Dispatcher.php: 1672464 #: includes/Queue/Dispatcher.php:249 1874 2465 msgid "Record created in Zoho." 1875 2466 msgstr "" 1876 2467 1877 #: includes/Support/BaseSubmissionHandler.php:10 12468 #: includes/Support/BaseSubmissionHandler.php:106 1878 2469 msgid "Feed is enabled but has no Zoho app or connected account selected." 1879 2470 msgstr "" 1880 2471 2472 #. translators: %s: the plugin's version number, e.g. 1.1.0. 2473 #: includes/Support/Branding.php:133 2474 #, php-format 2475 msgid "Version %s" 2476 msgstr "" 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 2480 msgid "for Zoho" 2481 msgstr "" 2482 1881 2483 #: 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."2484 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." 1883 2485 msgstr "" 1884 2486 1885 2487 #: 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."2488 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." 1887 2489 msgstr "" 1888 2490 … … 1896 2498 1897 2499 #. translators: %s: Zoho field name. 1898 #: includes/Support/ErrorTranslator.php: 452500 #: includes/Support/ErrorTranslator.php:51 1899 2501 #, php-format 1900 2502 msgid "Zoho validation failed: %s is required." 1901 2503 msgstr "" 1902 2504 1903 #: includes/Support/ErrorTranslator.php: 462505 #: includes/Support/ErrorTranslator.php:52 1904 2506 msgid "Record creation failed. A required field is missing." 1905 2507 msgstr "" 1906 2508 1907 2509 #. translators: %s: Zoho field name. 1908 #: includes/Support/ErrorTranslator.php: 582510 #: includes/Support/ErrorTranslator.php:64 1909 2511 #, php-format 1910 2512 msgid "Zoho validation failed: %s is invalid." 1911 2513 msgstr "" 1912 2514 1913 #: includes/Support/ErrorTranslator.php:6 22515 #: includes/Support/ErrorTranslator.php:68 1914 2516 msgid "This record already exists in Zoho (duplicate)." 1915 2517 msgstr "" 1916 2518 1917 #: includes/Support/ErrorTranslator.php: 662519 #: includes/Support/ErrorTranslator.php:72 1918 2520 msgid "Zoho rejected the connection. Reconnect the account under Connections." 1919 2521 msgstr "" 1920 2522 1921 #: includes/Support/ErrorTranslator.php:7 02523 #: includes/Support/ErrorTranslator.php:76 1922 2524 msgid "Record creation failed. Required fields are missing." 1923 2525 msgstr "" 1924 2526 1925 #: includes/Support/FormSources.php:31 2527 #: includes/Support/ErrorTranslator.php:107 2528 msgid "the connection timed out" 2529 msgstr "" 2530 2531 #: includes/Support/ErrorTranslator.php:109 2532 msgid "this server could not look up its address" 2533 msgstr "" 2534 2535 #: includes/Support/ErrorTranslator.php:111 2536 msgid "the connection was refused or blocked" 2537 msgstr "" 2538 2539 #: includes/Support/ErrorTranslator.php:113 2540 msgid "the secure connection could not be set up" 2541 msgstr "" 2542 2543 #: includes/Support/ErrorTranslator.php:115 2544 msgid "the connection failed" 2545 msgstr "" 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 2550 msgid "Could not reach Zoho (%1$s): %2$s." 2551 msgstr "" 2552 2553 #. translators: %s: what went wrong, e.g. "the connection timed out". 2554 #: includes/Support/ErrorTranslator.php:124 2555 #, php-format 2556 msgid "Could not reach Zoho: %s." 2557 msgstr "" 2558 2559 #: includes/Support/ErrorTranslator.php:130 2560 msgid "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." 2561 msgstr "" 2562 2563 #. translators: %d: cURL error number, to pass on to the hosting provider. 2564 #: includes/Support/ErrorTranslator.php:134 2565 #, php-format 2566 msgid "(cURL error %d)" 2567 msgstr "" 2568 2569 #: includes/Support/FormSources.php:35 1926 2570 msgid "Elementor Pro Forms" 1927 2571 msgstr "" 1928 2572 1929 #: includes/Support/FormSources.php: 372573 #: includes/Support/FormSources.php:41 1930 2574 msgid "Contact Form 7" 1931 2575 msgstr "" 1932 2576 1933 #: includes/Support/FormSources.php:4 32577 #: includes/Support/FormSources.php:47 1934 2578 msgid "WPForms" 1935 2579 msgstr "" 1936 2580 1937 #: includes/Support/FormSources.php: 492581 #: includes/Support/FormSources.php:53 1938 2582 msgid "Fluent Forms" 1939 2583 msgstr "" 1940 2584 1941 #: includes/Support/FormSources.php:5 52585 #: includes/Support/FormSources.php:59 1942 2586 msgid "Forminator" 1943 2587 msgstr "" 1944 2588 1945 #: includes/Support/FormSources.php:6 12589 #: includes/Support/FormSources.php:65 1946 2590 msgid "Ninja Forms" 1947 2591 msgstr "" 1948 2592 1949 #: includes/Support/FormSources.php: 672593 #: includes/Support/FormSources.php:71 1950 2594 msgid "Gravity Forms" 1951 2595 msgstr "" 1952 2596 1953 #: includes/Support/FormSources.php:7 32597 #: includes/Support/FormSources.php:77 1954 2598 msgid "Formidable Forms" 1955 2599 msgstr "" 1956 2600 1957 #: includes/Support/FormSources.php: 792601 #: includes/Support/FormSources.php:83 1958 2602 msgid "Happy Forms" 1959 2603 msgstr "" 1960 2604 1961 #: includes/Support/FormSources.php:8 52605 #: includes/Support/FormSources.php:89 1962 2606 msgid "SureForms" 1963 2607 msgstr "" 2608 2609 #: includes/Support/FormSources.php:115 2610 msgid "WooCommerce Customer" 2611 msgstr "" 2612 2613 #: includes/Support/FormSources.php:121 2614 msgid "MemberPress Member" 2615 msgstr "" 2616 2617 #: includes/Support/FormSources.php:127 2618 msgid "LearnDash Learner" 2619 msgstr "" 2620 2621 #: includes/Support/FormSources.php:133 2622 msgid "Tutor LMS Student" 2623 msgstr "" 2624 2625 #: includes/Support/FormSources.php:139 2626 msgid "GiveWP Donor" 2627 msgstr "" 2628 2629 #: includes/Support/FormSources.php:145 2630 msgid "EDD Customer" 2631 msgstr "" 2632 2633 #: includes/Support/FormSources.php:151 2634 msgid "Amelia Customer" 2635 msgstr "" 2636 2637 #: includes/Support/FormSources.php:157 2638 msgid "Dokan Vendor" 2639 msgstr "" 2640 2641 #: includes/Support/FormSources.php:163 2642 msgid "Job Manager Employer" 2643 msgstr "" 2644 2645 #: includes/Support/FormSources.php:169 2646 msgid "Event Attendee" 2647 msgstr "" 2648 2649 #: includes/Support/FormSources.php:175 2650 msgid "LatePoint Customer" 2651 msgstr "" 2652 2653 #: includes/Support/FormSources.php:181 2654 msgid "Bookly Customer" 2655 msgstr "" 2656 2657 #: includes/Support/FormSources.php:187 2658 msgid "Simply Schedule Appointment" 2659 msgstr "" 2660 2661 #: includes/Support/FormSources.php:193 2662 msgid "FluentBooking Attendee" 2663 msgstr "" 2664 2665 #: includes/Support/FormSources.php:199 2666 msgid "Booking Calendar" 2667 msgstr "" 2668 2669 #: includes/Support/FormSources.php:205 2670 msgid "Easy Appointments Booking" 2671 msgstr "" 2672 2673 #: includes/Support/FormSources.php:211 2674 msgid "WooCommerce Booking" 2675 msgstr "" 2676 2677 #: includes/Support/FormSources.php:225 2678 msgid "Action Hook" 2679 msgstr "" -
techwave-form-sync-for-zoho/trunk/readme.txt
r3649280 r3748247 1 === Techwave Form Syncfor Zoho ===1 === FormBridge for Zoho === 2 2 Contributors: kaderoishi 3 3 Tags: zoho, crm, forms, integration, leads 4 4 Requires at least: 6.2 5 Tested up to: 7. 05 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 1. 0.07 Stable tag: 1.1.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 13 13 == Description == 14 14 15 Techwave Form Syncfor 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.15 FormBridge 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. 16 16 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.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. 18 18 19 19 **What it covers** 20 20 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. 22 22 * **Six Zoho apps, not just CRM.** Zoho CRM, Zoho Bigin, Zoho Books, Zoho Desk, Zoho Campaigns and Zoho Marketing Automation. 23 23 * **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. … … 25 25 * **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. 26 26 * **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. 27 28 * **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. 28 29 * **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. … … 31 32 **Disclaimer** 32 33 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 Syncfor Zoho requires your own Zoho account, which is subject to Zoho's terms and pricing.34 FormBridge 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. 34 35 35 36 **How mapping works** 36 37 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.38 All 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. 38 39 39 40 == External services == … … 45 46 * **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. 46 47 * **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. 48 49 * **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. 49 50 50 51 **2. Zoho application APIs** 51 52 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. 53 54 * **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. 56 57 57 58 Zoho's own terms govern what happens to the data once it arrives: … … 62 63 **3. Deactivation feedback (optional, opt-in)** 63 64 64 When you deactivate Techwave Form Syncfor Zoho from the Plugins screen, the plugin asks why. Answering is entirely optional and can be skipped, and the plugin deactivates either way.65 When 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. 65 66 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. 67 68 * **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. 68 69 * **Why:** so the developer knows which problems make people stop using the plugin. It has no effect on how the plugin works. … … 80 81 **Data stored on your own site** 81 82 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. 84 85 * **Credentials.** Client secrets and refresh tokens are encrypted (AES-256-GCM) before being saved; access tokens are short-lived and cached in transients. 85 86 … … 90 91 3. 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. 91 92 4. 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.93 5. 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. 94 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, because the plugin listens for submissions itself. 95 7. 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. 95 96 96 97 == Frequently Asked Questions == … … 106 107 107 108 = 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.109 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. 109 110 110 111 = Which data centers are supported? = … … 115 116 116 117 = 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.118 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. 118 119 119 120 = 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.121 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 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. 121 122 122 123 = 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.124 Make 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. 124 125 125 126 = 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.127 Turn 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. 127 128 128 129 == Privacy == … … 130 131 Form 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. 131 132 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 daysby 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.133 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) 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. 133 134 134 135 The 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**. … … 136 137 Access tokens, refresh tokens, client secrets and authorization headers are never written to the logs in readable form. 137 138 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.139 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. 139 140 140 141 == Screenshots == 141 142 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.143 1. 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. 144 2. 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. 144 145 3. 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. 145 146 4. 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. 146 147 5. 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.148 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. 148 149 7. The Settings tab. Logging, submitted-value storage, automatic log cleanup, failure email alerts, and full data erasure on uninstall, grouped by what they affect. 149 150 150 151 == Changelog == 152 153 = 1.1.0 - 15 October 2026 = 154 The 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. 151 168 152 169 = 1.0.0 - 3 August 2026 = … … 163 180 * Personal-data exporter and eraser, suggested privacy-policy text, and opt-in full data erasure on uninstall. 164 181 * Fully translatable, keyboard accessible, and usable from 320px up. 182 183 == Upgrade Notice == 184 185 = 1.1.0 = 186 Renamed 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 1 1 <?php 2 2 /** 3 * Plugin Name: Techwave Form Syncfor Zoho4 * Plugin URI: https://techwavesolutions.net/ techwave-form-sync-for-zoho3 * Plugin Name: FormBridge for Zoho 4 * Plugin URI: https://techwavesolutions.net/formbridge-for-zoho/ 5 5 * Description: Send WordPress form submissions to Zoho CRM, Bigin, Books, Desk, Campaigns and Marketing Automation. 6 * Version: 1. 0.06 * Version: 1.1.0 7 7 * Requires at least: 6.2 8 8 * Requires PHP: 7.4 … … 21 21 defined( 'ABSPATH' ) || exit; 22 22 23 define( 'FORM2ZOHO_VERSION', '1. 0.0' );23 define( 'FORM2ZOHO_VERSION', '1.1.0' ); 24 24 // Human-readable date this version was published — shown on the Version 25 25 // tab. Keep in sync with the readme.txt changelog entry for the same 26 26 // version. 27 define( 'FORM2ZOHO_VERSION_DATE', ' 3 August2026' );27 define( 'FORM2ZOHO_VERSION_DATE', '15 October 2026' ); 28 28 define( 'FORM2ZOHO_FILE', __FILE__ ); 29 29 define( 'FORM2ZOHO_PATH', plugin_dir_path( __FILE__ ) ); -
techwave-form-sync-for-zoho/trunk/uninstall.php
r3649280 r3748247 6 6 * when they have turned on "Erase all plugin data when the plugin is deleted" 7 7 * 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. 10 9 * 11 10 * Off by default on purpose: simply deleting the plugin should not destroy … … 144 143 } 145 144 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 147 156 // access-token cache, OAuth state, parked retry payloads, the 148 157 // connection-failure mail throttle, the admin error notice, and the … … 159 168 $like = $wpdb->esc_like( '_transient_form2zoho_' ) . '%'; 160 169 $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_' ) . '%'; 161 175 162 176 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off cleanup on uninstall. 163 177 $wpdb->query( 164 178 $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", 166 180 $like, 167 $timeout 181 $timeout, 182 $locks 168 183 ) 169 184 ); … … 176 191 // away the whole site's cache — WordPress core, the theme and every 177 192 // 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. 186 197 } 187 198 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)