Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/TEMPLATES/repricer_marketplaces.html
Diff
/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/TEMPLATES/repricer_marketplaces.html
added on local at 2026-07-01 21:47:25
Added
+648
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 13351d19ec44
to 13351d19ec44
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | <!-- ===================================================================== | |
| 2 | Marketplaces dashboard. Tile per supported platform with status, | |
| 3 | working Connect / Manage / Disconnect controls. | |
| 4 | ||
| 5 | Connect flow: | |
| 6 | 1. Click Connect on a tile -> the per-platform modal opens (one | |
| 7 | modal per tile, pre-rendered, hidden until clicked). | |
| 8 | 2. Modal contains the credential form fields defined in | |
| 9 | MODS::RePricer::Marketplaces (cred_fields per platform). | |
| 10 | 3. Submit POSTs back to /marketplaces.cgi action=connect; the | |
| 11 | CGI persists to marketplace_accounts, then redirects back | |
| 12 | here with ?ok=connected&slug=X for the success banner. | |
| 13 | ||
| 14 | Per-platform real API push code is its own per-platform project -- | |
| 15 | this template only owns the credential-storage UX. | |
| 16 | ===================================================================== --> | |
| 17 | ||
| 18 | <style> | |
| 19 | .mp-card { display:flex; flex-direction:column; gap:14px; padding:20px; height:100%; } | |
| 20 | .mp-card .mp-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:auto; padding-top:6px; border-top:1px solid var(--col-border); } | |
| 21 | .mp-card .mp-actions .btn { flex:1; } | |
| 22 | .mp-card .mp-actions .btn.mp-btn-ghost { flex:0 0 auto; } | |
| 23 | .mp-handle { font-family: var(--font-mono, monospace); font-size: 11px; color: var(--col-text-3); letter-spacing: 0.5px; } | |
| 24 | .mp-err-inline { font-size: 11px; color: #f87171; margin-top: 4px; line-height: 1.4; } | |
| 25 | ||
| 26 | /* ===== Connect modal ================================================= | |
| 27 | Each platform gets its own pre-rendered modal so the cred fields | |
| 28 | are correctly templated server-side -- no JS field-list to keep in | |
| 29 | sync with the Perl spec. Opens via [data-mp-open], closes via | |
| 30 | [data-mp-close] or backdrop click or ESC. ====================== */ | |
| 31 | .mp-modal { | |
| 32 | position: fixed; inset: 0; z-index: 1000; | |
| 33 | display: none; | |
| 34 | background: rgba(3, 8, 31, 0.78); | |
| 35 | backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); | |
| 36 | align-items: flex-start; justify-content: center; | |
| 37 | padding: 48px 16px; overflow-y: auto; | |
| 38 | } | |
| 39 | .mp-modal.is-open { display: flex; } | |
| 40 | .mp-modal-card { | |
| 41 | max-width: 560px; width: 100%; | |
| 42 | background: var(--col-surface-1, #0c1729); | |
| 43 | border: 1px solid rgba(124, 58, 237, 0.35); | |
| 44 | border-radius: 14px; | |
| 45 | padding: 24px 26px 22px; | |
| 46 | box-shadow: 0 30px 60px rgba(0, 0, 0, 0.6); | |
| 47 | position: relative; | |
| 48 | } | |
| 49 | .mp-modal-close { | |
| 50 | position: absolute; top: 14px; right: 14px; | |
| 51 | background: rgba(124, 58, 237, 0.15); | |
| 52 | border: 1px solid rgba(124, 58, 237, 0.5); | |
| 53 | color: #fff; border-radius: 8px; | |
| 54 | width: 32px; height: 32px; cursor: pointer; | |
| 55 | font-size: 18px; line-height: 1; | |
| 56 | display: grid; place-items: center; | |
| 57 | } | |
| 58 | .mp-modal-close:hover { background: rgba(124, 58, 237, 0.30); } | |
| 59 | .mp-modal-head { | |
| 60 | display: flex; align-items: center; gap: 14px; margin-bottom: 14px; | |
| 61 | } | |
| 62 | .mp-modal-mark { | |
| 63 | width: 44px; height: 44px; flex-shrink: 0; | |
| 64 | border-radius: 10px; | |
| 65 | background: linear-gradient(130deg, var(--col-accent), var(--col-accent-bright)); | |
| 66 | display: grid; place-items: center; | |
| 67 | color: #fff; font-weight: 800; font-size: 18px; | |
| 68 | box-shadow: 0 8px 24px rgba(124, 58, 237, 0.35); | |
| 69 | } | |
| 70 | .mp-modal-title { font-family: var(--font-display, system-ui); font-size: 20px; font-weight: 700; color: #fff; line-height: 1.15; } | |
| 71 | .mp-modal-sub { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); font-weight: 700; margin-top: 2px; } | |
| 72 | ||
| 73 | .mp-field { margin-bottom: 14px; } | |
| 74 | .mp-field label { display: block; font-size: 12px; font-weight: 600; color: var(--col-text-2); margin-bottom: 5px; letter-spacing: 0.02em; } | |
| 75 | .mp-field input { | |
| 76 | width: 100%; box-sizing: border-box; | |
| 77 | background: rgba(15, 23, 42, 0.55); | |
| 78 | border: 1px solid var(--col-border); | |
| 79 | border-radius: 8px; | |
| 80 | color: #fff; | |
| 81 | padding: 10px 12px; | |
| 82 | font-size: 13px; | |
| 83 | font-family: var(--font-mono, monospace); | |
| 84 | } | |
| 85 | .mp-field input:focus { outline: none; border-color: var(--col-accent, #0f766e); box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18); } | |
| 86 | .mp-field .mp-hint { font-size: 11px; color: var(--col-text-3); margin-top: 4px; line-height: 1.5; } | |
| 87 | .mp-field .mp-hint a { color: #14b8a6; text-decoration: underline; } | |
| 88 | ||
| 89 | .mp-modal-actions { display: flex; gap: 10px; margin-top: 18px; } | |
| 90 | .mp-modal-actions .btn { flex: 1; } | |
| 91 | .mp-modal-actions .btn-cancel { flex: 0 0 auto; } | |
| 92 | ||
| 93 | .mp-modal-help { | |
| 94 | font-size: 11.5px; | |
| 95 | color: var(--col-text-3); | |
| 96 | line-height: 1.55; | |
| 97 | background: rgba(15, 23, 42, 0.55); | |
| 98 | border: 1px solid var(--col-border); | |
| 99 | border-radius: 8px; | |
| 100 | padding: 10px 12px; | |
| 101 | margin-bottom: 16px; | |
| 102 | } | |
| 103 | .mp-modal-help strong { color: #6ee7b7; } | |
| 104 | .mp-modal-help code { | |
| 105 | background: rgba(124, 58, 237, 0.10); | |
| 106 | border: 1px solid rgba(124, 58, 237, 0.30); | |
| 107 | border-radius: 4px; | |
| 108 | padding: 1px 5px; | |
| 109 | color: #6ee7b7; | |
| 110 | font-family: var(--font-mono, monospace); | |
| 111 | font-size: 11px; | |
| 112 | } | |
| 113 | ||
| 114 | /* Disconnect confirm modal -- same chrome, danger accent. */ | |
| 115 | .mp-modal.is-danger .mp-modal-card { border-color: rgba(239, 68, 68, 0.40); } | |
| 116 | .mp-modal.is-danger .mp-modal-mark { background: linear-gradient(130deg, #b91c1c, #ef4444); box-shadow: 0 8px 24px rgba(239, 68, 68, 0.30); } | |
| 117 | .mp-modal.is-danger .mp-modal-close { background: rgba(239, 68, 68, 0.15); border-color: rgba(239, 68, 68, 0.40); } | |
| 118 | </style> | |
| 119 | ||
| 120 | ||
| 121 | <div class="page-head"> | |
| 122 | <div> | |
| 123 | <span class="page-eyebrow"><span class="dot"></span> Marketplaces</span> | |
| 124 | <h1 class="page-title">Push to other marketplaces</h1> | |
| 125 | <p class="page-subtitle">$tile_count platforms supported. Connect with credentials from each platform — we store them encrypted at rest and use them to push your listings out.</p> | |
| 126 | </div> | |
| 127 | </div> | |
| 128 | ||
| 129 | <!-- Outcome banner from the POST-redirect-GET. Connect / disconnect / | |
| 130 | errors all surface here. --> | |
| 131 | [if:$show_ok] | |
| 132 | [if:$ok_connected] | |
| 133 | <div class="banner" style="margin-bottom:18px;background:rgba(34,197,94,0.10);border:1px solid rgba(34,197,94,0.40);color:#4ade80;border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px"> | |
| 134 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg> | |
| 135 | <div class="text-sm"><strong>$flag_name connected.</strong> You're ready to push listings to this platform.</div> | |
| 136 | </div> | |
| 137 | [/if] | |
| 138 | [if:$ok_disconnected] | |
| 139 | <div class="banner" style="margin-bottom:18px;background:rgba(5,150,105,0.10);border:1px solid rgba(5,150,105,0.40);color:#10b981;border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px"> | |
| 140 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg> | |
| 141 | <div class="text-sm"><strong>$flag_name disconnected.</strong> Tokens cleared from your account.</div> | |
| 142 | </div> | |
| 143 | [/if] | |
| 144 | [if:$ok_imported] | |
| 145 | <div class="banner" id="import-banner" style="margin-bottom:18px;background:rgba(34,197,94,0.10);border:1px solid rgba(34,197,94,0.40);color:#4ade80;border-radius:10px;padding:12px 14px;display:flex;align-items:flex-start;gap:10px"> | |
| 146 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" style="flex-shrink:0;margin-top:2px"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg> | |
| 147 | <div class="text-sm" style="flex:1;line-height:1.5"> | |
| 148 | <strong>Imported $import_done[if:$import_has_total] of $import_total[/if] model(s) from $flag_name.</strong>[if:$import_has_dups] <span style="color:#14b8a6">$import_dups already imported previously — skipped to avoid duplicates.</span>[/if][if:$import_has_more] <span id="auto-continue-msg">Pulling more in a moment…</span>[/if][if:!$import_has_more] They're saved as drafts in <a href="/models.cgi" style="color:#4ade80;text-decoration:underline">My Models</a> — review and publish when ready.[/if] | |
| 149 | [if:$import_has_more]<div class="text-xs" style="color:rgba(74,222,128,0.75);margin-top:4px;font-weight:500">⚠ Please keep this tab open and don't refresh until you see <strong>$import_total of $import_total</strong>. We'll auto-pull one model at a time until done.</div>[/if] | |
| 150 | </div> | |
| 151 | [if:$import_has_more] | |
| 152 | <button type="button" id="auto-continue-stop" style="background:rgba(239,68,68,0.15);border:1px solid rgba(239,68,68,0.40);color:#f87171;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;flex-shrink:0">Stop</button> | |
| 153 | [/if] | |
| 154 | </div> | |
| 155 | [/if] | |
| 156 | [/if] | |
| 157 | [if:$show_err] | |
| 158 | <div class="banner" style="margin-bottom:18px;background:rgba(239,68,68,0.10);border:1px solid rgba(239,68,68,0.40);color:#f87171;border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px"> | |
| 159 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| 160 | <div class="text-sm"><strong>Couldn't connect $flag_name.</strong> — $flag_err. Double-check the fields and try again.</div> | |
| 161 | </div> | |
| 162 | [/if] | |
| 163 | [if:$show_imperr] | |
| 164 | <div class="banner" style="margin-bottom:18px;background:rgba(239,68,68,0.10);border:1px solid rgba(239,68,68,0.40);color:#f87171;border-radius:10px;padding:12px 14px;display:flex;align-items:flex-start;gap:10px"> | |
| 165 | <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" style="flex-shrink:0;margin-top:2px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| 166 | <div class="text-sm" style="line-height:1.5"><strong>Couldn't pull models from $flag_name.</strong> — $flag_imperr<br><span class="text-xs text-dim">Click <strong>Manage</strong> on the $flag_name tile below to update the stored value, then try the pull again.</span></div> | |
| 167 | </div> | |
| 168 | [/if] | |
| 169 | ||
| 170 | <div class="banner info" style="margin-bottom:24px"> | |
| 171 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| 172 | <div class="text-xs" style="line-height:1.6"> | |
| 173 | <strong>How this works:</strong> repricer.com is your home base — buyers there pay you direct, no marketplace cut. | |
| 174 | Use this page to also list each design on the marketplaces where your audience already shops. | |
| 175 | <strong>Connect a platform</strong> by pasting your account handle + API token / access key from that platform. We store them so future "Push to marketplace" actions can ship a listing out without re-typing. | |
| 176 | </div> | |
| 177 | </div> | |
| 178 | ||
| 179 | <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:18px"> | |
| 180 | [loop:@tiles] | |
| 181 | <div class="card mp-card"> | |
| 182 | ||
| 183 | <div style="display:flex;align-items:center;gap:14px"> | |
| 184 | <div class="brand-mark" style="background:linear-gradient(130deg, var(--col-accent), var(--col-accent-bright));width:44px;height:44px;font-size:18px;flex-shrink:0">$loop1.icon</div> | |
| 185 | <div style="flex:1;min-width:0"> | |
| 186 | <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> | |
| 187 | <strong style="font-size:17px">$loop1.name</strong> | |
| 188 | $loop1.status_pill | |
| 189 | </div> | |
| 190 | <div class="text-xs text-dim" style="margin-top:2px;letter-spacing:1.5px;text-transform:uppercase">$loop1.auth · $loop1.fees</div> | |
| 191 | </div> | |
| 192 | </div> | |
| 193 | ||
| 194 | <p class="text-sm text-secondary" style="margin:0;line-height:1.5">$loop1.tagline</p> | |
| 195 | ||
| 196 | <div class="text-xs text-dim" style="line-height:1.5"><strong>Sells:</strong> $loop1.sells</div> | |
| 197 | ||
| 198 | [if:$loop1.is_connected] | |
| 199 | <div class="mp-handle">Connected as <strong style="color:#4ade80">$loop1.account_handle</strong></div> | |
| 200 | [if:$loop1.is_import_auto] | |
| 201 | <form method="post" action="/marketplaces.cgi" style="margin:0" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Importing… (this can take a minute)';"> | |
| 202 | <input type="hidden" name="action" value="import_models"> | |
| 203 | <input type="hidden" name="slug" value="$loop1.slug"> | |
| 204 | <button type="submit" class="btn btn-secondary btn-sm" style="width:100%;background:rgba(15,118,110,0.15);border:1px solid rgba(15,118,110,0.45);color:#6ee7b7">↓ Pull in your models</button> | |
| 205 | </form> | |
| 206 | [/if] | |
| 207 | [if:$loop1.is_import_manual] | |
| 208 | <button type="button" class="btn btn-secondary btn-sm" data-mp-open="$loop1.modal_id-paste" style="width:100%;background:rgba(15,118,110,0.15);border:1px solid rgba(15,118,110,0.45);color:#6ee7b7">↓ Paste your model URLs</button> | |
| 209 | [/if] | |
| 210 | [/if] | |
| 211 | [if:$loop1.is_error] | |
| 212 | <div class="mp-err-inline">$loop1.last_error</div> | |
| 213 | [/if] | |
| 214 | ||
| 215 | <div class="mp-actions"> | |
| 216 | [if:$loop1.is_connected] | |
| 217 | <button type="button" class="btn btn-secondary btn-sm" data-mp-open="$loop1.modal_id">Manage</button> | |
| 218 | <button type="button" class="btn btn-secondary btn-sm" data-mp-open="$loop1.modal_id-dc" style="background:rgba(239,68,68,0.10);border-color:rgba(239,68,68,0.35);color:#f87171">Disconnect</button> | |
| 219 | [/if] | |
| 220 | [if:!$loop1.is_connected] | |
| 221 | <button type="button" class="btn btn-primary btn-sm" data-mp-open="$loop1.modal_id">Connect</button> | |
| 222 | [/if] | |
| 223 | <a href="$loop1.signup_url" target="_blank" rel="noopener" class="btn btn-ghost btn-sm mp-btn-ghost" title="Open the signup page on $loop1.name">Sign up</a> | |
| 224 | </div> | |
| 225 | ||
| 226 | </div> | |
| 227 | [/loop] | |
| 228 | </div> | |
| 229 | ||
| 230 | ||
| 231 | <!-- ===== Per-platform Connect / Manage modals ======================= | |
| 232 | Each platform gets one form modal with its cred_fields rendered | |
| 233 | server-side. Hidden by default; opened via the buttons above. --> | |
| 234 | [loop:@tiles] | |
| 235 | <div class="mp-modal" id="$loop1.modal_id" role="dialog" aria-modal="true" aria-labelledby="$loop1.modal_id-title"> | |
| 236 | <div class="mp-modal-card"> | |
| 237 | <button type="button" class="mp-modal-close" data-mp-close aria-label="Close">×</button> | |
| 238 | <div class="mp-modal-head"> | |
| 239 | <div class="mp-modal-mark">$loop1.icon</div> | |
| 240 | <div> | |
| 241 | <div class="mp-modal-sub">[if:$loop1.is_connected]Manage[/if][if:!$loop1.is_connected]Connect[/if] · $loop1.auth</div> | |
| 242 | <div class="mp-modal-title" id="$loop1.modal_id-title">$loop1.name</div> | |
| 243 | </div> | |
| 244 | </div> | |
| 245 | ||
| 246 | <div class="mp-modal-help"> | |
| 247 | <strong>Where to find these:</strong> $loop1.intro_text | |
| 248 | </div> | |
| 249 | ||
| 250 | [if:$loop1.has_cred_fields] | |
| 251 | <form method="post" action="/marketplaces.cgi" autocomplete="off"> | |
| 252 | <input type="hidden" name="action" value="connect"> | |
| 253 | <input type="hidden" name="slug" value="$loop1.slug"> | |
| 254 | ||
| 255 | [loop:@cred_fields] | |
| 256 | <div class="mp-field"> | |
| 257 | <label for="$loop2.name-$loop1.slug">$loop2.label[if:$loop2.required] <span style="color:#f87171">*</span>[/if][if:$loop2.is_saved] <span style="background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.40);border-radius:4px;padding:1px 6px;font-size:9px;font-weight:700;letter-spacing:1px;margin-left:6px">✓ STORED</span>[/if]</label> | |
| 258 | [if:$loop2.secret] | |
| 259 | <input id="$loop2.name-$loop1.slug" name="$loop2.name" type="password" [if:$loop2.required]required[/if] autocomplete="off" spellcheck="false" placeholder="[if:$loop2.is_saved]•••••••••••• Already stored -- leave blank to keep[/if]" style="[if:$loop2.is_saved]border-color:rgba(34,197,94,0.35);background:rgba(34,197,94,0.04)[/if]"> | |
| 260 | [/if] | |
| 261 | [if:!$loop2.secret] | |
| 262 | <input id="$loop2.name-$loop1.slug" name="$loop2.name" type="$loop2.type" value="$loop2.value" [if:$loop2.required]required[/if] autocomplete="off" spellcheck="false" style="[if:$loop2.is_saved]border-color:rgba(34,197,94,0.35);background:rgba(34,197,94,0.04)[/if]"> | |
| 263 | [/if] | |
| 264 | [if:$loop2.is_saved]<div class="mp-hint" style="color:#4ade80;margin-top:6px"><strong>✓ Already stored in our system.</strong> No need to re-enter unless you want to update or replace it. Push and pull will use this saved value automatically.</div>[/if] | |
| 265 | [if:$loop2.hint]<div class="mp-hint">$loop2.hint</div>[/if] | |
| 266 | </div> | |
| 267 | [/loop] | |
| 268 | ||
| 269 | <div class="mp-modal-actions"> | |
| 270 | <button type="submit" class="btn btn-primary">[if:$loop1.is_connected]Save changes[/if][if:!$loop1.is_connected]Connect $loop1.name[/if]</button> | |
| 271 | <button type="button" class="btn btn-secondary btn-cancel" data-mp-close>Cancel</button> | |
| 272 | </div> | |
| 273 | </form> | |
| 274 | [/if] | |
| 275 | [if:!$loop1.has_cred_fields] | |
| 276 | <div class="text-sm text-dim" style="padding:14px 0">No credential schema defined for this platform yet. Contact support to add it.</div> | |
| 277 | <div class="mp-modal-actions"><button type="button" class="btn btn-secondary" data-mp-close>Close</button></div> | |
| 278 | [/if] | |
| 279 | </div> | |
| 280 | </div> | |
| 281 | ||
| 282 | [if:$loop1.is_connected] | |
| 283 | [if:$loop1.is_import_manual] | |
| 284 | <!-- Paste-import modal: shown for platforms that sit behind Cloudflare | |
| 285 | bot protection (no server-side scrape possible). User pastes a | |
| 286 | list of their model URLs, one per line, and we create drafts. --> | |
| 287 | <div class="mp-modal" id="$loop1.modal_id-paste" role="dialog" aria-modal="true" aria-labelledby="$loop1.modal_id-paste-title"> | |
| 288 | <div class="mp-modal-card"> | |
| 289 | <button type="button" class="mp-modal-close" data-mp-close aria-label="Close">×</button> | |
| 290 | <div class="mp-modal-head"> | |
| 291 | <div class="mp-modal-mark">$loop1.icon</div> | |
| 292 | <div> | |
| 293 | <div class="mp-modal-sub">Paste import · $loop1.name</div> | |
| 294 | <div class="mp-modal-title" id="$loop1.modal_id-paste-title">Pull your $loop1.name models</div> | |
| 295 | </div> | |
| 296 | </div> | |
| 297 | <div class="mp-modal-help"> | |
| 298 | <strong>Why this is manual:</strong> $loop1.name is behind Cloudflare bot protection so RePricer can't fetch your model list automatically. Open your $loop1.name profile in a new tab, copy the URL of each model you want to import, and paste them below — one per line. We'll create a draft for each. You can fill in titles, prices, and files later when you edit each draft. | |
| 299 | </div> | |
| 300 | <form method="post" action="/marketplaces.cgi" onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Importing…';"> | |
| 301 | <input type="hidden" name="action" value="import_models_manual"> | |
| 302 | <input type="hidden" name="slug" value="$loop1.slug"> | |
| 303 | <div class="mp-field"> | |
| 304 | <label for="$loop1.modal_id-paste-urls">Model URLs (one per line)</label> | |
| 305 | <textarea id="$loop1.modal_id-paste-urls" name="urls" rows="10" style="width:100%;box-sizing:border-box;background:rgba(15,23,42,0.55);border:1px solid var(--col-border);border-radius:8px;color:#fff;padding:10px 12px;font-size:12px;font-family:var(--font-mono,monospace);resize:vertical" placeholder="https://thangs.com/3d-model/my-first-design https://thangs.com/3d-model/another-design https://thangs.com/m/12345"></textarea> | |
| 306 | <div class="mp-hint">Lines starting with <code>#</code> are skipped (use them for notes). Blank lines OK. We auto-derive a draft title from the URL slug; you'll edit the rest in <a href="/models.cgi" style="color:#14b8a6">My Models</a>.</div> | |
| 307 | </div> | |
| 308 | <div class="mp-modal-actions"> | |
| 309 | <button type="submit" class="btn btn-primary">Import drafts</button> | |
| 310 | <button type="button" class="btn btn-secondary btn-cancel" data-mp-close>Cancel</button> | |
| 311 | </div> | |
| 312 | </form> | |
| 313 | </div> | |
| 314 | </div> | |
| 315 | [/if] | |
| 316 | <!-- Disconnect-confirm modal: separate from the manage modal so the | |
| 317 | "Save changes" form can't be confused with the destructive action. --> | |
| 318 | <div class="mp-modal is-danger" id="$loop1.modal_id-dc" role="dialog" aria-modal="true" aria-labelledby="$loop1.modal_id-dc-title"> | |
| 319 | <div class="mp-modal-card"> | |
| 320 | <button type="button" class="mp-modal-close" data-mp-close aria-label="Close">×</button> | |
| 321 | <div class="mp-modal-head"> | |
| 322 | <div class="mp-modal-mark">!</div> | |
| 323 | <div> | |
| 324 | <div class="mp-modal-sub" style="color:#fca5a5">Disconnect</div> | |
| 325 | <div class="mp-modal-title" id="$loop1.modal_id-dc-title">Remove $loop1.name credentials?</div> | |
| 326 | </div> | |
| 327 | </div> | |
| 328 | <div class="text-sm text-secondary" style="line-height:1.6;margin-bottom:18px"> | |
| 329 | We'll clear your stored access token + handle for <strong>$loop1.name</strong>. Future pushes to this platform will pause until you reconnect. Your listings on $loop1.name itself stay where they are — this only severs the link from RePricer. | |
| 330 | </div> | |
| 331 | <form method="post" action="/marketplaces.cgi"> | |
| 332 | <input type="hidden" name="action" value="disconnect"> | |
| 333 | <input type="hidden" name="slug" value="$loop1.slug"> | |
| 334 | <div class="mp-modal-actions"> | |
| 335 | <button type="submit" class="btn btn-primary" style="background:linear-gradient(135deg,#b91c1c,#ef4444);border:none">Yes, disconnect</button> | |
| 336 | <button type="button" class="btn btn-secondary btn-cancel" data-mp-close>Cancel</button> | |
| 337 | </div> | |
| 338 | </form> | |
| 339 | </div> | |
| 340 | </div> | |
| 341 | [/if] | |
| 342 | [/loop] | |
| 343 | ||
| 344 | ||
| 345 | <!-- ===== Manual export reminder ====================================== --> | |
| 346 | <div class="module" style="margin-top:32px"> | |
| 347 | <div class="module-head"> | |
| 348 | <div class="left"> | |
| 349 | <div class="module-icon"> | |
| 350 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| 351 | </div> | |
| 352 | <div> | |
| 353 | <div class="module-title">Manual export (always available)</div> | |
| 354 | <div class="module-sub">Even when a platform isn't connected, open any of your models and click "Export" to get a ready-to-paste listing for any platform.</div> | |
| 355 | </div> | |
| 356 | </div> | |
| 357 | <a href="/models.cgi" class="btn btn-primary btn-sm">Open Models →</a> | |
| 358 | </div> | |
| 359 | </div> | |
| 360 | ||
| 361 | ||
| 362 | <!-- ===== Auto-continue pull-import loop ================================ | |
| 363 | When the server returns more=1 in the success redirect, the | |
| 364 | import had more models to fetch but stopped at the per-call | |
| 365 | batch cap to stay under nginx's CGI timeout. This script auto- | |
| 366 | resubmits the Pull form for the same platform after a short | |
| 367 | delay, so the user clicks ONCE and we batch through the full | |
| 368 | library in the background. Each iteration is a fresh HTTP | |
| 369 | request, so we never hit the server-side timeout no matter how | |
| 370 | big the library is. | |
| 371 | ||
| 372 | The Stop button cancels the next-scheduled iteration. Already- | |
| 373 | imported models stay imported (dedup is row-by-row, committed | |
| 374 | as each completes). ================================================= --> | |
| 375 | <script> | |
| 376 | // ALWAYS-ON: clear the per-platform sessionStorage "stopped" flag | |
| 377 | // whenever the user manually submits a Pull form on ANY marketplaces | |
| 378 | // page (not just an in-progress one). Previously this listener was | |
| 379 | // nested inside the auto-loop IIFE below and only attached on | |
| 380 | // response pages with ?more=1 -- so clicking the Pull button from a | |
| 381 | // plain Marketplaces page left the flag set, the import did one | |
| 382 | // batch, redirected to a more=1 page, and the new auto-loop | |
| 383 | // immediately halted because the stale flag was still there. | |
| 384 | (function () { | |
| 385 | document.addEventListener('submit', function (ev) { | |
| 386 | var f = ev.target; | |
| 387 | if (!f || f.tagName !== 'FORM') return; | |
| 388 | if (f.getAttribute('action') !== '/marketplaces.cgi') return; | |
| 389 | var actionIn = f.querySelector('input[name="action"]'); | |
| 390 | var slugIn = f.querySelector('input[name="slug"]'); | |
| 391 | if (!actionIn || !slugIn) return; | |
| 392 | if (actionIn.value !== 'import_models' && actionIn.value !== 'import_models_manual') return; | |
| 393 | try { sessionStorage.removeItem('mp_auto_stopped_' + slugIn.value); } catch (e) {} | |
| 394 | }, true); | |
| 395 | })(); | |
| 396 | ||
| 397 | // Helper: morph the Stop button into a Continue button. Used both | |
| 398 | // when the user clicks Stop (after the red "Stopping..." settle) and | |
| 399 | // when the page loads with the stopped flag still set in | |
| 400 | // sessionStorage from a previous tab session. | |
| 401 | function _mpMorphStopToContinue(btn, target, stopKey) { | |
| 402 | if (!btn || !target) return null; | |
| 403 | var clone = btn.cloneNode(true); | |
| 404 | btn.parentNode.replaceChild(clone, btn); | |
| 405 | clone.textContent = 'Continue'; | |
| 406 | // Match the topbar Help button styling -- blue glassy chip so it | |
| 407 | // reads as a friendly action (not a destructive button). Same | |
| 408 | // gradient / border / text colors as .help-btn in repricer_wrapper. | |
| 409 | clone.style.background = 'linear-gradient(135deg, rgba(5,150,105,0.22) 0%, rgba(5,150,105,0.18) 100%)'; | |
| 410 | clone.style.border = '1px solid rgba(5,150,105,0.55)'; | |
| 411 | clone.style.color = '#6ee7b7'; | |
| 412 | clone.style.borderRadius = '10px'; | |
| 413 | clone.style.padding = '0 14px'; | |
| 414 | clone.style.height = '36px'; | |
| 415 | clone.style.fontWeight = '600'; | |
| 416 | clone.style.display = 'inline-flex'; | |
| 417 | clone.style.alignItems = 'center'; | |
| 418 | clone.style.gap = '7px'; | |
| 419 | clone.style.cursor = 'pointer'; | |
| 420 | clone.style.transition = 'background 0.15s, box-shadow 0.15s, color 0.15s'; | |
| 421 | clone.addEventListener('mouseenter', function () { | |
| 422 | clone.style.background = 'linear-gradient(135deg, rgba(5,150,105,0.32) 0%, rgba(5,150,105,0.26) 100%)'; | |
| 423 | clone.style.color = '#a7f3d0'; | |
| 424 | clone.style.boxShadow = '0 0 18px rgba(5,150,105,0.35)'; | |
| 425 | }); | |
| 426 | clone.addEventListener('mouseleave', function () { | |
| 427 | clone.style.background = 'linear-gradient(135deg, rgba(5,150,105,0.22) 0%, rgba(5,150,105,0.18) 100%)'; | |
| 428 | clone.style.color = '#6ee7b7'; | |
| 429 | clone.style.boxShadow = 'none'; | |
| 430 | }); | |
| 431 | clone.addEventListener('click', function () { | |
| 432 | try { sessionStorage.removeItem(stopKey); } catch (e) {} | |
| 433 | // Thread the running counts from the URL so the spinner page | |
| 434 | // shows "Imported X of Y" instead of the cold-start text. | |
| 435 | try { | |
| 436 | var p = new URLSearchParams(window.location.search); | |
| 437 | _mpThreadRunningCounts(target, p.get('done'), p.get('total')); | |
| 438 | } catch (e) {} | |
| 439 | target.submit(); | |
| 440 | }); | |
| 441 | return clone; | |
| 442 | } | |
| 443 | ||
| 444 | // Inject (or update) hidden running_done / running_total inputs on the | |
| 445 | // auto-import form so the spinner page (streaming response from the | |
| 446 | // next POST) can render its green banner with the correct count | |
| 447 | // instead of "Starting import" every iteration. | |
| 448 | function _mpThreadRunningCounts(form, done, total) { | |
| 449 | if (!form) return; | |
| 450 | function setHidden(name, value) { | |
| 451 | var el = form.querySelector('input[type="hidden"][name="' + name + '"]'); | |
| 452 | if (!el) { | |
| 453 | el = document.createElement('input'); | |
| 454 | el.type = 'hidden'; | |
| 455 | el.name = name; | |
| 456 | form.appendChild(el); | |
| 457 | } | |
| 458 | el.value = value == null ? '' : String(value); | |
| 459 | } | |
| 460 | setHidden('running_done', done == null ? '' : done); | |
| 461 | setHidden('running_total', total == null ? '' : total); | |
| 462 | } | |
| 463 | ||
| 464 | (function () { | |
| 465 | var params = new URLSearchParams(window.location.search); | |
| 466 | if (params.get('ok') !== 'imported') return; | |
| 467 | if (params.get('more') !== '1') return; | |
| 468 | var slug = params.get('slug') || ''; | |
| 469 | if (!slug) return; | |
| 470 | ||
| 471 | var STOP_KEY = 'mp_auto_stopped_' + slug; | |
| 472 | ||
| 473 | // Find the auto-import form for THIS platform (action=import_models | |
| 474 | // + matching slug). There can be multiple connected platforms on | |
| 475 | // the page, so we filter by slug to hit the right one. | |
| 476 | var target = null; | |
| 477 | var forms = document.querySelectorAll('form[action="/marketplaces.cgi"]'); | |
| 478 | for (var i = 0; i < forms.length; i++) { | |
| 479 | var a = forms[i].querySelector('input[name="action"]'); | |
| 480 | var s = forms[i].querySelector('input[name="slug"]'); | |
| 481 | if (a && s && a.value === 'import_models' && s.value === slug) { | |
| 482 | target = forms[i]; | |
| 483 | break; | |
| 484 | } | |
| 485 | } | |
| 486 | if (!target) return; | |
| 487 | ||
| 488 | // Honor a previous Stop click (sessionStorage flag survives page | |
| 489 | // navigations). Render a static "Stopped" message + morph the Stop | |
| 490 | // button into a Continue button so the user has an obvious way | |
| 491 | // back without having to scroll to find the tile. | |
| 492 | try { | |
| 493 | if (window.sessionStorage && sessionStorage.getItem(STOP_KEY) === '1') { | |
| 494 | var existing = document.getElementById('auto-continue-msg'); | |
| 495 | if (existing) { | |
| 496 | existing.innerHTML = '<strong>Auto-pull stopped by you.</strong> Click <strong>Continue</strong> to resume.'; | |
| 497 | } | |
| 498 | // Hide the "please keep tab open" warning -- doesn't apply here. | |
| 499 | var banner = document.getElementById('import-banner'); | |
| 500 | var warn = banner ? banner.querySelector('.text-xs') : null; | |
| 501 | if (warn) warn.style.display = 'none'; | |
| 502 | var stopBtnEl = document.getElementById('auto-continue-stop'); | |
| 503 | _mpMorphStopToContinue(stopBtnEl, target, STOP_KEY); | |
| 504 | return; | |
| 505 | } | |
| 506 | } catch (e) { /* sessionStorage blocked; fall through */ } | |
| 507 | ||
| 508 | // Visual countdown in the banner so the user knows what's happening. | |
| 509 | var msg = document.getElementById('auto-continue-msg'); | |
| 510 | var stopBtn = document.getElementById('auto-continue-stop'); | |
| 511 | var seconds = 2; | |
| 512 | var stopped = false; | |
| 513 | var ticker = null; | |
| 514 | ||
| 515 | function render() { | |
| 516 | if (msg && !stopped) { | |
| 517 | msg.textContent = 'Pulling next model in ' + seconds + 's…'; | |
| 518 | } | |
| 519 | } | |
| 520 | render(); | |
| 521 | ||
| 522 | ticker = setInterval(function () { | |
| 523 | if (stopped) return; | |
| 524 | seconds--; | |
| 525 | render(); | |
| 526 | if (seconds <= 0) { | |
| 527 | clearInterval(ticker); | |
| 528 | if (!stopped) { | |
| 529 | // Countdown finished -- switch to the "actively pulling" | |
| 530 | // state with animated dots so the user can tell the | |
| 531 | // request is in flight (the browser shows the previous | |
| 532 | // page while waiting for the POST response, otherwise | |
| 533 | // the banner would look frozen). Dots cycle . -> .. -> | |
| 534 | // ... every 400ms. Browser navigates when the response | |
| 535 | // lands, which kills this script naturally. | |
| 536 | if (msg) { | |
| 537 | var dotCount = 0; | |
| 538 | setInterval(function () { | |
| 539 | if (stopped) return; | |
| 540 | dotCount = (dotCount % 3) + 1; | |
| 541 | msg.textContent = 'Pulling next model now' + Array(dotCount + 1).join('.'); | |
| 542 | }, 400); | |
| 543 | } | |
| 544 | _mpThreadRunningCounts(target, params.get('done'), params.get('total')); | |
| 545 | target.submit(); | |
| 546 | } | |
| 547 | } | |
| 548 | }, 1000); | |
| 549 | ||
| 550 | if (stopBtn) { | |
| 551 | stopBtn.addEventListener('click', function () { | |
| 552 | stopped = true; | |
| 553 | if (ticker) clearInterval(ticker); | |
| 554 | // Persist the intent across the in-flight POST + page nav. | |
| 555 | // Without this, the next page load would resume auto-pull. | |
| 556 | try { sessionStorage.setItem(STOP_KEY, '1'); } catch (e) {} | |
| 557 | ||
| 558 | // Immediate visual feedback: flip the banner red and show an | |
| 559 | // animated "Stopping, please wait…" while we wait for any | |
| 560 | // in-flight POST to finish. If a request was already mid- | |
| 561 | // flight when Stop was clicked, we can't actually cancel it | |
| 562 | // server-side -- but at least the user sees we acknowledged | |
| 563 | // the click. After ~1.5s we settle back into the green | |
| 564 | // "stopped" state (or the page navigates first and the | |
| 565 | // sessionStorage flag takes over on the next page). | |
| 566 | var banner = document.getElementById('import-banner'); | |
| 567 | if (banner) { | |
| 568 | banner.style.background = 'rgba(239,68,68,0.14)'; | |
| 569 | banner.style.borderColor = 'rgba(239,68,68,0.55)'; | |
| 570 | banner.style.color = '#f87171'; | |
| 571 | } | |
| 572 | // Hide the "please keep this tab open" line -- it doesn't | |
| 573 | // apply once stop is requested. | |
| 574 | var warnLine = banner ? banner.querySelector('.text-xs') : null; | |
| 575 | if (warnLine) warnLine.style.display = 'none'; | |
| 576 | ||
| 577 | stopBtn.style.display = 'none'; | |
| 578 | ||
| 579 | // Animated dots so the user can see the click was received. | |
| 580 | var stopDots = 1; | |
| 581 | if (msg) { msg.textContent = 'Stopping, please wait.'; } | |
| 582 | var stopDotTimer = setInterval(function () { | |
| 583 | if (!msg) return; | |
| 584 | stopDots = (stopDots % 3) + 1; | |
| 585 | msg.textContent = 'Stopping, please wait' + Array(stopDots + 1).join('.'); | |
| 586 | }, 300); | |
| 587 | ||
| 588 | // Settle into the green "Stopped" final state after a moment. | |
| 589 | // Note: if a POST was in flight, the page may navigate before | |
| 590 | // this fires -- that's fine, sessionStorage handles the next | |
| 591 | // page's banner. | |
| 592 | setTimeout(function () { | |
| 593 | clearInterval(stopDotTimer); | |
| 594 | if (banner) { | |
| 595 | banner.style.background = ''; | |
| 596 | banner.style.borderColor = ''; | |
| 597 | banner.style.color = ''; | |
| 598 | } | |
| 599 | if (msg) { | |
| 600 | msg.innerHTML = '<strong>Stopped.</strong> Click <strong>Continue</strong> to resume.'; | |
| 601 | } | |
| 602 | // Bring the button back, but now as Continue. | |
| 603 | var continueBtn = _mpMorphStopToContinue(stopBtn, target, STOP_KEY); | |
| 604 | }, 1500); | |
| 605 | }); | |
| 606 | } | |
| 607 | })(); | |
| 608 | </script> | |
| 609 | ||
| 610 | ||
| 611 | <!-- ===== Modal open/close wiring ===================================== --> | |
| 612 | <script> | |
| 613 | (function () { | |
| 614 | // Open: any element with [data-mp-open="<modal-id>"]. | |
| 615 | document.addEventListener('click', function (e) { | |
| 616 | var opener = e.target.closest ? e.target.closest('[data-mp-open]') : null; | |
| 617 | if (opener) { | |
| 618 | var id = opener.getAttribute('data-mp-open'); | |
| 619 | var m = id ? document.getElementById(id) : null; | |
| 620 | if (m) { | |
| 621 | m.classList.add('is-open'); | |
| 622 | // Focus the first text input so the seller can start typing. | |
| 623 | var first = m.querySelector('input[type="text"], input[type="password"], input[type="url"]'); | |
| 624 | if (first) setTimeout(function () { first.focus(); }, 30); | |
| 625 | } | |
| 626 | e.preventDefault(); | |
| 627 | return; | |
| 628 | } | |
| 629 | // Close: any [data-mp-close], OR clicking the backdrop itself. | |
| 630 | var closer = e.target.closest ? e.target.closest('[data-mp-close]') : null; | |
| 631 | if (closer) { | |
| 632 | var m2 = closer.closest('.mp-modal'); | |
| 633 | if (m2) m2.classList.remove('is-open'); | |
| 634 | e.preventDefault(); | |
| 635 | return; | |
| 636 | } | |
| 637 | if (e.target.classList && e.target.classList.contains('mp-modal')) { | |
| 638 | e.target.classList.remove('is-open'); | |
| 639 | } | |
| 640 | }); | |
| 641 | // ESC closes any currently-open modal. | |
| 642 | document.addEventListener('keydown', function (e) { | |
| 643 | if (e.key !== 'Escape') return; | |
| 644 | var open = document.querySelectorAll('.mp-modal.is-open'); | |
| 645 | for (var i = 0; i < open.length; i++) open[i].classList.remove('is-open'); | |
| 646 | }); | |
| 647 | })(); | |
| 648 | </script> |