Plugin Directory

Changeset 3760857


Ignore:
Timestamp:
10/23/2026 03:36:23 AM (3 weeks ago)
Author:
clicksyncr
Message:

Bug fixes

Location:
clicksyncr-for-kit
Files:
176 added
48 edited

Legend:

Unmodified
Added
Removed
  • clicksyncr-for-kit/trunk/assets/css/admin.css

    r3726852 r3760857  
    33        background: #fff;
    44        border-bottom: 1px solid #c5d1d9;
    5         margin: 0 0 20px 0;
    6 }
     5        margin: -16px -16px 20px;
     6}
     7
    78#clicksyncr-for-kit .syncr-header__inner {
    89        display: flex;
    … …  
    1314        padding: 14px 16px;
    1415}
    15 #clicksyncr-for-kit .syncr-header {
    16         margin: -16px -16px 20px;
    17 }
     16
    1817#clicksyncr-for-kit .syncr-header__brand {
    1918        display: flex;
    … …  
    2120        gap: 10px;
    2221}
     22
    2323#clicksyncr-for-kit .syncr-header__title {
    2424        font-size: 18px;
    … …  
    2727        letter-spacing: -0.3px;
    2828}
     29
    2930#clicksyncr-for-kit .syncr-header__badge {
    3031        background: #4d5d6d;
    … …  
    3738        letter-spacing: 0.5px;
    3839}
     40
    3941#clicksyncr-for-kit .syncr-header__version {
    4042        font-size: 12px;
    … …  
    4244        font-weight: 400;
    4345}
     46
    4447#clicksyncr-for-kit .syncr-header__context {
    4548        display: flex;
    … …  
    4851        margin-left: auto;
    4952}
     53
    5054#clicksyncr-for-kit .syncr-header__status {
    5155        display: flex;
    … …  
    5559        border-right: 1px solid #c5d1d9;
    5660}
     61
    5762#clicksyncr-for-kit .syncr-header__status-dot {
    5863        width: 8px;
    … …  
    6065        border-radius: 50%;
    6166}
     67
    6268#clicksyncr-for-kit .syncr-header__status-dot--connected {
    6369        background: #00a32a;
    6470}
     71
    6572#clicksyncr-for-kit .syncr-header__status-dot--pending {
    6673        background: #dba617;
    6774}
     75
    6876#clicksyncr-for-kit .syncr-header__status-dot--neutral {
    6977        background: #c3c4c7;
    7078}
     79
    7180#clicksyncr-for-kit .syncr-header__status-text {
    7281        font-size: 13px;
    7382        color: #50575e;
    7483}
     84
    7585#clicksyncr-for-kit .syncr-header__actions {
    7686        display: flex;
    … …  
    7888        gap: 12px;
    7989}
     90
    8091#clicksyncr-for-kit .syncr-header__badge--pro,
    8192#clicksyncr-for-kit .syncr-header__badge--lite {
    … …  
    8394        color: #fff;
    8495}
     96
    8597#clicksyncr-for-kit .syncr-header__support {
    8698        display: flex;
    … …  
    93105        transition: color 0.2s;
    94106}
     107
    95108#clicksyncr-for-kit .syncr-header__support:hover {
    96109        color: #1d2327;
    97110}
     111
    98112#clicksyncr-for-kit .syncr-sparkles {
    99113        display: inline-block;
    … …  
    106120        background-size: contain;
    107121}
     122
    108123#clicksyncr-for-kit .syncr-header__review {
    109124        display: flex;
    … …  
    115130        transition: color 0.2s;
    116131}
     132
    117133#clicksyncr-for-kit .syncr-header__review:hover {
    118134        color: #1d2327;
    119135}
    120 #clicksyncr-for-kit .syncr-sparkles {
    121         display: inline-block;
    122         vertical-align: middle;
    123         width: 80px;
    124         height: 16px;
    125         background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 80 16'%3E%3Cpath fill='%23EFBF04' d='M8 0l2.5 5 5.5.8-4 3.9.9 5.3L8 12.5 3.1 15l.9-5.3-4-3.9L5.5 5zM24 0l2.5 5 5.5.8-4 3.9.9 5.3-4.9-2.5-4.9 2.5.9-5.3-4-3.9 5.5-.8zM40 0l2.5 5 5.5.8-4 3.9.9 5.3-4.9-2.5-4.9 2.5.9-5.3-4-3.9 5.5-.8zM56 0l2.5 5 5.5.8-4 3.9.9 5.3-4.9-2.5-4.9 2.5.9-5.3-4-3.9 5.5-.8zM72 0l2.5 5 5.5.8-4 3.9.9 5.3-4.9-2.5-4.9 2.5.9-5.3-4-3.9 5.5-.8z'/%3E%3C/svg%3E");
    126         background-repeat: no-repeat;
    127         background-size: contain;
    128 }
     136
    129137/* === end clicksyncr kit === */
    130138
    131139/* === clicksyncr kit: panel. generated, do not edit === */
    132140:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) { position: relative; }
     141
    133142:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) [hidden] { display: none !important; }
     143
    134144:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rows { padding: 4px 0 0; container-type: inline-size; }
    135145
    136146.sui-modal:has(.syncr-rows) .sui-modal-content.sui-modal-lg { max-width: 1100px !important; }
     147
    137148.ninja-forms-app #nf-builder.nf-drawer-closed::before { pointer-events: none; }
    138149
    139150:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-card { background: rgba(255, 255, 255, .95); border: 1px solid #d8e0e8; border-radius: 12px; margin: 0 0 20px; padding: 20px; }
     151
    140152:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-card > h3 { margin: 0 0 4px; font-size: 18px; font-weight: 600; text-transform: none; letter-spacing: normal; }
     153
    141154:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-card > .description { margin: 0 0 18px; color: #5d6878; }
     155
    142156:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-block--rules h4 { margin: 18px 0 4px; font-size: 13px; }
     157
    143158:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-block--rules > .description { margin: 0 0 12px; color: #5d6878; }
     159
    144160:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
     161
    145162:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-actions--end { justify-content: flex-end; margin-top: 12px; }
     163
    146164:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-notice { margin: 14px 0 4px; padding: 11px 13px; border-left: 3px solid #6c8ef3; border-radius: 2px; background: #f0f4ff; color: #1d2327; }
     165
    147166:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-locked-box { display: flex; align-items: center; gap: 10px; margin: 0 0 12px; padding: 14px; border: 1px dashed #ccd5df; border-radius: 9px; background: #f8fafc; color: #5d6878; }
     167
    148168:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-locked-box > span { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; background: #e8edf2; }
     169
    149170:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-locked-box p { margin: 0; }
    150171
    151172:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-key-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
     173
    152174:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) input.syncr-key { width: 25em; max-width: 100%; }
     175
    153176:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-key-toggle { display: inline-flex; align-items: center; justify-content: center; padding: 0 28px; }
     177
    154178:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-key-toggle .dashicons { line-height: 1; }
    155 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected:hover, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected:focus { background: #00a32a; border-color: #00a32a; color: #fff; }
     179
     180:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected:hover, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-btn-connected:focus { background: #007a1f; border-color: #007a1f; color: #fff; }
     181
    156182:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-feedback--error { color: #d63638; }
    157183
    158184:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
     185
    159186:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tiles--extra { margin-top: 16px; }
     187
    160188:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tiles--single { grid-template-columns: minmax(0, 1fr); }
     189
    161190:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tiles--single .syncr-tile label + label { display: none; }
     191
    162192:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile { display: block; padding: 13px; border: 1px solid #e1e6ec; border-radius: 9px; background: #fbfcfe; }
     193
    163194:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile .syncr-actions label { display: inline-flex; align-items: center; gap: 4px; width: auto; margin: 0 12px 0 0; }
     195
    164196:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile > p.description { margin: 10px 0 4px; }
     197
    165198:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile label, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile select { display: block; width: 100%; max-width: 100%; }
     199
    166200:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile label { margin-bottom: 5px; }
     201
    167202:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile label + label { margin-top: 6px; margin-bottom: 0; }
    168 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile__meta { color: #999; margin-left: 4px; }
    169 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile__required { color: #dd3d36; font-weight: 400; margin-left: 4px; }
     203
     204:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile__meta { color: #646970; margin-left: 4px; }
     205
     206:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile__required { color: #b32d2e; font-weight: 400; margin-left: 4px; }
     207
    170208:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile--missing select { border-color: #d63638; }
     209
    171210:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile .syncr-actions--end { margin-top: 10px; }
     211
    172212:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile input.regular-text { width: 100%; max-width: none; }
     213
    173214:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tile .description { margin: 6px 0 0; }
     215
    174216:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-readback { margin: 10px 0 0; color: #5d6878; }
     217
    175218:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-readback--warn { color: #d63638; }
     219
    176220:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-block--destination .syncr-actions--end { margin-top: 12px; }
    177221
    178222:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__table:not(:empty) { overflow: hidden; border: 1px solid #e1e6ec; border-radius: 9px; background: #fbfcfe; }
     223
    179224:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__head, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__row { display: grid; grid-template-columns: 42px minmax(145px, .85fr) minmax(145px, .75fr) minmax(220px, 1fr) 34px; align-items: center; }
     225
    180226:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__head { background: #f6f7f7; border-bottom: 1px solid #e1e6ec; }
     227
    181228:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__head span { padding: 9px 10px; color: #50575e; font-weight: 600; }
     229
    182230:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__row { background: #fbfcfe; }
     231
    183232:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__row + .syncr-rules__row { border-top: 1px solid #e1e6ec; }
     233
    184234:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__num { padding: 10px; color: #1d2327; font-weight: 600; white-space: nowrap; }
     235
    185236:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__cell { padding: 10px; }
     237
    186238:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__cell select { display: block; width: 100%; min-width: 0; max-width: 100%; }
     239
    187240:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__remove { display: inline-flex; width: 32px; height: 32px; align-items: center; justify-content: center; padding: 0; color: #646970; text-decoration: none; }
     241
    188242:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__remove:hover { color: #b32d2e; }
     243
    189244:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__remove:focus { color: #3858e9; text-decoration: none; }
     245
    190246:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__empty { margin: 0; color: #5d6878; }
    191247
    192248:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy { overflow: hidden; border: 1px solid #d8e0e8; border-radius: 10px; }
     249
    193250:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row { display: grid; grid-template-columns: minmax(170px, 220px) minmax(220px, 310px) minmax(240px, 1fr); align-items: center; gap: 20px; min-height: 76px; padding: 13px 15px; background: #fff; }
     251
    194252:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row + .syncr-policy__row { border-top: 1px solid #e7ebef; }
     253
    195254:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row:nth-child(even) { background: #fbfcfd; }
     255
    196256:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row--warning { background: #fffaf0; }
     257
    197258:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row + .syncr-policy__row--joined { border-top: 0; min-height: 0; padding-top: 0; margin-top: -8px; }
     259
    198260:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row:nth-child(even) + .syncr-policy__row--joined { background: #fbfcfd; }
     261
    199262:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row:nth-child(odd) + .syncr-policy__row--joined { background: #fff; }
     263
    200264:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-row--sub .syncr-policy__name { padding-left: 18px; }
     265
    201266:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__name strong, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__name span { display: block; }
     267
    202268:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__name strong { font-size: 13px; }
     269
    203270:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__name span, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__note, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__checks p { color: #5d6878; font-size: 12px; }
     271
    204272:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__note, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__checks p, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-managed p { margin: 0; }
     273
    205274:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control select, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control input[type="text"] { box-sizing: border-box; width: 100%; max-width: none; }
     275
    206276:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-pair { display: flex; gap: 6px; }
     277
    207278:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-pair select { width: 45%; }
     279
    208280:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__checks label { font-weight: 600; }
     281
    209282:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__checks p { margin-top: 2px; padding-left: 24px; }
     283
    210284:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-checklist { max-height: 190px; overflow: auto; background: #fff; border: 1px solid #8c8f94; border-radius: 4px; padding: 6px 10px; }
     285
    211286:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-managed { padding: 9px 10px; border: 1px solid #cbd4dc; border-radius: 6px; background: #f8fafc; }
     287
    212288:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-managed p { margin-top: 3px; color: #5d6878; font-size: 12px; }
     289
    213290:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-locked { margin: 0; color: #5d6878; }
     291
    214292:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-pro-conditions select.regular-text { width: auto; max-width: 100%; }
     293
    215294:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-pro-condition-row select, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__control .syncr-pro-condition-row input { width: auto; }
    216295
    217296:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools { overflow: hidden; border: 1px solid #e1e6ec; border-radius: 9px; background: #fbfcfe; }
     297
    218298:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__row { display: grid; grid-template-columns: minmax(190px, 240px) minmax(280px, 1fr); align-items: center; gap: 18px; padding: 14px; }
     299
    219300:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__row + .syncr-tools__row { border-top: 1px solid #e1e6ec; }
     301
    220302:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__copy strong, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__copy p { display: block; }
     303
    221304:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__copy strong { color: #1d2327; }
     305
    222306:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__copy p { margin: 2px 0 0; color: #646970; }
     307
    223308:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field { display: grid; grid-template-columns: minmax(220px, 1fr) auto; align-items: end; gap: 8px; }
     309
    224310:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field label { display: block; }
     311
    225312:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field input, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field select, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control textarea { box-sizing: border-box; width: 100%; max-width: none; }
     313
    226314:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field > div:not(.syncr-tool-results):not(.syncr-actions) { grid-column: 1; }
     315
    227316:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field .syncr-actions { grid-column: 2; }
     317
    228318:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control p { margin: 6px 0 0; }
     319
    229320:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-results { grid-column: 1 / -1; width: 100%; margin-top: 6px; }
     321
    230322:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-results p { margin: 4px 0; }
     323
    231324:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-results ul { margin: 2px 0 0 18px; list-style: disc; }
    232325
    233326@container (max-width: 782px) {
     327
    234328        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tiles { grid-template-columns: minmax(0, 1fr); }
     329
    235330        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-policy__row { grid-template-columns: minmax(0, 1fr); gap: 7px; }
     331
    236332        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__row { grid-template-columns: minmax(0, 1fr); gap: 10px; }
     333
    237334        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-tools__control .syncr-tool-field { grid-template-columns: minmax(0, 1fr); }
    238335}
     336
    239337@container (max-width: 600px) {
     338
    240339        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__head { display: none; }
     340
    241341        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__row { grid-template-columns: 42px minmax(0, 1fr) 34px; align-items: start; padding: 10px 0; }
     342
    242343        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__row .syncr-rules__num { grid-column: 1; grid-row: 1; }
     344
    243345        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__cell { grid-column: 2; padding: 4px 10px; }
     346
    244347        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__cell::before { display: block; margin-bottom: 4px; color: #50575e; font-weight: 600; content: attr(data-syncr-column); }
     348
    245349        :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-rules__remove { grid-column: 3; grid-row: 1; }
    246350}
    247351
    248352:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__toggle { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; }
     353
    249354:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__viewer { margin-top: 10px; }
     355
    250356:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__tabs { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
     357
    251358:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__tabs .button.is-active { background: #2271b1; border-color: #2271b1; color: #fff; }
     359
    252360:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__auto { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: #50575e; }
    253 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__log { margin: 0; max-height: 320px; overflow: auto; padding: 10px 12px; background: #1d2327; color: #f0f0f1; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-word; border-radius: 4px; }
     361
     362:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__log { margin: 0; max-height: 320px; overflow: auto; padding: 10px 12px; background: #1d2327; color: #f0f0f1; font: 12px/1.6 Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; border-radius: 4px; }
     363
    254364:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__report { width: 100%; font: 12px/1.5 Menlo, Consolas, monospace; white-space: pre; overflow: auto; }
     365
    255366:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__stats { display: grid; grid-template-columns: max-content 1fr; gap: 2px 16px; margin: 0 0 8px; }
     367
    256368:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__stats dt { color: #50575e; }
     369
    257370:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__stats dd { margin: 0; font-weight: 600; }
     371
    258372:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__error { color: #b32d2e; margin: 6px 0; }
     373
    259374:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__ok { color: #007017; margin-left: 8px; }
     375
    260376:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__bad { color: #b32d2e; margin-left: 8px; }
    261 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"])-section-advanced > .description { margin-bottom: 4px; }
     377
     378:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__note { color: #50617a; margin-left: 8px; }
     379
     380#clicksyncr-for-kit-section-advanced > .description { margin-bottom: 4px; }
    262381
    263382#submitdiv .syncr-kit-status { display: grid; grid-template-columns: max-content 1fr; gap: 2px 10px; align-items: baseline; }
     383
    264384#submitdiv .syncr-kit-status__name { font-weight: 600; text-decoration: none; }
     385
    265386#submitdiv .syncr-kit-status__facts { color: #646970; font-size: 12px; }
     387
    266388#submitdiv .syncr-kit-status__error { grid-column: 1 / -1; color: #b32d2e; font-size: 12px; }
     389
    267390#submitdiv .syncr-kit-status--failing .syncr-kit-status__facts { color: #b32d2e; }
    268391
    269392:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section__toggle { all: unset; cursor: pointer; display: flex; align-items: center; gap: 8px; width: 100%; }
     393
    270394:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section__toggle:focus-visible { outline: 2px solid #2271b1; outline-offset: 2px; border-radius: 2px; }
     395
    271396:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section__chevron { width: 8px; height: 8px; border-right: 2px solid #50575e; border-bottom: 2px solid #50575e; transform: rotate(45deg); transition: transform .15s; margin-left: 2px; }
     397
    272398:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section--collapsible.is-collapsed .syncr-section__chevron { transform: rotate(-45deg); }
     399
    273400:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section--collapsible.is-collapsed > .description, :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section--collapsible.is-collapsed > .syncr-tools { display: none; }
     401
    274402:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-section--collapsible.is-collapsed > h3 { margin-bottom: 0; }
    275403
    276404:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__echo { margin-top: 8px; font-size: 12px; }
     405
    277406:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__echo dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 4px 0 10px; }
     407
    278408:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__echo dt { color: #646970; }
     409
    279410:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__echo dd { margin: 0; word-break: break-all; }
     411
    280412:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .syncr-adv__sample { margin: 6px 0; padding: 8px; background: #f6f7f7; border: 1px solid #dcdcde; font-size: 11px; line-height: 1.5; white-space: pre-wrap; }
    281413
    282414#wpbody-content > .notice.syncr-notice { display: block; clear: both; }
     415
    283416.notice.syncr-notice { --syncr-brand: #533afd; --syncr-brand-dark: #4032c8; --syncr-brand-pale: #d6d9fc; --syncr-tint: #f5f5ff; --syncr-ink: #1a2c44; --syncr-body: #50617a; --syncr-muted: #64748d; border-left-color: var(--syncr-brand); padding: 14px 16px; }
     417
    284418.notice.syncr-notice p { margin: 0; }
     419
    285420.notice.syncr-notice .syncr-notice__title { font-size: 15px; font-weight: 600; color: var(--syncr-ink); margin: 0 0 6px; }
     421
    286422.notice.syncr-notice .syncr-notice__body { color: var(--syncr-body); max-width: 78ch; line-height: 1.6; margin: 0; }
     423
    287424.notice.syncr-notice .syncr-notice__actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 14px 0 0; }
     425
    288426.notice.syncr-notice .syncr-notice__action { text-decoration: none; font-size: 13px; line-height: 1; border-radius: 4px; cursor: pointer; transition: background-color 0.15s, color 0.15s, border-color 0.15s; }
     427
    289428.notice.syncr-notice .syncr-notice__action--primary { background: var(--syncr-brand); border: 1px solid var(--syncr-brand); color: #fff; padding: 9px 16px; font-weight: 600; }
     429
    290430.notice.syncr-notice .syncr-notice__action--primary:hover, .notice.syncr-notice .syncr-notice__action--primary:focus { background: var(--syncr-brand-dark); border-color: var(--syncr-brand-dark); color: #fff; }
     431
    291432.notice.syncr-notice .syncr-notice__action--secondary { background: #fff; border: 1px solid var(--syncr-brand-pale); color: var(--syncr-brand-dark); padding: 9px 16px; font-weight: 600; }
     433
    292434.notice.syncr-notice .syncr-notice__action--secondary:hover, .notice.syncr-notice .syncr-notice__action--secondary:focus { background: var(--syncr-tint); border-color: var(--syncr-brand); color: var(--syncr-brand-dark); }
    293 .notice.syncr-notice .syncr-notice__action--link { background: none; border: 0; color: var(--syncr-muted); padding: 9px 2px; }
     435
     436.notice.syncr-notice .syncr-notice__action--link { background: none; border: 0; color: var(--syncr-body); padding: 9px 2px; }
     437
    294438.notice.syncr-notice .syncr-notice__action--link:hover, .notice.syncr-notice .syncr-notice__action--link:focus { color: var(--syncr-ink); text-decoration: underline; }
     439
    295440.notice.syncr-notice--onboarding { background: var(--syncr-tint); }
     441
    296442.notice.syncr-notice--failing { border-left-color: #d63638; }
     443
    297444.notice.syncr-notice.inline { margin: 16px 0 8px; }
    298445
    299446:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: auto; min-height: 40px; height: auto; margin: 0; padding: 0 16px; border: 1px solid; border-radius: 2px; font: 500 13px/38px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; text-transform: none; text-decoration: none; box-shadow: none; cursor: pointer; }
     447
    300448:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button:not(.syncr-btn-connected) { background: transparent; border-color: #3858e9; color: #3858e9; }
     449
    301450:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button.button-primary:not(.syncr-btn-connected) { background: #3858e9; border-color: transparent; color: #fff; }
     451
    302452:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button:not(.syncr-btn-connected):hover { background: #f0f3ff; color: #2145c7; }
     453
    303454:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button.button-primary:not(.syncr-btn-connected):hover { background: #2145c7; color: #fff; }
     455
    304456:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button:focus-visible { outline: 2px solid #3858e9; outline-offset: 2px; }
     457
    305458:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]) .button:disabled { opacity: .55; cursor: default; }
     459
    306460[data-syncr-panel="clicksyncr-for-kit"] input[type="checkbox"] { width: 16px; height: 16px; min-width: 16px; margin: 0; }
    307461
    308462[data-syncr-panel="clicksyncr-for-kit"].syncr-panel { color: #1d2327; font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
     463
    309464:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"]).syncr-panel label { text-transform: none; letter-spacing: normal; }
     465
    310466[data-syncr-panel="clicksyncr-for-kit"].syncr-panel .syncr-fragment-title { margin: 0 0 6px; font-size: 14px; font-weight: 600; }
     467
    311468[data-syncr-panel="clicksyncr-for-kit"].syncr-panel textarea::placeholder { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
    312469
    313 :is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-destination-extra[aria-disabled="true"] { opacity: .55; cursor: pointer; }
     470:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-destination-extra[data-syncr-destination-tip] { opacity: .55; cursor: pointer; }
     471
    314472:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-destination-extra:focus { outline: 2px solid #2271b1; outline-offset: 3px; }
     473
    315474:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-main-caption { display: block; font-size: 12px; }
     475
    316476:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-destination-tip { position: absolute; z-index: 100001; display: block; box-sizing: border-box; width: 350px; max-width: calc(100% - 16px); margin: 0; padding: 10px; background: #fff; border: 1px solid #8c8f94; border-radius: 4px; color: #1d2327; font-size: 13px; }
     477
    317478:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-tile--primary { border-color: #2271b1; }
     479
    318480:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-pro-chip { display: inline-block; margin-left: 4px; padding: 1px 5px; border-radius: 3px; background: #dce7f4; color: #22528a; font-size: 10px; font-weight: 700; }
     481
    319482:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-destination-paused,
    320483:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-rule-paused { display: block; color: #76551e; font-size: 12px; }
     484
    321485:is(#clicksyncr-for-kit, [data-syncr-panel="clicksyncr-for-kit"] ) .syncr-license-row { padding: 12px; margin-bottom: 16px; border: 1px solid #ded7c4; border-radius: 4px; background: #fff8e7; }
     486
    322487/* === end clicksyncr kit: panel === */
    323 
    324 #syncr_kit_api_key {
    325         font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
    326 }
    327488
    328489.syncr-ver {
    … …  
    405566
    406567@media (max-width: 782px) {
     568
    407569        .fields-grid {
    408570                grid-template-columns: 1fr;
    … …  
    410572}
    411573
    412 #syncr_kit_data {
    413         display: none;
    414 }
    415 
    416574.syncr-hidden {
    417575        display: none !important;
    418576}
    419577
    420 .syncr-api-key-input,
    421 #syncr_kit_list_id {
     578.syncr-api-key-input {
    422579        vertical-align: middle;
    423580}
    424581
    425 .syncr-kit-toggle-key-visibility,
    426 #syncr_kit_connect_btn,
    427 #syncr_kit_refresh_lists {
     582.syncr-kit-toggle-key-visibility {
    428583        vertical-align: middle;
    429584        margin-left: 5px;
  • clicksyncr-for-kit/trunk/assets/js/builder-panel.js

    r3726852 r3760857  
    1 /** @package clicksyncr-for-kit */
    2 (function () {
    3   'use strict';
    4   const panelId = 'clicksyncr-for-kit';
    5   const settingPrefix = 'cskit_';
    6   const fieldKey = (value) => String(value || '').replace(/^\{field:(.+)\}$/, '$1').replace(/^0$/, '');
    7   const inputKey = (value) => String(value || '').replace(/^\{inputs\.([^}]+)\}$/, '$1');
    8   const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
     1/** @package */
     2( function () {
     3        'use strict';
     4        const panelId = 'clicksyncr-for-kit';
     5        const settingPrefix = 'cskit_';
     6        const fieldKey = ( value ) =>
     7                String( value || '' )
     8                        .replace( /^\{field:(.+)\}$/, '$1' )
     9                        .replace( /^0$/, '' );
     10        const inputKey = ( value ) =>
     11                String( value || '' ).replace( /^\{inputs\.([^}]+)\}$/, '$1' );
     12        const same = ( a, b ) => JSON.stringify( a ) === JSON.stringify( b );
    913
    10   function hydrate(root, values) {
    11     const tiles = root.querySelector('[id="' + panelId + '-destination"]');
    12     if (tiles) {
    13       tiles.dataset.syncrValue = values.group_id;
    14       tiles.querySelectorAll('input[type=radio]').forEach((input) => { input.checked = input.value === values.group_id; });
    15       const primary = tiles.querySelector('[data-syncr-primary]');
    16       if (primary) primary.value = values.group_id;
    17     }
    18     const map = root.querySelector('[data-syncr-map]');
    19     if (map) map.dataset.syncrMap = JSON.stringify(values.field_map);
    20     ['consent_field', 'status_preference'].forEach((key) => {
    21       const control = root.querySelector('[name="' + root.dataset.syncrNamePrefix + '[' + key + ']"]');
    22       if (control) control.value = values[key];
    23     });
    24   }
     14        function hydrate( root, values ) {
     15                const tiles = root.querySelector(
     16                        '[id="' + panelId + '-destination"]'
     17                );
     18                if ( tiles ) {
     19                        tiles.dataset.syncrValue = values.group_id;
     20                        tiles
     21                                .querySelectorAll( 'input[type=radio]' )
     22                                .forEach( ( input ) => {
     23                                        input.checked = input.value === values.group_id;
     24                                } );
     25                        const primary = tiles.querySelector( '[data-syncr-primary]' );
     26                        if ( primary ) {
     27                                primary.value = values.group_id;
     28                        }
     29                }
     30                const map = root.querySelector( '[data-syncr-map]' );
     31                if ( map ) {
     32                        map.dataset.syncrMap = JSON.stringify( values.field_map );
     33                }
     34                [ 'consent_field', 'status_preference' ].forEach( ( key ) => {
     35                        const control = root.querySelector(
     36                                '[name="' + root.dataset.syncrNamePrefix + '[' + key + ']"]'
     37                        );
     38                        if ( control ) {
     39                                control.value = values[ key ];
     40                        }
     41                } );
     42        }
    2543
    26   function bind(root, store) {
    27     if (root.syncrStore) return;
    28     root.syncrStore = store;
    29     const ninja = root.dataset.syncrBridge === 'ninjaforms';
    30     const keyOf = ninja ? fieldKey : inputKey;
    31     const map = [];
    32     if (ninja) {
    33       Object.keys(store.all()).forEach((key) => {
    34         if (key.indexOf(settingPrefix + 'map_') === 0 && keyOf(store.get(key))) {
    35           map.push({provider: key.slice((settingPrefix + 'map_').length), form: keyOf(store.get(key))});
    36         }
    37       });
    38     } else {
    39       if (store.get('fieldEmailAddress')) map.push({provider: 'email', form: keyOf(store.get('fieldEmailAddress'))});
    40       Object.entries(store.get('merge_fields') || {}).forEach(([provider, value]) => { if (keyOf(value)) map.push({provider, form: keyOf(value)}); });
    41     }
    42     hydrate(root, {
    43       group_id: String(store.get(ninja ? settingPrefix + 'group_id' : 'list_id') || ''),
    44       consent_field: ninja ? fieldKey(store.get(settingPrefix + 'consent_field')) : String(store.get('consent_field') || ''),
    45       status_preference: String(store.get(ninja ? settingPrefix + 'status_preference' : 'status_preference') || 'active'),
    46       field_map: map
    47     });
    48     const set = (key, value, normalize) => {
    49       const old = store.get(key);
    50       if (normalize ? normalize(old) === normalize(value) : same(old, value)) return;
    51       store.set(key, value);
    52     };
    53     const controls = () => Array.from(root.querySelectorAll('input[name],select[name],textarea[name]')).filter((e) => !e.disabled && !e.closest('.syncr-locked-box'));
    54     const proControls = JSON.parse(root.dataset.syncrProControls || '{}');
    55     const proValues = () => {
    56       const values = {};
    57       const active = controls();
    58       Object.entries(proControls).forEach(([key, type]) => {
    59         const name = root.dataset.syncrNamePrefix + '[' + key + ']';
    60         const inputs = active.filter((e) => e.name === name || e.name.indexOf(name + '[') === 0);
    61         if (type === 'rules') {
    62           const rows = {};
    63           inputs.forEach((control) => {
    64             const parts = Array.from(control.name.slice(name.length).matchAll(/\[([^\]]*)\]/g), (match) => match[1]);
    65             if (parts.length === 2) { rows[parts[0]] = rows[parts[0]] || {}; rows[parts[0]][parts[1]] = control.value; }
    66           });
    67           values[key] = Object.values(rows);
    68         } else if (type === 'checklist') values[key] = inputs.filter((e) => e.checked).map((e) => e.value);
    69         else if (type === 'pair') {
    70           values[key] = {};
    71           inputs.forEach((e) => { values[key][e.name.slice(name.length + 1, -1)] = e.value; });
    72         } else if (inputs.length) values[key] = type === 'checkbox' ? inputs[0].checked : inputs[0].value;
    73       });
    74       return values;
    75     };
    76     root.syncrCapturePro = () => { root.syncrProBaseline = proValues(); };
    77     const sync = () => {
    78       const prefix = root.dataset.syncrNamePrefix;
    79       const active = controls();
    80       const named = (key) => active.filter((e) => e.name === prefix + '[' + key + ']');
    81       ['group_id', 'consent_field', 'status_preference'].forEach((key) => {
    82         const control = named(key).find((e) => e.type !== 'radio' || e.checked);
    83         if (!control) return;
    84         const nativeKey = ninja ? settingPrefix + key : key === 'group_id' ? 'list_id' : key;
    85         const value = ninja && key === 'consent_field' && control.value ? '{field:' + control.value + '}' : control.value;
    86         set(nativeKey, value, ninja && key === 'consent_field' ? fieldKey : (v) => String(v || ''));
    87       });
    88       const mapping = {};
    89       active.filter((e) => e.name.indexOf(prefix + '[field_map][') === 0 && e.name.endsWith('[provider]')).forEach((provider) => {
    90         const field = active.find((e) => e.name === provider.name.replace(/\[provider\]$/, '[form]'));
    91         if (field) mapping[provider.value] = field.value;
    92       });
    93       if (ninja) {
    94         Object.entries(mapping).forEach(([provider, field]) => {
    95           const key = settingPrefix + 'map_' + provider;
    96           if (!field) { if (fieldKey(store.get(key))) store.remove(key); }
    97           else set(key, provider === 'email' ? field : '{field:' + field + '}', fieldKey);
    98         });
    99       } else {
    100         const fields = Object.assign({}, store.get('merge_fields') || {});
    101         Object.entries(mapping).forEach(([provider, field]) => {
    102           if (provider === 'email') set('fieldEmailAddress', field ? '{inputs.' + field + '}' : '', inputKey);
    103           else if (!field) delete fields[provider];
    104           else if (inputKey(fields[provider]) !== field) fields[provider] = '{inputs.' + field + '}';
    105         });
    106         if (!same(fields, store.get('merge_fields') || {})) store.set('merge_fields', fields);
    107       }
    108       if (root.syncrProBaseline) {
    109         const values = proValues();
    110         Object.keys(values).forEach((key) => {
    111           if (same(values[key], root.syncrProBaseline[key])) return;
    112           const nativeKey = key.replace(/:/g, '__');
    113           const type = proControls[key];
    114           const value = values[key];
    115           if (type === 'checklist') {
    116             const controlsForKey = active.filter((e) => e.name === prefix + '[' + key + '][]');
    117             controlsForKey.forEach((control) => set(nativeKey + '__' + control.value, ninja ? (control.checked ? 1 : 0) : control.checked));
    118           } else if (type === 'pair') {
    119             Object.entries(value).forEach(([part, item]) => set(nativeKey + '__' + part, item));
    120           } else if (type === 'field' && ninja) {
    121             set(nativeKey, value ? '{field:' + value + '}' : '', fieldKey);
    122           } else {
    123             set(nativeKey, type === 'checkbox' && ninja ? (value ? 1 : 0) : value);
    124           }
    125         });
    126         root.syncrProBaseline = values;
    127       }
    128     };
    129     ['change', 'input'].forEach((event) => root.addEventListener(event, (e) => { sync(); if (ninja) e.stopPropagation(); }));
    130     root.addEventListener('click', (e) => {
    131       if (e.target.closest('[data-syncr-make-primary],.syncr-rules__add,.syncr-rules__remove')) queueMicrotask(sync);
    132       if (ninja) e.stopPropagation();
    133     });
    134   }
     44        function bind( root, store ) {
     45                if ( root.syncrStore ) {
     46                        return;
     47                }
     48                root.syncrStore = store;
     49                const ninja = root.dataset.syncrBridge === 'ninjaforms';
     50                const keyOf = ninja ? fieldKey : inputKey;
     51                const map = [];
     52                if ( ninja ) {
     53                        Object.keys( store.all() ).forEach( ( key ) => {
     54                                if (
     55                                        key.indexOf( settingPrefix + 'map_' ) === 0 &&
     56                                        keyOf( store.get( key ) )
     57                                ) {
     58                                        map.push( {
     59                                                provider: key.slice(
     60                                                        ( settingPrefix + 'map_' ).length
     61                                                ),
     62                                                form: keyOf( store.get( key ) ),
     63                                        } );
     64                                }
     65                        } );
     66                } else {
     67                        if ( store.get( 'fieldEmailAddress' ) ) {
     68                                map.push( {
     69                                        provider: 'email',
     70                                        form: keyOf( store.get( 'fieldEmailAddress' ) ),
     71                                } );
     72                        }
     73                        Object.entries( store.get( 'merge_fields' ) || {} ).forEach(
     74                                ( [ provider, value ] ) => {
     75                                        if ( keyOf( value ) ) {
     76                                                map.push( { provider, form: keyOf( value ) } );
     77                                        }
     78                                }
     79                        );
     80                }
     81                hydrate( root, {
     82                        group_id: String(
     83                                store.get( ninja ? settingPrefix + 'group_id' : 'list_id' ) ||
     84                                        ''
     85                        ),
     86                        consent_field: ninja
     87                                ? fieldKey( store.get( settingPrefix + 'consent_field' ) )
     88                                : String( store.get( 'consent_field' ) || '' ),
     89                        status_preference: String(
     90                                store.get(
     91                                        ninja
     92                                                ? settingPrefix + 'status_preference'
     93                                                : 'status_preference'
     94                                ) || 'active'
     95                        ),
     96                        field_map: map,
     97                } );
     98                const set = ( key, value, normalize ) => {
     99                        const old = store.get( key );
     100                        if (
     101                                normalize
     102                                        ? normalize( old ) === normalize( value )
     103                                        : same( old, value )
     104                        ) {
     105                                return;
     106                        }
     107                        store.set( key, value );
     108                };
     109                const controls = () =>
     110                        Array.from(
     111                                root.querySelectorAll(
     112                                        'input[name],select[name],textarea[name]'
     113                                )
     114                        ).filter(
     115                                ( e ) => ! e.disabled && ! e.closest( '.syncr-locked-box' )
     116                        );
     117                const proControls = JSON.parse( root.dataset.syncrProControls || '{}' );
     118                const proValues = () => {
     119                        const values = {};
     120                        const active = controls();
     121                        Object.entries( proControls ).forEach( ( [ key, type ] ) => {
     122                                const name = root.dataset.syncrNamePrefix + '[' + key + ']';
     123                                const inputs = active.filter(
     124                                        ( e ) =>
     125                                                e.name === name || e.name.indexOf( name + '[' ) === 0
     126                                );
     127                                if ( type === 'rules' ) {
     128                                        const rows = {};
     129                                        inputs.forEach( ( control ) => {
     130                                                const parts = Array.from(
     131                                                        control.name
     132                                                                .slice( name.length )
     133                                                                .matchAll( /\[([^\]]*)\]/g ),
     134                                                        ( match ) => match[ 1 ]
     135                                                );
     136                                                if ( parts.length === 2 ) {
     137                                                        rows[ parts[ 0 ] ] = rows[ parts[ 0 ] ] || {};
     138                                                        rows[ parts[ 0 ] ][ parts[ 1 ] ] = control.value;
     139                                                }
     140                                        } );
     141                                        values[ key ] = Object.values( rows );
     142                                } else if ( type === 'checklist' ) {
     143                                        values[ key ] = inputs
     144                                                .filter( ( e ) => e.checked )
     145                                                .map( ( e ) => e.value );
     146                                } else if ( type === 'pair' ) {
     147                                        values[ key ] = {};
     148                                        inputs.forEach( ( e ) => {
     149                                                values[ key ][ e.name.slice( name.length + 1, -1 ) ] =
     150                                                        e.value;
     151                                        } );
     152                                } else if ( inputs.length ) {
     153                                        values[ key ] =
     154                                                type === 'checkbox'
     155                                                        ? inputs[ 0 ].checked
     156                                                        : inputs[ 0 ].value;
     157                                }
     158                        } );
     159                        return values;
     160                };
     161                root.syncrCapturePro = () => {
     162                        root.syncrProBaseline = proValues();
     163                };
     164                const sync = () => {
     165                        const prefix = root.dataset.syncrNamePrefix;
     166                        const active = controls();
     167                        const named = ( key ) =>
     168                                active.filter( ( e ) => e.name === prefix + '[' + key + ']' );
     169                        [ 'group_id', 'consent_field', 'status_preference' ].forEach(
     170                                ( key ) => {
     171                                        const control = named( key ).find(
     172                                                ( e ) => e.type !== 'radio' || e.checked
     173                                        );
     174                                        if ( ! control ) {
     175                                                return;
     176                                        }
     177                                        let nativeKey = key === 'group_id' ? 'list_id' : key;
     178                                        if ( ninja ) {
     179                                                nativeKey = settingPrefix + key;
     180                                        }
     181                                        const value =
     182                                                ninja && key === 'consent_field' && control.value
     183                                                        ? '{field:' + control.value + '}'
     184                                                        : control.value;
     185                                        set(
     186                                                nativeKey,
     187                                                value,
     188                                                ninja && key === 'consent_field'
     189                                                        ? fieldKey
     190                                                        : ( v ) => String( v || '' )
     191                                        );
     192                                }
     193                        );
     194                        const mapping = {};
     195                        active
     196                                .filter(
     197                                        ( e ) =>
     198                                                e.name.indexOf( prefix + '[field_map][' ) === 0 &&
     199                                                e.name.endsWith( '[provider]' )
     200                                )
     201                                .forEach( ( provider ) => {
     202                                        const field = active.find(
     203                                                ( e ) =>
     204                                                        e.name ===
     205                                                        provider.name.replace( /\[provider\]$/, '[form]' )
     206                                        );
     207                                        if ( field ) {
     208                                                mapping[ provider.value ] = field.value;
     209                                        }
     210                                } );
     211                        if ( ninja ) {
     212                                Object.entries( mapping ).forEach( ( [ provider, field ] ) => {
     213                                        const key = settingPrefix + 'map_' + provider;
     214                                        if ( ! field ) {
     215                                                if ( fieldKey( store.get( key ) ) ) {
     216                                                        store.remove( key );
     217                                                }
     218                                        } else {
     219                                                set(
     220                                                        key,
     221                                                        provider === 'email'
     222                                                                ? field
     223                                                                : '{field:' + field + '}',
     224                                                        fieldKey
     225                                                );
     226                                        }
     227                                } );
     228                        } else {
     229                                const fields = Object.assign(
     230                                        {},
     231                                        store.get( 'merge_fields' ) || {}
     232                                );
     233                                Object.entries( mapping ).forEach( ( [ provider, field ] ) => {
     234                                        if ( provider === 'email' ) {
     235                                                set(
     236                                                        'fieldEmailAddress',
     237                                                        field ? '{inputs.' + field + '}' : '',
     238                                                        inputKey
     239                                                );
     240                                        } else if ( ! field ) {
     241                                                delete fields[ provider ];
     242                                        } else if ( inputKey( fields[ provider ] ) !== field ) {
     243                                                fields[ provider ] = '{inputs.' + field + '}';
     244                                        }
     245                                } );
     246                                if ( ! same( fields, store.get( 'merge_fields' ) || {} ) ) {
     247                                        store.set( 'merge_fields', fields );
     248                                }
     249                        }
     250                        if ( root.syncrProBaseline ) {
     251                                const values = proValues();
     252                                Object.keys( values ).forEach( ( key ) => {
     253                                        if ( same( values[ key ], root.syncrProBaseline[ key ] ) ) {
     254                                                return;
     255                                        }
     256                                        const nativeKey = key.replace( /:/g, '__' );
     257                                        const type = proControls[ key ];
     258                                        const value = values[ key ];
     259                                        if ( type === 'checklist' ) {
     260                                                const controlsForKey = active.filter(
     261                                                        ( e ) => e.name === prefix + '[' + key + '][]'
     262                                                );
     263                                                controlsForKey.forEach( ( control ) => {
     264                                                        const checked = control.checked ? 1 : 0;
     265                                                        set(
     266                                                                nativeKey + '__' + control.value,
     267                                                                ninja ? checked : control.checked
     268                                                        );
     269                                                } );
     270                                        } else if ( type === 'pair' ) {
     271                                                Object.entries( value ).forEach( ( [ part, item ] ) =>
     272                                                        set( nativeKey + '__' + part, item )
     273                                                );
     274                                        } else if ( type === 'field' && ninja ) {
     275                                                set(
     276                                                        nativeKey,
     277                                                        value ? '{field:' + value + '}' : '',
     278                                                        fieldKey
     279                                                );
     280                                        } else {
     281                                                const checked = value ? 1 : 0;
     282                                                set(
     283                                                        nativeKey,
     284                                                        type === 'checkbox' && ninja ? checked : value
     285                                                );
     286                                        }
     287                                } );
     288                                root.syncrProBaseline = values;
     289                        }
     290                };
     291                [ 'change', 'input' ].forEach( ( event ) =>
     292                        root.addEventListener( event, ( e ) => {
     293                                sync();
     294                                if ( ninja ) {
     295                                        e.stopPropagation();
     296                                }
     297                        } )
     298                );
     299                root.addEventListener( 'click', ( e ) => {
     300                        if (
     301                                e.target.closest(
     302                                        '[data-syncr-make-primary],.syncr-rules__add,.syncr-rules__remove'
     303                                )
     304                        ) {
     305                                queueMicrotask( sync );
     306                        }
     307                        if ( ninja ) {
     308                                e.stopPropagation();
     309                        }
     310                } );
     311        }
    135312
    136   function prepare(root) {
    137     if (root.dataset.syncrBridge === 'ninjaforms') return !!root.syncrStore;
    138     if (root.dataset.syncrBridge !== 'fluentforms') return true;
    139     if (root.syncrStore) return true;
    140     let el = root;
    141     while (el && !(el.__vue__ && el.__vue__.settings)) el = el.parentElement;
    142     if (!el) return false;
    143     const vm = el.__vue__;
    144     bind(root, {all: () => vm.settings, get: (k) => vm.settings[k], set: (k, v) => vm.$set(vm.settings, k, v), remove: (k) => vm.$delete(vm.settings, k)});
    145     return true;
    146   }
    147   let hooked = false;
    148   function ninjaHook() {
    149     if (hooked || !window.Backbone || !window.Backbone.Radio) return;
    150     hooked = true;
    151     window.Backbone.Radio.channel('setting-type-clicksyncr_kit_panel').on('render:setting', function (setting, model, view) {
    152       const root = view.el.querySelector('[data-syncr-bridge="ninjaforms"]');
    153       if (root) bind(root, {all: () => model.attributes, get: (k) => model.get(k), set: (k, v) => model.set(k, v), remove: (k) => model.unset(k)});
    154     });
    155   }
    156   window.syncrBuilderPanel_kit = {prepare, ready: (root) => { if (root.syncrCapturePro) root.syncrCapturePro(); }};
    157   ninjaHook();
    158   document.addEventListener('DOMContentLoaded', ninjaHook);
    159 }());
     313        function prepare( root ) {
     314                if ( root.dataset.syncrBridge === 'ninjaforms' ) {
     315                        return !! root.syncrStore;
     316                }
     317                if ( root.dataset.syncrBridge !== 'fluentforms' ) {
     318                        return true;
     319                }
     320                if ( root.syncrStore ) {
     321                        return true;
     322                }
     323                let el = root;
     324                while ( el && ! ( el.__vue__ && el.__vue__.settings ) ) {
     325                        el = el.parentElement;
     326                }
     327                if ( ! el ) {
     328                        return false;
     329                }
     330                const vm = el.__vue__;
     331                bind( root, {
     332                        all: () => vm.settings,
     333                        get: ( k ) => vm.settings[ k ],
     334                        set: ( k, v ) => vm.$set( vm.settings, k, v ),
     335                        remove: ( k ) => vm.$delete( vm.settings, k ),
     336                } );
     337                return true;
     338        }
     339        let hooked = false;
     340        function ninjaHook() {
     341                if ( hooked || ! window.Backbone || ! window.Backbone.Radio ) {
     342                        return;
     343                }
     344                hooked = true;
     345                window.Backbone.Radio.channel( 'setting-type-clicksyncr_kit_panel' ).on(
     346                        'render:setting',
     347                        function ( setting, model, view ) {
     348                                const root = view.el.querySelector(
     349                                        '[data-syncr-bridge="ninjaforms"]'
     350                                );
     351                                if ( root ) {
     352                                        bind( root, {
     353                                                all: () => model.attributes,
     354                                                get: ( k ) => model.get( k ),
     355                                                set: ( k, v ) => model.set( k, v ),
     356                                                remove: ( k ) => model.unset( k ),
     357                                        } );
     358                                }
     359                        }
     360                );
     361        }
     362        window.syncrBuilderPanel_kit = {
     363                prepare,
     364                ready: ( root ) => {
     365                        if ( root.syncrCapturePro ) {
     366                                root.syncrCapturePro();
     367                        }
     368                },
     369        };
     370        ninjaHook();
     371        document.addEventListener( 'DOMContentLoaded', ninjaHook );
     372} )();
  • clicksyncr-for-kit/trunk/assets/js/form-picker.js

    r3726852 r3760857  
    1 /** @package clicksyncr-for-kit */
    2 (function () {
    3   'use strict';
    4   document.addEventListener('click', function (event) {
    5     const go = event.target.closest('[data-syncr-form-go]');
    6     if (!go) return;
    7     const picker = go.closest('[data-syncr-notice]').querySelector('[data-syncr-form-picker]');
    8     const url = new URL(picker.value, window.location.href);
    9     if (url.origin === window.location.origin && url.pathname.includes('/wp-admin/')) window.location.assign(url.href);
    10   });
    11   const params = new URLSearchParams(window.location.search);
    12   if (params.get('clicksyncr_create') === 'kit' && params.get('page') === 'fluent_forms') {
    13     const create = new MutationObserver(openCreate);
    14     const timeout = setTimeout(function () { create.disconnect(); }, 20000);
    15     function openCreate() {
    16       const button = document.querySelector('.ff_all_forms_action_row button.el-button--primary');
    17       if (!button) return;
    18       create.disconnect();
    19       clearTimeout(timeout);
    20       button.click();
    21     }
    22     create.observe(document.documentElement, {childList: true, subtree: true});
    23     openCreate();
    24     return;
    25   }
    26   if (params.get('clicksyncr_setup') !== 'kit') return;
    27   const page = params.get('page');
    28   if (page === 'formidable' || page === 'forminator-cform-wizard') {
    29     let added = false;
    30     let clicked = false;
    31     let lastClick = 0;
    32     const visible = (node) => node && node.getClientRects().length > 0;
    33     const timer = setInterval(openMapping, 200);
    34     const timeout = setTimeout(function () { clearInterval(timer); }, 20000);
    35     function finish() { clearInterval(timer); clearTimeout(timeout); }
    36     function click(button) {
    37       if (!visible(button) || Date.now() - lastClick < 500) return false;
    38       lastClick = Date.now();
    39       button.click();
    40       return true;
    41     }
    42     function openMapping() {
    43       if (page === 'formidable') {
    44         const panel = document.querySelector('.frm_single_clicksyncr_kit_settings');
    45         const control = panel && panel.querySelector('#clicksyncr-for-kit-panel');
    46         const toggle = panel && panel.querySelector('.widget-action');
    47         const expanded = toggle && toggle.getAttribute('aria-expanded') === 'true';
    48         if (visible(control) && expanded) { panel.scrollIntoView({block: 'center'}); finish(); return; }
    49         const add = [...document.querySelectorAll('[data-actiontype="clicksyncr_kit"].frm_active_action')].find(visible);
    50         if (!visible(panel) && !add) { click(document.querySelector('a[href="#email_settings"]')); return; }
    51         if (visible(panel)) { if (!expanded) click(toggle); return; }
    52         if (!added && click(add)) added = true;
    53       } else {
    54         if (visible(document.querySelector('#clicksyncr-for-kit-destination'))) { finish(); return; }
    55         const button = [...document.querySelectorAll('.connect-integration[data-slug="clicksyncrkit"]')].find(visible);
    56         if (!button) { click(document.querySelector('#forminator-navigation-integrations')); return; }
    57         if (!clicked && window.jQuery && window.jQuery._data(button, 'events')?.click && click(button)) clicked = true;
    58       }
    59     }
    60     openMapping();
    61     return;
    62   }
    63   if (page !== 'ninja-forms') return;
    64   let added = false;
    65   let lastClick = 0;
    66   const current = window.clicksyncr_kit_picker || {};
    67   const id = Number(current.actionId || params.get('clicksyncr_action'));
    68   const visible = (node) => node && node.getClientRects().length > 0;
    69   const timer = setInterval(openAction, 200);
    70   const timeout = setTimeout(function () { clearInterval(timer); }, 20000);
    71   function openAction() {
    72     const settings = document.querySelector('[data-syncr-bridge="ninjaforms"]');
    73     if (visible(settings)) {
    74       clearInterval(timer);
    75       clearTimeout(timeout);
    76       if (id > 0) {
    77         const url = new URL(location.href);
    78         url.searchParams.set('clicksyncr_action', String(id));
    79         history.replaceState(null, '', url.href);
    80       }
    81       return;
    82     }
    83     if (Date.now() - lastClick < 500) return;
    84     const row = id > 0 ? document.querySelector('[data-testid="nf-action-row-' + id + '"] .nf-edit-settings') : null;
    85     let button;
    86     if (visible(row)) button = row;
    87     else if (!visible(document.querySelector('[data-testid="nf-add-action-header"]'))) button = document.querySelector('[data-testid="nf-menu-actions"]');
    88     else if (!id && !added) {
    89       const type = document.querySelector('[data-testid="nf-action-type-clicksyncr_kit"]');
    90       if (visible(type)) { added = true; button = type; }
    91       else button = document.querySelector('[data-testid="nf-add-action-header"]');
    92     }
    93     if (visible(button)) { lastClick = Date.now(); button.click(); }
    94   }
    95   openAction();
    96 })();
     1/** @package */
     2( function () {
     3        'use strict';
     4        document.addEventListener( 'click', function ( event ) {
     5                const go = event.target.closest( '[data-syncr-form-go]' );
     6                if ( ! go ) {
     7                        return;
     8                }
     9                const picker = go
     10                        .closest( '[data-syncr-notice]' )
     11                        .querySelector( '[data-syncr-form-picker]' );
     12                const url = new URL( picker.value, window.location.href );
     13                if (
     14                        url.origin === window.location.origin &&
     15                        url.pathname.includes( '/wp-admin/' )
     16                ) {
     17                        window.location.assign( url.href );
     18                }
     19        } );
     20        const params = new URLSearchParams( window.location.search );
     21        if (
     22                params.get( 'clicksyncr_create' ) === 'kit' &&
     23                params.get( 'page' ) === 'fluent_forms'
     24        ) {
     25                const create = new MutationObserver( openCreate );
     26                const timeout = setTimeout( function () {
     27                        create.disconnect();
     28                }, 20000 );
     29                function openCreate() {
     30                        const button = document.querySelector(
     31                                '.ff_all_forms_action_row button.el-button--primary'
     32                        );
     33                        if ( ! button ) {
     34                                return;
     35                        }
     36                        create.disconnect();
     37                        clearTimeout( timeout );
     38                        button.click();
     39                }
     40                create.observe( document.documentElement, {
     41                        childList: true,
     42                        subtree: true,
     43                } );
     44                openCreate();
     45                return;
     46        }
     47        if ( params.get( 'clicksyncr_setup' ) !== 'kit' ) {
     48                return;
     49        }
     50        const page = params.get( 'page' );
     51        if ( page === 'formidable' || page === 'forminator-cform-wizard' ) {
     52                let added = false;
     53                let clicked = false;
     54                let lastClick = 0;
     55                const visible = ( node ) => node && node.getClientRects().length > 0;
     56                const timer = setInterval( openMapping, 200 );
     57                const timeout = setTimeout( function () {
     58                        clearInterval( timer );
     59                }, 20000 );
     60                function finish() {
     61                        clearInterval( timer );
     62                        clearTimeout( timeout );
     63                }
     64                function click( button ) {
     65                        if ( ! visible( button ) || Date.now() - lastClick < 500 ) {
     66                                return false;
     67                        }
     68                        lastClick = Date.now();
     69                        button.click();
     70                        return true;
     71                }
     72                function openMapping() {
     73                        if ( page === 'formidable' ) {
     74                                const panel = document.querySelector(
     75                                        '.frm_single_clicksyncr_kit_settings'
     76                                );
     77                                const control =
     78                                        panel && panel.querySelector( '#clicksyncr-for-kit-panel' );
     79                                const toggle = panel && panel.querySelector( '.widget-action' );
     80                                const expanded =
     81                                        toggle && toggle.getAttribute( 'aria-expanded' ) === 'true';
     82                                if ( visible( control ) && expanded ) {
     83                                        panel.scrollIntoView( { block: 'center' } );
     84                                        finish();
     85                                        return;
     86                                }
     87                                const add = [
     88                                        ...document.querySelectorAll(
     89                                                '[data-actiontype="clicksyncr_kit"].frm_active_action'
     90                                        ),
     91                                ].find( visible );
     92                                if ( ! visible( panel ) && ! add ) {
     93                                        click(
     94                                                document.querySelector( 'a[href="#email_settings"]' )
     95                                        );
     96                                        return;
     97                                }
     98                                if ( visible( panel ) ) {
     99                                        if ( ! expanded ) {
     100                                                click( toggle );
     101                                        }
     102                                        return;
     103                                }
     104                                if ( ! added && click( add ) ) {
     105                                        added = true;
     106                                }
     107                        } else {
     108                                if (
     109                                        visible(
     110                                                document.querySelector(
     111                                                        '#clicksyncr-for-kit-destination'
     112                                                )
     113                                        )
     114                                ) {
     115                                        finish();
     116                                        return;
     117                                }
     118                                const button = [
     119                                        ...document.querySelectorAll(
     120                                                '.connect-integration[data-slug="clicksyncrkit"]'
     121                                        ),
     122                                ].find( visible );
     123                                if ( ! button ) {
     124                                        click(
     125                                                document.querySelector(
     126                                                        '#forminator-navigation-integrations'
     127                                                )
     128                                        );
     129                                        return;
     130                                }
     131                                if (
     132                                        ! clicked &&
     133                                        window.jQuery &&
     134                                        window.jQuery._data( button, 'events' )?.click &&
     135                                        click( button )
     136                                ) {
     137                                        clicked = true;
     138                                }
     139                        }
     140                }
     141                openMapping();
     142                return;
     143        }
     144        if ( page !== 'ninja-forms' ) {
     145                return;
     146        }
     147        let added = false;
     148        let lastClick = 0;
     149        const current = window.clicksyncr_kit_picker || {};
     150        const id = Number( current.actionId || params.get( 'clicksyncr_action' ) );
     151        const visible = ( node ) => node && node.getClientRects().length > 0;
     152        const timer = setInterval( openAction, 200 );
     153        const timeout = setTimeout( function () {
     154                clearInterval( timer );
     155        }, 20000 );
     156        function openAction() {
     157                const settings = document.querySelector(
     158                        '[data-syncr-bridge="ninjaforms"]'
     159                );
     160                if ( visible( settings ) ) {
     161                        clearInterval( timer );
     162                        clearTimeout( timeout );
     163                        if ( id > 0 ) {
     164                                const url = new URL( location.href );
     165                                url.searchParams.set( 'clicksyncr_action', String( id ) );
     166                                history.replaceState( null, '', url.href );
     167                        }
     168                        return;
     169                }
     170                if ( Date.now() - lastClick < 500 ) {
     171                        return;
     172                }
     173                const row =
     174                        id > 0
     175                                ? document.querySelector(
     176                                                '[data-testid="nf-action-row-' +
     177                                                        id +
     178                                                        '"] .nf-edit-settings'
     179                                        )
     180                                : null;
     181                let button;
     182                if ( visible( row ) ) {
     183                        button = row;
     184                } else if (
     185                        ! visible(
     186                                document.querySelector( '[data-testid="nf-add-action-header"]' )
     187                        )
     188                ) {
     189                        button = document.querySelector(
     190                                '[data-testid="nf-menu-actions"]'
     191                        );
     192                } else if ( ! id && ! added ) {
     193                        const type = document.querySelector(
     194                                '[data-testid="nf-action-type-clicksyncr_kit"]'
     195                        );
     196                        if ( visible( type ) ) {
     197                                added = true;
     198                                button = type;
     199                        } else {
     200                                button = document.querySelector(
     201                                        '[data-testid="nf-add-action-header"]'
     202                                );
     203                        }
     204                }
     205                if ( visible( button ) ) {
     206                        lastClick = Date.now();
     207                        button.click();
     208                }
     209        }
     210        openAction();
     211} )();
  • clicksyncr-for-kit/trunk/assets/js/ninja-builder.js

    r3726852 r3760857  
    11/**
    2  * @package   clicksyncr-for-kit
     2 * @package
    33 * @author    clicksyncr
    44 * @copyright 2026 ClickSyncr
    … …  
    66 */
    77( function () {
    8   'use strict';
     8        'use strict';
    99
    10   var cfg = window.syncrNinja_kit || {};
    11   var PROMPT = cfg.placeholder || 'Select a form field';
    12   var EMPTY = cfg.notMapped || 'Not mapped';
    13   var NOT_USED = cfg.notUsed || 'Not used';
    14   var hooked = false;
    15   function freshHtml() {
    16     if (hooked || !window.Backbone || !window.Backbone.Radio) return;
    17     hooked = true;
    18     window.Backbone.Radio.channel('setting-type-html').on('before:renderSetting', function (setting, action) {
    19       if (action.get('type') !== 'clicksyncr_kit') return;
    20       action.set(setting.get('name'), setting.get('value'), {silent: true});
    21     });
    22   }
    23   freshHtml();
    24   var BADGE = /·\s*\[/;
    25   var REQUIRED = /\(required\)/i;
     10        const cfg = window.syncrNinja_kit || {};
     11        const PROMPT = cfg.placeholder || 'Select a form field';
     12        const EMPTY = cfg.notMapped || 'Not mapped';
     13        const NOT_USED = cfg.notUsed || 'Not used';
     14        let hooked = false;
     15        function freshHtml() {
     16                if ( hooked || ! window.Backbone || ! window.Backbone.Radio ) {
     17                        return;
     18                }
     19                hooked = true;
     20                window.Backbone.Radio.channel( 'setting-type-html' ).on(
     21                        'before:renderSetting',
     22                        function ( setting, action ) {
     23                                if ( action.get( 'type' ) !== 'clicksyncr_kit' ) {
     24                                        return;
     25                                }
     26                                action.set( setting.get( 'name' ), setting.get( 'value' ), {
     27                                        silent: true,
     28                                } );
     29                        }
     30                );
     31        }
     32        freshHtml();
     33        const BADGE = /·\s*\[/;
     34        const REQUIRED = /\(required\)/i;
    2635
    27   function wantedFor(text) {
    28     if (!BADGE.test(text)) return NOT_USED;
    29     return REQUIRED.test(text) ? PROMPT : EMPTY;
    30   }
     36        function wantedFor( text ) {
     37                if ( ! BADGE.test( text ) ) {
     38                        return NOT_USED;
     39                }
     40                return REQUIRED.test( text ) ? PROMPT : EMPTY;
     41        }
    3142
    32   function relabel() {
    33     var labels = document.querySelectorAll('#nf-drawer label, .nf-drawer label');
    34     Array.prototype.forEach.call(labels, function (label) {
    35       var select = label.querySelector('select');
    36       if (!select || !select.options.length) return;
    37       var first = select.options[0];
     43        function relabel() {
     44                const labels = document.querySelectorAll(
     45                        '#nf-drawer label, .nf-drawer label'
     46                );
     47                Array.prototype.forEach.call( labels, function ( label ) {
     48                        const select = label.querySelector( 'select' );
     49                        if ( ! select || ! select.options.length ) {
     50                                return;
     51                        }
     52                        const first = select.options[ 0 ];
    3853
    39       if (first.value !== '' && first.value !== '0') return;
    40       if (first.textContent.trim() !== '--' && first.textContent.trim() !== PROMPT && first.textContent.trim() !== EMPTY && first.textContent.trim() !== NOT_USED) return;
    41       var wanted = wantedFor(label.textContent.trim());
    42       if (first.textContent.trim() !== wanted) first.textContent = wanted;
    43     });
    44   }
     54                        if ( first.value !== '' && first.value !== '0' ) {
     55                                return;
     56                        }
     57                        if (
     58                                first.textContent.trim() !== '--' &&
     59                                first.textContent.trim() !== PROMPT &&
     60                                first.textContent.trim() !== EMPTY &&
     61                                first.textContent.trim() !== NOT_USED
     62                        ) {
     63                                return;
     64                        }
     65                        const wanted = wantedFor( label.textContent.trim() );
     66                        if ( first.textContent.trim() !== wanted ) {
     67                                first.textContent = wanted;
     68                        }
     69                } );
     70        }
    4571
    46   var queued = false;
    47   function schedule() {
    48     if (queued) return;
    49     queued = true;
    50     window.requestAnimationFrame(function () { queued = false; relabel(); });
    51   }
     72        let queued = false;
     73        function schedule() {
     74                if ( queued ) {
     75                        return;
     76                }
     77                queued = true;
     78                window.requestAnimationFrame( function () {
     79                        queued = false;
     80                        relabel();
     81                } );
     82        }
    5283
    53   function start() {
    54     freshHtml();
    55     relabel();
    56     if (window.MutationObserver) {
     84        function start() {
     85                freshHtml();
     86                relabel();
     87                if ( window.MutationObserver ) {
     88                        new MutationObserver( schedule ).observe( document.body, {
     89                                childList: true,
     90                                subtree: true,
     91                        } );
     92                }
     93        }
    5794
    58       new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
    59     }
    60   }
    61 
    62   if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
    63   else start();
    64 }());
     95        if ( document.readyState === 'loading' ) {
     96                document.addEventListener( 'DOMContentLoaded', start );
     97        } else {
     98                start();
     99        }
     100} )();
  • clicksyncr-for-kit/trunk/assets/js/notices.js

    r3726852 r3760857  
    11/**
    2  * @package   clicksyncr-for-kit
     2 * @package
    33 * @author    clicksyncr
    44 * @copyright 2026 ClickSyncr
    55 * @license   GPL-2.0-or-later
    66 */
    7 (function () {
    8   'use strict';
     7( function () {
     8        'use strict';
    99
    10   const cfg = window.clicksyncrNotices;
    11   if (!cfg) return;
     10        const cfg = window.clicksyncrNotices;
     11        if ( ! cfg ) {
     12                return;
     13        }
    1214
    13   document.addEventListener('click', function (event) {
    14     const trigger = event.target.closest('[data-syncr-act]');
    15     if (!trigger) return;
    16     const notice = trigger.closest('[data-syncr-notice]');
    17     if (!notice) return;
    18     if (trigger.tagName !== 'A') event.preventDefault();
    19     const id = notice.getAttribute('data-syncr-notice');
    20     notice.remove();
    21     fetch(cfg.url, {
    22       method: 'POST',
    23       credentials: 'same-origin',
    24       headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
    25       body: JSON.stringify({ id: id, act: trigger.getAttribute('data-syncr-act') })
    26     }).catch(function () {});
    27   });
    28 })();
     15        document.addEventListener( 'click', function ( event ) {
     16                const trigger = event.target.closest( '[data-syncr-act]' );
     17                if ( ! trigger ) {
     18                        return;
     19                }
     20                const notice = trigger.closest( '[data-syncr-notice]' );
     21                if ( ! notice ) {
     22                        return;
     23                }
     24                if ( trigger.tagName !== 'A' ) {
     25                        event.preventDefault();
     26                }
     27                const id = notice.getAttribute( 'data-syncr-notice' );
     28                notice.remove();
     29                fetch( cfg.url, {
     30                        method: 'POST',
     31                        credentials: 'same-origin',
     32                        headers: {
     33                                'Content-Type': 'application/json',
     34                                'X-WP-Nonce': cfg.nonce,
     35                        },
     36                        body: JSON.stringify( {
     37                                id,
     38                                act: trigger.getAttribute( 'data-syncr-act' ),
     39                        } ),
     40                } ).catch( function () {} );
     41        } );
     42} )();
  • clicksyncr-for-kit/trunk/assets/js/panel.js

    r3726852 r3760857  
    11/**
    2  * @package   clicksyncr-for-kit
     2 * @package
    33 * @author    clicksyncr
    44 * @copyright 2026 ClickSyncr
    55 * @license   GPL-2.0-or-later
    66 */
    7 (function () {
    8   'use strict';
    9 
    10   const PANEL_ID = 'clicksyncr-for-kit';
    11   const cfg = window.syncrPanel_kit || { restUrl: '', nonce: '', i18n: {} };
    12   const t = (key, fallback) => (cfg.i18n && cfg.i18n[key]) || fallback;
    13 
    14   function testMessage(result) {
    15     if (result.skipped) return result.message || t('advTestSkipped', 'Skipped. Nothing was sent.');
    16     if (result.success) return t('advTestOk', 'Added. Check the provider for this address.');
    17     return t('advTestFailed', 'Not added.') + (result.message ? ' ' + result.message : '');
    18   }
    19 
    20   function boot(root) {
    21 
    22   const meta = root.querySelector('[id="' + PANEL_ID + '-panel"]') || root;
    23   if (!root || !meta || root.dataset.syncrBooted === '1') return;
    24   if (window.syncrBuilderPanel_kit && !window.syncrBuilderPanel_kit.prepare(root)) return;
    25   root.dataset.syncrBooted = '1';
    26 
    27   const byId = (suffix) => root.id === PANEL_ID ? document.getElementById(PANEL_ID + '-' + suffix) : root.querySelector('[id="' + PANEL_ID + '-' + suffix + '"]');
    28   const el = (tag, attrs, text) => {
    29     const node = document.createElement(tag);
    30     Object.keys(attrs || {}).forEach((k) => node.setAttribute(k, attrs[k]));
    31     if (text !== undefined) node.textContent = text;
    32     return node;
    33   };
    34   const say = (node, text, isError) => {
    35     if (!node) return;
    36     node.textContent = text || '';
    37     node.classList.toggle('syncr-feedback--error', !!isError);
    38   };
    39 
    40   const settle = (control) => {
    41     if (control.tagName === 'SELECT') Array.from(control.options).forEach((o) => { o.defaultSelected = o.selected; });
    42     else if (control.type === 'checkbox' || control.type === 'radio') control.defaultChecked = control.checked;
    43   };
    44   const formId = parseInt(meta.dataset.syncrForm || '0', 10);
    45   const builder = meta.dataset.syncrBuilder || '';
    46   let kinds = { email: ['email'], number: ['number', 'range'], date: ['date'], not_text: ['checkbox', 'acceptance'] };
    47   try { kinds = Object.assign(kinds, JSON.parse(meta.dataset.syncrKinds || '{}')); } catch (e) {}
    48 
    49   async function api(path, body) {
    50     const resp = await fetch(cfg.restUrl + path, {
    51       method: 'POST',
    52       headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
    53       body: JSON.stringify(Object.assign({ form_id: formId, builder: builder }, body || {}))
    54     });
    55     return resp.json();
    56   }
    57 
    58   const formFields = Array.from((byId('field-options') || { options: [] }).options).map((o) => {
    59     let options = null;
    60     try { options = o.dataset.options ? JSON.parse(o.dataset.options) : null; } catch (e) { options = null; }
    61     return { id: o.value, label: o.textContent, kind: o.dataset.kind || '', options: Array.isArray(options) ? options : null };
    62   });
    63 
    64   let tip = null;
    65   let tipTrigger = null;
    66   let consumeEscape = false;
    67   const closeTip = () => {
    68     if (tipTrigger) tipTrigger.removeAttribute('aria-describedby');
    69     if (tip) tip.remove();
    70     tip = null; tipTrigger = null;
    71   };
    72   const openTip = (trigger) => {
    73     closeTip();
    74     tipTrigger = trigger;
    75     tip = el('span', { class: 'syncr-destination-tip', role: 'tooltip', id: PANEL_ID + '-destination-tip' });
    76     if (trigger.hasAttribute('data-syncr-key-tip')) {
    77       tip.append(document.createTextNode(trigger.getAttribute('data-syncr-key-tip')));
    78     } else {
    79       tip.append(document.createTextNode(t('destination_tip', '') + ' '));
    80       tip.appendChild(el('a', { href: t('pro_url', 'https://clicksyncr.com'), target: '_blank', rel: 'noopener noreferrer' }, t('see_pro', 'See Pro')));
    81     }
    82     trigger.setAttribute('aria-describedby', tip.id);
    83     root.appendChild(tip);
    84     const anchor = trigger.getBoundingClientRect();
    85     const box = tip.getBoundingClientRect();
    86     const boundary = root.getBoundingClientRect();
    87     tip.style.left = Math.max(8, Math.min(anchor.left - boundary.left, boundary.width - box.width - 8)) + 'px';
    88     const top = anchor.bottom + box.height + 8 > window.innerHeight ? anchor.top - box.height - 8 : anchor.bottom + 8;
    89     tip.style.top = Math.max(8, top - boundary.top) + 'px';
    90   };
    91   ['mouseover', 'focusin', 'click'].forEach((name) => root.addEventListener(name, (event) => {
    92     const trigger = event.target.closest('[data-syncr-destination-tip],[data-syncr-key-tip]');
    93     if (trigger) {
    94       if (name === 'click') event.preventDefault();
    95       if (trigger !== tipTrigger) openTip(trigger);
    96     } else if (name !== 'mouseover' && tip && !tip.contains(event.target)) closeTip();
    97   }));
    98   document.addEventListener('click', (event) => {
    99     if (tip && !tip.contains(event.target) && !tipTrigger.contains(event.target)) closeTip();
    100   });
    101   window.addEventListener('scroll', closeTip, true);
    102   window.addEventListener('resize', closeTip);
    103   document.addEventListener('keydown', (event) => {
    104     if (event.key !== 'Escape' || (!consumeEscape && (!tip || !tip.getClientRects().length))) return;
    105     event.preventDefault();
    106     event.stopPropagation();
    107     consumeEscape = true;
    108     closeTip();
    109   }, true);
    110   document.addEventListener('keyup', (event) => {
    111     if (event.key !== 'Escape' || !consumeEscape) return;
    112     event.preventDefault();
    113     event.stopPropagation();
    114     consumeEscape = false;
    115   }, true);
    116   root.addEventListener('keydown', (event) => {
    117     const trigger = event.target.closest('[data-syncr-destination-tip],[data-syncr-key-tip]');
    118     if (trigger && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openTip(trigger); }
    119   });
    120   const keyInput = byId('connection');
    121   const baseInput = byId('connection-base');
    122   const baseRow = byId('connection-base-row');
    123   const updateBase = () => {
    124     if (baseRow && keyInput) baseRow.hidden = !keyInput.value.toLowerCase().startsWith(baseRow.dataset.keyPrefix || '');
    125   };
    126   if (keyInput) keyInput.addEventListener('input', updateBase);
    127   updateBase();
    128   const connectBtn = byId('connection-connect');
    129   const connectFeedback = byId('connection-feedback');
    130   const KEY_MASK = '••••••••••••••••';
    131   const isMask = (v) => v.indexOf('•') !== -1;
    132   const masked = (key) => key.slice(0, 8) + KEY_MASK + key.slice(-4);
    133   if (connectBtn && keyInput) {
    134 
    135     const state = (connected) => {
    136       connectBtn.classList.toggle('syncr-btn-connected', connected);
    137       connectBtn.textContent = connected ? t('synced', 'Synced') : t('connect', 'Connect');
    138     };
    139 
    140     const revealBtn = byId('connection-reveal');
    141     let shown = false;
    142     const shownState = (on) => {
    143       shown = on;
    144       if (!revealBtn) return;
    145       revealBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
    146       revealBtn.setAttribute('aria-label', on ? t('hide_key', 'Hide the key') : t('show_key', 'Show the key'));
    147       const icon = revealBtn.querySelector('.dashicons');
    148       if (icon) { icon.classList.toggle('dashicons-visibility', !on); icon.classList.toggle('dashicons-hidden', on); }
    149     };
    150     if (revealBtn) {
    151       revealBtn.addEventListener('click', async () => {
    152         if (revealBtn.hasAttribute('data-syncr-key-tip')) return;
    153         if (shown) {
    154           keyInput.value = masked(keyInput.value);
    155           shownState(false);
    156           return;
    157         }
    158         if (!isMask(keyInput.value || '')) return;
    159         revealBtn.disabled = true;
    160         try {
    161           const resp = await api('/reveal-key', {});
    162           if (!resp.success || !resp.data || !resp.data.key) throw new Error(resp.message || t('failed', 'Could not load.'));
    163           keyInput.value = resp.data.key;
    164           shownState(true);
    165         } catch (err) {
    166           say(connectFeedback, err.message, true);
    167         }
    168         revealBtn.disabled = false;
    169       });
    170     }
    171     const connect = async () => {
    172       const key = (keyInput.value || '').trim();
    173       if (!key) { say(connectFeedback, t('enter_key', 'Enter an API key.'), true); keyInput.focus(); return; }
    174       if (isMask(key)) return;
    175       connectBtn.disabled = true;
    176       say(connectFeedback, t('connecting', 'Connecting…'));
    177       try {
    178         const credential = { api_key: key };
    179         if (baseInput && baseRow && !baseRow.hidden) credential.api_base_url = baseInput.value.trim();
    180         const resp = await api('/save-api-key', credential);
    181         if (!resp.success) throw new Error(resp.message || t('failed', 'That key was not accepted.'));
    182         keyInput.value = masked(key);
    183         keyInput.dataset.hasKey = '1';
    184         if (revealBtn) revealBtn.hidden = false;
    185         shownState(false);
    186         state(true);
    187         say(connectFeedback, t('connected', 'Connected. Choose where people go.'));
    188         await loadDestinations(resp.group_id || '');
    189       } catch (err) {
    190         state(false);
    191         say(connectFeedback, err.message, true);
    192       }
    193       connectBtn.disabled = false;
    194     };
    195     connectBtn.addEventListener('click', connect);
    196     const foundBtn = root.querySelector('[data-syncr-found-key]');
    197     if (foundBtn) foundBtn.addEventListener('click', async () => {
    198       foundBtn.disabled = true;
    199       const r = await api('/advanced/found-key', {});
    200       if (!r || !r.success || !r.key) { foundBtn.disabled = false; say(connectFeedback, (r && r.message) || t('failed', 'That key was not accepted.'), true); return; }
    201       keyInput.value = r.key;
    202       keyInput.dispatchEvent(new Event('input', { bubbles: true }));
    203       const line = foundBtn.closest('.syncr-found-key');
    204       if (line) line.remove();
    205       await connect();
    206     });
    207     keyInput.addEventListener('input', () => { if (!isMask(keyInput.value || '')) { state(false); shownState(false); } });
    208     keyInput.addEventListener('paste', () => setTimeout(() => { updateBase(); if ((keyInput.value || '').trim().length >= 30 && (!baseRow || baseRow.hidden || baseInput.value.trim())) connect(); }, 50));
    209   }
    210 
    211   const tiles = byId('destination');
    212   const refreshBtn = byId('destination-refresh');
    213   const destFeedback = byId('destination-feedback') || connectFeedback;
    214   const tilesLocked = !!tiles && tiles.dataset.syncrLocked === '1';
    215   const destinationValue = () => {
    216     if (!tiles) return '';
    217     const selected = tiles.querySelector('[data-syncr-primary],input[type=radio]:checked');
    218     return selected ? selected.value : (tiles.dataset.syncrValue || '');
    219   };
    220   function syncPrimary() {
    221     if (!tiles) return;
    222     const primary = destinationValue();
    223     const names = [];
    224     let paused = false;
    225     tiles.dataset.syncrValue = primary;
    226     Array.from(tiles.querySelectorAll('.syncr-tile')).forEach((tile) => {
    227       const isPrimary = tile.dataset.syncrDestination === primary;
    228       const box = tile.querySelector('input[type=checkbox]');
    229       tile.classList.toggle('syncr-tile--primary', isPrimary);
    230       const extra = tile.querySelector('.syncr-destination-extra');
    231       if (extra) extra.hidden = isPrimary;
    232       if (box && isPrimary && !tilesLocked) box.checked = false;
    233       const caption = tile.querySelector('.syncr-main-caption');
    234       if (caption) caption.textContent = isPrimary ? t('main_caption', 'Main list: every contact goes here') : t('send_here', 'Send each contact here');
    235       const saved = tilesLocked && box && box.checked && !isPrimary;
    236       const note = tile.querySelector('.syncr-destination-paused');
    237       if (note) note.hidden = !saved;
    238       paused = paused || saved;
    239       if (isPrimary || (!tilesLocked && box && box.checked)) names.push(tile.dataset.syncrLabel);
    240     });
    241     let status = byId('destination-status');
    242     if (!status) {
    243       status = el('p', { id: PANEL_ID + '-destination-status', role: 'status', class: 'syncr-destination-status' });
    244       tiles.after(status);
    245     }
    246     status.textContent = names.length ? t('dest_status', 'Each contact goes to:') + ' ' + names.join(', ') + (paused ? ' ' + t('saved_paused', 'Saved extra lists and rules are paused until you renew.') : '') : '';
    247   }
    248   function renderTiles(list, keep) {
    249     const ticked = new Set(Array.from(tiles.querySelectorAll('input[type=checkbox]:checked')).map((b) => b.value));
    250     tiles.textContent = '';
    251     tiles.classList.toggle('syncr-tiles--single', list.length === 1);
    252     if (!list.some((g) => String(g.id) === keep)) keep = list.length ? String(list[0].id) : '';
    253     if (!tilesLocked) tiles.appendChild(el('input', { type: 'hidden', name: tiles.dataset.syncrExtras.replace(/\[\]$/, ''), value: '' }));
    254     list.forEach((g, index) => {
    255       const id = String(g.id);
    256       const tile = el('div', { class: 'syncr-tile', 'data-syncr-destination': id, 'data-syncr-label': g.label });
    257       const label = el('label');
    258       const choice = el('input', { type: 'radio', name: tiles.dataset.syncrName, value: id });
    259       choice.checked = id === keep;
    260       label.append(choice, document.createTextNode(' ' + g.label));
    261       tile.appendChild(label);
    262       tile.appendChild(el('span', { class: 'syncr-main-caption' }));
    263       const attrs = {class: 'syncr-destination-extra'};
    264       if (tilesLocked) Object.assign(attrs, {role:'button',tabindex:'0','data-syncr-destination-tip':'','aria-disabled':'true'});
    265       const extra = el('label', attrs);
    266       extra.hidden = id === keep;
    267       const boxAttrs = {type:'checkbox',value:id};
    268       if (tilesLocked) Object.assign(boxAttrs, {tabindex:'-1','aria-disabled':'true'});
    269       else boxAttrs.name = builder === 'wpforms' ? tiles.dataset.syncrExtras.replace(/\[\]$/, '[' + index + ']') : tiles.dataset.syncrExtras;
    270       const box = el('input', boxAttrs);
    271       box.checked = ticked.has(id) && id !== keep;
    272       extra.append(box, document.createTextNode(' ' + t('also_here', 'Also add here')));
    273       if (tilesLocked) extra.appendChild(el('span', {class:'syncr-pro-chip'}, 'PRO'));
    274       tile.appendChild(extra);
    275       const note = el('span', {class:'syncr-destination-paused'}, t('saved_extra', 'Saved. Renew your license to send here too.'));
    276       note.hidden = !tilesLocked || !box.checked;
    277       tile.appendChild(note);
    278       tiles.appendChild(tile);
    279       settle(choice);settle(box);
    280     });
    281     tiles.dataset.syncrValue = keep;
    282     syncPrimary();
    283   }
    284   if (tiles) {
    285     tiles.addEventListener('change', (event) => {
    286       if (event.target.type === 'radio') { syncPrimary(); loadFields(); }
    287       else if (!tilesLocked && event.target.type === 'checkbox') syncPrimary();
    288     });
    289     syncPrimary();
    290   }
    291   if (refreshBtn) refreshBtn.addEventListener('click', () => loadDestinations(destinationValue()));
    292 
    293   async function loadDestinations(keep) {
    294     if (!tiles) return;
    295     if (refreshBtn) refreshBtn.disabled = true;
    296     try {
    297       const resp = await api('/groups', {});
    298       if (!resp.success || !Array.isArray(resp.data)) throw new Error(resp.message || t('failed', 'Could not load.'));
    299       renderTiles(resp.data.map((g) => ({ id: g.id, label: g.label })), keep || destinationValue());
    300       const empty = byId('destination-empty');
    301       if (empty) empty.hidden = resp.data.length > 0;
    302       say(destFeedback, resp.data.length ? '' : t('destinations_0', ''));
    303       if (destinationValue()) await loadFields();
    304     } catch (err) {
    305       say(destFeedback, err.message, true);
    306     }
    307     if (refreshBtn) refreshBtn.disabled = false;
    308   }
    309 
    310   const mapping = byId('mapping');
    311   const savedMap = mapping ? JSON.parse(mapping.dataset.syncrMap || '[]') : [];
    312   const mapName = mapping ? (mapping.dataset.syncrName || '') : '';
    313   const KIND_COMPAT = { number: kinds.number, integer: kinds.number, date: kinds.date, datetime: kinds.date };
    314 
    315   function compatible(providerType, providerId) {
    316     if (providerId === 'email') return formFields.filter((f) => kinds.email.indexOf(f.kind) !== -1);
    317     const allowed = KIND_COMPAT[(providerType || '').toLowerCase()];
    318     if (!allowed) return formFields.filter((f) => kinds.not_text.indexOf(f.kind) === -1);
    319     return formFields.filter((f) => f.kind === '' || allowed.indexOf(f.kind) !== -1);
    320   }
    321 
    322   function currentMap() {
    323     if (!mapping) return savedMap;
    324     const rows = Array.from(mapping.querySelectorAll('.syncr-map-row'));
    325     if (!rows.length) return savedMap;
    326     return rows.map((row) => ({
    327       provider: row.querySelector('input[type=hidden]').value,
    328       form: row.querySelector('select').value
    329     })).filter((m) => m.form);
    330   }
    331 
    332   async function loadFields() {
    333     if (!mapping || !destinationValue()) return;
    334     const before = currentMap();
    335     try {
    336       const resp = await api('/fields', { group_id: destinationValue() });
    337       if (!resp.success || !Array.isArray(resp.data)) throw new Error(resp.message || t('failed', 'Could not load.'));
    338       renderMapping(resp.data, before);
    339     } catch (err) {
    340       say(destFeedback, err.message, true);
    341     }
    342   }
    343 
    344   function renderMapping(providerFields, saved) {
    345     const section = mapping.closest('.syncr-section') || root;
    346     const contributed = Array.from(section.querySelectorAll('.syncr-tile--contributed'));
    347     mapping.textContent = '';
    348     const grid = el('div', { class: 'syncr-tiles syncr-tiles--mapping' });
    349     mapping.appendChild(grid);
    350 
    351     providerFields.forEach((field, index) => {
    352       const required = field.id === 'email';
    353       const tile = el('div', { class: 'syncr-tile syncr-map-row' });
    354       const select = el('select', { name: mapName + '[' + index + '][form]', id: PANEL_ID + '-map-' + field.id });
    355       const label = el('label', { for: select.id }, field.label || field.id);
    356       label.appendChild(el('span', { class: 'syncr-tile__meta' }, field.id + ' · ' + (field.type || 'text')));
    357       if (required) label.appendChild(el('span', { class: 'syncr-tile__required' }, t('required', 'Required')));
    358 
    359       select.appendChild(el('option', { value: '' }, required ? t('select_field', 'Select a form field') : t('choose', 'Choose…')));
    360       const options = compatible(field.type, field.id);
    361       const own = options.filter((f) => f.kind !== 'context');
    362       const context = options.filter((f) => f.kind === 'context');
    363       own.forEach((f) => select.appendChild(el('option', { value: f.id }, f.label)));
    364       if (context.length) {
    365 
    366         const og = el('optgroup', { label: t('context_group', 'Signup context') });
    367         context.forEach((f) => og.appendChild(el('option', { value: f.id }, f.label)));
    368         select.appendChild(og);
    369       }
    370       if (!own.length) {
    371         const kind = (field.type || '').toLowerCase();
    372         const hint = required ? t('need_email', 'Add an email field to the form') : (kind === 'date' ? t('need_date', 'Add a date field to the form') : (KIND_COMPAT[kind] ? t('need_number', 'Add a number field to the form') : ''));
    373         if (hint) { const none = el('option', { value: '', disabled: 'disabled' }, hint); select.appendChild(none); }
    374       }
    375       const savedRow = saved.find((m) => m.provider === field.id);
    376       if (savedRow && savedRow.form) {
    377         if (Array.from(select.options).some((o) => o.value === savedRow.form)) {
    378           select.value = savedRow.form;
    379         } else {
    380 
    381           const gone = el('option', { value: '', 'data-syncr-missing': savedRow.form }, t('missing', '%s is not on this form').replace('%s', '[' + savedRow.form + ']'));
    382           select.insertBefore(gone, select.firstChild);
    383           select.value = '';
    384           tile.classList.add('syncr-tile--missing');
    385         }
    386       } else if (required && own.length) {
    387         select.value = own[0].id;
    388       }
    389       settle(select);
    390 
    391       tile.appendChild(label);
    392       tile.appendChild(select);
    393       tile.appendChild(el('input', { type: 'hidden', name: mapName + '[' + index + '][provider]', value: field.id }));
    394       grid.appendChild(tile);
    395     });
    396     contributed.forEach((tile) => grid.appendChild(tile));
    397     section.querySelectorAll('.syncr-tiles--extra').forEach((wrap) => { if (!wrap.children.length) wrap.remove(); });
    398     readback();
    399   }
    400 
    401   const rulesBox = byId('destination_rules');
    402   if (rulesBox) {
    403     const rulesName = rulesBox.dataset.syncrName || '';
    404     let groupsMap = {};
    405     try { groupsMap = JSON.parse(rulesBox.dataset.syncrGroups || '{}'); } catch (e) { groupsMap = {}; }
    406     let savedRules = [];
    407     try { savedRules = JSON.parse(rulesBox.dataset.syncrRules || '[]'); } catch (e) { savedRules = []; }
    408     const ruleTargets = (() => { try { return JSON.parse(rulesBox.dataset.syncrTargets || '[]'); } catch (e) { return []; } })();
    409     const choiceFields = () => formFields.filter((f) => f.options && f.options.length);
    410     const table = el('div', { class: 'syncr-rules__table' });
    411     const empty = el('p', { class: 'syncr-rules__empty description' }, t('rule_none', 'No rules yet. Everyone joins the groups above.'));
    412     const actions = el('div', { class: 'syncr-actions syncr-actions--end' });
    413     const add = el('button', { type: 'button', class: 'button syncr-rules__add' }, t('rule_add', 'Add rule'));
    414     actions.appendChild(add);
    415     rulesBox.appendChild(table); rulesBox.appendChild(empty); rulesBox.appendChild(actions);
    416     const head = () => {
    417       const h = el('div', { class: 'syncr-rules__head', 'aria-hidden': 'true' });
    418       ['#', t('rule_when', 'When this field'), t('rule_is', 'is this value'), t('rule_add_to', 'add subscriber to'), ''].forEach((text) => h.appendChild(el('span', {}, text)));
    419       return h;
    420     };
    421     const rows = () => Array.from(table.querySelectorAll('.syncr-rules__row'));
    422     const renumber = () => {
    423       rows().forEach((row, i) => {
    424         row.querySelector('.syncr-rules__num').textContent = String(i + 1).padStart(2, '0');
    425         row.querySelectorAll('[data-part]').forEach((c) => { c.name = rulesName + '[' + i + '][' + c.dataset.part + ']'; });
    426       });
    427       const any = rows().length > 0;
    428       empty.hidden = any;
    429       const existing = table.querySelector('.syncr-rules__head');
    430       if (any && !existing) table.insertBefore(head(), table.firstChild);
    431       if (!any && existing) existing.remove();
    432     };
    433     const valueOptions = (select, fieldId, chosen, initial) => {
    434       select.textContent = '';
    435       select.appendChild(el('option', { value: '' }, t('rule_choose_value', 'Choose a value')));
    436       const field = choiceFields().find((f) => f.id === fieldId);
    437       (field ? field.options : []).forEach((opt) => select.appendChild(el('option', { value: opt.value }, opt.label || opt.value)));
    438       if (chosen && !Array.from(select.options).some((o) => o.value === chosen)) select.appendChild(el('option', { value: chosen }, chosen));
    439       select.value = chosen || '';
    440       if (initial) settle(select);
    441     };
    442     const cell = (label, control) => {
    443       const c = el('div', { class: 'syncr-rules__cell', 'data-syncr-column': label });
    444       c.appendChild(control);
    445       return c;
    446     };
    447     const row = (rule, initial) => {
    448       const line = el('div', { class: 'syncr-rules__row' });
    449       const fieldSel = el('select', { 'data-part': 'field', 'aria-label': t('rule_when', 'When this field') });
    450       fieldSel.appendChild(el('option', { value: '' }, t('rule_choose_field', 'Choose a field')));
    451       choiceFields().forEach((f) => fieldSel.appendChild(el('option', { value: f.id }, '[' + f.id + ']')));
    452       if (rule.field && !Array.from(fieldSel.options).some((o) => o.value === rule.field)) fieldSel.appendChild(el('option', { value: rule.field }, '[' + rule.field + '] ' + t('rule_missing', '(not on this form)')));
    453       fieldSel.value = rule.field || '';
    454       const valueSel = el('select', { 'data-part': 'value', 'aria-label': t('rule_is', 'is this value') });
    455       valueOptions(valueSel, rule.field || '', rule.value || '', initial);
    456       const groupSel = el('select', { 'data-part': 'group', 'aria-label': t('rule_add_to', 'add subscriber to') });
    457       groupSel.appendChild(el('option', { value: '' }, t('rule_choose_group', 'Choose a group')));
    458       Object.keys(groupsMap).forEach((id) => groupSel.appendChild(el('option', { value: id }, groupsMap[id])));
    459 
    460       const byGroup = {};
    461       ruleTargets.forEach((tg) => { (byGroup[tg.group || t('rule_targets', 'Other')] ||= []).push(tg); });
    462       Object.keys(byGroup).forEach((label) => {
    463         const og = el('optgroup', { label: label });
    464         byGroup[label].forEach((tg) => og.appendChild(el('option', { value: tg.value }, tg.label || tg.value)));
    465         groupSel.appendChild(og);
    466       });
    467       if (rule.group && !Array.from(groupSel.options).some((o) => o.value === rule.group)) groupSel.appendChild(el('option', { value: rule.group }, rule.group));
    468       groupSel.value = rule.group || '';
    469       const remove = el('button', { type: 'button', class: 'button-link syncr-rules__remove', title: t('rule_remove', 'Remove rule') });
    470       remove.appendChild(el('span', { class: 'dashicons dashicons-no-alt', 'aria-hidden': 'true' }));
    471       remove.appendChild(el('span', { class: 'screen-reader-text' }, t('rule_remove', 'Remove rule')));
    472       fieldSel.addEventListener('change', () => valueOptions(valueSel, fieldSel.value, '', false));
    473       remove.addEventListener('click', () => { line.remove(); renumber(); });
    474       line.appendChild(el('strong', { class: 'syncr-rules__num' }, ''));
    475       line.appendChild(cell(t('rule_when', 'When this field'), fieldSel));
    476       line.appendChild(cell(t('rule_is', 'is this value'), valueSel));
    477       line.appendChild(cell(t('rule_add_to', 'add subscriber to'), groupSel));
    478       line.appendChild(remove);
    479       if (initial) { settle(fieldSel); settle(groupSel); }
    480       table.appendChild(line);
    481       renumber();
    482     };
    483     savedRules.forEach((r) => row(r, true));
    484     if (!choiceFields().length) {
    485       add.hidden = true;
    486       empty.textContent = t('rule_no_fields', 'This form has no choice fields (a drop-down, checkboxes or radio buttons), so nothing here can decide where they go.');
    487     }
    488     add.addEventListener('click', () => { row({ field: '', value: '', group: '' }, false); });
    489     renumber();
    490   }
    491 
    492   const dependents = Array.from(root.querySelectorAll('[data-syncr-show]'));
    493   function controlValue(name) {
    494     const nodes = Array.from(root.querySelectorAll('[name="' + name + '"]'));
    495     if (!nodes.length) return null;
    496     const node = nodes[0];
    497     if (node.type === 'checkbox') return node.checked ? (node.value || '1') : '';
    498     if (node.type === 'radio') { const on = nodes.find((n) => n.checked); return on ? on.value : ''; }
    499     return node.value || '';
    500   }
    501   function applyDependents() {
    502     dependents.forEach((row) => {
    503       let conditions = [];
    504       try { conditions = JSON.parse(row.dataset.syncrShow || '[]'); } catch (e) { return; }
    505       let show = true;
    506       conditions.forEach((c) => {
    507         const v = controlValue(c.name);
    508         if (v === null) return;
    509         if ('is' in c && v !== c.is) show = false;
    510         if ('not' in c && v === c.not) show = false;
    511       });
    512       row.hidden = !show;
    513     });
    514   }
    515   if (dependents.length) {
    516     applyDependents();
    517     root.addEventListener('change', applyDependents);
    518   }
    519 
    520   const readbackEl = root.querySelector('.syncr-readback');
    521   function readback() {
    522     if (!readbackEl || !mapping) return;
    523     const selects = Array.from(mapping.querySelectorAll('.syncr-map-row select'));
    524     if (!selects.length) return;
    525     const mapped = selects.filter((s) => s.value).length;
    526     const missing = selects.filter((s) => !s.value && s.selectedOptions[0] && s.selectedOptions[0].dataset.syncrMissing).length;
    527     let text = t('mapped', '%1$d of %2$d fields mapped.').replace('%1$d', String(mapped)).replace('%2$d', String(selects.length));
    528     if (missing) text += ' ' + t('missing_count', '%d mapped to fields that are no longer on this form; save to clear them.').replace('%d', String(missing));
    529     readbackEl.textContent = text;
    530     readbackEl.classList.toggle('syncr-readback--warn', missing > 0);
    531   }
    532   root.addEventListener('change', (event) => {
    533     const tile = event.target.closest && event.target.closest('.syncr-tile--missing');
    534     if (tile && event.target.value) tile.classList.remove('syncr-tile--missing');
    535     readback();
    536   });
    537 
    538   const adv = (part) => byId('adv-' + part);
    539   const advSay = (el, text, ok) => { if (!el) return; el.textContent = text; el.className = ok === false ? 'syncr-adv__bad' : 'syncr-adv__ok'; };
    540   const debugToggle = adv('debug');
    541   const debugState = adv('debug-state');
    542   if (debugToggle) {
    543     debugToggle.addEventListener('change', async () => {
    544       debugToggle.disabled = true;
    545       const r = await api('/advanced/settings', { debug_log: debugToggle.checked });
    546       debugToggle.disabled = false;
    547       if (!r || !r.success) { debugToggle.checked = !debugToggle.checked; return; }
    548       debugToggle.checked = !!r.logging;
    549       if (debugState) debugState.textContent = r.logging ? (cfg.i18n.advLogOn || 'On for 14 days, then off by itself.') : (cfg.i18n.advLogOff || 'Off.');
    550       if (r.logging) loadLog();
    551     });
    552   }
    553   const logBox = adv('log');
    554   const logMeta = adv('log-meta');
    555   const logAuto = adv('log-auto');
    556   let logSource = 'own';
    557   let logTimer = null;
    558   async function loadLog() {
    559     if (!logBox) return;
    560     const r = await api('/advanced/log', { source: logSource });
    561     if (!r || !r.success) { logBox.textContent = cfg.i18n.advLogFailed || 'Could not read the log.'; return; }
    562     if (!r.exists) {
    563       logBox.textContent = logSource === 'site'
    564         ? (cfg.i18n.advNoSiteLog || 'This site keeps no PHP error log. Set WP_DEBUG_LOG in wp-config.php while you test, then look here again.')
    565         : (cfg.i18n.advNoLog || 'No log yet. Switch the debug log on and sync a form.');
    566     } else if (!r.lines.length) {
    567       logBox.textContent = r.filtered ? t('no_matching_log', 'No matching ClickSyncr entries.') : (cfg.i18n.advLogEmpty || 'The log is empty.');
    568     } else {
    569       logBox.textContent = r.lines.join('\n');
    570       logBox.scrollTop = logBox.scrollHeight;
    571     }
    572     if (logMeta) logMeta.textContent = r.exists ? (r.path + ' · ' + Math.round(r.size / 1024) + ' KB · ' + r.lines.length + ' ' + (cfg.i18n.advLines || 'lines shown')) : '';
    573     if (logMeta && r.filtered) logMeta.textContent += ' · ' + t('filtered_log', 'ClickSyncr entries only');
    574   }
    575   Array.from(root.querySelectorAll('[data-syncr-log]')).forEach((btn) => {
    576     btn.addEventListener('click', () => {
    577       Array.from(root.querySelectorAll('[data-syncr-log]')).forEach((b) => b.classList.toggle('is-active', b === btn));
    578       logSource = btn.dataset.syncrLog;
    579       loadLog();
    580     });
    581   });
    582   if (adv('log-open')) adv('log-open').addEventListener('click', loadLog);
    583   if (adv('log-clear')) adv('log-clear').addEventListener('click', async () => {
    584     if (!window.confirm(t('advClearConfirm', 'Clear the plugin log?'))) return;
    585     const r = await api('/advanced/log-clear', {});
    586     if (!r || !r.success) { if (logBox) logBox.textContent = t('advClearFailed', 'Could not clear the log.'); return; }
    587     loadLog();
    588   });
    589   if (adv('log-download')) adv('log-download').addEventListener('click', () => {
    590     window.open(cfg.restUrl + '/advanced/log-download?_wpnonce=' + encodeURIComponent(cfg.nonce) + '&builder=' + encodeURIComponent(builder) + '&form_id=' + encodeURIComponent(formId), '_blank');
    591   });
    592   if (logAuto) logAuto.addEventListener('change', () => {
    593     if (logTimer) { clearInterval(logTimer); logTimer = null; }
    594     if (logAuto.checked) { loadLog(); logTimer = setInterval(() => { if (root.isConnected) loadLog(); else { clearInterval(logTimer); logTimer = null; } }, 5000); }
    595   });
    596 
    597   const reportBox = adv('report');
    598   const reportCopy = adv('report-copy');
    599   if (adv('report-build')) adv('report-build').addEventListener('click', async () => {
    600     advSay(adv('report-feedback'), cfg.i18n.advBuilding || 'Building…', true);
    601     const r = await api('/advanced/report', {});
    602     if (!r || !r.success) { advSay(adv('report-feedback'), cfg.i18n.advReportFailed || 'Could not build the report.', false); return; }
    603     reportBox.value = r.report;
    604     if (reportCopy) reportCopy.disabled = false;
    605     advSay(adv('report-feedback'), '', true);
    606   });
    607   if (reportCopy) reportCopy.addEventListener('click', async () => {
    608     try { await navigator.clipboard.writeText(reportBox.value); advSay(adv('report-feedback'), cfg.i18n.advCopied || 'Copied.', true); }
    609     catch (e) { reportBox.select(); advSay(adv('report-feedback'), cfg.i18n.advSelectCopy || 'Selected. Press Ctrl+C or Cmd+C.', true); }
    610   });
    611 
    612   if (adv('refresh')) adv('refresh').addEventListener('click', async () => {
    613     const feedback = adv('refresh-feedback');
    614     advSay(feedback, '…', true);
    615     const result = await api('/advanced/refresh', {});
    616     if (!result || !result.success) { advSay(feedback, t('advFailed', 'Failed.'), false); return; }
    617     if (tiles) {
    618       await loadDestinations(destinationValue());
    619       advSay(feedback, t('advRefreshed', 'Refreshed.'), true);
    620       return;
    621     }
    622     const groups = await api('/groups', {});
    623     if (!groups || !groups.success) { advSay(feedback, groups && groups.message || t('advFailed', 'Failed.'), false); return; }
    624     const group = meta.dataset.syncrGroup || (groups.data && groups.data[0] && groups.data[0].id);
    625     if (group) {
    626       const fields = await api('/fields', { group_id: group });
    627       if (!fields || !fields.success) { advSay(feedback, fields && fields.message || t('advFailed', 'Failed.'), false); return; }
    628     }
    629     advSay(feedback, t('advRefreshedNative', 'Refreshed. Reload this form to see updated lists and fields.'), true);
    630   });
    631 
    632   const liveStats = root.querySelector('.syncr-adv__stats');
    633   if (builder === 'ninjaforms' && liveStats) {
    634     api('/advanced/stats', {}).then((r) => {
    635       if (r && r.success && root.isConnected) liveStats.outerHTML = r.html;
    636       else advSay(adv('stats-feedback'), cfg.i18n.advFailed || 'Failed.', false);
    637     }).catch(() => advSay(adv('stats-feedback'), cfg.i18n.advFailed || 'Failed.', false));
    638   }
    639   if (adv('stats-reset')) adv('stats-reset').addEventListener('click', async () => {
    640     const r = await api('/advanced/stats-reset', {});
    641     advSay(adv('stats-feedback'), r && r.success ? (cfg.i18n.advReset || 'Counters reset. Reload to see zeros.') : (cfg.i18n.advFailed || 'Failed.'), !!(r && r.success));
    642   });
    643 
    644   if (adv('current')) adv('current').addEventListener('change', async () => {
    645     const box = adv('current');
    646     const before = !box.checked;
    647     box.disabled = true;
    648     try {
    649       const result = await api('/advanced/current', { enabled: box.checked });
    650       box.checked = result && typeof result.enabled === 'boolean' ? result.enabled : before;
    651       box.disabled = false;
    652       advSay(adv('current-feedback'), result && result.success ? t('advSaved', 'Saved.') : t('advFailed', 'Failed.'), !!(result && result.success));
    653     } catch (error) {
    654       box.checked = before;
    655       box.disabled = false;
    656       advSay(adv('current-feedback'), t('advFailed', 'Failed.'), false);
    657     }
    658   });
    659 
    660   if (adv('purge')) adv('purge').addEventListener('change', async () => {
    661     const box = adv('purge');
    662     box.disabled = true;
    663     const r = await api('/advanced/settings', { purge_on_uninstall: box.checked });
    664     box.disabled = false;
    665     if (!r || !r.success) { box.checked = !box.checked; advSay(adv('purge-feedback'), cfg.i18n.advFailed || 'Failed.', false); return; }
    666     advSay(adv('purge-feedback'), cfg.i18n.advSaved || 'Saved.', true);
    667   });
    668 
    669   const testBtn = adv('test');
    670   if (testBtn) testBtn.addEventListener('click', async () => {
    671     const email = (adv('test-email') || { value: '' }).value.trim();
    672     const out = adv('test-feedback');
    673     if (!email) { advSay(out, cfg.i18n.advTestEmail || 'Enter an email address first.', false); return; }
    674     testBtn.disabled = true;
    675     advSay(out, cfg.i18n.advSending || 'Sending…', true);
    676     const r = await api('/advanced/test', { email: email });
    677     testBtn.disabled = false;
    678     if (!r) { advSay(out, cfg.i18n.advFailed || 'Failed.', false); return; }
    679     let text = testMessage(r);
    680     if (r.dropped && r.dropped.length) text += ' ' + (cfg.i18n.advDropped || 'Dropped fields:') + ' ' + r.dropped.join(', ');
    681     advSay(out, text, !!r.success);
    682 
    683     const echo = out && out.parentNode ? out.parentNode.querySelector('.syncr-adv__echo') : null;
    684     if (echo) echo.remove();
    685     if (r.success && !r.skipped && out && out.parentNode) {
    686       const wrap = el('div', { class: 'syncr-adv__echo' });
    687       const list = (title, obj) => {
    688         const keys = Object.keys(obj || {});
    689         if (!keys.length) return;
    690         wrap.appendChild(el('strong', {}, title));
    691         const dl = el('dl', {});
    692         keys.forEach((k) => { dl.appendChild(el('dt', {}, k)); dl.appendChild(el('dd', {}, String(obj[k]))); });
    693         wrap.appendChild(dl);
    694       };
    695       list(cfg.i18n.advSent || 'Sent', r.sent);
    696       list(cfg.i18n.advStored || 'The provider answered', r.stored);
    697       out.parentNode.appendChild(wrap);
    698     }
    699   });
    700 
    701   if (adv('context')) adv('context').addEventListener('change', async () => {
    702     const box = adv('context');
    703     box.disabled = true;
    704     const r = await api('/advanced/settings', { sender_context: box.checked });
    705     box.disabled = false;
    706     if (!r || !r.success) { box.checked = !box.checked; advSay(adv('context-feedback'), cfg.i18n.advFailed || 'Failed.', false); return; }
    707     advSay(adv('context-feedback'), cfg.i18n.advSaved || 'Saved.', true);
    708   });
    709 
    710   if (adv('form-reset')) adv('form-reset').addEventListener('click', async () => {
    711     if (!window.confirm(cfg.i18n.advResetConfirm || 'Forget everything saved here for this form?')) return;
    712     const btn = adv('form-reset');
    713     btn.disabled = true;
    714     const r = await api('/advanced/form-reset', {});
    715     btn.disabled = false;
    716     if (!r || !r.success) { advSay(adv('form-reset-feedback'), (r && r.message) || cfg.i18n.advFailed || 'Failed.', false); return; }
    717     advSay(adv('form-reset-feedback'), cfg.i18n.advResetDone || 'Reset. Reload to see the empty panel.', true);
    718   });
    719 
    720   Array.from(document.querySelectorAll('#submitdiv a[data-syncr-tab="clicksyncr-for-kit-tab"]')).forEach((a) => {
    721     a.addEventListener('click', (e) => {
    722       const tab = byId('tab');
    723       if (!tab) return;
    724       e.preventDefault();
    725       tab.click();
    726       const panel = document.getElementById('clicksyncr-for-kit');
    727       if (panel) panel.scrollIntoView({ block: 'start', behavior: 'smooth' });
    728     });
    729   });
    730 
    731   Array.from(root.querySelectorAll('.syncr-section--collapsible')).forEach((section) => {
    732     const toggle = section.querySelector('.syncr-section__toggle');
    733     if (!toggle) return;
    734     const key = 'syncr-kit-open-' + section.id;
    735     const set = (open) => {
    736       section.classList.toggle('is-collapsed', !open);
    737       toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
    738       try { localStorage.setItem(key, open ? '1' : '0'); } catch (e) {}
    739     };
    740     let remembered = null;
    741     try { remembered = localStorage.getItem(key); } catch (e) { remembered = null; }
    742     if (remembered === '1') set(true);
    743     toggle.addEventListener('click', () => set(section.classList.contains('is-collapsed')));
    744   });
    745 
    746   if (window.syncrBuilderPanel_kit) window.syncrBuilderPanel_kit.ready(root);
    747   if (destinationValue()) {
    748     loadFields();
    749   }
    750   }
    751 
    752   const start = () => document.querySelectorAll('#' + PANEL_ID + ', [data-syncr-panel="' + PANEL_ID + '"]').forEach((root) => boot(root));
    753   start();
    754   if (window.MutationObserver) {
    755     const watcher = new MutationObserver(start);
    756     watcher.observe(document.body, { childList: true, subtree: true });
    757   }
    758 })();
     7( function () {
     8        'use strict';
     9
     10        const PANEL_ID = 'clicksyncr-for-kit';
     11        const cfg = window.syncrPanel_kit || { restUrl: '', nonce: '', i18n: {} };
     12        const t = ( key, fallback ) => ( cfg.i18n && cfg.i18n[ key ] ) || fallback;
     13
     14        function testMessage( result ) {
     15                if ( result.skipped ) {
     16                        return (
     17                                result.message ||
     18                                t( 'advTestSkipped', 'Skipped. Nothing was sent.' )
     19                        );
     20                }
     21                if ( result.success ) {
     22                        return t(
     23                                'advTestOk',
     24                                'Added. Check the provider for this address.'
     25                        );
     26                }
     27                return (
     28                        t( 'advTestFailed', 'Not added.' ) +
     29                        ( result.message ? ' ' + result.message : '' )
     30                );
     31        }
     32
     33        function boot( root ) {
     34                const meta =
     35                        root.querySelector( '[id="' + PANEL_ID + '-panel"]' ) || root;
     36                if ( ! root || ! meta || root.dataset.syncrBooted === '1' ) {
     37                        return;
     38                }
     39                if (
     40                        window.syncrBuilderPanel_kit &&
     41                        ! window.syncrBuilderPanel_kit.prepare( root )
     42                ) {
     43                        return;
     44                }
     45                root.dataset.syncrBooted = '1';
     46
     47                const byId = ( suffix ) =>
     48                        root.id === PANEL_ID
     49                                ? document.getElementById( PANEL_ID + '-' + suffix )
     50                                : root.querySelector(
     51                                                '[id="' + PANEL_ID + '-' + suffix + '"]'
     52                                        );
     53                const el = ( tag, attrs, text ) => {
     54                        const node = document.createElement( tag );
     55                        Object.keys( attrs || {} ).forEach( ( k ) =>
     56                                node.setAttribute( k, attrs[ k ] )
     57                        );
     58                        if ( text !== undefined ) {
     59                                node.textContent = text;
     60                        }
     61                        return node;
     62                };
     63                const say = ( node, text, isError ) => {
     64                        if ( ! node ) {
     65                                return;
     66                        }
     67                        node.textContent = text || '';
     68                        node.classList.toggle( 'syncr-feedback--error', !! isError );
     69                };
     70
     71                const settle = ( control ) => {
     72                        if ( control.tagName === 'SELECT' ) {
     73                                Array.from( control.options ).forEach( ( o ) => {
     74                                        o.defaultSelected = o.selected;
     75                                } );
     76                        } else if (
     77                                control.type === 'checkbox' ||
     78                                control.type === 'radio'
     79                        ) {
     80                                control.defaultChecked = control.checked;
     81                        }
     82                };
     83                const formId = parseInt( meta.dataset.syncrForm || '0', 10 );
     84                const builder = meta.dataset.syncrBuilder || '';
     85                let kinds = {
     86                        email: [ 'email' ],
     87                        number: [ 'number', 'range' ],
     88                        date: [ 'date' ],
     89                        not_text: [ 'checkbox', 'acceptance' ],
     90                };
     91                try {
     92                        kinds = Object.assign(
     93                                kinds,
     94                                JSON.parse( meta.dataset.syncrKinds || '{}' )
     95                        );
     96                } catch {}
     97
     98                async function api( path, body ) {
     99                        const resp = await fetch( cfg.restUrl + path, {
     100                                method: 'POST',
     101                                headers: {
     102                                        'Content-Type': 'application/json',
     103                                        'X-WP-Nonce': cfg.nonce,
     104                                },
     105                                body: JSON.stringify(
     106                                        Object.assign( { form_id: formId, builder }, body || {} )
     107                                ),
     108                        } );
     109                        return resp.json();
     110                }
     111
     112                const formFields = Array.from(
     113                        ( byId( 'field-options' ) || { options: [] } ).options
     114                ).map( ( o ) => {
     115                        let options = null;
     116                        try {
     117                                options = o.dataset.options
     118                                        ? JSON.parse( o.dataset.options )
     119                                        : null;
     120                        } catch {
     121                                options = null;
     122                        }
     123                        return {
     124                                id: o.value,
     125                                label: o.textContent,
     126                                kind: o.dataset.kind || '',
     127                                options: Array.isArray( options ) ? options : null,
     128                        };
     129                } );
     130
     131                let tip = null;
     132                let tipTrigger = null;
     133                let consumeEscape = false;
     134                const closeTip = () => {
     135                        if ( tipTrigger ) {
     136                                tipTrigger.removeAttribute( 'aria-describedby' );
     137                        }
     138                        if ( tip ) {
     139                                tip.remove();
     140                        }
     141                        tip = null;
     142                        tipTrigger = null;
     143                };
     144                const openTip = ( trigger ) => {
     145                        closeTip();
     146                        tipTrigger = trigger;
     147                        tip = el( 'span', {
     148                                class: 'syncr-destination-tip',
     149                                role: 'tooltip',
     150                                id: PANEL_ID + '-destination-tip',
     151                        } );
     152                        if ( trigger.hasAttribute( 'data-syncr-key-tip' ) ) {
     153                                tip.append(
     154                                        document.createTextNode(
     155                                                trigger.getAttribute( 'data-syncr-key-tip' )
     156                                        )
     157                                );
     158                        } else {
     159                                tip.append(
     160                                        document.createTextNode( t( 'destination_tip', '' ) + ' ' )
     161                                );
     162                                tip.appendChild(
     163                                        el(
     164                                                'a',
     165                                                {
     166                                                        href: t( 'pro_url', 'https://clicksyncr.com' ),
     167                                                        target: '_blank',
     168                                                        rel: 'noopener noreferrer',
     169                                                },
     170                                                t( 'see_pro', 'See Pro' )
     171                                        )
     172                                );
     173                        }
     174                        trigger.setAttribute( 'aria-describedby', tip.id );
     175                        root.appendChild( tip );
     176                        const anchor = trigger.getBoundingClientRect();
     177                        const box = tip.getBoundingClientRect();
     178                        const boundary = root.getBoundingClientRect();
     179                        tip.style.left =
     180                                Math.max(
     181                                        8,
     182                                        Math.min(
     183                                                anchor.left - boundary.left,
     184                                                boundary.width - box.width - 8
     185                                        )
     186                                ) + 'px';
     187                        const top =
     188                                anchor.bottom + box.height + 8 > window.innerHeight
     189                                        ? anchor.top - box.height - 8
     190                                        : anchor.bottom + 8;
     191                        tip.style.top = Math.max( 8, top - boundary.top ) + 'px';
     192                };
     193                [ 'mouseover', 'focusin', 'click' ].forEach( ( name ) =>
     194                        root.addEventListener( name, ( event ) => {
     195                                const trigger = event.target.closest(
     196                                        '[data-syncr-destination-tip],[data-syncr-key-tip]'
     197                                );
     198                                if ( trigger ) {
     199                                        if ( name === 'click' ) {
     200                                                event.preventDefault();
     201                                        }
     202                                        if ( trigger !== tipTrigger ) {
     203                                                openTip( trigger );
     204                                        }
     205                                } else if (
     206                                        name !== 'mouseover' &&
     207                                        tip &&
     208                                        ! tip.contains( event.target )
     209                                ) {
     210                                        closeTip();
     211                                }
     212                        } )
     213                );
     214                document.addEventListener( 'click', ( event ) => {
     215                        if (
     216                                tip &&
     217                                ! tip.contains( event.target ) &&
     218                                ! tipTrigger.contains( event.target )
     219                        ) {
     220                                closeTip();
     221                        }
     222                } );
     223                const leaveTip = ( event ) => {
     224                        const next = event.relatedTarget;
     225                        if ( ! tip || ! tipTrigger ) {
     226                                return;
     227                        }
     228                        if (
     229                                next &&
     230                                ( tip.contains( next ) || tipTrigger.contains( next ) )
     231                        ) {
     232                                return;
     233                        }
     234                        closeTip();
     235                };
     236                root.addEventListener( 'mouseout', ( event ) => {
     237                        if (
     238                                tipTrigger &&
     239                                ( tipTrigger.contains( event.target ) ||
     240                                        ( tip && tip.contains( event.target ) ) )
     241                        ) {
     242                                leaveTip( event );
     243                        }
     244                } );
     245                root.addEventListener( 'focusout', ( event ) => {
     246                        if (
     247                                tipTrigger &&
     248                                ( tipTrigger.contains( event.target ) ||
     249                                        ( tip && tip.contains( event.target ) ) )
     250                        ) {
     251                                leaveTip( event );
     252                        }
     253                } );
     254                window.addEventListener( 'scroll', closeTip, true );
     255                window.addEventListener( 'resize', closeTip );
     256                document.addEventListener(
     257                        'keydown',
     258                        ( event ) => {
     259                                if (
     260                                        event.key !== 'Escape' ||
     261                                        ( ! consumeEscape &&
     262                                                ( ! tip || ! tip.getClientRects().length ) )
     263                                ) {
     264                                        return;
     265                                }
     266                                event.preventDefault();
     267                                event.stopPropagation();
     268                                consumeEscape = true;
     269                                closeTip();
     270                        },
     271                        true
     272                );
     273                document.addEventListener(
     274                        'keyup',
     275                        ( event ) => {
     276                                if ( event.key !== 'Escape' || ! consumeEscape ) {
     277                                        return;
     278                                }
     279                                event.preventDefault();
     280                                event.stopPropagation();
     281                                consumeEscape = false;
     282                        },
     283                        true
     284                );
     285                root.addEventListener( 'keydown', ( event ) => {
     286                        const trigger = event.target.closest(
     287                                '[data-syncr-destination-tip],[data-syncr-key-tip]'
     288                        );
     289                        if ( trigger && ( event.key === 'Enter' || event.key === ' ' ) ) {
     290                                event.preventDefault();
     291                                openTip( trigger );
     292                        }
     293                } );
     294                const keyInput = byId( 'connection' );
     295                const baseInput = byId( 'connection-base' );
     296                const baseRow = byId( 'connection-base-row' );
     297                const updateBase = () => {
     298                        if ( baseRow && keyInput ) {
     299                                baseRow.hidden = ! keyInput.value
     300                                        .toLowerCase()
     301                                        .startsWith( baseRow.dataset.keyPrefix || '' );
     302                        }
     303                };
     304                if ( keyInput ) {
     305                        keyInput.addEventListener( 'input', updateBase );
     306                }
     307                updateBase();
     308                const connectBtn = byId( 'connection-connect' );
     309                const connectFeedback = byId( 'connection-feedback' );
     310                const KEY_MASK = '••••••••••••••••';
     311                const isMask = ( v ) => v.indexOf( '•' ) !== -1;
     312                const masked = ( key ) =>
     313                        key.slice( 0, 8 ) + KEY_MASK + key.slice( -4 );
     314                if ( connectBtn && keyInput ) {
     315                        const state = ( connected ) => {
     316                                connectBtn.classList.toggle( 'syncr-btn-connected', connected );
     317                                connectBtn.textContent = connected
     318                                        ? t( 'synced', 'Synced' )
     319                                        : t( 'connect', 'Connect' );
     320                        };
     321
     322                        const revealBtn = byId( 'connection-reveal' );
     323                        let shown = false;
     324                        const shownState = ( on ) => {
     325                                shown = on;
     326                                if ( ! revealBtn ) {
     327                                        return;
     328                                }
     329                                revealBtn.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
     330                                revealBtn.setAttribute(
     331                                        'aria-label',
     332                                        on
     333                                                ? t( 'hide_key', 'Hide the key' )
     334                                                : t( 'show_key', 'Show the key' )
     335                                );
     336                                const icon = revealBtn.querySelector( '.dashicons' );
     337                                if ( icon ) {
     338                                        icon.classList.toggle( 'dashicons-visibility', ! on );
     339                                        icon.classList.toggle( 'dashicons-hidden', on );
     340                                }
     341                        };
     342                        if ( revealBtn ) {
     343                                revealBtn.addEventListener( 'click', async () => {
     344                                        if ( revealBtn.hasAttribute( 'data-syncr-key-tip' ) ) {
     345                                                return;
     346                                        }
     347                                        if ( shown ) {
     348                                                keyInput.value = masked( keyInput.value );
     349                                                shownState( false );
     350                                                return;
     351                                        }
     352                                        if ( ! isMask( keyInput.value || '' ) ) {
     353                                                return;
     354                                        }
     355                                        revealBtn.disabled = true;
     356                                        try {
     357                                                const resp = await api( '/reveal-key', {} );
     358                                                if (
     359                                                        ! resp.success ||
     360                                                        ! resp.data ||
     361                                                        ! resp.data.key
     362                                                ) {
     363                                                        throw new Error(
     364                                                                resp.message || t( 'failed', 'Could not load.' )
     365                                                        );
     366                                                }
     367                                                keyInput.value = resp.data.key;
     368                                                shownState( true );
     369                                        } catch ( err ) {
     370                                                say( connectFeedback, err.message, true );
     371                                        }
     372                                        revealBtn.disabled = false;
     373                                } );
     374                        }
     375                        const connect = async () => {
     376                                const key = ( keyInput.value || '' ).trim();
     377                                if ( ! key ) {
     378                                        say(
     379                                                connectFeedback,
     380                                                t( 'enter_key', 'Enter an API key.' ),
     381                                                true
     382                                        );
     383                                        keyInput.focus();
     384                                        return;
     385                                }
     386                                if ( isMask( key ) ) {
     387                                        return;
     388                                }
     389                                connectBtn.disabled = true;
     390                                say( connectFeedback, t( 'connecting', 'Connecting…' ) );
     391                                try {
     392                                        const credential = { api_key: key };
     393                                        if ( baseInput && baseRow && ! baseRow.hidden ) {
     394                                                credential.api_base_url = baseInput.value.trim();
     395                                        }
     396                                        const resp = await api( '/save-api-key', credential );
     397                                        if ( ! resp.success ) {
     398                                                throw new Error(
     399                                                        resp.message ||
     400                                                                t( 'failed', 'That key was not accepted.' )
     401                                                );
     402                                        }
     403                                        keyInput.value = masked( key );
     404                                        keyInput.dataset.hasKey = '1';
     405                                        if ( revealBtn ) {
     406                                                revealBtn.hidden = false;
     407                                        }
     408                                        shownState( false );
     409                                        state( true );
     410                                        const pill = root.querySelector( '.syncr-header__status' );
     411                                        if ( pill ) {
     412                                                const saved = !! ( resp.group_id || '' );
     413                                                const dot = pill.querySelector(
     414                                                        '.syncr-header__status-dot'
     415                                                );
     416                                                const text = pill.querySelector(
     417                                                        '.syncr-header__status-text'
     418                                                );
     419                                                if ( dot ) {
     420                                                        dot.className =
     421                                                                'syncr-header__status-dot syncr-header__status-dot--' +
     422                                                                ( saved ? 'connected' : 'pending' );
     423                                                }
     424                                                if ( text ) {
     425                                                        text.textContent = saved
     426                                                                ? t( 'header_connected', '' )
     427                                                                : t( 'header_pending', '' );
     428                                                }
     429                                        }
     430                                        say(
     431                                                connectFeedback,
     432                                                t( 'connected', 'Connected. Choose where people go.' )
     433                                        );
     434                                        await loadDestinations( resp.group_id || '' );
     435                                } catch ( err ) {
     436                                        state( false );
     437                                        say( connectFeedback, err.message, true );
     438                                }
     439                                connectBtn.disabled = false;
     440                        };
     441                        connectBtn.addEventListener( 'click', connect );
     442                        const foundBtn = root.querySelector( '[data-syncr-found-key]' );
     443                        if ( foundBtn ) {
     444                                foundBtn.addEventListener( 'click', async () => {
     445                                        foundBtn.disabled = true;
     446                                        const r = await api( '/advanced/found-key', {} );
     447                                        if ( ! r || ! r.success || ! r.key ) {
     448                                                foundBtn.disabled = false;
     449                                                say(
     450                                                        connectFeedback,
     451                                                        ( r && r.message ) ||
     452                                                                t( 'failed', 'That key was not accepted.' ),
     453                                                        true
     454                                                );
     455                                                return;
     456                                        }
     457                                        keyInput.value = r.key;
     458                                        keyInput.dispatchEvent(
     459                                                new Event( 'input', { bubbles: true } )
     460                                        );
     461                                        const line = foundBtn.closest( '.syncr-found-key' );
     462                                        if ( line ) {
     463                                                line.remove();
     464                                        }
     465                                        await connect();
     466                                } );
     467                        }
     468                        keyInput.addEventListener( 'input', () => {
     469                                if ( ! isMask( keyInput.value || '' ) ) {
     470                                        state( false );
     471                                        shownState( false );
     472                                }
     473                        } );
     474                        keyInput.addEventListener( 'paste', () =>
     475                                setTimeout( () => {
     476                                        updateBase();
     477                                        if (
     478                                                ( keyInput.value || '' ).trim().length >= 30 &&
     479                                                ( ! baseRow ||
     480                                                        baseRow.hidden ||
     481                                                        baseInput.value.trim() )
     482                                        ) {
     483                                                connect();
     484                                        }
     485                                }, 50 )
     486                        );
     487                }
     488
     489                const tiles = byId( 'destination' );
     490                const refreshBtn = byId( 'destination-refresh' );
     491                const destFeedback = byId( 'destination-feedback' ) || connectFeedback;
     492                const tilesLocked = !! tiles && tiles.dataset.syncrLocked === '1';
     493                const destinationValue = () => {
     494                        if ( ! tiles ) {
     495                                return '';
     496                        }
     497                        const selected = tiles.querySelector(
     498                                '[data-syncr-primary],input[type=radio]:checked'
     499                        );
     500                        return selected ? selected.value : tiles.dataset.syncrValue || '';
     501                };
     502                function syncPrimary() {
     503                        if ( ! tiles ) {
     504                                return;
     505                        }
     506                        const primary = destinationValue();
     507                        const names = [];
     508                        let paused = false;
     509                        tiles.dataset.syncrValue = primary;
     510                        Array.from( tiles.querySelectorAll( '.syncr-tile' ) ).forEach(
     511                                ( tile ) => {
     512                                        const isPrimary = tile.dataset.syncrDestination === primary;
     513                                        const box = tile.querySelector( 'input[type=checkbox]' );
     514                                        tile.classList.toggle( 'syncr-tile--primary', isPrimary );
     515                                        const extra = tile.querySelector(
     516                                                '.syncr-destination-extra'
     517                                        );
     518                                        if ( extra ) {
     519                                                extra.hidden = isPrimary;
     520                                        }
     521                                        if ( box && isPrimary && ! tilesLocked ) {
     522                                                box.checked = false;
     523                                        }
     524                                        const caption = tile.querySelector( '.syncr-main-caption' );
     525                                        if ( caption ) {
     526                                                caption.textContent = isPrimary
     527                                                        ? t(
     528                                                                        'main_caption',
     529                                                                        'Main list: every contact goes here'
     530                                                                )
     531                                                        : t( 'send_here', 'Send each contact here' );
     532                                        }
     533                                        const saved =
     534                                                tilesLocked && box && box.checked && ! isPrimary;
     535                                        const note = tile.querySelector(
     536                                                '.syncr-destination-paused'
     537                                        );
     538                                        if ( note ) {
     539                                                note.hidden = ! saved;
     540                                        }
     541                                        paused = paused || saved;
     542                                        if (
     543                                                isPrimary ||
     544                                                ( ! tilesLocked && box && box.checked )
     545                                        ) {
     546                                                names.push( tile.dataset.syncrLabel );
     547                                        }
     548                                }
     549                        );
     550                        let status = byId( 'destination-status' );
     551                        if ( ! status ) {
     552                                status = el( 'p', {
     553                                        id: PANEL_ID + '-destination-status',
     554                                        role: 'status',
     555                                        class: 'syncr-destination-status',
     556                                } );
     557                                tiles.after( status );
     558                        }
     559                        status.textContent = names.length
     560                                ? t( 'dest_status', 'Each contact goes to:' ) +
     561                                        ' ' +
     562                                        names.join( ', ' ) +
     563                                        ( paused
     564                                                ? ' ' +
     565                                                        t(
     566                                                                'saved_paused',
     567                                                                'Saved extra lists and rules are paused until you renew.'
     568                                                        )
     569                                                : '' )
     570                                : '';
     571                }
     572                function renderTiles( list, keep ) {
     573                        const ticked = new Set(
     574                                Array.from(
     575                                        tiles.querySelectorAll( 'input[type=checkbox]:checked' )
     576                                ).map( ( b ) => b.value )
     577                        );
     578                        tiles.textContent = '';
     579                        tiles.classList.toggle( 'syncr-tiles--single', list.length === 1 );
     580                        if ( ! list.some( ( g ) => String( g.id ) === keep ) ) {
     581                                keep = list.length ? String( list[ 0 ].id ) : '';
     582                        }
     583                        if ( ! tilesLocked ) {
     584                                tiles.appendChild(
     585                                        el( 'input', {
     586                                                type: 'hidden',
     587                                                name: tiles.dataset.syncrExtras.replace( /\[\]$/, '' ),
     588                                                value: '',
     589                                        } )
     590                                );
     591                        }
     592                        list.forEach( ( g, index ) => {
     593                                const id = String( g.id );
     594                                const tile = el( 'div', {
     595                                        class: 'syncr-tile',
     596                                        'data-syncr-destination': id,
     597                                        'data-syncr-label': g.label,
     598                                } );
     599                                const label = el( 'label' );
     600                                const choice = el( 'input', {
     601                                        type: 'radio',
     602                                        name: tiles.dataset.syncrName,
     603                                        value: id,
     604                                } );
     605                                choice.checked = id === keep;
     606                                label.append(
     607                                        choice,
     608                                        document.createTextNode( ' ' + g.label )
     609                                );
     610                                tile.appendChild( label );
     611                                tile.appendChild(
     612                                        el( 'span', { class: 'syncr-main-caption' } )
     613                                );
     614                                const attrs = { class: 'syncr-destination-extra' };
     615                                if ( tilesLocked ) {
     616                                        Object.assign( attrs, {
     617                                                role: 'button',
     618                                                tabindex: '0',
     619                                                'data-syncr-destination-tip': '',
     620                                        } );
     621                                }
     622                                const extra = el( 'label', attrs );
     623                                extra.hidden = id === keep;
     624                                const boxAttrs = { type: 'checkbox', value: id };
     625                                if ( tilesLocked ) {
     626                                        Object.assign( boxAttrs, {
     627                                                tabindex: '-1',
     628                                                'aria-disabled': 'true',
     629                                                disabled: 'disabled',
     630                                        } );
     631                                } else {
     632                                        boxAttrs.name =
     633                                                builder === 'wpforms'
     634                                                        ? tiles.dataset.syncrExtras.replace(
     635                                                                        /\[\]$/,
     636                                                                        '[' + index + ']'
     637                                                                )
     638                                                        : tiles.dataset.syncrExtras;
     639                                }
     640                                const box = el( 'input', boxAttrs );
     641                                box.checked = ticked.has( id ) && id !== keep;
     642                                extra.append(
     643                                        box,
     644                                        document.createTextNode(
     645                                                ' ' + t( 'also_here', 'Also add here' )
     646                                        )
     647                                );
     648                                if ( tilesLocked ) {
     649                                        extra.appendChild(
     650                                                el( 'span', { class: 'syncr-pro-chip' }, 'PRO' )
     651                                        );
     652                                }
     653                                tile.appendChild( extra );
     654                                const note = el(
     655                                        'span',
     656                                        { class: 'syncr-destination-paused' },
     657                                        t(
     658                                                'saved_extra',
     659                                                'Saved. Renew your license to send here too.'
     660                                        )
     661                                );
     662                                note.hidden = ! tilesLocked || ! box.checked;
     663                                tile.appendChild( note );
     664                                tiles.appendChild( tile );
     665                                settle( choice );
     666                                settle( box );
     667                        } );
     668                        tiles.dataset.syncrValue = keep;
     669                        syncPrimary();
     670                }
     671                if ( tiles ) {
     672                        tiles.addEventListener( 'change', ( event ) => {
     673                                if ( event.target.type === 'radio' ) {
     674                                        syncPrimary();
     675                                        loadFields();
     676                                } else if (
     677                                        ! tilesLocked &&
     678                                        event.target.type === 'checkbox'
     679                                ) {
     680                                        syncPrimary();
     681                                }
     682                        } );
     683                        syncPrimary();
     684                }
     685                if ( refreshBtn ) {
     686                        refreshBtn.addEventListener( 'click', () =>
     687                                loadDestinations( destinationValue() )
     688                        );
     689                }
     690
     691                async function loadDestinations( keep ) {
     692                        if ( ! tiles ) {
     693                                return;
     694                        }
     695                        if ( refreshBtn ) {
     696                                refreshBtn.disabled = true;
     697                        }
     698                        try {
     699                                const resp = await api( '/groups', {} );
     700                                if ( ! resp.success || ! Array.isArray( resp.data ) ) {
     701                                        throw new Error(
     702                                                resp.message || t( 'failed', 'Could not load.' )
     703                                        );
     704                                }
     705                                renderTiles(
     706                                        resp.data.map( ( g ) => ( { id: g.id, label: g.label } ) ),
     707                                        keep || destinationValue()
     708                                );
     709                                const empty = byId( 'destination-empty' );
     710                                if ( empty ) {
     711                                        empty.hidden = resp.data.length > 0;
     712                                }
     713                                say(
     714                                        destFeedback,
     715                                        resp.data.length ? '' : t( 'destinations_0', '' )
     716                                );
     717                                if ( destinationValue() ) {
     718                                        await loadFields();
     719                                }
     720                        } catch ( err ) {
     721                                say( destFeedback, err.message, true );
     722                        }
     723                        if ( refreshBtn ) {
     724                                refreshBtn.disabled = false;
     725                        }
     726                }
     727
     728                const mapping = byId( 'mapping' );
     729                const savedMap = mapping
     730                        ? JSON.parse( mapping.dataset.syncrMap || '[]' )
     731                        : [];
     732                const mapName = mapping ? mapping.dataset.syncrName || '' : '';
     733                const KIND_COMPAT = {
     734                        number: kinds.number,
     735                        integer: kinds.number,
     736                        date: kinds.date,
     737                        datetime: kinds.date,
     738                };
     739
     740                function compatible( providerType, providerId ) {
     741                        if ( providerId === 'email' ) {
     742                                return formFields.filter(
     743                                        ( f ) => kinds.email.indexOf( f.kind ) !== -1
     744                                );
     745                        }
     746                        const allowed = KIND_COMPAT[ ( providerType || '' ).toLowerCase() ];
     747                        if ( ! allowed ) {
     748                                return formFields.filter(
     749                                        ( f ) => kinds.not_text.indexOf( f.kind ) === -1
     750                                );
     751                        }
     752                        return formFields.filter(
     753                                ( f ) => f.kind === '' || allowed.indexOf( f.kind ) !== -1
     754                        );
     755                }
     756
     757                function currentMap() {
     758                        if ( ! mapping ) {
     759                                return savedMap;
     760                        }
     761                        const rows = Array.from(
     762                                mapping.querySelectorAll( '.syncr-map-row' )
     763                        );
     764                        if ( ! rows.length ) {
     765                                return savedMap;
     766                        }
     767                        return rows
     768                                .map( ( row ) => ( {
     769                                        provider: row.querySelector( 'input[type=hidden]' ).value,
     770                                        form: row.querySelector( 'select' ).value,
     771                                } ) )
     772                                .filter( ( m ) => m.form );
     773                }
     774
     775                async function loadFields() {
     776                        if (
     777                                ! mapping ||
     778                                ! destinationValue() ||
     779                                ( keyInput && keyInput.dataset.hasKey !== '1' )
     780                        ) {
     781                                return;
     782                        }
     783                        const before = currentMap();
     784                        try {
     785                                const resp = await api( '/fields', {
     786                                        group_id: destinationValue(),
     787                                } );
     788                                if ( ! resp.success || ! Array.isArray( resp.data ) ) {
     789                                        throw new Error(
     790                                                resp.message || t( 'failed', 'Could not load.' )
     791                                        );
     792                                }
     793                                renderMapping( resp.data, before );
     794                        } catch ( err ) {
     795                                say( destFeedback, err.message, true );
     796                        }
     797                }
     798
     799                function renderMapping( providerFields, saved ) {
     800                        const section = mapping.closest( '.syncr-section' ) || root;
     801                        const contributed = Array.from(
     802                                section.querySelectorAll( '.syncr-tile--contributed' )
     803                        );
     804                        mapping.textContent = '';
     805                        const grid = el( 'div', {
     806                                class: 'syncr-tiles syncr-tiles--mapping',
     807                        } );
     808                        mapping.appendChild( grid );
     809
     810                        providerFields.forEach( ( field, index ) => {
     811                                const required = field.id === 'email';
     812                                const tile = el( 'div', { class: 'syncr-tile syncr-map-row' } );
     813                                const select = el( 'select', {
     814                                        name: mapName + '[' + index + '][form]',
     815                                        id: PANEL_ID + '-map-' + field.id,
     816                                } );
     817                                const label = el(
     818                                        'label',
     819                                        { for: select.id },
     820                                        field.label || field.id
     821                                );
     822                                label.appendChild(
     823                                        el(
     824                                                'span',
     825                                                { class: 'syncr-tile__meta' },
     826                                                field.id + ' · ' + ( field.type || 'text' )
     827                                        )
     828                                );
     829                                if ( required ) {
     830                                        label.appendChild(
     831                                                el(
     832                                                        'span',
     833                                                        { class: 'syncr-tile__required' },
     834                                                        t( 'required', 'Required' )
     835                                                )
     836                                        );
     837                                }
     838
     839                                select.appendChild(
     840                                        el(
     841                                                'option',
     842                                                { value: '' },
     843                                                required
     844                                                        ? t( 'select_field', 'Select a form field' )
     845                                                        : t( 'choose', 'Choose…' )
     846                                        )
     847                                );
     848                                const options = compatible( field.type, field.id );
     849                                const own = options.filter( ( f ) => f.kind !== 'context' );
     850                                const context = options.filter( ( f ) => f.kind === 'context' );
     851                                own.forEach( ( f ) =>
     852                                        select.appendChild(
     853                                                el( 'option', { value: f.id }, f.label )
     854                                        )
     855                                );
     856                                if ( context.length ) {
     857                                        const og = el( 'optgroup', {
     858                                                label: t( 'context_group', 'Signup context' ),
     859                                        } );
     860                                        context.forEach( ( f ) =>
     861                                                og.appendChild(
     862                                                        el( 'option', { value: f.id }, f.label )
     863                                                )
     864                                        );
     865                                        select.appendChild( og );
     866                                }
     867                                if ( ! own.length ) {
     868                                        const kind = ( field.type || '' ).toLowerCase();
     869                                        let hint = '';
     870                                        if ( required ) {
     871                                                hint = t(
     872                                                        'need_email',
     873                                                        'Add an email field to the form'
     874                                                );
     875                                        } else if ( kind === 'date' ) {
     876                                                hint = t( 'need_date', 'Add a date field to the form' );
     877                                        } else if ( KIND_COMPAT[ kind ] ) {
     878                                                hint = t(
     879                                                        'need_number',
     880                                                        'Add a number field to the form'
     881                                                );
     882                                        }
     883                                        if ( hint ) {
     884                                                const none = el(
     885                                                        'option',
     886                                                        { value: '', disabled: 'disabled' },
     887                                                        hint
     888                                                );
     889                                                select.appendChild( none );
     890                                        }
     891                                        if (
     892                                                ! context.length &&
     893                                                ! (
     894                                                        saved.find( ( m ) => m.provider === field.id ) || {}
     895                                                ).form
     896                                        ) {
     897                                                if ( hint ) {
     898                                                        select.firstChild.textContent = hint;
     899                                                }
     900                                                if ( hint ) {
     901                                                        select.removeChild( select.lastChild );
     902                                                }
     903                                                select.disabled = true;
     904                                        }
     905                                }
     906                                const savedRow = saved.find( ( m ) => m.provider === field.id );
     907                                if ( savedRow && savedRow.form ) {
     908                                        if (
     909                                                Array.from( select.options ).some(
     910                                                        ( o ) => o.value === savedRow.form
     911                                                )
     912                                        ) {
     913                                                select.value = savedRow.form;
     914                                        } else {
     915                                                const gone = el(
     916                                                        'option',
     917                                                        { value: '', 'data-syncr-missing': savedRow.form },
     918                                                        t( 'missing', '%s is not on this form' ).replace(
     919                                                                '%s',
     920                                                                '[' + savedRow.form + ']'
     921                                                        )
     922                                                );
     923                                                select.insertBefore( gone, select.firstChild );
     924                                                select.value = '';
     925                                                tile.classList.add( 'syncr-tile--missing' );
     926                                        }
     927                                } else if ( required && own.length ) {
     928                                        select.value = own[ 0 ].id;
     929                                }
     930                                settle( select );
     931
     932                                tile.appendChild( label );
     933                                tile.appendChild( select );
     934                                tile.appendChild(
     935                                        el( 'input', {
     936                                                type: 'hidden',
     937                                                name: mapName + '[' + index + '][provider]',
     938                                                value: field.id,
     939                                        } )
     940                                );
     941                                grid.appendChild( tile );
     942                        } );
     943                        contributed.forEach( ( tile ) => grid.appendChild( tile ) );
     944                        section
     945                                .querySelectorAll( '.syncr-tiles--extra' )
     946                                .forEach( ( wrap ) => {
     947                                        if ( ! wrap.children.length ) {
     948                                                wrap.remove();
     949                                        }
     950                                } );
     951                        readback();
     952                }
     953
     954                const rulesBox = byId( 'destination_rules' );
     955                if ( rulesBox ) {
     956                        const rulesName = rulesBox.dataset.syncrName || '';
     957                        let groupsMap = {};
     958                        try {
     959                                groupsMap = JSON.parse( rulesBox.dataset.syncrGroups || '{}' );
     960                        } catch {
     961                                groupsMap = {};
     962                        }
     963                        let savedRules = [];
     964                        try {
     965                                savedRules = JSON.parse( rulesBox.dataset.syncrRules || '[]' );
     966                        } catch {
     967                                savedRules = [];
     968                        }
     969                        const ruleTargets = ( () => {
     970                                try {
     971                                        return JSON.parse( rulesBox.dataset.syncrTargets || '[]' );
     972                                } catch {
     973                                        return [];
     974                                }
     975                        } )();
     976                        const choiceFields = () =>
     977                                formFields.filter( ( f ) => f.options && f.options.length );
     978                        const table = el( 'div', { class: 'syncr-rules__table' } );
     979                        const empty = el(
     980                                'p',
     981                                { class: 'syncr-rules__empty description' },
     982                                t(
     983                                        'rule_none',
     984                                        'No rules yet. Everyone joins the groups above.'
     985                                )
     986                        );
     987                        const actions = el( 'div', {
     988                                class: 'syncr-actions syncr-actions--end',
     989                        } );
     990                        const add = el(
     991                                'button',
     992                                { type: 'button', class: 'button syncr-rules__add' },
     993                                t( 'rule_add', 'Add rule' )
     994                        );
     995                        actions.appendChild( add );
     996                        rulesBox.appendChild( table );
     997                        rulesBox.appendChild( empty );
     998                        rulesBox.appendChild( actions );
     999                        const head = () => {
     1000                                const h = el( 'div', {
     1001                                        class: 'syncr-rules__head',
     1002                                        'aria-hidden': 'true',
     1003                                } );
     1004                                [
     1005                                        '#',
     1006                                        t( 'rule_when', 'When this field' ),
     1007                                        t( 'rule_is', 'is this value' ),
     1008                                        t( 'rule_add_to', 'add subscriber to' ),
     1009                                        '',
     1010                                ].forEach( ( text ) =>
     1011                                        h.appendChild( el( 'span', {}, text ) )
     1012                                );
     1013                                return h;
     1014                        };
     1015                        const rows = () =>
     1016                                Array.from( table.querySelectorAll( '.syncr-rules__row' ) );
     1017                        const renumber = () => {
     1018                                rows().forEach( ( row, i ) => {
     1019                                        row.querySelector( '.syncr-rules__num' ).textContent =
     1020                                                String( i + 1 ).padStart( 2, '0' );
     1021                                        row.querySelectorAll( '[data-part]' ).forEach( ( c ) => {
     1022                                                c.name =
     1023                                                        rulesName + '[' + i + '][' + c.dataset.part + ']';
     1024                                        } );
     1025                                } );
     1026                                const any = rows().length > 0;
     1027                                empty.hidden = any;
     1028                                const existing = table.querySelector( '.syncr-rules__head' );
     1029                                if ( any && ! existing ) {
     1030                                        table.insertBefore( head(), table.firstChild );
     1031                                }
     1032                                if ( ! any && existing ) {
     1033                                        existing.remove();
     1034                                }
     1035                        };
     1036                        const valueOptions = ( select, fieldId, chosen, initial ) => {
     1037                                select.textContent = '';
     1038                                select.appendChild(
     1039                                        el(
     1040                                                'option',
     1041                                                { value: '' },
     1042                                                t( 'rule_choose_value', 'Choose a value' )
     1043                                        )
     1044                                );
     1045                                const field = choiceFields().find( ( f ) => f.id === fieldId );
     1046                                ( field ? field.options : [] ).forEach( ( opt ) =>
     1047                                        select.appendChild(
     1048                                                el(
     1049                                                        'option',
     1050                                                        { value: opt.value },
     1051                                                        opt.label || opt.value
     1052                                                )
     1053                                        )
     1054                                );
     1055                                if (
     1056                                        chosen &&
     1057                                        ! Array.from( select.options ).some(
     1058                                                ( o ) => o.value === chosen
     1059                                        )
     1060                                ) {
     1061                                        select.appendChild(
     1062                                                el( 'option', { value: chosen }, chosen )
     1063                                        );
     1064                                }
     1065                                select.value = chosen || '';
     1066                                if ( initial ) {
     1067                                        settle( select );
     1068                                }
     1069                        };
     1070                        const cell = ( label, control ) => {
     1071                                const c = el( 'div', {
     1072                                        class: 'syncr-rules__cell',
     1073                                        'data-syncr-column': label,
     1074                                } );
     1075                                c.appendChild( control );
     1076                                return c;
     1077                        };
     1078                        const row = ( rule, initial ) => {
     1079                                const line = el( 'div', { class: 'syncr-rules__row' } );
     1080                                const fieldSel = el( 'select', {
     1081                                        'data-part': 'field',
     1082                                        'aria-label': t( 'rule_when', 'When this field' ),
     1083                                } );
     1084                                fieldSel.appendChild(
     1085                                        el(
     1086                                                'option',
     1087                                                { value: '' },
     1088                                                t( 'rule_choose_field', 'Choose a field' )
     1089                                        )
     1090                                );
     1091                                choiceFields().forEach( ( f ) =>
     1092                                        fieldSel.appendChild(
     1093                                                el( 'option', { value: f.id }, '[' + f.id + ']' )
     1094                                        )
     1095                                );
     1096                                if (
     1097                                        rule.field &&
     1098                                        ! Array.from( fieldSel.options ).some(
     1099                                                ( o ) => o.value === rule.field
     1100                                        )
     1101                                ) {
     1102                                        fieldSel.appendChild(
     1103                                                el(
     1104                                                        'option',
     1105                                                        { value: rule.field },
     1106                                                        '[' +
     1107                                                                rule.field +
     1108                                                                '] ' +
     1109                                                                t( 'rule_missing', '(not on this form)' )
     1110                                                )
     1111                                        );
     1112                                }
     1113                                fieldSel.value = rule.field || '';
     1114                                const valueSel = el( 'select', {
     1115                                        'data-part': 'value',
     1116                                        'aria-label': t( 'rule_is', 'is this value' ),
     1117                                } );
     1118                                valueOptions(
     1119                                        valueSel,
     1120                                        rule.field || '',
     1121                                        rule.value || '',
     1122                                        initial
     1123                                );
     1124                                const groupSel = el( 'select', {
     1125                                        'data-part': 'group',
     1126                                        'aria-label': t( 'rule_add_to', 'add subscriber to' ),
     1127                                } );
     1128                                groupSel.appendChild(
     1129                                        el(
     1130                                                'option',
     1131                                                { value: '' },
     1132                                                t( 'rule_choose_group', 'Choose a group' )
     1133                                        )
     1134                                );
     1135                                Object.keys( groupsMap ).forEach( ( id ) =>
     1136                                        groupSel.appendChild(
     1137                                                el( 'option', { value: id }, groupsMap[ id ] )
     1138                                        )
     1139                                );
     1140
     1141                                const byGroup = {};
     1142                                ruleTargets.forEach( ( tg ) => {
     1143                                        ( byGroup[ tg.group || t( 'rule_targets', 'Other' ) ] ||=
     1144                                                [] ).push( tg );
     1145                                } );
     1146                                Object.keys( byGroup ).forEach( ( label ) => {
     1147                                        const og = el( 'optgroup', { label } );
     1148                                        byGroup[ label ].forEach( ( tg ) =>
     1149                                                og.appendChild(
     1150                                                        el(
     1151                                                                'option',
     1152                                                                { value: tg.value },
     1153                                                                tg.label || tg.value
     1154                                                        )
     1155                                                )
     1156                                        );
     1157                                        groupSel.appendChild( og );
     1158                                } );
     1159                                if (
     1160                                        rule.group &&
     1161                                        ! Array.from( groupSel.options ).some(
     1162                                                ( o ) => o.value === rule.group
     1163                                        )
     1164                                ) {
     1165                                        groupSel.appendChild(
     1166                                                el( 'option', { value: rule.group }, rule.group )
     1167                                        );
     1168                                }
     1169                                groupSel.value = rule.group || '';
     1170                                const remove = el( 'button', {
     1171                                        type: 'button',
     1172                                        class: 'button-link syncr-rules__remove',
     1173                                        title: t( 'rule_remove', 'Remove rule' ),
     1174                                } );
     1175                                remove.appendChild(
     1176                                        el( 'span', {
     1177                                                class: 'dashicons dashicons-no-alt',
     1178                                                'aria-hidden': 'true',
     1179                                        } )
     1180                                );
     1181                                remove.appendChild(
     1182                                        el(
     1183                                                'span',
     1184                                                { class: 'screen-reader-text' },
     1185                                                t( 'rule_remove', 'Remove rule' )
     1186                                        )
     1187                                );
     1188                                fieldSel.addEventListener( 'change', () =>
     1189                                        valueOptions( valueSel, fieldSel.value, '', false )
     1190                                );
     1191                                remove.addEventListener( 'click', () => {
     1192                                        line.remove();
     1193                                        renumber();
     1194                                } );
     1195                                line.appendChild(
     1196                                        el( 'strong', { class: 'syncr-rules__num' }, '' )
     1197                                );
     1198                                line.appendChild(
     1199                                        cell( t( 'rule_when', 'When this field' ), fieldSel )
     1200                                );
     1201                                line.appendChild(
     1202                                        cell( t( 'rule_is', 'is this value' ), valueSel )
     1203                                );
     1204                                line.appendChild(
     1205                                        cell( t( 'rule_add_to', 'add subscriber to' ), groupSel )
     1206                                );
     1207                                line.appendChild( remove );
     1208                                if ( initial ) {
     1209                                        settle( fieldSel );
     1210                                        settle( groupSel );
     1211                                }
     1212                                table.appendChild( line );
     1213                                renumber();
     1214                        };
     1215                        savedRules.forEach( ( r ) => row( r, true ) );
     1216                        if ( ! choiceFields().length ) {
     1217                                add.hidden = true;
     1218                                empty.textContent = t(
     1219                                        'rule_no_fields',
     1220                                        'This form has no choice fields (a drop-down, checkboxes or radio buttons), so nothing here can decide where they go.'
     1221                                );
     1222                        }
     1223                        add.addEventListener( 'click', () => {
     1224                                row( { field: '', value: '', group: '' }, false );
     1225                        } );
     1226                        renumber();
     1227                }
     1228
     1229                const dependents = Array.from(
     1230                        root.querySelectorAll( '[data-syncr-show]' )
     1231                );
     1232                function controlValue( name ) {
     1233                        const nodes = Array.from(
     1234                                root.querySelectorAll( '[name="' + name + '"]' )
     1235                        );
     1236                        if ( ! nodes.length ) {
     1237                                return null;
     1238                        }
     1239                        const node = nodes[ 0 ];
     1240                        if ( node.type === 'checkbox' ) {
     1241                                return node.checked ? node.value || '1' : '';
     1242                        }
     1243                        if ( node.type === 'radio' ) {
     1244                                const on = nodes.find( ( n ) => n.checked );
     1245                                return on ? on.value : '';
     1246                        }
     1247                        return node.value || '';
     1248                }
     1249                function applyDependents() {
     1250                        dependents.forEach( ( row ) => {
     1251                                let conditions = [];
     1252                                try {
     1253                                        conditions = JSON.parse( row.dataset.syncrShow || '[]' );
     1254                                } catch {
     1255                                        return;
     1256                                }
     1257                                let show = true;
     1258                                conditions.forEach( ( c ) => {
     1259                                        const v = controlValue( c.name );
     1260                                        if ( v === null ) {
     1261                                                return;
     1262                                        }
     1263                                        if ( 'is' in c && v !== c.is ) {
     1264                                                show = false;
     1265                                        }
     1266                                        if ( 'not' in c && v === c.not ) {
     1267                                                show = false;
     1268                                        }
     1269                                } );
     1270                                row.hidden = ! show;
     1271                        } );
     1272                }
     1273                if ( dependents.length ) {
     1274                        applyDependents();
     1275                        root.addEventListener( 'change', applyDependents );
     1276                }
     1277
     1278                const readbackEl = root.querySelector( '.syncr-readback' );
     1279                function readback() {
     1280                        if ( ! readbackEl || ! mapping ) {
     1281                                return;
     1282                        }
     1283                        const selects = Array.from(
     1284                                mapping.querySelectorAll( '.syncr-map-row select' )
     1285                        );
     1286                        if ( ! selects.length ) {
     1287                                return;
     1288                        }
     1289                        const mapped = selects.filter( ( s ) => s.value ).length;
     1290                        const missing = selects.filter(
     1291                                ( s ) =>
     1292                                        ! s.value &&
     1293                                        s.selectedOptions[ 0 ] &&
     1294                                        s.selectedOptions[ 0 ].dataset.syncrMissing
     1295                        ).length;
     1296                        let text = t( 'mapped', '%1$d of %2$d fields mapped.' )
     1297                                .replace( '%1$d', String( mapped ) )
     1298                                .replace( '%2$d', String( selects.length ) );
     1299                        if ( missing ) {
     1300                                text +=
     1301                                        ' ' +
     1302                                        t(
     1303                                                'missing_count',
     1304                                                '%d mapped to fields that are no longer on this form; save to clear them.'
     1305                                        ).replace( '%d', String( missing ) );
     1306                        }
     1307                        readbackEl.textContent = text;
     1308                        readbackEl.classList.toggle( 'syncr-readback--warn', missing > 0 );
     1309                }
     1310                root.addEventListener( 'change', ( event ) => {
     1311                        const tile =
     1312                                event.target.closest &&
     1313                                event.target.closest( '.syncr-tile--missing' );
     1314                        if ( tile && event.target.value ) {
     1315                                tile.classList.remove( 'syncr-tile--missing' );
     1316                        }
     1317                        readback();
     1318                } );
     1319
     1320                const adv = ( part ) => byId( 'adv-' + part );
     1321                const advSay = ( node, text, ok ) => {
     1322                        if ( ! node ) {
     1323                                return;
     1324                        }
     1325                        node.textContent = text;
     1326                        node.className = 'syncr-adv__ok';
     1327                        if ( ok === false ) {
     1328                                node.className = 'syncr-adv__bad';
     1329                        }
     1330                        if ( ok === null ) {
     1331                                node.className = 'syncr-adv__note';
     1332                        }
     1333                };
     1334                const busyStart = ( box ) => {
     1335                        box.syncrSeq = ( box.syncrSeq || 0 ) + 1;
     1336                        box.setAttribute( 'aria-busy', 'true' );
     1337                        return box.syncrSeq;
     1338                };
     1339                const busyEnd = ( box, seq ) => {
     1340                        if ( seq !== box.syncrSeq ) {
     1341                                return false;
     1342                        }
     1343                        box.removeAttribute( 'aria-busy' );
     1344                        return true;
     1345                };
     1346                const debugToggle = adv( 'debug' );
     1347                const debugState = adv( 'debug-state' );
     1348                if ( debugToggle ) {
     1349                        debugToggle.addEventListener( 'change', async () => {
     1350                                const seq = busyStart( debugToggle );
     1351                                const r = await api( '/advanced/settings', {
     1352                                        debug_log: debugToggle.checked,
     1353                                } );
     1354                                if ( ! busyEnd( debugToggle, seq ) ) {
     1355                                        return;
     1356                                }
     1357                                if ( ! r || ! r.success ) {
     1358                                        debugToggle.checked = ! debugToggle.checked;
     1359                                        return;
     1360                                }
     1361                                debugToggle.checked = !! r.logging;
     1362                                if ( debugState ) {
     1363                                        debugState.textContent = r.logging
     1364                                                ? cfg.i18n.advLogOn ||
     1365                                                        'On for 14 days, then off by itself.'
     1366                                                : cfg.i18n.advLogOff || 'Off.';
     1367                                }
     1368                                if ( r.logging ) {
     1369                                        loadLog();
     1370                                }
     1371                        } );
     1372                }
     1373                const logBox = adv( 'log' );
     1374                const logMeta = adv( 'log-meta' );
     1375                const logAuto = adv( 'log-auto' );
     1376                let logSource = 'own';
     1377                let logTimer = null;
     1378                async function loadLog() {
     1379                        if ( ! logBox ) {
     1380                                return;
     1381                        }
     1382                        const r = await api( '/advanced/log', { source: logSource } );
     1383                        if ( ! r || ! r.success ) {
     1384                                logBox.textContent =
     1385                                        cfg.i18n.advLogFailed || 'Could not read the log.';
     1386                                return;
     1387                        }
     1388                        if ( ! r.exists ) {
     1389                                logBox.textContent =
     1390                                        logSource === 'site'
     1391                                                ? cfg.i18n.advNoSiteLog ||
     1392                                                        'This site keeps no PHP error log. Set WP_DEBUG_LOG in wp-config.php while you test, then look here again.'
     1393                                                : cfg.i18n.advNoLog ||
     1394                                                        'No log yet. Switch the debug log on and sync a form.';
     1395                        } else if ( ! r.lines.length ) {
     1396                                logBox.textContent = r.filtered
     1397                                        ? t( 'no_matching_log', 'No matching ClickSyncr entries.' )
     1398                                        : cfg.i18n.advLogEmpty || 'The log is empty.';
     1399                        } else {
     1400                                logBox.textContent = r.lines.join( '\n' );
     1401                                logBox.scrollTop = logBox.scrollHeight;
     1402                        }
     1403                        if ( logMeta ) {
     1404                                logMeta.textContent = r.exists
     1405                                        ? r.path +
     1406                                                ' · ' +
     1407                                                Math.round( r.size / 1024 ) +
     1408                                                ' KB · ' +
     1409                                                r.lines.length +
     1410                                                ' ' +
     1411                                                ( cfg.i18n.advLines || 'lines shown' )
     1412                                        : '';
     1413                        }
     1414                        if ( logMeta && r.filtered ) {
     1415                                logMeta.textContent +=
     1416                                        ' · ' + t( 'filtered_log', 'ClickSyncr entries only' );
     1417                        }
     1418                }
     1419                Array.from( root.querySelectorAll( '[data-syncr-log]' ) ).forEach(
     1420                        ( btn ) => {
     1421                                btn.addEventListener( 'click', () => {
     1422                                        Array.from(
     1423                                                root.querySelectorAll( '[data-syncr-log]' )
     1424                                        ).forEach( ( b ) => {
     1425                                                b.classList.toggle( 'is-active', b === btn );
     1426                                                b.setAttribute(
     1427                                                        'aria-pressed',
     1428                                                        b === btn ? 'true' : 'false'
     1429                                                );
     1430                                        } );
     1431                                        logSource = btn.dataset.syncrLog;
     1432                                        loadLog();
     1433                                } );
     1434                        }
     1435                );
     1436                if ( adv( 'log-open' ) ) {
     1437                        adv( 'log-open' ).addEventListener( 'click', loadLog );
     1438                }
     1439                if ( adv( 'log-clear' ) ) {
     1440                        adv( 'log-clear' ).addEventListener( 'click', async () => {
     1441                                if (
     1442                                        ! window.confirm(
     1443                                                t( 'advClearConfirm', 'Clear the plugin log?' )
     1444                                        )
     1445                                ) {
     1446                                        return;
     1447                                }
     1448                                const r = await api( '/advanced/log-clear', {} );
     1449                                if ( ! r || ! r.success ) {
     1450                                        if ( logBox ) {
     1451                                                logBox.textContent = t(
     1452                                                        'advClearFailed',
     1453                                                        'Could not clear the log.'
     1454                                                );
     1455                                        }
     1456                                        return;
     1457                                }
     1458                                loadLog();
     1459                        } );
     1460                }
     1461                if ( adv( 'log-download' ) ) {
     1462                        adv( 'log-download' ).addEventListener( 'click', () => {
     1463                                window.open(
     1464                                        cfg.restUrl +
     1465                                                '/advanced/log-download?_wpnonce=' +
     1466                                                encodeURIComponent( cfg.nonce ) +
     1467                                                '&builder=' +
     1468                                                encodeURIComponent( builder ) +
     1469                                                '&form_id=' +
     1470                                                encodeURIComponent( formId ),
     1471                                        '_blank'
     1472                                );
     1473                        } );
     1474                }
     1475                let logPolling = false;
     1476                const pollLog = async () => {
     1477                        logTimer = null;
     1478                        if (
     1479                                logPolling ||
     1480                                ! logAuto.checked ||
     1481                                ! root.isConnected ||
     1482                                document.visibilityState === 'hidden'
     1483                        ) {
     1484                                return;
     1485                        }
     1486                        logPolling = true;
     1487                        try {
     1488                                await loadLog();
     1489                        } finally {
     1490                                logPolling = false;
     1491                        }
     1492                        if ( logAuto.checked && root.isConnected && ! logTimer ) {
     1493                                logTimer = setTimeout( pollLog, 5000 );
     1494                        }
     1495                };
     1496                if ( logAuto ) {
     1497                        logAuto.addEventListener( 'change', () => {
     1498                                if ( logTimer ) {
     1499                                        clearTimeout( logTimer );
     1500                                        logTimer = null;
     1501                                }
     1502                                if ( logAuto.checked ) {
     1503                                        pollLog();
     1504                                }
     1505                        } );
     1506                }
     1507                if ( logAuto ) {
     1508                        window.addEventListener( 'pagehide', () => {
     1509                                if ( logTimer ) {
     1510                                        clearTimeout( logTimer );
     1511                                        logTimer = null;
     1512                                }
     1513                        } );
     1514                }
     1515                if ( logAuto ) {
     1516                        document.addEventListener( 'visibilitychange', () => {
     1517                                if (
     1518                                        document.visibilityState === 'visible' &&
     1519                                        logAuto.checked &&
     1520                                        ! logTimer
     1521                                ) {
     1522                                        pollLog();
     1523                                }
     1524                        } );
     1525                }
     1526
     1527                const reportBox = adv( 'report' );
     1528                const reportCopy = adv( 'report-copy' );
     1529                if ( adv( 'report-build' ) ) {
     1530                        adv( 'report-build' ).addEventListener( 'click', async () => {
     1531                                advSay(
     1532                                        adv( 'report-feedback' ),
     1533                                        cfg.i18n.advBuilding || 'Building…',
     1534                                        true
     1535                                );
     1536                                const r = await api( '/advanced/report', {} );
     1537                                if ( ! r || ! r.success ) {
     1538                                        advSay(
     1539                                                adv( 'report-feedback' ),
     1540                                                cfg.i18n.advReportFailed ||
     1541                                                        'Could not build the report.',
     1542                                                false
     1543                                        );
     1544                                        return;
     1545                                }
     1546                                reportBox.value = r.report;
     1547                                if ( reportCopy ) {
     1548                                        reportCopy.disabled = false;
     1549                                }
     1550                                advSay( adv( 'report-feedback' ), '', true );
     1551                        } );
     1552                }
     1553                if ( reportCopy ) {
     1554                        reportCopy.addEventListener( 'click', async () => {
     1555                                try {
     1556                                        await navigator.clipboard.writeText( reportBox.value );
     1557                                        advSay(
     1558                                                adv( 'report-feedback' ),
     1559                                                cfg.i18n.advCopied || 'Copied.',
     1560                                                true
     1561                                        );
     1562                                } catch {
     1563                                        reportBox.select();
     1564                                        advSay(
     1565                                                adv( 'report-feedback' ),
     1566                                                cfg.i18n.advSelectCopy ||
     1567                                                        'Selected. Press Ctrl+C or Cmd+C.',
     1568                                                true
     1569                                        );
     1570                                }
     1571                        } );
     1572                }
     1573
     1574                if ( adv( 'refresh' ) ) {
     1575                        adv( 'refresh' ).addEventListener( 'click', async () => {
     1576                                const feedback = adv( 'refresh-feedback' );
     1577                                advSay( feedback, '…', true );
     1578                                const result = await api( '/advanced/refresh', {} );
     1579                                if ( ! result || ! result.success ) {
     1580                                        advSay( feedback, t( 'advFailed', 'Failed.' ), false );
     1581                                        return;
     1582                                }
     1583                                if ( tiles ) {
     1584                                        await loadDestinations( destinationValue() );
     1585                                        advSay( feedback, t( 'advRefreshed', 'Refreshed.' ), true );
     1586                                        return;
     1587                                }
     1588                                const groups = await api( '/groups', {} );
     1589                                if ( ! groups || ! groups.success ) {
     1590                                        advSay(
     1591                                                feedback,
     1592                                                ( groups && groups.message ) ||
     1593                                                        t( 'advFailed', 'Failed.' ),
     1594                                                false
     1595                                        );
     1596                                        return;
     1597                                }
     1598                                const group =
     1599                                        meta.dataset.syncrGroup ||
     1600                                        ( groups.data && groups.data[ 0 ] && groups.data[ 0 ].id );
     1601                                if ( group ) {
     1602                                        const fields = await api( '/fields', { group_id: group } );
     1603                                        if ( ! fields || ! fields.success ) {
     1604                                                advSay(
     1605                                                        feedback,
     1606                                                        ( fields && fields.message ) ||
     1607                                                                t( 'advFailed', 'Failed.' ),
     1608                                                        false
     1609                                                );
     1610                                                return;
     1611                                        }
     1612                                }
     1613                                advSay(
     1614                                        feedback,
     1615                                        t(
     1616                                                'advRefreshedNative',
     1617                                                'Refreshed. Reload this form to see updated lists and fields.'
     1618                                        ),
     1619                                        true
     1620                                );
     1621                        } );
     1622                }
     1623
     1624                const liveStats = root.querySelector( '.syncr-adv__stats' );
     1625                if ( builder === 'ninjaforms' && liveStats ) {
     1626                        api( '/advanced/stats', {} )
     1627                                .then( ( r ) => {
     1628                                        if ( r && r.success && root.isConnected ) {
     1629                                                liveStats.outerHTML = r.html;
     1630                                        } else {
     1631                                                advSay(
     1632                                                        adv( 'stats-feedback' ),
     1633                                                        cfg.i18n.advFailed || 'Failed.',
     1634                                                        false
     1635                                                );
     1636                                        }
     1637                                } )
     1638                                .catch( () =>
     1639                                        advSay(
     1640                                                adv( 'stats-feedback' ),
     1641                                                cfg.i18n.advFailed || 'Failed.',
     1642                                                false
     1643                                        )
     1644                                );
     1645                }
     1646                if ( adv( 'stats-reset' ) ) {
     1647                        adv( 'stats-reset' ).addEventListener( 'click', async () => {
     1648                                const r = await api( '/advanced/stats-reset', {} );
     1649                                advSay(
     1650                                        adv( 'stats-feedback' ),
     1651                                        r && r.success
     1652                                                ? cfg.i18n.advReset ||
     1653                                                                'Counters reset. Reload to see zeros.'
     1654                                                : cfg.i18n.advFailed || 'Failed.',
     1655                                        !! ( r && r.success )
     1656                                );
     1657                        } );
     1658                }
     1659
     1660                if ( adv( 'current' ) ) {
     1661                        adv( 'current' ).addEventListener( 'change', async () => {
     1662                                const box = adv( 'current' );
     1663                                const before = ! box.checked;
     1664                                const seq = busyStart( box );
     1665                                try {
     1666                                        const result = await api( '/advanced/current', {
     1667                                                enabled: box.checked,
     1668                                        } );
     1669                                        if ( ! busyEnd( box, seq ) ) {
     1670                                                return;
     1671                                        }
     1672                                        box.checked =
     1673                                                result && typeof result.enabled === 'boolean'
     1674                                                        ? result.enabled
     1675                                                        : before;
     1676                                        advSay(
     1677                                                adv( 'current-feedback' ),
     1678                                                result && result.success
     1679                                                        ? t( 'advSaved', 'Saved.' )
     1680                                                        : t( 'advFailed', 'Failed.' ),
     1681                                                !! ( result && result.success )
     1682                                        );
     1683                                } catch {
     1684                                        if ( ! busyEnd( box, seq ) ) {
     1685                                                return;
     1686                                        }
     1687                                        box.checked = before;
     1688                                        advSay(
     1689                                                adv( 'current-feedback' ),
     1690                                                t( 'advFailed', 'Failed.' ),
     1691                                                false
     1692                                        );
     1693                                }
     1694                        } );
     1695                }
     1696
     1697                if ( adv( 'sharing' ) ) {
     1698                        adv( 'sharing' ).addEventListener( 'change', async () => {
     1699                                const box = adv( 'sharing' );
     1700                                const before = ! box.checked;
     1701                                const seq = busyStart( box );
     1702                                try {
     1703                                        const result = await api( '/advanced/sharing', {
     1704                                                enabled: box.checked,
     1705                                        } );
     1706                                        if ( ! busyEnd( box, seq ) ) {
     1707                                                return;
     1708                                        }
     1709                                        box.checked =
     1710                                                result && typeof result.enabled === 'boolean'
     1711                                                        ? result.enabled
     1712                                                        : before;
     1713                                        advSay(
     1714                                                adv( 'sharing-feedback' ),
     1715                                                result && result.success
     1716                                                        ? t( 'advSaved', 'Saved.' )
     1717                                                        : t( 'advFailed', 'Failed.' ),
     1718                                                !! ( result && result.success )
     1719                                        );
     1720                                } catch {
     1721                                        if ( ! busyEnd( box, seq ) ) {
     1722                                                return;
     1723                                        }
     1724                                        box.checked = before;
     1725                                        advSay(
     1726                                                adv( 'sharing-feedback' ),
     1727                                                t( 'advFailed', 'Failed.' ),
     1728                                                false
     1729                                        );
     1730                                }
     1731                        } );
     1732                }
     1733
     1734                if ( adv( 'purge' ) ) {
     1735                        adv( 'purge' ).addEventListener( 'change', async () => {
     1736                                const box = adv( 'purge' );
     1737                                const seq = busyStart( box );
     1738                                const r = await api( '/advanced/settings', {
     1739                                        purge_on_uninstall: box.checked,
     1740                                } );
     1741                                if ( ! busyEnd( box, seq ) ) {
     1742                                        return;
     1743                                }
     1744                                if ( ! r || ! r.success ) {
     1745                                        box.checked = ! box.checked;
     1746                                        advSay(
     1747                                                adv( 'purge-feedback' ),
     1748                                                cfg.i18n.advFailed || 'Failed.',
     1749                                                false
     1750                                        );
     1751                                        return;
     1752                                }
     1753                                advSay(
     1754                                        adv( 'purge-feedback' ),
     1755                                        cfg.i18n.advSaved || 'Saved.',
     1756                                        true
     1757                                );
     1758                        } );
     1759                }
     1760
     1761                const testBtn = adv( 'test' );
     1762                if (
     1763                        testBtn &&
     1764                        testBtn.disabled &&
     1765                        typeof PerformanceObserver === 'function'
     1766                ) {
     1767                        let pending = null;
     1768                        let last = 0;
     1769                        const watcher = new PerformanceObserver( ( list ) => {
     1770                                const saved = list
     1771                                        .getEntries()
     1772                                        .some(
     1773                                                ( e ) =>
     1774                                                        [ 'xmlhttprequest', 'fetch' ].indexOf(
     1775                                                                e.initiatorType
     1776                                                        ) !== -1 && e.name.indexOf( cfg.restUrl ) !== 0
     1777                                        );
     1778                                if ( ! saved || pending || ! destinationValue() ) {
     1779                                        return;
     1780                                }
     1781                                pending = setTimeout(
     1782                                        async () => {
     1783                                                last = Date.now();
     1784                                                const r = await api( '/advanced/stats', {} ).catch(
     1785                                                        () => null
     1786                                                );
     1787                                                pending = null;
     1788                                                if ( ! r || ! r.ready || ! testBtn.disabled ) {
     1789                                                        return;
     1790                                                }
     1791                                                watcher.disconnect();
     1792                                                testBtn.disabled = false;
     1793                                                const email = adv( 'test-email' );
     1794                                                if ( email ) {
     1795                                                        email.disabled = false;
     1796                                                }
     1797                                                const out = adv( 'test-feedback' );
     1798                                                if ( out ) {
     1799                                                        out.textContent = '';
     1800                                                }
     1801                                        },
     1802                                        Math.max( 800, 5000 - ( Date.now() - last ) )
     1803                                );
     1804                        } );
     1805                        watcher.observe( { type: 'resource' } );
     1806                }
     1807                if ( testBtn ) {
     1808                        testBtn.addEventListener( 'click', async () => {
     1809                                const email = (
     1810                                        adv( 'test-email' ) || { value: '' }
     1811                                ).value.trim();
     1812                                const out = adv( 'test-feedback' );
     1813                                if ( ! email ) {
     1814                                        advSay(
     1815                                                out,
     1816                                                cfg.i18n.advTestEmail ||
     1817                                                        'Enter an email address first.',
     1818                                                false
     1819                                        );
     1820                                        return;
     1821                                }
     1822                                testBtn.disabled = true;
     1823                                advSay( out, cfg.i18n.advSending || 'Sending…', true );
     1824                                const r = await api( '/advanced/test', { email } );
     1825                                testBtn.disabled = false;
     1826                                if ( ! r ) {
     1827                                        advSay( out, cfg.i18n.advFailed || 'Failed.', false );
     1828                                        return;
     1829                                }
     1830                                let text = testMessage( r );
     1831                                if ( r.dropped && r.dropped.length ) {
     1832                                        text +=
     1833                                                ' ' +
     1834                                                ( cfg.i18n.advDropped || 'Dropped fields:' ) +
     1835                                                ' ' +
     1836                                                r.dropped.join( ', ' );
     1837                                }
     1838                                advSay( out, text, r.skipped ? null : !! r.success );
     1839
     1840                                const echo =
     1841                                        out && out.parentNode
     1842                                                ? out.parentNode.querySelector( '.syncr-adv__echo' )
     1843                                                : null;
     1844                                if ( echo ) {
     1845                                        echo.remove();
     1846                                }
     1847                                if ( r.success && ! r.skipped && out && out.parentNode ) {
     1848                                        const wrap = el( 'div', { class: 'syncr-adv__echo' } );
     1849                                        const list = ( title, obj ) => {
     1850                                                const keys = Object.keys( obj || {} );
     1851                                                if ( ! keys.length ) {
     1852                                                        return;
     1853                                                }
     1854                                                wrap.appendChild( el( 'strong', {}, title ) );
     1855                                                const dl = el( 'dl', {} );
     1856                                                keys.forEach( ( k ) => {
     1857                                                        dl.appendChild( el( 'dt', {}, k ) );
     1858                                                        dl.appendChild(
     1859                                                                el( 'dd', {}, String( obj[ k ] ) )
     1860                                                        );
     1861                                                } );
     1862                                                wrap.appendChild( dl );
     1863                                        };
     1864                                        list( cfg.i18n.advSent || 'Sent', r.sent );
     1865                                        list(
     1866                                                cfg.i18n.advStored || 'The provider answered',
     1867                                                r.stored
     1868                                        );
     1869                                        out.parentNode.appendChild( wrap );
     1870                                }
     1871                        } );
     1872                }
     1873
     1874                if ( adv( 'context' ) ) {
     1875                        adv( 'context' ).addEventListener( 'change', async () => {
     1876                                const box = adv( 'context' );
     1877                                const seq = busyStart( box );
     1878                                const r = await api( '/advanced/settings', {
     1879                                        sender_context: box.checked,
     1880                                } );
     1881                                if ( ! busyEnd( box, seq ) ) {
     1882                                        return;
     1883                                }
     1884                                if ( ! r || ! r.success ) {
     1885                                        box.checked = ! box.checked;
     1886                                        advSay(
     1887                                                adv( 'context-feedback' ),
     1888                                                cfg.i18n.advFailed || 'Failed.',
     1889                                                false
     1890                                        );
     1891                                        return;
     1892                                }
     1893                                advSay(
     1894                                        adv( 'context-feedback' ),
     1895                                        cfg.i18n.advSaved || 'Saved.',
     1896                                        true
     1897                                );
     1898                        } );
     1899                }
     1900
     1901                if ( adv( 'form-reset' ) ) {
     1902                        adv( 'form-reset' ).addEventListener( 'click', async () => {
     1903                                if (
     1904                                        ! window.confirm(
     1905                                                cfg.i18n.advResetConfirm ||
     1906                                                        'Forget everything saved here for this form?'
     1907                                        )
     1908                                ) {
     1909                                        return;
     1910                                }
     1911                                const btn = adv( 'form-reset' );
     1912                                btn.disabled = true;
     1913                                const r = await api( '/advanced/form-reset', {} );
     1914                                btn.disabled = false;
     1915                                if ( ! r || ! r.success ) {
     1916                                        advSay(
     1917                                                adv( 'form-reset-feedback' ),
     1918                                                ( r && r.message ) || cfg.i18n.advFailed || 'Failed.',
     1919                                                false
     1920                                        );
     1921                                        return;
     1922                                }
     1923                                advSay(
     1924                                        adv( 'form-reset-feedback' ),
     1925                                        cfg.i18n.advResetDone ||
     1926                                                'Reset. Reload to see the empty panel.',
     1927                                        true
     1928                                );
     1929                        } );
     1930                }
     1931
     1932                Array.from(
     1933                        document.querySelectorAll(
     1934                                '#submitdiv a[data-syncr-tab="clicksyncr-for-kit-tab"]'
     1935                        )
     1936                ).forEach( ( a ) => {
     1937                        a.addEventListener( 'click', ( e ) => {
     1938                                const tab = byId( 'tab' );
     1939                                if ( ! tab ) {
     1940                                        return;
     1941                                }
     1942                                e.preventDefault();
     1943                                tab.click();
     1944                                const panel = document.getElementById( 'clicksyncr-for-kit' );
     1945                                if ( panel ) {
     1946                                        panel.scrollIntoView( {
     1947                                                block: 'start',
     1948                                                behavior: 'smooth',
     1949                                        } );
     1950                                }
     1951                        } );
     1952                } );
     1953
     1954                Array.from(
     1955                        root.querySelectorAll( '.syncr-section--collapsible' )
     1956                ).forEach( ( section ) => {
     1957                        const toggle = section.querySelector( '.syncr-section__toggle' );
     1958                        if ( ! toggle ) {
     1959                                return;
     1960                        }
     1961                        const key = 'syncr-kit-open-' + section.id;
     1962                        const set = ( open ) => {
     1963                                section.classList.toggle( 'is-collapsed', ! open );
     1964                                toggle.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
     1965                                try {
     1966                                        localStorage.setItem( key, open ? '1' : '0' );
     1967                                } catch {}
     1968                        };
     1969                        let remembered = null;
     1970                        try {
     1971                                remembered = localStorage.getItem( key );
     1972                        } catch {
     1973                                remembered = null;
     1974                        }
     1975                        if ( remembered === '1' ) {
     1976                                set( true );
     1977                        }
     1978                        toggle.addEventListener( 'click', () =>
     1979                                set( section.classList.contains( 'is-collapsed' ) )
     1980                        );
     1981                } );
     1982
     1983                if ( window.syncrBuilderPanel_kit ) {
     1984                        window.syncrBuilderPanel_kit.ready( root );
     1985                }
     1986                if ( destinationValue() ) {
     1987                        loadFields();
     1988                }
     1989        }
     1990
     1991        const start = () =>
     1992                document
     1993                        .querySelectorAll(
     1994                                '#' + PANEL_ID + ', [data-syncr-panel="' + PANEL_ID + '"]'
     1995                        )
     1996                        .forEach( ( root ) => boot( root ) );
     1997        start();
     1998        if ( window.MutationObserver ) {
     1999                const watcher = new MutationObserver( start );
     2000                watcher.observe( document.body, { childList: true, subtree: true } );
     2001        }
     2002} )();
  • clicksyncr-for-kit/trunk/clicksyncr-for-kit.php

    r3726852 r3760857  
    44 * Plugin URI: https://clicksyncr.com/integrations/kit
    55 * Description: Connect your forms to Kit. Sync every submission from Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable, or Forminator to a Kit form as a subscriber, with custom-field mapping and consent control. Works with accounts previously known as ConvertKit.
    6  * Version: 26.10.03.13
     6 * Version: 26.10.22.23
    77 * Author: ClickSyncr
    88 * Author URI: https://profiles.wordpress.org/clicksyncr
  • clicksyncr-for-kit/trunk/includes/bootstrap.php

    r3726852 r3760857  
    105105
    106106        \ClickSyncr\Kit\Core\Provider::register();
     107        \ClickSyncr\Kit\Core\Credentials::boot();
    107108        \ClickSyncr\Kit\Core\Advanced::boot();
    108109        \ClickSyncr\Kit\Core\Context::boot();
     110
    109111        \ClickSyncr\Kit\Admin\Notices::boot();
    110112        foreach ( Builder::adapters() as $syncr_kit_adapter ) {
  • clicksyncr-for-kit/trunk/includes/signls/SOURCE.md

    r3726852 r3760857  
    11# Source provenance
    22
    3 - SDK version: `1.1.12`
     3- SDK version: `1.1.15`
    44- Package root: `sdk/wordpress/`
    55- License: `GPL-3.0-or-later`
  • clicksyncr-for-kit/trunk/includes/signls/loader.php

    r3726852 r3760857  
    147147require_once __DIR__ . '/bridge-1-1-7.php';
    148148
    149 Signls_Sdk_Loader::add_candidate( '1.1.12', __DIR__ . '/sdk.php', '7.4.0' );
     149Signls_Sdk_Loader::add_candidate( '1.1.15', __DIR__ . '/sdk.php', '7.4.0' );
  • clicksyncr-for-kit/trunk/includes/signls/manifest.sha256

    r3726852 r3760857  
    11# signls-sdk-manifest-v1
    2 sdk_version=1.1.12
    3 canonical_commit=b9d164fade0be4be457894db62f68a1770865e29
    4 canonical_tag=v1.1.12
     2sdk_version=1.1.15
     3canonical_commit=0e80d3165265fbd1f8994f4b42c4b4b6b94923eb
     4canonical_tag=v1.1.15
    55license=GPL-3.0-or-later
    66045ddb9a7b60743eac12126857d21e389a3bba94bd1b5f0b876c0b2d5d14a09f  bridge-1-1-5.php
    … …  
    880a5e2014fbdced833ca4f8b09dff4b63464831b5a41b39b2f1f1d0296f28bf2e  bridge-1-1-7.php
    99c75e5f21e26098fcf67f427623d3763345201378eca508b61956a14b3004c526  LICENSE
    10 c0c339ba6525a80c6b9d0f92f0e4353bea0e4ca3d544300d85381f61c9e23048  SOURCE.md
    11 be4f32ced82b8235d53a2ff73bf165f079927a51f64aa3996066477801339c89  loader.php
     10b900d9eb4d5c2d3cdbf94c85d04911e0160dd7e6e4ee8b9632d0a08e4534611c  SOURCE.md
     11e5b4defd1fe083c2ab23e69d006eea6d43ca7e765731ed444a11886200e2b436  loader.php
    121224b25f051643be1a81953df6e6bd5c420ef335f229165c69cfc043c61933935c  sdk.php
    13 f3f260473dc8d9d07d764447a7604e1b2b59f83448ad23285b8365f259062251  src/Collector.php
     13da8bf9abd53ca89d0cbc9fee0d21eb23c38ed398df733a8c824f320dba13f38f  src/Collector.php
    1414d1ddaa1cdec0878a8d2969576ade2dd4b0561b561d231a62ea93205fd5ae429c  src/Consent.php
    1515b928bb884fa5b738f631b8139a9b1e3fca232feb1eea847bcdbb25ec6b94d2c8  src/CounterStore.php
    16 21ebaa64439fe02ce0b2f30400030e566c9fe97d631fa58f25fb2ac947390a6a  src/Identity.php
     167d0cd0524abcacbe27dd203dc1b3b1b76ae62182efa05033cb0db1a5194f0029  src/Identity.php
    17178c7c552964adcb702a0d0932993ce2c6f7bc6164266ab6a07af1351913a1e30e  src/ObservationSanitizer.php
    18183b383acc9a80060b19cba1dcd004a8ccfaf290d3e2cdbff62005eabe2f7de2a6  src/ProductAdapterInterface.php
    1919a036ba2eaf7226933becb7d46e2071e4bb480f1cb2bbf999cc5212fefdd38485  src/Runtime.php
    20 d6a5be13697522768fa6d8d9194953aab6b5bf7970822314e6d982fe750dc472  src/Sanitizer.php
     20d56d47159caa59436ab629cb1fecdec72e9e0875b69bf4c38181d65b731d38bc  src/Sanitizer.php
    21217a4dc9dc000fcbc873b2476677b1b4dd70214d7ace8023f03455c456412a73e4  src/Scheduler.php
    22 38c91f7efc978934b51ce606edb0dcabbe7022dd3005913eb444a48091dd829e  src/SiteIdentity.php
     227e38c926de036e80c15501f0e4bd9b1b85db7bc597225af5738de3028591c605  src/SiteIdentity.php
    2323f1aeb80697d3f17194374a5ad80ca1ffeb2c9c0f36023be37e20609455bced5b  src/StateStore.php
    24 9f8769213a7d313d319f6b5bc2f45bb53115964fd752ff523253c6faf73181cc  src/Transport.php
     24b58d61359946611de3df3920eb64d7b01e20e6d6b89a1dad45e9ad548379d135  src/Transport.php
  • clicksyncr-for-kit/trunk/includes/signls/src/Collector.php

    r3726852 r3760857  
    4949                        $payload['observations'] = ObservationSanitizer::sanitize( $observation_source, $observation_schema );
    5050                        $legacy                  = isset( $payload['observations']['legacy_lifecycle'] ) && is_array( $payload['observations']['legacy_lifecycle'] ) ? $payload['observations']['legacy_lifecycle'] : array();
    51                         if ( array_key_exists( 'site_url', $legacy ) && '' === $legacy['site_url'] ) {
    52                                 $payload['observations']['legacy_lifecycle'] = array();
    53                                 $missing                                     = isset( $payload['observations']['missing_sections'] ) && is_array( $payload['observations']['missing_sections'] ) ? $payload['observations']['missing_sections'] : array();
    54                                 $missing[]                                   = 'legacy_lifecycle';
    55                                 $payload['observations']['missing_sections'] = array_values( array_unique( array_filter( $missing, 'is_string' ) ) );
    56                                 if ( 'complete' === ( $payload['observations']['collection_status'] ?? '' ) ) {
    57                                         $payload['observations']['collection_status'] = 'partial';
    58                                 }
     51                        if ( array_key_exists( 'legacy_lifecycle', $payload['observations'] ) && empty( $legacy['site_url'] ) ) {
     52                                $payload['observations']['legacy_lifecycle']  = (object) array();
     53                                $missing                                      = isset( $payload['observations']['missing_sections'] ) && is_array( $payload['observations']['missing_sections'] ) ? $payload['observations']['missing_sections'] : array();
     54                                $missing[]                                    = 'legacy_lifecycle';
     55                                $payload['observations']['missing_sections']  = array_values( array_unique( array_filter( $missing, 'is_string' ) ) );
     56                                $payload['observations']['collection_status'] = 'partial';
    5957                        }
    6058                }
  • clicksyncr-for-kit/trunk/includes/signls/src/Identity.php

    r3726852 r3760857  
    22/**
    33 * Stable installation and rotating device identity.
     4 *
     5 * The device rotates only when the stored-origin fingerprint changes (a real
     6 * migration or clone), at most once per 24 hours, or when the server proves a
     7 * credential is shared. Upgrades re-key the stored fingerprint without rotating.
    48 *
    59 * @package signls-sdk
    … …  
    1317final class Identity {
    1418
     19        public const ORIGIN_VERSION = SiteIdentity::ORIGIN_VERSION;
     20
     21        private const ROTATION_INTERVAL = DAY_IN_SECONDS;
     22
    1523        private $state;
    1624
    … …  
    2230                $current_origin = $this->origin_fingerprint();
    2331                $stored_origin  = (string) $this->state->get( 'origin_fingerprint', '' );
     32                $stored_version = (int) $this->state->get( 'origin_fingerprint_version', 0 );
    2433                $device_id      = (string) $this->state->get( 'device_id', '' );
    2534
    26                 if ( ! self::valid_uuid( $device_id ) || ( '' !== $stored_origin && ! hash_equals( $stored_origin, $current_origin ) ) ) {
     35                if ( ! self::valid_uuid( $device_id ) ) {
    2736                        $device_id = self::uuid4();
    2837                        $this->state->set_many(
    2938                                array(
    30                                         'device_id'          => $device_id,
    31                                         'origin_fingerprint' => $current_origin,
     39                                        'device_id'                  => $device_id,
     40                                        'origin_fingerprint'         => $current_origin,
     41                                        'origin_fingerprint_version' => self::ORIGIN_VERSION,
    3242                                )
    3343                        );
    3444                        $this->clear_credential();
    35                 } elseif ( '' === $stored_origin ) {
    36                         $this->state->set( 'origin_fingerprint', $current_origin );
     45                        return $device_id;
     46                }
     47
     48                if ( '' === $stored_origin ) {
     49                        $this->state->set_many(
     50                                array(
     51                                        'origin_fingerprint'         => $current_origin,
     52                                        'origin_fingerprint_version' => self::ORIGIN_VERSION,
     53                                )
     54                        );
     55                        return $device_id;
     56                }
     57
     58                if ( $stored_version < self::ORIGIN_VERSION ) {
     59                        // Upgrade from an older fingerprint formula: re-key, keep device, credential and sequence.
     60                        // If the old-formula value does not match either, this may be a clone made before the
     61                        // upgrade; flag it and let the server's sequence verdict decide (Transport).
     62                        $values = array(
     63                                'origin_fingerprint'         => $current_origin,
     64                                'origin_fingerprint_version' => self::ORIGIN_VERSION,
     65                        );
     66                        if ( ! hash_equals( $stored_origin, $this->legacy_origin_fingerprint() ) ) {
     67                                $values['rekey_unverified_at'] = time();
     68                        }
     69                        $this->state->set_many( $values );
     70                        return $device_id;
     71                }
     72
     73                if ( ! hash_equals( $stored_origin, $current_origin ) ) {
     74                        if ( $this->rotation_allowed() ) {
     75                                return $this->rotate_device( 'origin_changed' );
     76                        }
     77                        $this->state->set_many(
     78                                array(
     79                                        'rotation_suppressed_at'     => time(),
     80                                        'rotation_suppressed_reason' => 'origin_changed',
     81                                )
     82                        );
     83                        $this->notify( 'signls_sdk_v1_identity_rotation_suppressed', 'origin_changed', $stored_origin, $current_origin );
    3784                }
    3885
    … …  
    4087        }
    4188
    42         public function rotate_device(): string {
    43                 $device_id = self::uuid4();
     89        public function rotation_allowed(): bool {
     90                return time() - (int) $this->state->get( 'last_device_rotation_at', 0 ) >= self::ROTATION_INTERVAL;
     91        }
     92
     93        public function rekey_unverified(): bool {
     94                return (int) $this->state->get( 'rekey_unverified_at', 0 ) > 0;
     95        }
     96
     97        public function rotate_device( string $reason = 'manual' ): string {
     98                $old_origin = (string) $this->state->get( 'origin_fingerprint', '' );
     99                $new_origin = $this->origin_fingerprint();
     100                $device_id  = self::uuid4();
     101                $reason     = Sanitizer::slug( $reason, 48, 'manual' );
    44102                $this->state->set_many(
    45103                        array(
    46                                 'device_id'          => $device_id,
    47                                 'origin_fingerprint' => $this->origin_fingerprint(),
     104                                'device_id'                   => $device_id,
     105                                'origin_fingerprint'          => $new_origin,
     106                                'origin_fingerprint_version'  => self::ORIGIN_VERSION,
     107                                'last_device_rotation_at'     => time(),
     108                                'last_device_rotation_reason' => $reason,
     109                                'device_rotation_count'       => (int) $this->state->get( 'device_rotation_count', 0 ) + 1,
    48110                        )
    49111                );
    50112                $this->clear_credential();
     113                $this->notify( 'signls_sdk_v1_identity_rotated', $reason, $old_origin, $new_origin );
    51114                return $device_id;
    52115        }
    … …  
    67130                                        'retry_attempt',
    68131                                        'clock_offset',
     132                                        'rekey_unverified_at',
     133                                        'sequence_resync_at',
     134                                        'rotation_suppressed_at',
     135                                        'rotation_suppressed_reason',
    69136                                ),
    70137                                StateStore::pending_keys()
    … …  
    74141
    75142        public function forget_device(): void {
    76                 $this->state->delete_keys( array( 'device_id', 'origin_fingerprint' ) );
     143                $this->state->delete_keys( array( 'device_id', 'origin_fingerprint', 'origin_fingerprint_version' ) );
    77144                $this->clear_credential();
    78145        }
    79146
    80         private function origin_fingerprint(): string {
     147        /**
     148         * Version-2 fingerprint: site origin (stored home option) plus the table prefix.
     149         */
     150        public function origin_fingerprint(): string {
     151                return $this->fingerprint( ( new SiteIdentity() )->origin_fingerprint() );
     152        }
     153
     154        /**
     155         * Version-1 fingerprint as SDK 1.1.11 and 1.1.12 stored it; used only during the upgrade re-key.
     156         */
     157        public function legacy_origin_fingerprint(): string {
     158                return $this->fingerprint( ( new SiteIdentity() )->legacy_origin_fingerprint() );
     159        }
     160
     161        private function fingerprint( string $site_origin ): string {
    81162                $salt = function_exists( 'wp_salt' ) ? (string) wp_salt( 'auth' ) : 'signls-test-salt';
    82163                global $wpdb;
    83164                $prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : 'wp_';
    84                 return hash_hmac( 'sha256', ( new SiteIdentity() )->origin_fingerprint() . '|' . $prefix, $salt );
     165                return hash_hmac( 'sha256', $site_origin . '|' . $prefix, $salt );
     166        }
     167
     168        /**
     169         * Notify observers of an identity transition.
     170         *
     171         * @param non-empty-string $hook Fixed identity event hook.
     172         */
     173        private function notify( string $hook, string $reason, string $old_fingerprint, string $new_fingerprint ): void {
     174                if ( function_exists( 'do_action' ) ) {
     175                        do_action( $hook, $this->state->product(), $reason, substr( $old_fingerprint, 0, 12 ), substr( $new_fingerprint, 0, 12 ) );
     176                }
    85177        }
    86178
  • clicksyncr-for-kit/trunk/includes/signls/src/Sanitizer.php

    r3726852 r3760857  
    100100         */
    101101        private static function normalized_url( $value, int $maximum ): string {
    102                 if ( ! is_string( $value ) || '' === $value || self::text( $value, strlen( $value ) ) !== $value ) {
     102                if ( ! is_string( $value ) || $maximum < 1 || '' === $value || 1 !== preg_match( '//u', $value ) || preg_match( '/[\x00-\x20\x7F]/', $value ) ) {
    103103                        return '';
    104104                }
    … …  
    108108                }
    109109                $scheme = strtolower( (string) $parts['scheme'] );
    110                 $host   = strtolower( (string) $parts['host'] );
     110                $host   = strtolower( rtrim( (string) $parts['host'], '.' ) );
    111111                if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host || preg_match( '/\s/u', $host ) ) {
    112112                        return '';
    113113                }
    114114                if ( 1 === preg_match( '/[^\x00-\x7F]/', $host ) ) {
    115                         $ascii = function_exists( 'idn_to_ascii' ) ? idn_to_ascii( $host ) : false;
     115                        $ascii = function_exists( 'idn_to_ascii' ) ? idn_to_ascii( $host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ) : false;
    116116                        if ( false === $ascii || '' === $ascii ) {
    117117                                return '';
    … …  
    130130                        return '';
    131131                }
    132                 $base = $scheme . '://' . $host . ( $port > 0 ? ':' . $port : '' );
     132                $base = $scheme . '://' . $host . ( isset( $parts['port'] ) ? ':' . $port : '' );
    133133                if ( strlen( $base ) > $maximum ) {
    134134                        return '';
    … …  
    143143                }
    144144                $path = preg_replace( '/%(?:[a-f0-9]?)$/i', '', $path );
    145                 $url  = $base . $path;
    146                 if ( isset( $parts['query'] ) && '' !== $parts['query'] ) {
    147                         $query = '?' . $parts['query'];
    148                         if ( strlen( $url . $query ) <= $maximum && self::text( $query, strlen( $query ) ) === $query ) {
    149                                 $url .= $query;
    150                         }
    151                 }
    152                 return $url;
     145                return rtrim( $base . $path, '/' );
    153146        }
    154147
  • clicksyncr-for-kit/trunk/includes/signls/src/SiteIdentity.php

    r3726852 r3760857  
    22/**
    33 * Shared random WordPress-origin identity.
     4 *
     5 * The origin fingerprint is derived from the stored `home` option, read
     6 * unfiltered from the options table, so request-scoped values (WPML or
     7 * Polylang language prefixes and domains, dynamic WP_HOME, proxies that flip
     8 * the scheme, scanners hitting the server by IP) can never rotate the site.
     9 * A fingerprint-version marker lets an upgrade re-key the stored value
     10 * without rotating, and a 24-hour cap bounds rotations on a real change.
    411 *
    512 * @package signls-sdk
    … …  
    1724        private const ORIGIN_OPTION = 'signls_sdk_site_origin';
    1825
     26        private const ORIGIN_VERSION_OPTION = 'signls_sdk_site_origin_version';
     27
     28        private const ROTATED_AT_OPTION = 'signls_sdk_site_rotated_at';
     29
     30        /** Fingerprint formula version. 1 = home_url('/') (SDK <= 1.1.12), 2 = stored home option (SDK >= 1.1.13). */
     31        public const ORIGIN_VERSION = 2;
     32
    1933        public function site_id(): string {
    2034                $current_origin          = $this->origin_fingerprint();
    2135                $stored_origin_candidate = get_option( self::ORIGIN_OPTION, '' );
    2236                $site_id_candidate       = get_option( self::ID_OPTION, '' );
     37                $version_candidate       = get_option( self::ORIGIN_VERSION_OPTION, 0 );
    2338                $stored_origin           = is_scalar( $stored_origin_candidate ) ? (string) $stored_origin_candidate : '';
    2439                $site_id                 = is_scalar( $site_id_candidate ) ? (string) $site_id_candidate : '';
     40                $version                 = is_scalar( $version_candidate ) ? (int) $version_candidate : 0;
    2541
    26                 if ( ! self::valid( $site_id ) || ( '' !== $stored_origin && ! hash_equals( $stored_origin, $current_origin ) ) ) {
     42                if ( ! self::valid( $site_id ) ) {
    2743                        $site_id = bin2hex( random_bytes( 16 ) );
    2844                        self::replace_option( self::ID_OPTION, $site_id );
    2945                        self::replace_option( self::ORIGIN_OPTION, $current_origin );
    30                 } elseif ( '' === $stored_origin ) {
     46                        self::replace_option( self::ORIGIN_VERSION_OPTION, (string) self::ORIGIN_VERSION );
     47                        return $site_id;
     48                }
     49
     50                if ( '' === $stored_origin || $version < self::ORIGIN_VERSION ) {
     51                        // First run on this formula, or an upgrade from an older formula: re-key, never rotate.
    3152                        self::replace_option( self::ORIGIN_OPTION, $current_origin );
     53                        self::replace_option( self::ORIGIN_VERSION_OPTION, (string) self::ORIGIN_VERSION );
     54                        return $site_id;
     55                }
     56
     57                if ( ! hash_equals( $stored_origin, $current_origin ) ) {
     58                        $rotated_at_candidate = get_option( self::ROTATED_AT_OPTION, 0 );
     59                        $rotated_at           = is_scalar( $rotated_at_candidate ) ? (int) $rotated_at_candidate : 0;
     60                        if ( time() - $rotated_at < DAY_IN_SECONDS ) {
     61                                self::notify( 'signls_sdk_v1_site_rotation_suppressed', 'origin_changed', $stored_origin, $current_origin );
     62                                return $site_id;
     63                        }
     64                        $site_id = bin2hex( random_bytes( 16 ) );
     65                        self::replace_option( self::ID_OPTION, $site_id );
     66                        self::replace_option( self::ORIGIN_OPTION, $current_origin );
     67                        self::replace_option( self::ORIGIN_VERSION_OPTION, (string) self::ORIGIN_VERSION );
     68                        self::replace_option( self::ROTATED_AT_OPTION, (string) time() );
     69                        self::notify( 'signls_sdk_v1_site_rotated', 'origin_changed', $stored_origin, $current_origin );
    3270                }
    3371
    … …  
    3573        }
    3674
     75        /**
     76         * Version-2 fingerprint: HMAC of the normalized stored home option.
     77         */
    3778        public function origin_fingerprint(): string {
     79                $salt = function_exists( 'wp_salt' ) ? (string) wp_salt( 'auth' ) : 'signls-test-salt';
     80                return hash_hmac( 'sha256', 'v2|' . self::normalized_origin( self::stored_home() ), $salt );
     81        }
     82
     83        /**
     84         * Version-1 fingerprint (SDK 1.1.11 and 1.1.12): HMAC of scheme://host:port/path of home_url('/').
     85         * Kept only so an upgrade can tell a continued install from a pre-upgrade clone.
     86         */
     87        public function legacy_origin_fingerprint(): string {
    3888                $origin = function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'http://local/';
    3989                $parts  = wp_parse_url( $origin );
    … …  
    4696                $salt   = function_exists( 'wp_salt' ) ? (string) wp_salt( 'auth' ) : 'signls-test-salt';
    4797                return hash_hmac( 'sha256', $scheme . '://' . $host . ':' . $port . $path, $salt );
     98        }
     99
     100        /**
     101         * The `home` option as stored in this blog's options table, bypassing WP_HOME and option filters.
     102         * Falls back to get_option() and then home_url() only when the table cannot be read.
     103         */
     104        public static function stored_home(): string {
     105                global $wpdb;
     106                $raw = null;
     107                if ( is_object( $wpdb ) && isset( $wpdb->options ) && method_exists( $wpdb, 'get_var' ) && method_exists( $wpdb, 'prepare' ) ) {
     108                        try {
     109                                $raw = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Unfiltered read of the stored origin is the point.
     110                                        $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'home' ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Core table name.
     111                                );
     112                        } catch ( \Throwable $error ) {
     113                                $raw = null;
     114                        }
     115                }
     116                if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
     117                        $fallback = function_exists( 'get_option' ) ? get_option( 'home', '' ) : '';
     118                        $raw      = is_string( $fallback ) ? $fallback : '';
     119                }
     120                if ( '' === trim( $raw ) ) {
     121                        $raw = function_exists( 'home_url' ) ? (string) home_url( '/' ) : 'http://local/';
     122                }
     123                return trim( $raw );
     124        }
     125
     126        /**
     127         * host[:port][/path] in lowercase, without scheme, without the default ports, without edge slashes.
     128         * An http to https move, a trailing slash or a language prefix on the request must not change it.
     129         */
     130        public static function normalized_origin( string $url ): string {
     131                $parts = wp_parse_url( trim( $url ) );
     132                $parts = is_array( $parts ) ? $parts : array();
     133                if ( ! isset( $parts['host'] ) && isset( $parts['path'] ) && false === strpos( (string) $parts['path'], '/' ) ) {
     134                        $parts = wp_parse_url( 'http://' . trim( $url ) );
     135                        $parts = is_array( $parts ) ? $parts : array();
     136                }
     137                $host = isset( $parts['host'] ) ? strtolower( rtrim( (string) $parts['host'], '.' ) ) : 'local';
     138                $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0;
     139                $path = isset( $parts['path'] ) ? trim( (string) $parts['path'], '/' ) : '';
     140                return $host . ( $port > 0 && ! in_array( $port, array( 80, 443 ), true ) ? ':' . $port : '' ) . ( '' !== $path ? '/' . $path : '' );
     141        }
     142
     143        /**
     144         * Notify observers of an identity transition.
     145         *
     146         * @param non-empty-string $hook Fixed identity event hook.
     147         */
     148        private static function notify( string $hook, string $reason, string $old_fingerprint, string $new_fingerprint ): void {
     149                if ( function_exists( 'do_action' ) ) {
     150                        do_action( $hook, $reason, substr( $old_fingerprint, 0, 12 ), substr( $new_fingerprint, 0, 12 ) );
     151                }
    48152        }
    49153
  • clicksyncr-for-kit/trunk/includes/signls/src/Transport.php

    r3726852 r3760857  
    1515        private const DEFAULT_ENDPOINT = 'https://signls.dev';
    1616
    17         private const SDK_VERSION = '1.1.12';
     17        private const SDK_VERSION = '1.1.15';
     18
     19        /** A second sequence conflict this soon after a resync means two installs share one credential. */
     20        private const SHARED_CREDENTIAL_WINDOW = 1800;
    1821
    1922        private const CLOCK_SKEW_SECONDS = 300;
    … …  
    4548
    4649        public function prepare( ProductAdapterInterface $adapter ) {
     50                $this->identity->device_id();
    4751                $revision = $this->payload_revision( $adapter );
    4852                $pending  = $this->reconcile_pending_identity( $adapter, $revision );
    … …  
    7074
    7175        public function deliver( ProductAdapterInterface $adapter, array $payload ): array {
     76                $this->identity->device_id();
    7277                $started = time();
    7378                $this->state->set( 'last_attempt_started_at', $started );
    74                 $this->identity->device_id();
    7579
    7680                if ( '' === (string) $this->state->get( 'credential_id', '' ) ) {
    … …  
    236240                        );
    237241                        $this->state->clear_pending();
     242                        $this->state->delete_keys( array( 'rekey_unverified_at' ) );
    238243                        return array(
    239244                                'ok'        => true,
    … …  
    249254                if ( 'sequence_conflict' === $response['class'] ) {
    250255                        $response['permanent'] = false;
    251                         $expected              = isset( $response['data']['expected_sequence'] ) && is_int( $response['data']['expected_sequence'] ) ? $response['data']['expected_sequence'] : 0;
     256                        $now                   = time();
     257                        // A conflict while the post-upgrade re-key is unverified: this install may be a clone made
     258                        // before the upgrade that kept production's credential. Separate it now.
     259                        if ( $this->identity->rekey_unverified() && ! $sequence_retry ) {
     260                                if ( $this->identity->rotation_allowed() ) {
     261                                        $this->identity->rotate_device( 'clone_suspected_after_rekey' );
     262                                        $response['rotated'] = true;
     263                                        return $response;
     264                                }
     265                                $this->state->delete_keys( array( 'rekey_unverified_at' ) );
     266                        }
     267                        // A second conflict shortly after a resync: two installs are sending on one credential.
     268                        $last_resync = (int) $this->state->get( 'sequence_resync_at', 0 );
     269                        if ( ! $sequence_retry && $last_resync > 0 && $now - $last_resync <= self::SHARED_CREDENTIAL_WINDOW && $this->identity->rotation_allowed() ) {
     270                                $this->identity->rotate_device( 'credential_shared' );
     271                                $response['rotated'] = true;
     272                                return $response;
     273                        }
     274                        $expected = isset( $response['data']['expected_sequence'] ) && is_int( $response['data']['expected_sequence'] ) ? $response['data']['expected_sequence'] : 0;
    252275                        if ( ! $sequence_retry && $expected > 0 ) {
    253                                 $old_body = json_decode( $pending, true );
    254                                 if ( ! is_array( $old_body ) || ! isset( $old_body['payload'] ) || ! is_array( $old_body['payload'] ) ) {
    255                                         return array(
    256                                                 'ok'        => false,
    257                                                 'class'     => 'invalid_pending',
    258                                                 'status'    => 0,
    259                                                 'permanent' => false,
     276                                $old_body = json_decode( $pending );
     277                                if ( ! is_object( $old_body ) || ! isset( $old_body->payload ) || ! is_object( $old_body->payload ) || array() === get_object_vars( $old_body->payload ) ) {
     278                                        return $this->quarantine(
     279                                                array(
     280                                                        'ok'        => false,
     281                                                        'class'     => 'invalid_payload',
     282                                                        'status'    => 0,
     283                                                        'permanent' => true,
     284                                                )
    260285                                        );
    261286                                }
    262287                                if ( $this->state->reconcile_sequence( $expected ) ) {
    263                                         return $this->snapshot( $adapter, $old_body['payload'], $clock_retry, true );
     288                                        $this->state->set( 'sequence_resync_at', $now );
     289                                        return $this->snapshot( $adapter, get_object_vars( $old_body->payload ), $clock_retry, true );
    264290                                }
    265291                        }
    … …  
    366392                $values['retry_attempt'] = $attempt + 1;
    367393                if ( in_array( $values['failure_class'], array( 'invalid_credential', 'device_already_enrolled' ), true ) ) {
    368                         $last_rotation = (int) $this->state->get( 'last_device_rotation_at', 0 );
    369                         if ( time() - $last_rotation >= DAY_IN_SECONDS ) {
    370                                 $this->identity->rotate_device();
    371                                 $values['last_device_rotation_at'] = time();
     394                        if ( $this->identity->rotation_allowed() ) {
     395                                $this->identity->rotate_device( $values['failure_class'] );
    372396                        }
    373397                        $result['permanent'] = false;
     398                }
     399                if ( ! empty( $result['rotated'] ) ) {
     400                        // The device was just rotated; the next run enrolls afresh. Do not keep a stale retry schedule.
     401                        $values['retry_attempt'] = 0;
    374402                }
    375403                if ( empty( $result['permanent'] ) ) {
  • clicksyncr-for-kit/trunk/includes/src/Admin/AdminBar.php

    r3726852 r3760857  
    279279                                        'parent' => self::MENU,
    280280                                        'id'     => 'clicksyncr-licence',
    281                                         'title'  => esc_html__( 'Activate your Pro licence', 'clicksyncr-for-kit' ),
     281                                        'title'  => esc_html__( 'Activate your Pro license', 'clicksyncr-for-kit' ),
    282282                                        'href'   => admin_url( 'options-general.php?page=clicksyncr-pro' ),
    283283                                        'meta'   => array( 'class' => 'syncr-kit-ab-update' ),
  • clicksyncr-for-kit/trunk/includes/src/Admin/PanelHeader.php

    r3726852 r3760857  
    3232                        return array( 'neutral', sprintf( __( '%s not connected', 'clicksyncr-for-kit' ), Brand::provider() ) );
    3333                }
     34                $texts = self::connected_texts();
    3435                if ( '' === $destination ) {
     36                        return array( 'pending', $texts['pending'] );
     37                }
     38                return array( 'connected', $texts['connected'] );
     39        }
     40
     41        /** @return array{pending:string,connected:string} */
     42        public static function connected_texts(): array {
     43                return array(
    3544                        /* translators: 1: email platform name, 2: list noun. */
    36                         return array( 'pending', sprintf( __( '%1$s connected, no %2$s saved', 'clicksyncr-for-kit' ), Brand::provider(), Provider::destination() ) );
    37                 }
    38                 /* translators: %s: the email platform name. */
    39                 return array( 'connected', sprintf( __( '%s connected', 'clicksyncr-for-kit' ), Brand::provider() ) );
     45                        'pending'   => sprintf( __( '%1$s connected, no %2$s saved', 'clicksyncr-for-kit' ), Brand::provider(), Provider::destination() ),
     46                        /* translators: %s: the email platform name. */
     47                        'connected' => sprintf( __( '%s connected', 'clicksyncr-for-kit' ), Brand::provider() ),
     48                );
    4049        }
    4150
  • clicksyncr-for-kit/trunk/includes/src/Admin/PanelRows.php

    r3726852 r3760857  
    259259                }
    260260                $body = $locked
    261                         ? '<p class="description syncr-locked">' . ( Edition::pro_active() ? sprintf( /* translators: %s: link to ClickSyncr Pro */ esc_html__( 'A %s licence unlocks this.', 'clicksyncr-for-kit' ), Brand::pro_link( 'panel', self::$builder . '-' . Val::str( $row['id'] ?? '' ) ) ) : sprintf( /* translators: %s: link to ClickSyncr Pro */ esc_html__( 'Available in %s.', 'clicksyncr-for-kit' ), Brand::pro_link( 'panel', self::$builder . '-' . Val::str( $row['id'] ?? '' ) ) ) ) . '</p>'
     261                        ? '<p class="description syncr-locked">' . ( Edition::pro_active() ? sprintf( /* translators: %s: link to ClickSyncr Pro */ esc_html__( 'A %s license unlocks this.', 'clicksyncr-for-kit' ), Brand::pro_link( 'panel', self::$builder . '-' . Val::str( $row['id'] ?? '' ) ) ) : sprintf( /* translators: %s: link to ClickSyncr Pro */ esc_html__( 'Available in %s.', 'clicksyncr-for-kit' ), Brand::pro_link( 'panel', self::$builder . '-' . Val::str( $row['id'] ?? '' ) ) ) ) . '</p>'
    262262                        : self::control( $row );
    263263                printf(
    … …  
    358358                        self::tile_items( $options, $chosen, $ticked, $name, $extras, $locked ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in tile_items().
    359359                );
     360                echo '<p id="clicksyncr-for-kit-destination-status" role="status" class="syncr-destination-status"></p>';
    360361                printf(
    361362                        '<div class="syncr-actions syncr-actions--end"><span id="clicksyncr-for-kit-destination-feedback" class="syncr-feedback" aria-live="polite"></span> <button type="button" class="button" id="clicksyncr-for-kit-destination-refresh">%s</button></div>',
    … …  
    392393                        $out .= '<span class="syncr-main-caption">' . esc_html( $caption ) . '</span>';
    393394                        $extra_name = 'wpforms' === self::$builder ? substr( $extras, 0, -2 ) . '[' . $i . ']' : $extras;
    394                         $out .= '<label class="syncr-destination-extra"' . ( $primary ? ' hidden' : '' ) . ( $locked ? ' tabindex="0" role="button" data-syncr-destination-tip aria-disabled="true"' : '' ) . '>';
    395                         $out .= '<input type="checkbox"' . ( $locked ? ' tabindex="-1" aria-disabled="true"' : ' name="' . esc_attr( $extra_name ) . '"' ) . ' value="' . esc_attr( $value ) . '"' . ( $saved && ! $primary ? ' checked' : '' ) . ' /> ' . esc_html__( 'Also add here', 'clicksyncr-for-kit' ) . ( $locked ? ' <span class="syncr-pro-chip">PRO</span>' : '' ) . '</label>';
     395                        $out .= '<label class="syncr-destination-extra"' . ( $primary ? ' hidden' : '' ) . ( $locked ? ' tabindex="0" role="button" data-syncr-destination-tip' : '' ) . '>';
     396                        $out .= '<input type="checkbox"' . ( $locked ? ' tabindex="-1" aria-disabled="true" disabled' : ' name="' . esc_attr( $extra_name ) . '"' ) . ' value="' . esc_attr( $value ) . '"' . ( $saved && ! $primary ? ' checked' : '' ) . ' /> ' . esc_html__( 'Also add here', 'clicksyncr-for-kit' ) . ( $locked ? ' <span class="syncr-pro-chip">PRO</span>' : '' ) . '</label>';
    396397                        $out .= '<span class="syncr-destination-paused"' . ( $locked && $saved && ! $primary ? '' : ' hidden' ) . '>' . esc_html__( 'Saved. Renew your license to send here too.', 'clicksyncr-for-kit' ) . '</span></div>';
    397398                        ++$i;
    … …  
    425426                        esc_html( Val::str( $row['label'] ?? '' ) ),
    426427                        esc_html( Val::str( $row['hint'] ?? '' ) ),
    427                         $locked ? '<button type="button" class="button syncr-destination-extra" data-syncr-destination-tip aria-disabled="true">' . esc_html__( 'Add rule', 'clicksyncr-for-kit' ) . '</button> <span class="syncr-pro-chip">PRO</span>' : self::control( $row ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by control() from escaped parts.
     428                        $locked ? '<button type="button" class="button syncr-destination-extra" data-syncr-destination-tip>' . esc_html__( 'Add rule', 'clicksyncr-for-kit' ) . '</button> <span class="syncr-pro-chip">PRO</span>' : self::control( $row ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by control() from escaped parts.
    428429                        $locked ? '<p class="syncr-notice">' . sprintf( /* translators: %s: link to ClickSyncr Pro */ esc_html__( 'Answer-based rules require %s.', 'clicksyncr-for-kit' ), Brand::pro_link( 'panel', self::$builder . '-destination_rules' ) ) . '</p>' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pro_link() returns escaped markup.
    429430                );
  • clicksyncr-for-kit/trunk/includes/src/Builders/Adapter.php

    r3726852 r3760857  
    4242        public function key( int $form_id ): string;
    4343
     44        public function own_key( int $form_id ): string;
     45
     46        /**
     47         * @return list<int>
     48         */
     49        public function configured_forms(): array;
     50
    4451        public function hook(): string;
    4552
  • clicksyncr-for-kit/trunk/includes/src/Builders/CF7.php

    r3726852 r3760857  
    247247
    248248        public function key( int $form_id ): string {
    249                 $own = Val::str( Settings::get( $form_id, 'api_key', '' ) );
    250 
    251                 return '' !== $own ? $own : Credentials::shared();
     249                return Credentials::resolve( $this->own_key( $form_id ) );
     250        }
     251
     252        public function own_key( int $form_id ): string {
     253                return trim( Val::str( Settings::get( $form_id, 'api_key', '' ) ) );
     254        }
     255
     256        /**
     257         * @return list<int>
     258         */
     259        public function configured_forms(): array {
     260                $out   = array();
     261                $posts = get_posts(
     262                        array(
     263                                'post_type'   => 'wpcf7_contact_form',
     264                                'post_status' => 'any',
     265                                'numberposts' => -1,
     266                                'fields'      => 'ids',
     267                        )
     268                );
     269                foreach ( $posts as $form_id ) {
     270                        if ( '' !== Val::str( $this->values( Val::int( $form_id ) )['group_id'] ?? '' ) ) {
     271                                $out[] = Val::int( $form_id );
     272                        }
     273                }
     274
     275                return $out;
    252276        }
    253277
    … …  
    278302                $posted = isset( $_POST[ self::POST_KEY ] ) ? self::clean( wp_unslash( $_POST[ self::POST_KEY ] ) ) : array();
    279303                if ( array() === $posted ) {
     304                        return;
     305                }
     306                if ( $form_id <= 0 ) {
     307                        add_action( 'wpcf7_after_save', array( $this, 'save_panel' ) );
    280308                        return;
    281309                }
    … …  
    365393                        'connecting'        => __( 'Connecting…', 'clicksyncr-for-kit' ),
    366394                        'connected'         => __( 'Connected. Choose where people go.', 'clicksyncr-for-kit' ),
     395                        'header_pending'    => \ClickSyncr\Kit\Admin\PanelHeader::connected_texts()['pending'],
     396                        'header_connected'  => \ClickSyncr\Kit\Admin\PanelHeader::connected_texts()['connected'],
    367397                        'failed'            => __( 'That key was not accepted.', 'clicksyncr-for-kit' ),
    368398                        'loading'           => __( 'Loading…', 'clicksyncr-for-kit' ),
  • clicksyncr-for-kit/trunk/includes/src/Builders/FluentForms.php

    r3726852 r3760857  
    253253
    254254        public function key( int $form_id ): string {
    255                 unset( $form_id );
    256 
    257                 return Credentials::shared();
     255                return Credentials::resolve( $this->own_key( $form_id ) );
     256        }
     257
     258        public function own_key( int $form_id ): string {
     259                return Credentials::stored( Builder::FluentForms, $form_id );
     260        }
     261
     262        /**
     263         * @return list<int>
     264         */
     265        public function configured_forms(): array {
     266                global $wpdb;
     267                if ( ! $wpdb instanceof \wpdb ) {
     268                        return array();
     269                }
     270                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one read of a Fluent Forms table during the key upgrade.
     271                $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT form_id FROM %i WHERE meta_key = %s', $wpdb->prefix . 'fluentform_form_meta', self::FEEDS ) );
     272
     273                return array_values( array_filter( array_map( array( Val::class, 'int' ), is_array( $ids ) ? $ids : array() ) ) );
    258274        }
    259275
    … …  
    262278                        return false;
    263279                }
    264                 unset( $form_id );
    265                 Credentials::store_shared( $key, true, 'fluentforms' );
    266 
    267                 return true;
     280
     281                return Credentials::store_form( Builder::FluentForms, $form_id, $key );
    268282        }
    269283
  • clicksyncr-for-kit/trunk/includes/src/Builders/FluentIntegration.php

    r3726852 r3760857  
    3535final class FluentIntegration extends IntegrationManagerController {
    3636
    37         private const KEY_MASK = '••••••••';
    38 
    39         public static function masked_key( string $api_key ): string {
    40                 return '' === $api_key ? '' : self::KEY_MASK . substr( $api_key, -4 );
    41         }
    42 
    43         private static function is_mask( string $value ): bool {
    44                 return 1 === preg_match( '/^[\x{2022}*]{4,}/u', $value );
    45         }
    46 
    4737        /**
    4838         * @param mixed $application
    … …  
    6252                $this->logo        = 'data:image/svg+xml;base64,' . base64_encode( '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="8" fill="#2563eb"/><text x="20" y="27" font-size="17" fill="#fff" text-anchor="middle" font-family="Arial, sans-serif">' . esc_html( strtoupper( 'kit' ) ) . '</text></svg>' );
    6353
     54                $this->hasGlobalMenu = false; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Fluent Forms property contract.
    6455                $this->registerAdminHooks();
    6556
    66                 add_filter( 'fluentform/notifying_async_' . FluentForms::INTEGRATION, '__return_false' );
    6757                add_filter( 'fluentform/save_integration_value_' . FluentForms::INTEGRATION, array( $this, 'sanitizeSettings' ), 10, 3 );
    6858        }
    … …  
    7161
    7262        /**
    73          * @return array<array-key,mixed>
    74          * @param mixed $fields
    75          */
    76         public function getGlobalFields( $fields ): array {
    77                 unset( $fields );
    78 
    79                 $config = array(
    80                         'logo'             => $this->logo,
    81                         'menu_title'       => __( 'ClickSyncr for Kit Settings', 'clicksyncr-for-kit' ),
    82                         /* translators: %s: provider name */
    83                         'menu_description' => sprintf( __( 'Enter your %s API key to connect.', 'clicksyncr-for-kit' ), Provider::label() ),
    84                         /* translators: %s: provider name */
    85                         'valid_message'    => sprintf( __( 'Your %s API key is valid', 'clicksyncr-for-kit' ), Provider::label() ),
    86                         /* translators: %s: provider name */
    87                         'invalid_message'  => sprintf( __( 'Your %s API key is not valid', 'clicksyncr-for-kit' ), Provider::label() ),
    88                         'save_button_text' => __( 'Save Settings', 'clicksyncr-for-kit' ),
    89                         'fields'           => array(
    90                                 'apiKey' => array(
    91                                         'type'       => 'password',
    92                                         /* translators: %s: provider name */
    93                                         'label_tips' => sprintf( __( 'Paste your %s API key.', 'clicksyncr-for-kit' ), Provider::label() ),
    94                                         'label'      => Provider::key_label(),
    95                                 ),
    96                         ),
    97                         'hide_on_valid'    => true,
    98                         'discard_settings' => array(
    99                                 /* translators: %s: provider name */
    100                                 'section_description' => sprintf( __( 'Your %s integration is up and running.', 'clicksyncr-for-kit' ), Provider::label() ),
    101                                 /* translators: %s: provider name */
    102                                 'button_text'         => sprintf( __( 'Disconnect %s', 'clicksyncr-for-kit' ), Provider::label() ),
    103                                 'data'                => array( 'apiKey' => '' ),
    104                                 'show_verify'         => true,
    105                         ),
    106                 );
    107                 return Val::arr( apply_filters( 'clicksyncr_kit_credential_fields', $config ) );
    108         }
    109 
    110         /**
    111          * @return array<array-key,mixed>
    112          * @param mixed $settings
    113          */
    114         public function getGlobalSettings( $settings ): array {
    115                 unset( $settings );
    116 
    117                 $stored           = wp_parse_args(
    118                         Val::arr( get_option( Val::str( $this->optionKey ) ) ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Fluent Forms property contract.
    119                         array(
    120                                 'apiKey' => '',
    121                                 'status' => '',
    122                         )
    123                 );
    124                 $stored['apiKey'] = self::masked_key( Val::str( $stored['apiKey'] ) );
    125 
    126                 return $stored;
    127         }
    128 
    129         /**
    130          * @param mixed $settings
    131          */
    132         public function saveGlobalSettings( $settings ): void {
    133                 if ( ! \ClickSyncr\Kit\Core\Access::credentials() ) {
    134                         wp_send_json_error( array( 'message' => __( 'Administrator access required.', 'clicksyncr-for-kit' ) ), 403 );
    135                 }
    136                 $api_key = Val::str( Val::arr( $settings )['apiKey'] ?? '' );
    137 
    138                 if ( self::is_mask( $api_key ) ) {
    139                         wp_send_json_success(
    140                                 array(
    141                                         /* translators: %s: provider name */
    142                                         'message' => sprintf( __( 'Your saved %s API key was kept', 'clicksyncr-for-kit' ), Provider::label() ),
    143                                         'status'  => '' !== Credentials::shared(),
    144                                 ),
    145                                 200
    146                         );
    147                 }
    148 
    149                 if ( '' === $api_key ) {
    150                         Credentials::store_shared( '', false, 'fluentforms' );
    151                         wp_send_json_success(
    152                                 array(
    153                                         'message' => __( 'Your settings have been updated and disconnected', 'clicksyncr-for-kit' ),
    154                                         'status'  => false,
    155                                 ),
    156                                 200
    157                         );
    158                 }
    159 
    160                 $client = Provider::CLIENT;
    161                 $result = class_exists( $client ) ? Val::arr( ( new $client( $api_key ) )->test() ) : array(
    162                         'success' => false,
    163                         'message' => '',
    164                 );
    165 
    166                 $result = Val::arr( apply_filters( 'clicksyncr_kit_credential_validation', $result, $api_key, Val::arr( $settings ) ) );
    167 
    168                 if ( empty( $result['success'] ) ) {
    169                         wp_send_json_error( array( 'message' => Val::str( $result['message'] ?? '' ) ), 400 );
    170                 }
    171 
    172                 Credentials::store_shared( $api_key, true, 'fluentforms' );
    173 
    174                 wp_send_json_success(
    175                         array(
    176                                 /* translators: %s: provider name */
    177                                 'message' => sprintf( __( 'Your %s API key has been verified and saved', 'clicksyncr-for-kit' ), Provider::label() ),
    178                                 'status'  => true,
    179                         ),
    180                         200
    181                 );
    182         }
    183 
    184 
     63         * @return bool
     64         */
     65        public function isConfigured() {
     66                return true;
     67        }
    18568
    18669        /**
    … …  
    21497        public static function describe( int $form_id, array $values ): array {
    21598                $adapter = new FluentForms();
    216                 $key     = Credentials::shared();
     99                $key     = $adapter->key( $form_id );
    217100
    218101                return Panel::describe(
    … …  
    345228         */
    346229        public function getMergeFields( $list, $listId, $formId ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- Fluent Forms named-parameter contract.
    347                 unset( $list, $listId, $formId ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- Fluent Forms named-parameter contract.
     230                unset( $list, $listId ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- Fluent Forms named-parameter contract.
    348231
    349232                if ( ! $this->isConfigured() ) {
    … …  
    352235
    353236                $fields = array();
    354                 foreach ( Catalog::mappable_fields_typed( Credentials::shared() ) as $key => $row ) {
     237                foreach ( Catalog::mappable_fields_typed( ( new FluentForms() )->key( Val::int( $formId ) ) ) as $key => $row ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- Fluent Forms named-parameter contract.
    355238                        $row            = Val::arr( $row );
    356239                        $fields[ $key ] = Catalog::label_with_type( Val::str( $row['label'] ?? (string) $key ), Val::str( $row['type'] ?? '' ) );
    … …  
    487370
    488371                $result = SubscriberSync::push(
    489                         Credentials::shared(),
     372                        ( new FluentForms() )->key( Val::int( is_object( $form ) ? ( $form->id ?? 0 ) : ( Val::arr( $form )['id'] ?? 0 ) ) ),
    490373                        Val::str( $feed_data['list_id'] ?? '' ),
    491374                        array( 'email' => $email ) + Val::str_map( $feed_data['merge_fields'] ?? null ),
    … …  
    493376                                StatusPreference::from_setting( $feed_data['status_preference'] ?? '' ),
    494377                                Val::str( $feed_data['consent_field'] ?? '' ),
    495                                 $form_data
     378                                $form_data,
     379                                StatusResolver::kind( ( new FluentForms() )->fields( Val::int( is_object( $form ) ? ( $form->id ?? 0 ) : ( Val::arr( $form )['id'] ?? 0 ) ) ), Val::str( $feed_data['consent_field'] ?? '' ) )
    496380                        ),
    497381                        Builder::FluentForms,
    … …  
    507391                $message   = $succeeded
    508392                        /* translators: %s: provider name */
    509                         ? sprintf( __( '%s feed has been processed successfully', 'clicksyncr-for-kit' ), Provider::label() )
     393                        ? sprintf( __( 'Added to %s by ClickSyncr', 'clicksyncr-for-kit' ), Provider::label() )
    510394                        /* translators: %s: provider name */
    511395                        : Val::str_or( Val::str( $result['message'] ?? '' ), sprintf( __( '%s feed failed', 'clicksyncr-for-kit' ), Provider::label() ) );
  • clicksyncr-for-kit/trunk/includes/src/Builders/Formidable.php

    r3726852 r3760857  
    194194                        return;
    195195                }
    196                 $vars    = get_object_vars( $action );
    197                 $content = self::apply( Val::arr( $vars['post_content'] ?? null ), $own );
    198                 wp_update_post(
    199                         array(
    200                                 'ID'           => Val::int( $vars['ID'] ?? 0 ),
    201                                 'post_content' => (string) wp_json_encode( $content ),
    202                         )
     196                $vars = get_object_vars( $action );
     197                self::write( Val::int( $vars['ID'] ?? 0 ), self::apply( Val::arr( $vars['post_content'] ?? null ), $own ) );
     198        }
     199
     200        /**
     201         * @param array<array-key,mixed> $content
     202         * @return array<array-key,mixed>|null
     203         */
     204        private static function write( int $action_id, array $content ): ?array {
     205                if ( $action_id <= 0 ) {
     206                        return null;
     207                }
     208                $result = wp_update_post(
     209                        wp_slash(
     210                                array(
     211                                        'ID'           => $action_id,
     212                                        'post_content' => (string) wp_json_encode( $content ),
     213                                )
     214                        ),
     215                        true
    203216                );
     217                if ( $result !== $action_id ) {
     218                        return null;
     219                }
     220                clean_post_cache( $action_id );
     221                $post   = get_post( $action_id );
     222                $stored = is_object( $post ) ? json_decode( Val::str( get_object_vars( $post )['post_content'] ?? '' ), true ) : null;
     223
     224                return is_array( $stored ) ? $stored : null;
    204225        }
    205226
    … …  
    250271
    251272        public function key( int $form_id ): string {
    252                 return Credentials::resolve( Val::str( $this->values( $form_id )['api_key'] ?? '' ) );
     273                return Credentials::resolve( $this->own_key( $form_id ) );
     274        }
     275
     276        public function own_key( int $form_id ): string {
     277                $action = self::first_action( $form_id );
     278
     279                return null === $action ? '' : trim( Val::str( Val::arr( get_object_vars( $action )['post_content'] ?? null )['api_key'] ?? '' ) );
     280        }
     281
     282        /**
     283         * @return list<int>
     284         */
     285        public function configured_forms(): array {
     286                $out   = array();
     287                $posts = get_posts(
     288                        array(
     289                                'post_type'        => 'frm_form_actions',
     290                                'post_status'      => 'any',
     291                                'numberposts'      => -1,
     292                                'post_excerpt'     => self::SLUG,
     293                                'suppress_filters' => false,
     294                        )
     295                );
     296                foreach ( $posts as $post ) {
     297                        if ( $post instanceof \WP_Post && self::SLUG === $post->post_excerpt && $post->menu_order > 0 ) {
     298                                $out[] = (int) $post->menu_order;
     299                        }
     300                }
     301
     302                return array_values( array_unique( $out ) );
    253303        }
    254304
    … …  
    257307                        return false;
    258308                }
    259                 unset( $form_id );
    260                 Credentials::store_shared( $key, true, 'formidable' );
    261 
    262                 return true;
     309                $action = self::first_action( $form_id );
     310                if ( null === $action ) {
     311                        return false;
     312                }
     313                $vars               = get_object_vars( $action );
     314                $content            = Val::arr( $vars['post_content'] ?? null );
     315                $content['api_key'] = sanitize_text_field( $key );
     316                $stored             = self::write( Val::int( $vars['ID'] ?? 0 ), $content );
     317
     318                return null !== $stored && Val::str( $stored['api_key'] ?? '' ) === $content['api_key'];
    263319        }
    264320
    265321        public static function first_action( int $form_id ): ?object {
    266                 if ( $form_id <= 0 || ! class_exists( '\\FrmFormAction' ) || ! method_exists( '\\FrmFormAction', 'get_action_for_form' ) ) {
     322                if ( $form_id <= 0 || ! class_exists( '\\FrmFormAction' ) ) {
    267323                        return null;
    268324                }
  • clicksyncr-for-kit/trunk/includes/src/Builders/FormidableAction.php

    r3726852 r3760857  
    9494                $vars     = is_object( $form_action ) ? get_object_vars( $form_action ) : Val::arr( $form_action );
    9595                $settings = Val::arr( $vars['post_content'] ?? null );
    96                 $form_id  = Val::int( $this->form_id ?? 0 );
     96                $form_id  = Val::int( get_object_vars( $this )['form_id'] ?? 0 );
    9797                $form_id  = 0 !== $form_id ? $form_id : Val::int( $vars['menu_order'] ?? 0 );
    9898                $values   = Formidable::values_from( $settings );
    … …  
    145145                unset( $new_instance );
    146146                $inst_vars = is_object( $instance ) ? get_object_vars( $instance ) : Val::arr( $instance );
    147                 $form_id = Val::int( $inst_vars['menu_order'] ?? ( $this->form_id ?? 0 ) );
     147                $form_id = Val::int( $inst_vars['menu_order'] ?? Val::int( get_object_vars( $this )['form_id'] ?? 0 ) );
    148148                \ClickSyncr\Kit\Core\Access::require_form( 'formidable', $form_id );
    149149                $content  = Val::arr( $post_content );
  • clicksyncr-for-kit/trunk/includes/src/Builders/Forminator.php

    r3726852 r3760857  
    7878
    7979                return array(
    80                         'api_key'           => '',
     80                        'api_key'           => Val::str( $settings['api_key'] ?? '' ),
    8181                        'group_id'          => Val::str( $settings['kit_group'] ?? ( $settings['ml_group'] ?? ( $settings['group_id'] ?? '' ) ) ),
    8282                        'field_map'         => $map,
    … …  
    181181
    182182        public function key( int $form_id ): string {
    183                 unset( $form_id );
    184 
    185                 return Credentials::shared();
     183                return Credentials::resolve( $this->own_key( $form_id ) );
     184        }
     185
     186        public function own_key( int $form_id ): string {
     187                return trim( Val::str( self::settings( $form_id )['api_key'] ?? '' ) );
     188        }
     189
     190        /**
     191         * @return list<int>
     192         */
     193        public function configured_forms(): array {
     194                $out   = array();
     195                $posts = get_posts(
     196                        array(
     197                                'post_type'   => 'forminator_forms',
     198                                'post_status' => 'any',
     199                                'numberposts' => -1,
     200                                'fields'      => 'ids',
     201                        )
     202                );
     203                foreach ( $posts as $form_id ) {
     204                        if ( '' !== Val::str( self::values_from( self::settings( Val::int( $form_id ) ) )['group_id'] ?? '' ) ) {
     205                                $out[] = Val::int( $form_id );
     206                        }
     207                }
     208
     209                return $out;
    186210        }
    187211
    … …  
    190214                        return false;
    191215                }
    192                 unset( $form_id );
    193                 Credentials::store_shared( $key, true, 'forminator' );
    194 
    195                 return true;
     216                $meta = self::meta_key( $form_id );
     217                if ( '' === $meta ) {
     218                        return false;
     219                }
     220                $settings = self::settings( $form_id );
     221                $settings['api_key'] = $key;
     222                update_post_meta( $form_id, $meta, $settings );
     223                return hash_equals( $key, Val::str( self::settings( $form_id )['api_key'] ?? '' ) );
    196224        }
    197225
  • clicksyncr-for-kit/trunk/includes/src/Builders/ForminatorFormSettings.php

    r3726852 r3760857  
    5555        public static function describe( int $form_id, array $values ): array {
    5656                $adapter = new Forminator();
    57                 $key     = Credentials::shared();
     57                $key     = $adapter->key( $form_id );
    5858
    5959                $values['api_key'] = $key;
    … …  
    9595                $form_id   = Val::int( $this->module_id );
    9696                $posted    = Val::arr( $submitted['syncr'] ?? null );
    97                 $key       = Credentials::shared();
     97                $key       = ( new Forminator() )->key( $form_id );
    9898                $error     = '';
    9999
  • clicksyncr-for-kit/trunk/includes/src/Builders/Gravity.php

    r3726852 r3760857  
    181181
    182182        public function key( int $form_id ): string {
    183                 unset( $form_id );
    184 
    185                 return Credentials::shared();
     183                return Credentials::resolve( $this->own_key( $form_id ) );
     184        }
     185
     186        public function own_key( int $form_id ): string {
     187                return Credentials::stored( Builder::Gravity, $form_id );
     188        }
     189
     190        /**
     191         * @return list<int>
     192         */
     193        public function configured_forms(): array {
     194                $out   = array();
     195                $feeds = class_exists( '\\GFAPI' ) ? \GFAPI::get_feeds( null, null, GravityFeed::SLUG, null ) : array();
     196                foreach ( is_array( $feeds ) ? $feeds : array() as $feed ) {
     197                        $form_id = Val::int( Val::arr( $feed )['form_id'] ?? 0 );
     198                        if ( $form_id > 0 ) {
     199                                $out[] = $form_id;
     200                        }
     201                }
     202
     203                return array_values( array_unique( $out ) );
    186204        }
    187205
    … …  
    190208                        return false;
    191209                }
    192                 unset( $form_id );
    193                 Credentials::store_shared( $key, true, 'gravity' );
    194 
    195                 return true;
     210
     211                return Credentials::store_form( Builder::Gravity, $form_id, $key );
    196212        }
    197213
  • clicksyncr-for-kit/trunk/includes/src/Builders/GravityFeed.php

    r3726852 r3760857  
    3737        public const SLUG = 'clicksyncr_kit';
    3838
     39        /**
     40         * @var bool
     41         */
     42        protected $_async_feed_processing = true; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore -- Gravity Forms add-on API.
     43
    3944        protected $_version                  = SYNCR_KIT_VER; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore -- Gravity Forms add-on API.
    4045        protected $_min_gravityforms_version = '2.5'; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore -- Gravity Forms add-on API.
    … …  
    7479                                /* translators: %s: provider name */
    7580                                'title'       => esc_html( sprintf( __( '%s account', 'clicksyncr-for-kit' ), Provider::label() ) ),
    76                                 'description' => '<p>' . esc_html__( 'The key is shared with every form builder ClickSyncr supports on this site.', 'clicksyncr-for-kit' ) . '</p>',
    7781                                'fields'      => array(
    7882                                        array(
    79                                                 'name' => 'kit_key_status',
     83                                                'name' => 'kit_per_form_keys',
    8084                                                'type' => 'html',
    81                                                 'html' => static fn (): string => self::key_status_html(),
    82                                         ),
    83                                         array(
    84                                                 'name'        => 'kit_api_key',
    85                                                 'label'       => esc_html( Provider::key_label() ),
    86                                                 'type'        => 'text',
    87                                                 'input_type'  => 'password',
    88                                                 'class'       => 'medium',
    89                                                 'description' => '' !== Credentials::shared()
    90                                                         ? esc_html__( 'Paste a new key to replace the saved one, or leave this empty to keep it.', 'clicksyncr-for-kit' )
    91                                                         /* translators: %s: provider name */
    92                                                         : esc_html( sprintf( __( 'Create an API key in %s, then paste it here.', 'clicksyncr-for-kit' ), Provider::label() ) ),
    93                                         ),
    94                                         array(
    95                                                 'name'    => 'kit_disconnect',
    96                                                 'label'   => esc_html__( 'Disconnect', 'clicksyncr-for-kit' ),
    97                                                 'type'    => 'checkbox',
    98                                                 'choices' => array(
    99                                                         array(
    100                                                                 'name'  => 'kit_disconnect',
    101                                                                 /* translators: %s: provider name */
    102                                                                 'label' => esc_html( sprintf( __( 'Remove the saved %s key from this site', 'clicksyncr-for-kit' ), Provider::label() ) ),
    103                                                         ),
    104                                                 ),
     85                                                'html' => '<p>' . esc_html( Provider::per_form_line() ) . '</p>',
    10586                                        ),
    10687                                ),
    10788                        ),
    10889                );
    109         }
    110 
    111         private static function key_status_html(): string {
    112                 $shared = Credentials::shared();
    113                 if ( '' === $shared ) {
    114                         return '';
    115                 }
    116 
    117                 return '<p>' . esc_html(
    118                         sprintf(
    119                         /* translators: %s: the last four characters of the saved key */
    120                                 __( 'A key ending in %s is saved.', 'clicksyncr-for-kit' ),
    121                                 '••••' . substr( $shared, -4 )
    122                         )
    123                 ) . '</p>';
    12490        }
    12591
    … …  
    13298                        return $this->get_plugin_settings();
    13399                }
    134                 $settings   = (array) $settings;
    135                 $submitted  = Val::str( rgar( $settings, 'kit_api_key' ) );
    136                 $disconnect = ! empty( rgar( $settings, 'kit_disconnect' ) );
    137                 $client     = Provider::CLIENT;
    138 
    139                 if ( $disconnect ) {
    140                         Credentials::store_shared( '', false, 'gravity' );
    141                 } elseif ( '' !== $submitted && class_exists( $client ) && ! empty( ( new $client( $submitted ) )->test()['success'] ) ) {
    142                         Credentials::store_shared( $submitted, true, 'gravity' );
    143                 }
    144 
     100                $settings                       = (array) $settings;
    145101                $settings['kit_api_key'] = '';
    146102                unset( $settings['kit_disconnect'] );
    … …  
    173129         */
    174130        public function can_create_feed() {
    175                 return '' !== Credentials::shared();
     131                return true;
    176132        }
    177133
    … …  
    195151        public function describe( int $form_id, array $values ): array {
    196152                $adapter = new Gravity();
    197                 $key     = Credentials::shared();
     153                $key     = $adapter->key( $form_id );
    198154
    199155                return Panel::describe(
    … …  
    352308
    353309                $consent_field = Val::str( rgars( (array) $feed, 'meta/consent_field' ) );
     310                $consent_kind  = '' === $consent_field ? '' : StatusResolver::kind( ( new Gravity() )->fields( Val::int( rgar( (array) $form, 'id' ) ) ), $consent_field );
    354311                $answers       = array();
    355312                foreach ( (array) $entry as $entry_key => $entry_value ) {
    … …  
    359316                }
    360317
    361                 SubscriberSync::push(
    362                         Credentials::shared(),
     318                $result = SubscriberSync::push(
     319                        ( new Gravity() )->key( Val::int( rgar( (array) $form, 'id' ) ) ),
    363320                        Val::str( rgars( (array) $feed, 'meta/kit_group' ) ),
    364321                        $subscriber_data,
    … …  
    366323                                StatusPreference::from_setting( rgars( (array) $feed, 'meta/status_preference' ) ),
    367324                                $consent_field,
    368                                 '' === $consent_field ? '' : rgar( (array) $entry, $consent_field )
     325                                self::answer( (array) $entry, $consent_field, $consent_kind ),
     326                                $consent_kind
    369327                        ),
    370328                        Builder::Gravity,
    … …  
    372330                        $answers
    373331                );
     332                if ( ! empty( $result['skipped'] ) ) {
     333                        return;
     334                }
     335                $succeeded = ! empty( $result['success'] );
     336                $text = $succeeded
     337                        /* translators: %s: provider name */
     338                        ? sprintf( __( 'Added to %s by ClickSyncr', 'clicksyncr-for-kit' ), Provider::label() )
     339                        /* translators: %s: provider name */
     340                        : Val::str_or( Val::str( $result['message'] ?? '' ), sprintf( __( '%s sync failed', 'clicksyncr-for-kit' ), Provider::label() ) );
     341                $this->add_note( Val::int( rgar( (array) $entry, 'id' ) ), $text, $succeeded ? 'success' : 'error' );
    374342        }
    375343
    … …  
    389357        public function get_column_value_kit_group( $feed ): string {
    390358                $group_id = Val::str( rgars( (array) $feed, 'meta/kit_group' ) );
    391                 $options  = Catalog::group_options( Credentials::shared() );
     359                $options  = Catalog::group_options( ( new Gravity() )->key( Val::int( rgar( (array) $feed, 'form_id' ) ) ) );
    392360
    393361                return Val::str( $options[ $group_id ] ?? $group_id );
    394362        }
     363
     364        /**
     365         * @param array<array-key,mixed> $entry
     366         * @return mixed
     367         */
     368        public static function answer( array $entry, string $field_id, string $kind ) {
     369                if ( '' === $field_id ) {
     370                        return '';
     371                }
     372                $value = $entry[ $field_id ] ?? '';
     373                if ( '' !== Val::flat( $value ) || false !== strpos( $field_id, '.' ) ) {
     374                        return $value;
     375                }
     376                if ( 'consent' === $kind ) {
     377                        return $entry[ $field_id . '.1' ] ?? '';
     378                }
     379                $picked = array();
     380                foreach ( $entry as $key => $item ) {
     381                        if ( strncmp( (string) $key, $field_id . '.', strlen( $field_id ) + 1 ) === 0 ) {
     382                                $picked[] = $item;
     383                        }
     384                }
     385
     386                return $picked;
     387        }
    395388}
  • clicksyncr-for-kit/trunk/includes/src/Builders/NinjaAction.php

    r3726852 r3760857  
    1414use ClickSyncr\Kit\Core\Brand;
    1515use ClickSyncr\Kit\Core\Catalog;
     16use ClickSyncr\Kit\Core\Dispatch;
    1617use ClickSyncr\Kit\Core\Edition;
    1718use ClickSyncr\Kit\Core\Panel;
    … …  
    135136                }
    136137
    137                 SubscriberSync::push(
    138                         $api_key,
    139                         Val::str( $action_settings[ NinjaForms::S . 'group_id' ] ?? '' ),
    140                         $subscriber_data,
    141                         self::consent_status( $action_settings, Val::int( $form_id ), $answers ),
    142                         Builder::NinjaForms,
    143                         Val::int( $form_id ),
    144                         $answers
     138                Dispatch::later(
     139                        array( SubscriberSync::class, 'push' ),
     140                        array(
     141                                $api_key,
     142                                Val::str( $action_settings[ NinjaForms::S . 'group_id' ] ?? '' ),
     143                                $subscriber_data,
     144                                self::consent_status( $action_settings, Val::int( $form_id ), $answers ),
     145                                Builder::NinjaForms,
     146                                Val::int( $form_id ),
     147                                $answers,
     148                        )
    145149                );
    146150
    … …  
    173177                        StatusPreference::from_setting( $settings[ NinjaForms::S . 'status_preference' ] ?? '' ),
    174178                        $field,
    175                         $answers[ $field ] ?? ''
     179                        $answers[ $field ] ?? '',
     180                        StatusResolver::kind( ( new NinjaForms() )->fields( $form_id ), $field )
    176181                );
    177182        }
  • clicksyncr-for-kit/trunk/includes/src/Builders/NinjaForms.php

    r3726852 r3760857  
    2929        public const ACTION     = 'clicksyncr_kit';
    3030        public const SETTING    = 'clicksyncr_kit_api_key';
    31         public const DISCONNECT = 'clicksyncr_kit_disconnect';
    3231        public const S          = 'cskit_';
    3332
    … …  
    3736
    3837        public function register(): void {
    39                 add_filter( 'ninja_forms_plugin_settings_groups', array( $this, 'settings_group' ) );
    40                 add_filter( 'ninja_forms_plugin_settings', array( $this, 'settings_fields' ) );
    41                 add_filter( 'ninja_forms_update_setting_' . self::SETTING, array( $this, 'store_setting' ) );
    42                 add_filter( 'ninja_forms_update_setting_' . self::DISCONNECT, array( $this, 'disconnect' ) );
    4338
    4439                add_action( 'init', array( $this, 'register_action' ), 0 );
    … …  
    9287
    9388
    94         /**
    95          * @param mixed $value
    96          */
    97         public function store_setting( $value ): string {
    98                 if ( ! \ClickSyncr\Kit\Core\Access::credentials() ) {
    99                         return '';
    100                 }
    101                 $api_key = Val::str( $value );
    102                 $client  = Provider::CLIENT;
    103                 if ( '' !== $api_key && class_exists( $client ) && ! empty( ( new $client( $api_key ) )->test()['success'] ) ) {
    104                         Credentials::store_shared( $api_key, true, 'ninjaforms' );
    105                 }
    106 
    107                 return '';
    108         }
    109 
    110         /**
    111          * @param mixed $value
    112          */
    113         public function disconnect( $value ): string {
    114                 if ( ! \ClickSyncr\Kit\Core\Access::credentials() ) {
    115                         return '';
    116                 }
    117                 if ( ! empty( $value ) ) {
    118                         Credentials::store_shared( '', false, 'ninjaforms' );
    119                 }
    120 
    121                 return '';
    122         }
    123 
    124         private static function migrate_legacy_setting(): void {
     89        public static function migrate_legacy_setting(): void {
    12590                if ( ! function_exists( 'Ninja_Forms' ) ) {
    12691                        return;
    … …  
    136101        }
    137102
    138         /**
    139          * @return array<mixed>
    140          * @param mixed $groups
    141          */
    142         public function settings_group( $groups ): array {
    143                 $groups                 = Val::arr( $groups );
    144                 $groups[ self::ACTION ] = array(
    145                         'id'    => self::ACTION,
    146                         'label' => __( 'ClickSyncr for Kit', 'clicksyncr-for-kit' ),
    147                 );
    148 
    149                 return $groups;
    150         }
    151 
    152         /**
    153          * @return array<mixed>
    154          * @param mixed $settings
    155          */
    156         public function settings_fields( $settings ): array {
    157                 $settings = Val::arr( $settings );
    158                 self::migrate_legacy_setting();
    159                 $shared = Credentials::shared();
    160 
    161                 $settings[ self::ACTION ] = array(
    162                         self::SETTING => array(
    163                                 'id'    => self::SETTING,
    164                                 'type'  => 'textbox',
    165                                 'label' => Provider::key_label(),
    166                                 'desc'  => '' !== $shared
    167                                         ? sprintf(
    168                                                 /* translators: %s: the last four characters of the saved key */
    169                                                 __( 'A key ending in %s is saved. Paste a new key to replace it, or leave this empty to keep it.', 'clicksyncr-for-kit' ),
    170                                                 '••••' . substr( $shared, -4 )
    171                                         )
    172                                         /* translators: %s: provider name */
    173                                         : sprintf( __( 'Paste your %s API key to sync Ninja Forms submissions.', 'clicksyncr-for-kit' ), Provider::label() ),
    174                         ),
    175                 );
    176                 if ( '' !== $shared ) {
    177                         $settings[ self::ACTION ][ self::DISCONNECT ] = array(
    178                                 'id'    => self::DISCONNECT,
    179                                 'type'  => 'checkbox',
    180                                 /* translators: %s: provider name */
    181                                 'label' => sprintf( __( 'Remove the saved %s key from this site', 'clicksyncr-for-kit' ), Provider::label() ),
    182                         );
    183                 }
    184 
    185                 return $settings;
    186         }
    187 
    188103        public function key( int $form_id ): string {
    189                 unset( $form_id );
    190                 $setting = function_exists( 'Ninja_Forms' ) ? Val::str( Ninja_Forms()->get_setting( self::SETTING ) ) : '';
    191 
    192                 return Credentials::resolve( $setting );
     104                return Credentials::resolve( $this->own_key( $form_id ) );
     105        }
     106
     107        public function own_key( int $form_id ): string {
     108                return Credentials::stored( Builder::NinjaForms, $form_id );
     109        }
     110
     111        /**
     112         * @return list<int>
     113         */
     114        public function configured_forms(): array {
     115                global $wpdb;
     116                if ( ! $wpdb instanceof \wpdb ) {
     117                        return array();
     118                }
     119                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one read of the Ninja Forms actions table during the key upgrade.
     120                $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT parent_id FROM %i WHERE type = %s', $wpdb->prefix . 'nf3_actions', self::ACTION ) );
     121
     122                return array_values( array_filter( array_map( array( Val::class, 'int' ), is_array( $ids ) ? $ids : array() ) ) );
    193123        }
    194124
    … …  
    197127                        return false;
    198128                }
    199                 unset( $form_id );
    200                 Credentials::store_shared( $key, true, 'ninjaforms' );
    201 
    202                 return true;
     129
     130                return Credentials::store_form( Builder::NinjaForms, $form_id, $key );
    203131        }
    204132
  • clicksyncr-for-kit/trunk/includes/src/Builders/WPForms.php

    r3726852 r3760857  
    196196                                if ( class_exists( $client ) && ! empty( ( new $client( $typed ) )->test()['success'] ) ) {
    197197                                        $settings[ self::FLAT . 'api_key' ] = $typed;
    198                                         if ( '' === Credentials::shared() ) {
    199                                                 Credentials::store_shared( $typed, true, 'wpforms' );
    200                                         }
    201198                                }
    202199                        }
    … …  
    255252                }
    256253
    257                 $decoded = is_array( $data ) && array() !== $data
    258                         ? $data
    259                         : Val::arr( json_decode( wp_unslash( Val::str( $form['post_content'] ?? '' ) ), true ) );
     254                $decoded = Val::arr( json_decode( wp_unslash( Val::str( $form['post_content'] ?? '' ) ), true ) );
     255                if ( array() === $decoded && is_array( $data ) ) {
     256                        $decoded = $data;
     257                }
    260258                if ( array() === $decoded ) {
    261259                        return $form;
    … …  
    340338
    341339        public function key( int $form_id ): string {
    342                 $own = Val::str( $this->values( $form_id )['api_key'] ?? '' );
    343 
    344                 return '' !== $own ? $own : Credentials::shared();
     340                return Credentials::resolve( $this->own_key( $form_id ) );
     341        }
     342
     343        public function own_key( int $form_id ): string {
     344                return trim( Val::str( $this->values( $form_id )['api_key'] ?? '' ) );
     345        }
     346
     347        /**
     348         * @return list<int>
     349         */
     350        public function configured_forms(): array {
     351                $out   = array();
     352                $posts = get_posts(
     353                        array(
     354                                'post_type'   => 'wpforms',
     355                                'post_status' => 'any',
     356                                'numberposts' => -1,
     357                                'fields'      => 'ids',
     358                        )
     359                );
     360                foreach ( $posts as $form_id ) {
     361                        if ( '' !== Val::str( $this->values( Val::int( $form_id ) )['group_id'] ?? '' ) ) {
     362                                $out[] = Val::int( $form_id );
     363                        }
     364                }
     365
     366                return $out;
    345367        }
    346368
  • clicksyncr-for-kit/trunk/includes/src/CF7/SubmissionHandler.php

    r3726852 r3760857  
    1313use ClickSyncr\Kit\Core\Builder;
    1414use ClickSyncr\Kit\Core\Credentials;
     15use ClickSyncr\Kit\Core\Dispatch;
    1516use ClickSyncr\Kit\Core\Settings;
    1617use ClickSyncr\Kit\Core\StatusPreference;
    … …  
    4849                $posted_data = Val::arr( $submission->get_posted_data() );
    4950
    50                 SubscriberSync::push(
    51                         Credentials::resolve( Val::str( Settings::get( $form_id, 'api_key' ) ) ),
    52                         Val::str( Settings::get( $form_id, 'group_id' ) ),
    53                         FieldMapper::map_fields( $posted_data, $field_map ),
    54                         StatusResolver::resolve(
    55                                 StatusPreference::from_setting( Settings::get( $form_id, 'status_preference' ) ),
    56                                 Val::str( Settings::get( $form_id, 'consent_field', '' ) ),
    57                                 $posted_data
    58                         ),
    59                         Builder::CF7,
    60                         $form_id,
    61                         $posted_data
     51                Dispatch::later(
     52                        array( SubscriberSync::class, 'push' ),
     53                        array(
     54                                Credentials::resolve( Val::str( Settings::get( $form_id, 'api_key' ) ) ),
     55                                Val::str( Settings::get( $form_id, 'group_id' ) ),
     56                                FieldMapper::map_fields( $posted_data, $field_map ),
     57                                StatusResolver::resolve(
     58                                        StatusPreference::from_setting( Settings::get( $form_id, 'status_preference' ) ),
     59                                        Val::str( Settings::get( $form_id, 'consent_field', '' ) ),
     60                                        $posted_data,
     61                                        StatusResolver::kind( ( new \ClickSyncr\Kit\Builders\CF7() )->fields( $form_id ), Val::str( Settings::get( $form_id, 'consent_field', '' ) ) )
     62                                ),
     63                                Builder::CF7,
     64                                $form_id,
     65                                $posted_data,
     66                        )
    6267                );
    6368        }
  • clicksyncr-for-kit/trunk/includes/src/Core/Access.php

    r3726852 r3760857  
    6565                        return Current::allowed( '' === $builder ? 'cf7' : $builder );
    6666                }
     67                if ( '/advanced/sharing' === $route ) {
     68                        $builder = Val::str( $request->get_param( 'builder' ) );
     69                        return class_exists( \ClickSyncr\Kit\Signals\Sharing::class ) && \ClickSyncr\Kit\Signals\Sharing::allowed( '' === $builder ? 'cf7' : $builder );
     70                }
    6771                if ( '/notices/act' === $route ) {
    6872                        return current_user_can( 'manage_options' );
    … …  
    178182                                return 'wpforms' === get_post_type( $form_id );
    179183                        case 'fluentforms':
    180                                 return class_exists( '\FluentForm\App\Models\Form' ) && null !== \FluentForm\App\Models\Form::find( $form_id );
     184                                return class_exists( '\FluentForm\App\Api\Form' ) && null !== ( new \FluentForm\App\Api\Form() )->find( $form_id );
    181185                        case 'gravity':
    182186                                $form = \GFAPI::get_form( $form_id );
  • clicksyncr-for-kit/trunk/includes/src/Core/Advanced.php

    r3726852 r3760857  
    1010
    1111namespace ClickSyncr\Kit\Core;
     12
     13use ClickSyncr\Kit\Signals\Sharing;
    1214
    1315if ( ! defined( 'ABSPATH' ) ) {
    … …  
    308310                }
    309311                $host = Val::str( wp_parse_url( $url, PHP_URL_HOST ) );
    310                 if ( '' === self::API_HOST || false === stripos( $host, self::API_HOST ) ) {
     312                if ( ! ApiHost::allows( $url ) ) {
    311313                        return;
    312314                }
    … …  
    389391                $row( 'Connector', 'clicksyncr-for-kit ' . ( defined( 'SYNCR_KIT_VER' ) ? SYNCR_KIT_VER : '' ) );
    390392                $row( 'Pro', Edition::pro_installed() ? Edition::pro_version() . ( Edition::pro_active() ? ' (active)' : ' (installed, not active)' ) : 'not installed' );
    391                 $row( 'Key stored', '' !== Credentials::shared() );
     393                $row( 'Keys', Credentials::per_form() ? 'one per form' : 'site key until an administrator opens the dashboard' );
    392394                $row( 'Debug log', self::logging() ? 'on until ' . gmdate( 'Y-m-d H:i', self::settings()['debug_until'] ) . ' UTC' : 'off' );
    393395                $row( 'Site log', '' === self::site_log_path() ? 'none' : basename( self::site_log_path() ) );
    … …  
    401403                $start = microtime( true );
    402404                $probe = wp_remote_head(
    403                         'https://' . self::API_HOST . '/',
     405                        ApiHost::base() . '/',
    404406                        array(
    405407                                'timeout'     => 5,
    … …  
    407409                        )
    408410                );
    409                 $row( 'Host', self::API_HOST );
    410                 $row( 'Answer', is_wp_error( $probe ) || ! is_array( $probe ) ? 'error: ' . ( is_object( $probe ) && method_exists( $probe, 'get_error_message' ) ? (string) $probe->get_error_message() : 'no response' ) : 'HTTP ' . wp_remote_retrieve_response_code( $probe ) . ' in ' . (int) round( ( microtime( true ) - $start ) * 1000 ) . ' ms' );
     411                $row( 'Host', Val::str( wp_parse_url( ApiHost::base(), PHP_URL_HOST ) ) );
     412                $row( 'Answer', $probe instanceof \WP_Error ? 'error: ' . $probe->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code( $probe ) . ' in ' . (int) round( ( microtime( true ) - $start ) * 1000 ) . ' ms' );
    411413
    412414                $section( 'Form builders' );
    … …  
    496498                        )
    497499                );
     500                if ( class_exists( Sharing::class ) ) {
     501                        register_rest_route(
     502                                self::REST_NAMESPACE,
     503                                '/advanced/sharing',
     504                                array(
     505                                        'methods'             => 'POST',
     506                                        'callback'            => array( Sharing::class, 'save' ),
     507                                        'permission_callback' => array( self::class, 'permitted' ),
     508                                )
     509                        );
     510                }
    498511                register_rest_route(
    499512                        self::REST_NAMESPACE,
    … …  
    607620                                'success' => true,
    608621                                'html'    => self::stats_html( $builder, $form_id ),
     622                                'ready'   => self::test_ready( $builder, $form_id ),
    609623                        )
    610624                );
    … …  
    673687                                array(
    674688                                        'success' => false,
    675                                         /* translators: %s: list noun. */
    676                                         'message' => sprintf( __( 'Save an API key and a %s for this form first.', 'clicksyncr-for-kit' ), Provider::destination() ),
     689                                        'message' => self::not_ready_message(),
    677690                                ),
    678691                                400
    … …  
    708721                remove_action( 'clicksyncr_kit_synced', $observe, 1 );
    709722
    710                 $reason = trim( Val::str( $result['message'] ?? '' ) );
     723                $reason = rtrim( trim( Val::str( $result['message'] ?? '' ) ), '.' );
    711724                if ( '' === $reason ) {
    712725                        $reason = __( 'Nothing was sent', 'clicksyncr-for-kit' );
    … …  
    715728                return new \WP_REST_Response(
    716729                        array(
    717                                 'success' => ! empty( $result['success'] ),
     730                                'success' => ! empty( $result['success'] ) || ! empty( $result['skipped'] ),
    718731                                'skipped' => ! empty( $result['skipped'] ),
    719732                                'sent'    => self::scalars( $sent ),
    … …  
    749762                        ) . '</p>'
    750763                        . '<div class="syncr-adv__viewer">'
    751                         . '<div class="syncr-adv__tabs" role="tablist">'
    752                         . '<button type="button" class="button button-small is-active" data-syncr-log="own">' . esc_html__( 'Plugin log', 'clicksyncr-for-kit' ) . '</button> '
    753                         . '<button type="button" class="button button-small" data-syncr-log="site">' . esc_html__( 'Site errors', 'clicksyncr-for-kit' ) . '</button> '
     764                        . '<div class="syncr-adv__tabs">'
     765                        . '<button type="button" class="button button-small is-active" aria-pressed="true" data-syncr-log="own">' . esc_html__( 'Plugin log', 'clicksyncr-for-kit' ) . '</button> '
     766                        . '<button type="button" class="button button-small" aria-pressed="false" data-syncr-log="site">' . esc_html__( 'Site errors', 'clicksyncr-for-kit' ) . '</button> '
    754767                        . '<label class="syncr-adv__auto"><input type="checkbox" id="' . $id( 'log-auto' ) . '" /> ' . esc_html__( 'Auto-refresh', 'clicksyncr-for-kit' ) . '</label>'
    755768                        . '</div>'
    … …  
    790803
    791804                $current_html = Current::html( $builder );
     805                $sharing_html = class_exists( Sharing::class ) ? Sharing::html( $builder ) : '';
    792806
    793807                $rows = array_merge(
    … …  
    802816                                Panel::row( 'adv_reset', __( 'Reset this form', 'clicksyncr-for-kit' ), sprintf( __( 'This form only. Forgets the %1$s key, %2$s, mapping, consent and status saved here, and its counters. The form itself is untouched.', 'clicksyncr-for-kit' ), 'Kit', Provider::destination() ), 'html', 'adv_reset', '', array( 'html' => $reset_html ) ),
    803817                                ...( '' === $current_html ? array() : array( Panel::row( 'adv_current', __( 'New versions', 'clicksyncr-for-kit' ), '', 'html', 'adv_current', '', array( 'html' => $current_html ) ) ) ),
     818                                ...( '' === $sharing_html ? array() : array( Panel::row( 'adv_sharing', __( 'Help Us Improve', 'clicksyncr-for-kit' ), '', 'html', 'adv_sharing', '', array( 'html' => $sharing_html ) ) ) ),
    804819                                Panel::row( 'adv_purge', __( 'On uninstall', 'clicksyncr-for-kit' ), $site, 'html', 'adv_purge', '', array( 'html' => $purge_html ) ),
    805820                        )
    … …  
    932947        }
    933948
     949        public static function test_ready( string $builder, int $form_id ): bool {
     950                if ( $form_id <= 0 || null === Builder::tryFrom( $builder ) ) {
     951                        return false;
     952                }
     953                $class = Builder::adapter( $builder );
     954                if ( ! class_exists( $class ) ) {
     955                        return false;
     956                }
     957                /**
     958                 * @var \ClickSyncr\Kit\Builders\Adapter $adapter
     959                 */
     960                $adapter = new $class();
     961
     962                return '' !== $adapter->key( $form_id ) && '' !== Val::str( $adapter->values( $form_id )['group_id'] ?? '' );
     963        }
     964
     965        public static function not_ready_message(): string {
     966                /* translators: %s: list noun. */
     967                return sprintf( __( 'Save an API key and a %s for this form first.', 'clicksyncr-for-kit' ), Provider::destination() );
     968        }
     969
    934970        /** @return array<array-key,mixed> */
    935         public static function test_row(): array {
    936                 $id   = 'clicksyncr-for-kit-adv-test';
    937                 $html = '<div class="syncr-tool-field"><div><label for="' . $id . '-email">' . esc_html__( 'Send a test to', 'clicksyncr-for-kit' ) . '</label>'
     971        public static function test_row( string $builder = '', int $form_id = 0 ): array {
     972                $id    = 'clicksyncr-for-kit-adv-test';
     973                $ready = self::test_ready( $builder, $form_id );
     974                $html  = '<div class="syncr-tool-field"><div><label for="' . $id . '-email">' . esc_html__( 'Send a test to', 'clicksyncr-for-kit' ) . '</label>'
    938975                        . '<input type="email" id="' . $id . '-email" class="regular-text" placeholder="you@example.com" autocomplete="off" /></div>'
    939                         . '<div class="syncr-actions"><button type="button" class="button button-primary" id="' . $id . '">' . esc_html__( 'Send test', 'clicksyncr-for-kit' ) . '</button></div></div>'
    940                         . '<div class="syncr-tool-results"><span id="' . $id . '-feedback" aria-live="polite"></span></div>';
     976                        . '<div class="syncr-actions"><button type="button" class="button button-primary" id="' . $id . '"' . ( $ready ? '' : ' disabled' ) . '>' . esc_html__( 'Send test', 'clicksyncr-for-kit' ) . '</button></div></div>'
     977                        . '<div class="syncr-tool-results"><span id="' . $id . '-feedback" aria-live="polite">' . ( $ready ? '' : esc_html( self::not_ready_message() ) ) . '</span></div>';
    941978
    942979                return Panel::row( 'adv_test', __( 'Send a test', 'clicksyncr-for-kit' ), __( 'A real subscriber, added through this form\'s saved settings, so you see exactly what a visitor gets.', 'clicksyncr-for-kit' ), 'html', 'adv_test', '', array( 'html' => $html ) );
  • clicksyncr-for-kit/trunk/includes/src/Core/Context.php

    r3726852 r3760857  
    259259                        return $subscriber;
    260260                }
    261                 $parts                = preg_split( '/\s+/', $full, 2 );
     261                $parts = preg_split( '/\s+/', $full, 2 );
     262                if ( false === $parts ) {
     263                        return $subscriber;
     264                }
    262265                $subscriber[ $first ] = $parts[0];
    263266                $subscriber[ $last ]  = $parts[1] ?? '';
    … …  
    407410                        return $message;
    408411                }
    409                 $plain = class_exists( '\\WPForms\\Emails\\Helpers' ) && method_exists( '\\WPForms\\Emails\\Helpers', 'is_plain_text_template' ) && \WPForms\Emails\Helpers::is_plain_text_template( Val::str( $template ) );
     412                $plain = class_exists( '\\WPForms\\Emails\\Helpers' ) && \WPForms\Emails\Helpers::is_plain_text_template( Val::str( $template ) );
    410413
    411414                return $message . self::block( Builder::WPForms, $form_id, ! $plain );
  • clicksyncr-for-kit/trunk/includes/src/Core/Credentials.php

    r3726852 r3760857  
    1313        private const OPTION = '_clicksyncr_kit_settings';
    1414
     15        public const SCHEMA = 'syncr_kit_key_schema';
     16
     17        private const LOCK = 'syncr_kit_key_upgrade';
     18
     19        private const FORM = 'syncr_kit_key_';
     20
     21        public static function boot(): void {
     22                add_action( 'admin_init', array( self::class, 'upgrade' ) );
     23        }
     24
    1525        public static function shared(): string {
    1626                $settings = Val::arr( get_option( self::OPTION ) );
    1727                return Val::str( $settings['apiKey'] ?? '' );
     28        }
     29
     30        public static function per_form(): bool {
     31                return 2 <= Val::int( get_option( self::SCHEMA, 0 ) );
    1832        }
    1933
    … …  
    2539                        }
    2640                }
    27                 return self::shared();
     41                return self::per_form() ? '' : self::shared();
     42        }
     43
     44        public static function stored( string $builder, int $form_id ): string {
     45                return $form_id > 0 ? trim( Val::str( get_option( self::name( $builder, $form_id ), '' ) ) ) : '';
     46        }
     47
     48        public static function store_form( string $builder, int $form_id, string $key ): bool {
     49                if ( $form_id <= 0 || ! current_user_can( 'manage_options' ) ) {
     50                        return false;
     51                }
     52                $name = self::name( $builder, $form_id );
     53                $key  = sanitize_text_field( $key );
     54                if ( '' === $key ) {
     55                        delete_option( $name );
     56                } else {
     57                        update_option( $name, $key, false );
     58                }
     59
     60                return self::stored( $builder, $form_id ) === $key;
     61        }
     62
     63        public static function upgrade(): void {
     64                if ( self::per_form() || ! current_user_can( 'manage_options' ) ) {
     65                        return;
     66                }
     67                if ( ! add_option( self::LOCK, (string) time(), '', false ) ) {
     68                        if ( time() - Val::int( get_option( self::LOCK, 0 ) ) < 300 ) {
     69                                return;
     70                        }
     71                        update_option( self::LOCK, (string) time(), false );
     72                }
     73                if ( class_exists( \ClickSyncr\Kit\Builders\NinjaForms::class ) ) {
     74                        \ClickSyncr\Kit\Builders\NinjaForms::migrate_legacy_setting();
     75                }
     76                if ( class_exists( '\\GFForms' ) && class_exists( \ClickSyncr\Kit\Builders\GravityFeed::class ) ) {
     77                        \ClickSyncr\Kit\Builders\GravityFeed::get_instance()->get_plugin_settings();
     78                }
     79                $shared = self::shared();
     80                if ( '' !== $shared ) {
     81                        foreach ( Builder::adapters() as $adapter ) {
     82                                foreach ( $adapter->configured_forms() as $form_id ) {
     83                                        if ( '' === $adapter->own_key( $form_id ) ) {
     84                                                $adapter->store_key( $form_id, $shared );
     85                                        }
     86                                }
     87                        }
     88                }
     89                update_option( self::SCHEMA, 2, false );
     90                delete_option( self::LOCK );
    2891        }
    2992
    … …  
    41104                );
    42105        }
     106
     107        private static function name( string $builder, int $form_id ): string {
     108                return self::FORM . sanitize_key( $builder ) . '_' . $form_id;
     109        }
    43110}
  • clicksyncr-for-kit/trunk/includes/src/Core/Panel.php

    r3726852 r3760857  
    197197                                /* translators: 1: record noun, 2: provider name */
    198198                                'intent' => sprintf( __( 'Inspect a %1$s at %2$s or run an occasional task without leaving this screen.', 'clicksyncr-for-kit' ), $r, $label ),
    199                                 'rows'   => array( Advanced::test_row() ),
     199                                'rows'   => array( Advanced::test_row( $builder, $form_id ) ),
    200200                        ),
    201201                        array(
  • clicksyncr-for-kit/trunk/includes/src/Core/Provider.php

    r3726852 r3760857  
    3434        public static function label(): string {
    3535                return 'Kit';
     36        }
     37
     38        public static function per_form_line(): string {
     39                /* translators: %s: provider name */
     40                return sprintf( __( 'Each form has its own %s API key. Enter it in the ClickSyncr panel of the form.', 'clicksyncr-for-kit' ), self::label() );
    3641        }
    3742
  • clicksyncr-for-kit/trunk/includes/src/Core/StatusResolver.php

    r3726852 r3760857  
    1515}
    1616
     17final class StatusResolver {
    1718
    18 final class StatusResolver {
     19        private const YES = array( '1', 'true', 'on', 'yes', 'checked' );
     20
     21        private const NO = array( '0', 'false', 'off', 'no', 'unchecked', 'not checked' );
     22
     23        private const BOXES = array( 'acceptance', 'checkbox', 'gdpr-checkbox', 'gdprcheckbox', 'consent', 'listcheckbox', 'input_checkbox', 'terms_and_condition', 'gdpr_agreement' );
    1924
    2025        /**
    … …  
    2429                string $preference,
    2530                string $consent_field,
    26                 array $posted_data
     31                array $posted_data,
     32                string $kind = ''
    2733        ): ?string {
    28                 return self::resolve_value( $preference, $consent_field, $posted_data[ $consent_field ] ?? '' );
     34                return self::resolve_value( $preference, $consent_field, $posted_data[ $consent_field ] ?? '', $kind );
    2935        }
    3036
    … …  
    3541                string $preference,
    3642                string $consent_field,
    37                 $consent_value
     43                $consent_value,
     44                string $kind = ''
    3845        ): ?string {
    3946                if ( '' === $consent_field ) {
    … …  
    4148                }
    4249
    43                 return self::consent_given( $consent_value )
     50                return self::consent_given( $consent_value, $kind )
    4451                        ? SubscriberStatus::Active
    4552                        : SubscriberStatus::Unconfirmed;
    … …  
    4754
    4855        /**
     56         * @param array<array-key,mixed> $fields
     57         */
     58        public static function kind( array $fields, string $consent_field ): string {
     59                return Val::str( Val::arr( $fields[ $consent_field ] ?? null )['kind'] ?? '' );
     60        }
     61
     62        /**
    4963         * @param mixed $raw
    5064         */
    51         private static function consent_given( $raw ): bool {
    52                 $raw = Val::flat( $raw );
     65        public static function consent_given( $raw, string $kind = '' ): bool {
     66                $answers = is_array( $raw ) ? $raw : array( $raw );
     67                foreach ( $answers as $answer ) {
     68                        if ( ! is_scalar( $answer ) ) {
     69                                continue;
     70                        }
     71                        $answer = strtolower( trim( (string) $answer ) );
     72                        if ( '' === $answer || in_array( $answer, self::NO, true ) ) {
     73                                continue;
     74                        }
     75                        if ( in_array( $answer, self::YES, true ) || in_array( $kind, self::BOXES, true ) ) {
     76                                return true;
     77                        }
     78                }
    5379
    54                 return '' !== $raw && false !== filter_var( $raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
     80                return false;
    5581        }
    5682}
  • clicksyncr-for-kit/trunk/includes/src/Core/SubscriberSync.php

    r3726852 r3760857  
    2121        /**
    2222         * @param array<string,string> $subscriber_data
     23         * @param array<array-key,mixed>  $form_data The builder's posted values, when it has them.
    2324         * @return array<array-key,mixed>
    2425         */
    … …  
    6465                $email = $subscriber_data['email'] ?? '';
    6566
    66                 if ( '' === $api_key || '' === $group_id || ! is_email( $email ) ) {
     67                if ( '' === $api_key ) {
     68                        $result = array(
     69                                'success' => false,
     70                                'skipped' => true,
     71                                'message' => 'Nothing was sent. This form has no API key.',
     72                        );
     73                } elseif ( '' === $group_id || ! is_email( $email ) ) {
    6774                        $result = array(
    6875                                'success' => false,
  • clicksyncr-for-kit/trunk/includes/src/Formidable/Handler.php

    r3726852 r3760857  
    1414use ClickSyncr\Kit\Core\Catalog;
    1515use ClickSyncr\Kit\Core\Credentials;
     16use ClickSyncr\Kit\Core\Dispatch;
    1617use ClickSyncr\Kit\Core\StatusPreference;
    1718use ClickSyncr\Kit\Core\StatusResolver;
    … …  
    5859                $consent_field = Val::str( $settings['consent_field'] ?? '' );
    5960
    60                 SubscriberSync::push(
    61                         $api_key,
    62                         Val::str( $settings['group_id'] ?? '' ),
    63                         $subscriber_data,
    64                         StatusResolver::resolve_value(
    65                                 StatusPreference::from_setting( $settings['status_preference'] ?? '' ),
    66                                 $consent_field,
    67                                 self::field_value( $entry, $consent_field )
    68                         ),
    69                         Builder::Formidable,
    70                         Val::int( $entry_vars['form_id'] ?? 0 ),
    71                         self::answers( $entry_vars )
     61                Dispatch::later(
     62                        array( SubscriberSync::class, 'push' ),
     63                        array(
     64                                $api_key,
     65                                Val::str( $settings['group_id'] ?? '' ),
     66                                $subscriber_data,
     67                                StatusResolver::resolve_value(
     68                                        StatusPreference::from_setting( $settings['status_preference'] ?? '' ),
     69                                        $consent_field,
     70                                        self::field_value( $entry, $consent_field ),
     71                                        StatusResolver::kind( ( new \ClickSyncr\Kit\Builders\Formidable() )->fields( Val::int( $entry_vars['form_id'] ?? 0 ) ), $consent_field )
     72                                ),
     73                                Builder::Formidable,
     74                                Val::int( $entry_vars['form_id'] ?? 0 ),
     75                                self::answers( $entry_vars ),
     76                        )
    7277                );
    7378        }
  • clicksyncr-for-kit/trunk/includes/src/Forminator/FormHooks.php

    r3726852 r3760857  
    1212use ClickSyncr\Kit\Core\Builder;
    1313use ClickSyncr\Kit\Core\Catalog;
     14use ClickSyncr\Kit\Core\Dispatch;
    1415use ClickSyncr\Kit\Core\Credentials;
    1516use ClickSyncr\Kit\Core\StatusPreference;
    … …  
    3738         */
    3839        public function add_entry_fields( $submitted_data, $current_entry_fields = array(), $entry = null ) {
    39                 unset( $current_entry_fields, $entry );
     40                unset( $current_entry_fields );
    4041
    4142                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Forminator validates the submission before invoking this hook.
    4243                $submitted_data = Submission::normalize( Val::arr( $submitted_data ), wp_unslash( $_POST ) );
    43                 $api_key        = Credentials::shared();
     44                $api_key        = ( new \ClickSyncr\Kit\Builders\Forminator() )->key( (int) $this->module_id );
    4445                $settings       = Val::arr( $this->settings_instance instanceof Forminator_Integration_Settings ? $this->settings_instance->get_settings_values() : array() );
    4546
    … …  
    5859                $group_id      = Val::str( Forminator::values_from( $settings )['group_id'] );
    5960
    60                 $result = SubscriberSync::push(
    61                         $api_key,
    62                         $group_id,
    63                         $subscriber_data,
    64                         StatusResolver::resolve_value(
    65                                 StatusPreference::from_setting( $settings['status_preference'] ?? '' ),
    66                                 $consent_field,
    67                                 '' === $consent_field ? '' : ( $submitted_data[ $consent_field ] ?? '' )
    68                         ),
    69                         Builder::Forminator,
    70                         Val::int( $this->module_id ),
    71                         $submitted_data
     61                Dispatch::later(
     62                        array( self::class, 'deliver' ),
     63                        array(
     64                                $api_key,
     65                                $group_id,
     66                                $subscriber_data,
     67                                StatusResolver::resolve_value(
     68                                        StatusPreference::from_setting( $settings['status_preference'] ?? '' ),
     69                                        $consent_field,
     70                                        '' === $consent_field ? '' : ( $submitted_data[ $consent_field ] ?? '' ),
     71                                        StatusResolver::kind( ( new \ClickSyncr\Kit\Builders\Forminator() )->fields( (int) $this->module_id ), $consent_field )
     72                                ),
     73                                Val::int( $this->module_id ),
     74                                $submitted_data,
     75                                $entry,
     76                        )
    7277                );
     78
     79                return array();
     80        }
     81
     82        /**
     83         * @param array<string,string>   $subscriber_data
     84         * @param array<array-key,mixed> $submitted_data
     85         * @param mixed                  $entry
     86         */
     87        public static function deliver( string $api_key, string $group_id, array $subscriber_data, ?string $status, int $form_id, array $submitted_data, $entry ): void {
     88                $result = SubscriberSync::push( $api_key, $group_id, $subscriber_data, $status, Builder::Forminator, $form_id, $submitted_data );
     89                if ( ! empty( $result['skipped'] ) || ! is_object( $entry ) || ! method_exists( $entry, 'set_fields' ) ) {
     90                        return;
     91                }
     92                $rows = array();
     93                foreach ( self::entry_fields( $result, Val::str( Catalog::group_options( $api_key )[ $group_id ] ?? '' ) ) as $row ) {
     94                        $rows[] = array(
     95                                'name'  => 'forminator_addon_clicksyncrkit_' . $row['name'],
     96                                'value' => $row['value'],
     97                        );
     98                }
     99                $entry->set_fields( $rows );
     100        }
     101
     102        /**
     103         * @param array<array-key,mixed> $result
     104         * @return list<array{name:string,value:string}>
     105         */
     106        public static function entry_fields( array $result, string $group_name ): array {
     107                if ( ! empty( $result['skipped'] ) ) {
     108                        return array();
     109                }
    73110
    74111                return array(
    … …  
    80117                                'name'  => 'description',
    81118                                'value' => empty( $result['success'] )
    82                                         ? Val::str_or( $result['message'] ?? '', esc_html__( 'Kit sync failed', 'clicksyncr-for-kit' ) )
    83                                         : esc_html__( 'Added to Kit', 'clicksyncr-for-kit' ),
     119                                        ? Val::str_or( Val::str( $result['message'] ?? '' ), esc_html__( 'Kit sync failed', 'clicksyncr-for-kit' ) )
     120                                        : esc_html__( 'Added to Kit by ClickSyncr', 'clicksyncr-for-kit' ),
    84121                        ),
    85122                        array(
    86123                                'name'  => 'group_name',
    87                                 'value' => Val::str( Catalog::group_options( $api_key )[ $group_id ] ?? '' ),
     124                                'value' => $group_name,
    88125                        ),
    89126                );
  • clicksyncr-for-kit/trunk/includes/src/Forminator/Integration.php

    r3726852 r3760857  
    99declare(strict_types=1);
    1010
    11 use ClickSyncr\Kit\API\KitClient;
    1211use ClickSyncr\Kit\Core\Assets;
    13 use ClickSyncr\Kit\Core\Credentials;
     12use ClickSyncr\Kit\Core\Provider;
    1413use ClickSyncr\Kit\Core\Val;
    1514
    … …  
    8079         */
    8180        public function is_authorized() {
    82                 return '' !== Credentials::shared();
     81                return array() !== Val::arr( $this->get_settings_values() );
    8382        }
    8483
    … …  
    109108
    110109                $submitted_data = Val::arr( $submitted_data );
    111                 $error          = '';
     110                if ( isset( $submitted_data['activate'] ) ) {
     111                        $this->save_settings_values( array( 'per_form_keys' => true ) );
    112112
    113                 if ( isset( $submitted_data['api_key'] ) ) {
    114                         $api_key = Val::str( $submitted_data['api_key'] );
     113                        if ( ! forminator_addon_is_active( $this->get_slug() ) ) {
     114                                Forminator_Integration_Loader::get_instance()->activate_addon( $this->get_slug() );
     115                        }
    115116
    116                         if ( '' === $api_key ) {
    117                                 $error = esc_html__( 'Please add a Kit API key.', 'clicksyncr-for-kit' );
    118                         } elseif ( empty( ( new KitClient( $api_key ) )->test()['success'] ) ) {
    119                                 $error = esc_html__( 'Invalid API key. Please check and try again.', 'clicksyncr-for-kit' );
    120                         } else {
    121                                 Credentials::store_shared( $api_key, true, 'forminator' );
    122                                 $this->save_settings_values( array( 'api_key' => $api_key ) );
     117                        $this->adopt_orphaned_form_settings();
    123118
    124                                 if ( ! forminator_addon_is_active( $this->get_slug() ) ) {
    125                                         Forminator_Integration_Loader::get_instance()->activate_addon( $this->get_slug() );
    126                                 }
    127 
    128                                 $this->adopt_orphaned_form_settings();
    129 
    130                                 return array(
    131                                         'html'         => $this->success_authorize(),
    132                                         'redirect'     => false,
    133                                         'has_errors'   => false,
    134                                         'notification' => array(
    135                                                 'type' => 'success',
    136                                                 'text' => '<strong>' . $this->get_title() . '</strong> ' . esc_html__( 'is connected successfully.', 'clicksyncr-for-kit' ),
    137                                         ),
    138                                 );
    139                         }
     119                        return array(
     120                                'html'         => $this->success_authorize(),
     121                                'redirect'     => false,
     122                                'has_errors'   => false,
     123                                'notification' => array(
     124                                        'type' => 'success',
     125                                        'text' => '<strong>' . $this->get_title() . '</strong> ' . esc_html__( 'is ready. Add your API key on each form.', 'clicksyncr-for-kit' ),
     126                                ),
     127                        );
    140128                }
    141 
    142                 $buttons = array(
    143                         'submit' => array(
    144                                 'markup' => self::get_button_markup(
    145                                         $this->is_connected()
    146                                                 ? esc_html__( 'Save', 'clicksyncr-for-kit' )
    147                                                 : esc_html__( 'Connect', 'clicksyncr-for-kit' ),
    148                                         'forminator-addon-connect'
    149                                 ),
    150                         ),
    151                 );
    152129
    153130                $html  = '<div class="forminator-integration-popup__header">';
    154131                $html .= '<h3 class="sui-box-title sui-lg">' . esc_html__( 'Configure ClickSyncr for Kit', 'clicksyncr-for-kit' ) . '</h3>';
    155                 $html .= '<p>' . esc_html__( 'The key is shared with every form builder ClickSyncr supports on this site.', 'clicksyncr-for-kit' ) . '</p>';
     132                $html .= '<p>' . esc_html( Provider::per_form_line() ) . '</p>';
    156133                $html .= '</div>';
    157                 $html .= '<form>';
    158                 $html .= '<div class="sui-form-field' . ( '' !== $error ? ' sui-form-field-error' : '' ) . '">';
    159                 $html .= '<label class="sui-label">' . esc_html__( 'Kit API Key', 'clicksyncr-for-kit' ) . '</label>';
    160                 $html .= '<input name="api_key" value="' . esc_attr( Credentials::shared() ) . '" placeholder="' . esc_attr__( 'Enter your Kit API key', 'clicksyncr-for-kit' ) . '" class="sui-form-control" />';
    161                 $html .= '' !== $error ? '<span class="sui-error-message">' . esc_html( $error ) . '</span>' : '';
    162                 $html .= '</div>';
    163                 $html .= '</form>';
     134                $html .= '<form><input type="hidden" name="activate" value="1" /></form>';
    164135
    165136                return array(
    166137                        'html'       => $html,
    167138                        'redirect'   => false,
    168                         'buttons'    => $buttons,
    169                         'has_errors' => '' !== $error,
     139                        'buttons'    => array(
     140                                'submit' => array(
     141                                        'markup' => self::get_button_markup( esc_html__( 'Activate', 'clicksyncr-for-kit' ), 'forminator-addon-connect' ),
     142                                ),
     143                        ),
     144                        'has_errors' => false,
    170145                        'size'       => 'small',
    171146                );
  • clicksyncr-for-kit/trunk/includes/src/REST/Connection.php

    r3726852 r3760857  
    66
    77use ClickSyncr\Kit\API\KitClient;
    8 use ClickSyncr\Kit\Core\Credentials;
    98use ClickSyncr\Kit\Core\Settings;
    109use ClickSyncr\Kit\Core\Val;
    … …  
    2827                $api_key = sanitize_text_field( $api_key );
    2928                $client  = new KitClient( $api_key );
    30                 $verified = $client->test();
     29                $verified = Val::arr( apply_filters( 'clicksyncr_kit_credential_validation', $client->test(), $api_key, $params ) );
    3130                if ( empty( $verified['success'] ) ) {
    3231                        return new \WP_REST_Response(
    … …  
    5857                if ( $groups_response['success'] ) {
    5958                        $groups = Val::arr( $groups_response['data']['data'] ?? null );
    60 
    61                         if ( '' === Credentials::shared() ) {
    62                                 $builder = Val::str( $request->get_param( 'builder' ) );
    63                                 Credentials::store_shared( $api_key, true, '' !== $builder ? $builder : 'cf7' );
    64                         }
    6559
    6660                        $first_group_id = Val::str( Val::arr( $groups[0] ?? null )['id'] ?? '' );
  • clicksyncr-for-kit/trunk/includes/src/Signals/Sharing.php

    r3726852 r3760857  
    99namespace ClickSyncr\Kit\Signals;
    1010
     11use ClickSyncr\Kit\Core\Access;
     12use ClickSyncr\Kit\Core\Val;
    1113use Signls\Sdk\V1\Consent;
    1214use Signls\Sdk\V1\Runtime;
    … …  
    2628                        )
    2729                );
    28                 add_action( 'admin_menu', array( self::class, 'menu' ) );
    29                 add_action( 'admin_post_syncr_kit_signal_choice', array( self::class, 'save' ) );
     30                add_action( 'init', array( self::class, 'default_on' ) );
    3031                add_filter( 'http_request_args', array( self::class, 'request_metadata' ), 10, 2 );
    3132                add_action(
    … …  
    5859        }
    5960
    60         public static function menu(): void {
    61                 add_options_page( __( 'ClickSyncr signal sharing', 'clicksyncr-for-kit' ), __( 'ClickSyncr for Kit signal sharing', 'clicksyncr-for-kit' ), 'manage_options', 'clicksyncr-for-kit-signals', array( self::class, 'screen' ) );
    62         }
    63 
    6461        public static function status(): string {
     62                if ( ! class_exists( Consent::class, false ) ) {
     63                        return 'unset';
     64                }
    6565                return ( new Consent( new StateStore( 'clicksyncr-for-kit' ) ) )->status();
    6666        }
    6767
    68         public static function screen(): void {
    69                 if ( ! current_user_can( 'manage_options' ) ) {
    70                         return;
    71                 }
    72                 ?>
    73                 <div class="wrap">
    74                         <h1><?php esc_html_e( 'ClickSyncr signal sharing', 'clicksyncr-for-kit' ); ?></h1>
    75                         <p><?php esc_html_e( 'Help improve ClickSyncr by sharing an optional diagnostic report with Signls at signls.dev. It includes software versions, aggregate form and mapping counts, delivery totals, and whether ClickSyncr Pro is active. Random installation and device identifiers link reports. No contact or submission values, API keys, license keys, site URLs, or admin email addresses are shared.', 'clicksyncr-for-kit' ); ?></p>
    76                         <p><?php esc_html_e( 'Sharing is off until you enable it. You can turn it off here at any time. Turning it off cancels scheduled reports and removes the local device credential.', 'clicksyncr-for-kit' ); ?></p>
    77                         <p><a href="https://clicksyncr.com/external-services"><?php esc_html_e( 'External services and privacy details', 'clicksyncr-for-kit' ); ?></a></p>
    78                         <p id="syncr-signal-status"><?php echo esc_html( 'enabled' === self::status() ? __( 'Sharing enabled', 'clicksyncr-for-kit' ) : __( 'Sharing off', 'clicksyncr-for-kit' ) ); ?></p>
    79                         <form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
    80                                 <input type="hidden" name="action" value="syncr_kit_signal_choice">
    81                                 <?php wp_nonce_field( 'syncr_kit_signal_choice' ); ?>
    82                                 <button class="button button-primary" name="choice" value="enable" type="submit"><?php esc_html_e( 'Enable sharing', 'clicksyncr-for-kit' ); ?></button>
    83                                 <button class="button" name="choice" value="disable" type="submit"><?php esc_html_e( 'Keep sharing off', 'clicksyncr-for-kit' ); ?></button>
    84                         </form>
    85                 </div>
    86                 <?php
     68        public static function enabled(): bool {
     69                return 'disabled' !== self::status();
    8770        }
    8871
    89         public static function save(): void {
    90                 if ( ! current_user_can( 'manage_options' ) ) {
    91                         wp_die( esc_html__( 'You cannot change signal sharing.', 'clicksyncr-for-kit' ), '', array( 'response' => 403 ) );
     72        public static function default_on(): void {
     73                if ( class_exists( Runtime::class, false ) && 'unset' === self::status() ) {
     74                        Runtime::enable( 'clicksyncr-for-kit', 'network_install_default', 1 );
    9275                }
    93                 check_admin_referer( 'syncr_kit_signal_choice' );
    94                 $choice = isset( $_POST['choice'] ) && is_string( $_POST['choice'] ) ? sanitize_key( wp_unslash( $_POST['choice'] ) ) : '';
    95                 if ( ! in_array( $choice, array( 'enable', 'disable' ), true ) ) {
    96                         wp_die( esc_html__( 'Choose whether to enable sharing.', 'clicksyncr-for-kit' ), '', array( 'response' => 400 ) );
     76        }
     77
     78        public static function allowed( string $builder ): bool {
     79                return Access::account( $builder );
     80        }
     81
     82        public static function html( string $builder ): string {
     83                if ( ! class_exists( Runtime::class, false ) || ! self::allowed( $builder ) ) {
     84                        return '';
    9785                }
    98                 $saved = 'enable' === $choice ? Runtime::enable( 'clicksyncr-for-kit', 'settings', 1 ) : Runtime::disable( 'clicksyncr-for-kit', 'settings', 1 );
    99                 if ( ! $saved ) {
    100                         wp_die( esc_html__( 'Your choice could not be saved. Please try again.', 'clicksyncr-for-kit' ), '', array( 'response' => 503 ) );
     86                return '<label class="syncr-adv__toggle"><input type="checkbox" id="clicksyncr-for-kit-adv-sharing"'
     87                        . checked( self::enabled(), true, false ) . ' /> '
     88                        . esc_html__( 'Help us improve the plugin.', 'clicksyncr-for-kit' ) . '</label> '
     89                        . '<a href="https://clicksyncr.com/privacy" target="_blank" rel="noopener noreferrer">' . esc_html__( 'Learn about it', 'clicksyncr-for-kit' ) . '</a>'
     90                        . '<span id="clicksyncr-for-kit-adv-sharing-feedback" aria-live="polite"></span>';
     91        }
     92
     93        /** @return \WP_REST_Response|\WP_Error */
     94        public static function save( \WP_REST_Request $request ) {
     95                $builder = Val::str( $request->get_param( 'builder' ) );
     96                $builder = '' === $builder ? 'cf7' : $builder;
     97                $nonce   = Val::str( $request->get_header( 'X-WP-Nonce' ) );
     98                if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) || ! self::allowed( $builder ) ) {
     99                        return new \WP_Error( 'clicksyncr_forbidden', __( 'You do not have permission to perform this action.', 'clicksyncr-for-kit' ), array( 'status' => 403 ) );
    101100                }
    102                 wp_safe_redirect( admin_url( 'options-general.php?page=clicksyncr-for-kit-signals' ) );
    103                 exit;
     101                $body = $request->get_json_params();
     102                if ( ! is_array( $body ) || ! array_key_exists( 'enabled', $body ) || ! is_bool( $body['enabled'] ) ) {
     103                        return new \WP_Error( 'clicksyncr_invalid_choice', __( 'Choose on or off.', 'clicksyncr-for-kit' ), array( 'status' => 400 ) );
     104                }
     105                $saved   = $body['enabled'] ? Runtime::enable( 'clicksyncr-for-kit', 'settings', 1 ) : Runtime::disable( 'clicksyncr-for-kit', 'settings', 1 );
     106                $success = $saved && self::enabled() === $body['enabled'];
     107                return new \WP_REST_Response(
     108                        array(
     109                                'success' => $success,
     110                                'enabled' => self::enabled(),
     111                        ),
     112                        $success ? 200 : 503
     113                );
    104114        }
    105115}
  • clicksyncr-for-kit/trunk/includes/src/WPForms/SubmissionHandler.php

    r3726852 r3760857  
    1313use ClickSyncr\Kit\Core\Builder;
    1414use ClickSyncr\Kit\Core\Credentials;
     15use ClickSyncr\Kit\Core\Dispatch;
    1516use ClickSyncr\Kit\Core\StatusPreference;
    1617use ClickSyncr\Kit\Core\StatusResolver;
    … …  
    2627        private const MAP_PREFIX = 'syncr_kit_map_';
    2728
     29        public const TASK = 'clicksyncr_kit_wpforms_send';
     30
    2831        public function __construct() {
    2932                add_action( 'wpforms_process_complete', array( $this, 'handle_submission' ), 10, 4 );
     33                add_action( self::TASK, array( self::class, 'run_task' ) );
    3034        }
    3135
    … …  
    6165                $consent_field = Val::str( $settings['syncr_kit_consent_field'] ?? '' );
    6266
     67                $form_id = Val::int( $form_data['id'] ?? 0 );
     68                $status  = StatusResolver::resolve_value(
     69                        StatusPreference::from_setting( $settings['syncr_kit_status_preference'] ?? '' ),
     70                        $consent_field,
     71                        Val::arr( $fields[ $consent_field ] ?? null )['value'] ?? '',
     72                        Val::str( Val::arr( Val::arr( $form_data['fields'] ?? null )[ $consent_field ] ?? null )['type'] ?? '' )
     73                );
     74                $answers = self::answers( $fields );
     75                $tasks   = function_exists( 'wpforms' ) ? wpforms()->obj( 'tasks' ) : null;
     76                if ( is_object( $tasks ) && method_exists( $tasks, 'create' ) && null !== $tasks->create( self::TASK )->async()->params( $form_id, $subscriber_data, $status, $answers )->register() ) {
     77                        return;
     78                }
     79                Dispatch::later( array( self::class, 'send' ), array( $form_id, $subscriber_data, $status, $answers ) );
     80        }
     81
     82        /**
     83         * @param mixed $meta_id
     84         */
     85        public static function run_task( $meta_id ): void {
     86                if ( ! class_exists( '\\WPForms\\Tasks\\Meta' ) ) {
     87                        return;
     88                }
     89                $meta = ( new \WPForms\Tasks\Meta() )->get( Val::int( $meta_id ) );
     90                $args = is_object( $meta ) ? Val::arr( $meta->data ?? null ) : array();
     91                if ( 4 !== count( $args ) ) {
     92                        return;
     93                }
     94                $status = $args[2] ?? null;
     95                self::send( Val::int( $args[0] ?? 0 ), Val::str_map( $args[1] ?? null ), is_string( $status ) ? $status : null, Val::arr( $args[3] ?? null ) );
     96        }
     97
     98        /**
     99         * @param array<string,string>   $subscriber_data
     100         * @param array<array-key,mixed> $answers
     101         */
     102        public static function send( int $form_id, array $subscriber_data, ?string $status, array $answers ): void {
     103                $handler   = function_exists( 'wpforms' ) ? wpforms()->obj( 'form' ) : null;
     104                $args      = array(
     105                        'content_only' => true,
     106                        'cap'          => false,
     107                );
     108                $form_data = is_object( $handler ) && method_exists( $handler, 'get' ) ? Val::arr( $handler->get( $form_id, $args ) ) : array();
     109                $settings  = Val::arr( $form_data['settings'] ?? null );
     110
    63111                SubscriberSync::push(
    64112                        Credentials::resolve( Val::str( $settings['syncr_kit_api_key'] ?? '' ) ),
    65113                        Val::str( $settings['syncr_kit_group_id'] ?? '' ),
    66114                        $subscriber_data,
    67                         StatusResolver::resolve_value(
    68                                 StatusPreference::from_setting( $settings['syncr_kit_status_preference'] ?? '' ),
    69                                 $consent_field,
    70                                 Val::arr( $fields[ $consent_field ] ?? null )['value'] ?? ''
    71                         ),
     115                        $status,
    72116                        Builder::WPForms,
    73                         Val::int( $form_data['id'] ?? 0 ),
    74                         self::answers( $fields )
     117                        $form_id,
     118                        $answers
    75119                );
    76120        }
  • clicksyncr-for-kit/trunk/readme.txt

    r3726852 r3760857  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 26.10.03.13
     7Stable tag: 26.10.22.23
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3434* **Your Kit forms load automatically**: choose which [Kit form](https://clicksyncr.com/integrations/kit) new subscribers join, right inside the form editor.
    3535* **Map any field**: send first names and your Kit custom fields straight from your form.
    36 * **Built-in opt-in**: add a [consent checkbox](https://clicksyncr.com/help) and ClickSyncr adds subscribers to your Kit form when they tick it, or saves them as a subscriber only when they don't.
     36* **Built-in opt-in**: add a [consent checkbox](https://clicksyncr.com/help) and ClickSyncr adds the subscriber when a visitor ticks it, and sends nothing at all when they leave it unticked.
    3737* **Subscriber status control**: add to the Kit form or save as a subscriber only, per form.
    3838* **Honest double opt-in**: your Kit form's own setting decides whether Kit sends its confirmation email, and the plugin says so instead of pretending.
    … …  
    6969== External services ==
    7070
    71 Optional signal sharing sends software versions, aggregate configuration and delivery counts, and random installation/device identifiers to Signls only after an administrator enables it in this plugin's signal-sharing settings. It excludes contact/submission values and credentials. You can turn sharing off at any time. See [external services and privacy details](https://clicksyncr.com/external-services).
     71Diagnostic sharing is on by default and can be turned off in the Advanced section of any connected form. It sends software versions, aggregate configuration and delivery counts, and random installation/device identifiers to Signls. It excludes contact/submission values and credentials. See [privacy details](https://clicksyncr.com/privacy).
    7272
    7373This plugin connects to Kit. When you connect, refresh or open a configured form's settings, it authenticates with your API key and requests the available lists, fields and account settings. A configured form submission sends the email address and the fields you map to your selected destination. A test submission sends the test address and configured fields. Subscription and consent settings determine whether a submission is sent.
    … …  
    126126= How does the opt-in consent field work? =
    127127
    128 Pick a consent field (a checkbox, acceptance, or radio) from your form. When a visitor ticks it, they are added to the Kit form you selected; if they leave it unticked, they are saved in Kit as a subscriber only, without joining the form, so no form emails or automations fire. Leave it as "None" to use the subscriber status you chose for the form.
     128Pick a consent field (a checkbox, acceptance, or radio) from your form. When a visitor ticks it, the subscriber is sent to Kit; if they leave it unticked, nothing is sent at all, so nobody is added without agreeing. Leave it as "None" to use the subscriber status you chose for the form.
    129129
    130130= Does it work with Kit double opt-in? =
    … …  
    146146== Screenshots ==
    147147
    148 1. The ClickSyncr for Kit – Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable & Forminator panel in the Contact Form 7 editor: connect your Kit API key, choose a Kit form, map form fields to Kit subscriber fields, and set an opt-in consent field.
     1481. The ClickSyncr for Kit – Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Formidable & Forminator panel in the Contact Form 7 editor: connect your Kit API key, choose a form, map form fields to Kit fields, and set an opt-in consent field.
    149149
    150150== Changelog ==
    151151
     152= 26.10.22.23 =
     153
     154[Version 26.10.22.23 release notes](https://clicksyncr.com/integrations/kit#26.10.22.23)
     155
    152156For the full changelog with details, see the [release history](https://clicksyncr.com/integrations/kit#changelog).
     157
     158= 26.10.18.15 =
     159
     160[Version 26.10.18.15 release notes](https://clicksyncr.com/integrations/kit#26.10.18.15)
    153161
    154162= 26.10.03.13 =
Note: See TracChangeset for help on using the changeset viewer.