added on local at 2026-07-01 21:47:08
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- Marketplaces page (Connect Amazon / eBay / Walmart). | |
| 4 | # | |
| 5 | # GET /marketplaces.cgi | |
| 6 | # ?flash=...&flash_kind=... (set by oauth_*.cgi on return) | |
| 7 | # | |
| 8 | # POST /marketplaces.cgi | |
| 9 | # action=connect_oauth platform=amazon|ebay region=us|uk|... | |
| 10 | # -> mints a CSRF state, stashes it on users.oauth_state | |
| 11 | # + users.oauth_marketplace_id, then 302s the browser to the | |
| 12 | # platform's authorize URL. | |
| 13 | # | |
| 14 | # action=save_walmart consumer_id=... private_key=... region=us|ca|mx | |
| 15 | # [channel_type=...] | |
| 16 | # -> upserts the Walmart marketplace_accounts row. | |
| 17 | # | |
| 18 | # action=disconnect platform=amazon|ebay|walmart | |
| 19 | # -> sets status=disconnected, blanks tokens, leaves the row so | |
| 20 | # history (linked products / price changes) is preserved. | |
| 21 | # | |
| 22 | # action=test platform=amazon|ebay|walmart | |
| 23 | # -> calls adapter->test_connection and stashes the result in a | |
| 24 | # flash for display. | |
| 25 | #====================================================================== | |
| 26 | use strict; | |
| 27 | use warnings; | |
| 28 | ||
| 29 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 30 | use CGI; | |
| 31 | use JSON::PP; | |
| 32 | use MODS::Template; | |
| 33 | use MODS::DBConnect; | |
| 34 | use MODS::Login; | |
| 35 | use MODS::RePricer::Config; | |
| 36 | use MODS::RePricer::Wrapper; | |
| 37 | use MODS::RePricer::Marketplaces; | |
| 38 | use MODS::RePricer::Marketplaces::Amazon; | |
| 39 | use MODS::RePricer::Marketplaces::EBay; | |
| 40 | ||
| 41 | $|=1; | |
| 42 | ||
| 43 | my $q = CGI->new; | |
| 44 | my $form = $q->Vars; | |
| 45 | my $auth = MODS::Login->new; | |
| 46 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 47 | my $db = MODS::DBConnect->new; | |
| 48 | my $cfg = MODS::RePricer::Config->new; | |
| 49 | my $DB = $cfg->settings('database_name'); | |
| 50 | ||
| 51 | my $userinfo = $auth->login_verify(); | |
| 52 | unless ($userinfo) { | |
| 53 | # OAuth-callback entries want the login redirect to preserve their | |
| 54 | # return path so the seller ends up back on /marketplaces.cgi. | |
| 55 | my $entry_probe = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : ''; | |
| 56 | if ($entry_probe =~ /^oauth_(amazon|ebay)$/) { | |
| 57 | print "Status: 302 Found\nLocation: /login.cgi?next=/marketplaces.cgi\n\n"; | |
| 58 | } else { | |
| 59 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 60 | } | |
| 61 | exit; | |
| 62 | } | |
| 63 | my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g; | |
| 64 | ||
| 65 | # SCRIPT_NAME dispatch: OAuth callbacks fold in via the same login path | |
| 66 | # so we don't ship separate scripts for the amazon / ebay return URLs. | |
| 67 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'marketplaces'; | |
| 68 | if ($entry eq 'oauth_amazon') { _oauth_amazon_cb(); exit; } | |
| 69 | if ($entry eq 'oauth_ebay') { _oauth_ebay_cb(); exit; } | |
| 70 | ||
| 71 | sub _h { my $s = shift // ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; $s } | |
| 72 | sub _q { my $s = shift // ''; $s =~ s/'/''/g; $s } | |
| 73 | sub _e { my $s = shift // ''; $s =~ s/([^A-Za-z0-9\-._~])/sprintf('%%%02X', ord($1))/ge; $s } | |
| 74 | ||
| 75 | sub _rand_state { | |
| 76 | my @c = ('A'..'Z','a'..'z','0'..'9'); | |
| 77 | return join '', map { $c[int(rand @c)] } 1..40; | |
| 78 | } | |
| 79 | ||
| 80 | # ----- POST ----------------------------------------------------------- | |
| 81 | if (($ENV{REQUEST_METHOD} || '') eq 'POST') { | |
| 82 | my $dbh = $db->db_connect(); | |
| 83 | my $act = $form->{action} || ''; | |
| 84 | my $flash = ''; | |
| 85 | my $kind = 'info'; | |
| 86 | ||
| 87 | if ($act eq 'connect_oauth') { | |
| 88 | my $plat = ($form->{platform} || '') =~ /^(amazon|ebay)$/ ? $1 : ''; | |
| 89 | my $region = ($form->{region} || '') =~ /^[a-z_]{2,12}$/ ? $form->{region} : ''; | |
| 90 | if (!$plat) { $flash = 'Unknown platform.'; $kind = 'error'; } | |
| 91 | else { | |
| 92 | my $p = MODS::RePricer::Marketplaces::by_slug($plat); | |
| 93 | if (!$p) { $flash = "No registry entry for $plat"; $kind = 'error'; } | |
| 94 | else { | |
| 95 | my $marketplace_id = ''; | |
| 96 | foreach my $r (@{ $p->{regions} || [] }) { | |
| 97 | if ($r->{code} eq $region) { $marketplace_id = $r->{marketplace_id}; last; } | |
| 98 | } | |
| 99 | $marketplace_id ||= ($p->{regions}[0]{marketplace_id} || ''); | |
| 100 | my $state = _rand_state(); | |
| 101 | my $sq = _q($state); | |
| 102 | my $mq = _q($marketplace_id); | |
| 103 | $db->db_readwrite($dbh, qq~ | |
| 104 | UPDATE `${DB}`.users SET oauth_state='$sq', oauth_marketplace_id='$mq' WHERE id='$uid' | |
| 105 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 106 | ||
| 107 | my $url; | |
| 108 | if ($plat eq 'amazon') { | |
| 109 | $url = MODS::RePricer::Marketplaces::Amazon->authorize_url($state); | |
| 110 | } elsif ($plat eq 'ebay') { | |
| 111 | $url = MODS::RePricer::Marketplaces::EBay->authorize_url($state); | |
| 112 | } | |
| 113 | if (!$url) { | |
| 114 | $flash = "$plat OAuth is not configured -- super-admin must set client ID / secret in /admin_config.cgi"; | |
| 115 | $kind = 'error'; | |
| 116 | } else { | |
| 117 | print "Status: 302 Found\nLocation: $url\n\n"; | |
| 118 | exit; | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | elsif ($act eq 'save_walmart') { | |
| 124 | my $cid = $form->{consumer_id} || ''; | |
| 125 | my $key = $form->{private_key} || ''; | |
| 126 | my $region = ($form->{region} || '') =~ /^(us|ca|mx)$/ ? $1 : 'us'; | |
| 127 | my $chan = $form->{channel_type} || ''; | |
| 128 | $cid =~ s/^\s+|\s+\z//g; | |
| 129 | $key =~ s/\r//g; | |
| 130 | if ($cid !~ /^[A-Fa-f0-9\-]{8,}$/) { | |
| 131 | $flash = 'Consumer ID looks wrong -- expected a UUID-like value.'; $kind = 'error'; | |
| 132 | } | |
| 133 | elsif ($key !~ /-----BEGIN [A-Z ]*PRIVATE KEY-----/) { | |
| 134 | $flash = 'Private key must be a PEM-formatted RSA key (begins with -----BEGIN ...PRIVATE KEY-----)'; | |
| 135 | $kind = 'error'; | |
| 136 | } | |
| 137 | else { | |
| 138 | my %region_to_mp = (us => 'WALMART_US', ca => 'WALMART_CA', mx => 'WALMART_MX'); | |
| 139 | my $mp = $region_to_mp{$region}; | |
| 140 | my $meta_json = JSON::PP::encode_json({ | |
| 141 | marketplace_id => $mp, | |
| 142 | channel_type => $chan, | |
| 143 | }); | |
| 144 | my $cidq = _q($cid); | |
| 145 | my $keyq = _q($key); | |
| 146 | my $metaq = _q($meta_json); | |
| 147 | my $existing = $db->db_readwrite($dbh, qq~ | |
| 148 | SELECT id FROM `${DB}`.marketplace_accounts | |
| 149 | WHERE user_id='$uid' AND platform='walmart' LIMIT 1 | |
| 150 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 151 | if ($existing && $existing->{id}) { | |
| 152 | my $id = $existing->{id}; $id =~ s/[^0-9]//g; | |
| 153 | $db->db_readwrite($dbh, qq~ | |
| 154 | UPDATE `${DB}`.marketplace_accounts | |
| 155 | SET status='connected', | |
| 156 | access_token='$keyq', | |
| 157 | account_handle='$cidq', | |
| 158 | metadata='$metaq', | |
| 159 | last_error=NULL, | |
| 160 | connected_at=COALESCE(connected_at, NOW()), | |
| 161 | last_used_at=NOW(), | |
| 162 | updated_at=NOW() | |
| 163 | WHERE id='$id' | |
| 164 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 165 | } else { | |
| 166 | $db->db_readwrite($dbh, qq~ | |
| 167 | INSERT INTO `${DB}`.marketplace_accounts | |
| 168 | (user_id, platform, status, access_token, account_handle, | |
| 169 | metadata, connected_at, last_used_at, created_at) | |
| 170 | VALUES ('$uid', 'walmart', 'connected', '$keyq', '$cidq', | |
| 171 | '$metaq', NOW(), NOW(), NOW()) | |
| 172 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 173 | } | |
| 174 | $flash = 'Walmart credentials saved.'; $kind = 'success'; | |
| 175 | } | |
| 176 | } | |
| 177 | elsif ($act eq 'disconnect') { | |
| 178 | my $plat = ($form->{platform} || '') =~ /^(amazon|ebay|walmart)$/ ? $1 : ''; | |
| 179 | if (!$plat) { $flash = 'Unknown platform.'; $kind = 'error'; } | |
| 180 | else { | |
| 181 | $db->db_readwrite($dbh, qq~ | |
| 182 | UPDATE `${DB}`.marketplace_accounts | |
| 183 | SET status='disconnected', | |
| 184 | access_token=NULL, refresh_token=NULL, | |
| 185 | token_expires_at=NULL, | |
| 186 | updated_at=NOW() | |
| 187 | WHERE user_id='$uid' AND platform='$plat' | |
| 188 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 189 | $flash = "Disconnected $plat."; $kind = 'warn'; | |
| 190 | } | |
| 191 | } | |
| 192 | elsif ($act eq 'test') { | |
| 193 | my $plat = ($form->{platform} || '') =~ /^(amazon|ebay|walmart)$/ ? $1 : ''; | |
| 194 | if (!$plat) { $flash = 'Unknown platform.'; $kind = 'error'; } | |
| 195 | else { | |
| 196 | my $row = $db->db_readwrite($dbh, qq~ | |
| 197 | SELECT * FROM `${DB}`.marketplace_accounts | |
| 198 | WHERE user_id='$uid' AND platform='$plat' AND status='connected' LIMIT 1 | |
| 199 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 200 | if (!($row && $row->{id})) { | |
| 201 | $flash = "$plat is not connected."; $kind = 'warn'; | |
| 202 | } else { | |
| 203 | my $adapter = MODS::RePricer::Marketplaces::adapter_for($row); | |
| 204 | if (!$adapter) { | |
| 205 | $flash = "Couldn't load adapter for $plat"; $kind = 'error'; | |
| 206 | } else { | |
| 207 | my $r = $adapter->test_connection; | |
| 208 | if ($r->{ok}) { | |
| 209 | $flash = "$plat test passed: " . ($r->{info} || 'OK'); $kind = 'success'; | |
| 210 | } else { | |
| 211 | $flash = "$plat test failed: " . ($r->{error} || 'unknown'); $kind = 'error'; | |
| 212 | } | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
| 217 | else { | |
| 218 | $flash = "Unknown action: $act"; $kind = 'error'; | |
| 219 | } | |
| 220 | ||
| 221 | my $u = '/marketplaces.cgi'; | |
| 222 | if (length $flash) { | |
| 223 | $u .= '?flash=' . _e($flash) . '&flash_kind=' . $kind; | |
| 224 | } | |
| 225 | print "Status: 302 Found\nLocation: $u\n\n"; | |
| 226 | exit; | |
| 227 | } | |
| 228 | ||
| 229 | # ----- GET ------------------------------------------------------------ | |
| 230 | my $dbh = $db->db_connect(); | |
| 231 | my $accounts_q = $db->db_readwrite_multiple($dbh, qq~ | |
| 232 | SELECT * FROM `${DB}`.marketplace_accounts | |
| 233 | WHERE user_id='$uid' | |
| 234 | ORDER BY platform, id | |
| 235 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 236 | # Group by platform -- a seller might have multiple Amazon (US + UK + JP) | |
| 237 | # or eBay (US + UK) accounts. Show them all. | |
| 238 | my %by_plat; # platform => arrayref of account rows | |
| 239 | foreach my $r (@$accounts_q) { | |
| 240 | push @{ $by_plat{$r->{platform}} //= [] }, $r; | |
| 241 | } | |
| 242 | ||
| 243 | my $flash = _h($form->{flash} // ''); | |
| 244 | my $flash_kind = ($form->{flash_kind} // 'info'); | |
| 245 | $flash_kind = 'info' unless $flash_kind =~ /^(info|success|warn|error)$/; | |
| 246 | ||
| 247 | # Build the per-platform cards HTML inline. This page is bespoke | |
| 248 | # enough that doing it inline beats parameterizing a template. | |
| 249 | my $cards = ''; | |
| 250 | foreach my $p (MODS::RePricer::Marketplaces::platforms()) { | |
| 251 | my $slug = $p->{slug}; | |
| 252 | my $accts = $by_plat{$slug} || []; # all accounts on this platform | |
| 253 | my $primary = $accts->[0]; # used for the card header summary | |
| 254 | ||
| 255 | # Card summary state reflects the BEST-status account; if any is | |
| 256 | # connected we show "Connected (N accounts)". | |
| 257 | my $status_label = 'Not connected'; | |
| 258 | my $status_class = 'gray'; | |
| 259 | my $any_connected = 0; | |
| 260 | foreach my $a (@$accts) { | |
| 261 | my $st = $a->{status} // ''; | |
| 262 | if ($st eq 'connected') { $any_connected = 1; last; } | |
| 263 | } | |
| 264 | if ($any_connected) { | |
| 265 | $status_class = 'green'; | |
| 266 | $status_label = (@$accts > 1) ? 'Connected (' . scalar(@$accts) . ' accounts)' : 'Connected'; | |
| 267 | } elsif (@$accts) { | |
| 268 | my $st = $primary->{status} // ''; | |
| 269 | if ($st eq 'expired') { $status_label = 'Token expired';$status_class = 'amber'; } | |
| 270 | elsif ($st eq 'error') { $status_label = 'Error'; $status_class = 'red'; } | |
| 271 | elsif ($st eq 'disconnected'){ $status_label = 'Disconnected'; $status_class = 'gray'; } | |
| 272 | } | |
| 273 | ||
| 274 | # Aggregate display values from the primary account (or empty). | |
| 275 | my $handle = $primary ? _h($primary->{account_handle} // '') : ''; | |
| 276 | my $last_err = $primary ? _h($primary->{last_error} // '') : ''; | |
| 277 | my $last_used = $primary ? _h($primary->{last_used_at} // '') : ''; | |
| 278 | # Keep an `$acct` alias for the rest of the original code so test + | |
| 279 | # disconnect actions still fire against the primary connected account. | |
| 280 | my $acct = $primary; | |
| 281 | ||
| 282 | # Region select for OAuth platforms. | |
| 283 | my $region_select = ''; | |
| 284 | foreach my $r (@{ $p->{regions} || [] }) { | |
| 285 | my $code = _h($r->{code}); | |
| 286 | my $lbl = _h($r->{label}); | |
| 287 | $region_select .= qq~<option value="$code">$lbl</option>~; | |
| 288 | } | |
| 289 | ||
| 290 | my $controls = ''; | |
| 291 | if ($p->{auth} eq 'oauth') { | |
| 292 | my $btn_label = $acct && ($acct->{status} // '') eq 'connected' ? 'Reconnect' : 'Connect'; | |
| 293 | $controls .= <<HTML; | |
| 294 | <form method="POST" action="/marketplaces.cgi" style="display:flex;gap:8px;align-items:center;margin:12px 0 8px"> | |
| 295 | <input type="hidden" name="action" value="connect_oauth"> | |
| 296 | <input type="hidden" name="platform" value="$slug"> | |
| 297 | <select name="region" style="padding:6px 8px;background:#0b1226;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 298 | $region_select | |
| 299 | </select> | |
| 300 | <button type="submit" class="btn btn-primary btn-sm">$btn_label $p->{name}</button> | |
| 301 | </form> | |
| 302 | HTML | |
| 303 | } elsif ($p->{auth} eq 'api_key' && $slug eq 'walmart') { | |
| 304 | my $cur_handle = $handle ? "value=\"$handle\"" : ''; | |
| 305 | $controls .= <<HTML; | |
| 306 | <form method="POST" action="/marketplaces.cgi" style="margin:12px 0 8px"> | |
| 307 | <input type="hidden" name="action" value="save_walmart"> | |
| 308 | <div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:6px"> | |
| 309 | <label style="flex:1;min-width:240px"> | |
| 310 | <span style="display:block;font-size:11px;color:#7e92b6;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">Consumer ID (UUID)</span> | |
| 311 | <input type="text" name="consumer_id" $cur_handle required | |
| 312 | placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" | |
| 313 | style="width:100%;padding:7px 10px;background:#0b1226;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 314 | </label> | |
| 315 | <label style="width:120px"> | |
| 316 | <span style="display:block;font-size:11px;color:#7e92b6;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">Region</span> | |
| 317 | <select name="region" style="width:100%;padding:7px 8px;background:#0b1226;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 318 | <option value="us">US</option> | |
| 319 | <option value="ca">Canada</option> | |
| 320 | <option value="mx">Mexico</option> | |
| 321 | </select> | |
| 322 | </label> | |
| 323 | <label style="width:200px"> | |
| 324 | <span style="display:block;font-size:11px;color:#7e92b6;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">Channel Type (optional)</span> | |
| 325 | <input type="text" name="channel_type" | |
| 326 | placeholder="leave blank if unsure" | |
| 327 | style="width:100%;padding:7px 10px;background:#0b1226;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px"> | |
| 328 | </label> | |
| 329 | </div> | |
| 330 | <label style="display:block;margin-bottom:8px"> | |
| 331 | <span style="display:block;font-size:11px;color:#7e92b6;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">Private Key (PEM)</span> | |
| 332 | <textarea name="private_key" rows="6" required | |
| 333 | placeholder="-----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----" | |
| 334 | style="width:100%;padding:8px 10px;background:#0b1226;border:1px solid #1f2a4a;color:#e6ecf6;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px"></textarea> | |
| 335 | </label> | |
| 336 | <button type="submit" class="btn btn-primary btn-sm">Save Walmart credentials</button> | |
| 337 | </form> | |
| 338 | HTML | |
| 339 | } | |
| 340 | ||
| 341 | # Test + Disconnect row for an existing connected account. | |
| 342 | my $actions_row = ''; | |
| 343 | if ($acct && ($acct->{status} // '') eq 'connected') { | |
| 344 | $actions_row = <<HTML; | |
| 345 | <div style="display:flex;gap:8px;margin-top:10px;border-top:1px dashed #1f2a4a;padding-top:10px"> | |
| 346 | <form method="POST" action="/marketplaces.cgi" style="display:inline"> | |
| 347 | <input type="hidden" name="action" value="test"> | |
| 348 | <input type="hidden" name="platform" value="$slug"> | |
| 349 | <button type="submit" class="btn btn-secondary btn-sm">Test connection</button> | |
| 350 | </form> | |
| 351 | <form method="POST" action="/marketplaces.cgi" style="display:inline" | |
| 352 | onsubmit="return confirm('Disconnect $p->{name}? Your products will stay; this just revokes API access.');"> | |
| 353 | <input type="hidden" name="action" value="disconnect"> | |
| 354 | <input type="hidden" name="platform" value="$slug"> | |
| 355 | <button type="submit" class="btn btn-ghost btn-sm" style="color:#fca5a5">Disconnect</button> | |
| 356 | </form> | |
| 357 | </div> | |
| 358 | HTML | |
| 359 | } | |
| 360 | ||
| 361 | my $err_html = $last_err ? qq~<div style="font-size:12px;color:#fca5a5;margin-top:6px">Last error: $last_err</div>~ : ''; | |
| 362 | my $handle_html = $handle ? qq~<div style="font-size:12px;color:#7e92b6;margin-top:6px">Linked account: <b style="color:#e6ecf6">$handle</b></div>~ : ''; | |
| 363 | my $last_used_html = $last_used ? qq~<div style="font-size:11px;color:#7e92b6;margin-top:4px">Last used: $last_used</div>~ : ''; | |
| 364 | ||
| 365 | # Multi-account list (only renders when there's > 1 account on this | |
| 366 | # platform). Lets sellers see at a glance which of their multiple | |
| 367 | # marketplace accounts is in which state. | |
| 368 | my $multi_html = ''; | |
| 369 | if (@$accts > 1) { | |
| 370 | $multi_html = '<div style="margin-top:10px;padding-top:10px;border-top:1px dashed #1f2a4a;font-size:12px;color:#7e92b6">'; | |
| 371 | $multi_html .= '<div style="text-transform:uppercase;letter-spacing:1px;font-size:10px;margin-bottom:6px">All ' . _h($p->{name}) . ' accounts</div>'; | |
| 372 | foreach my $a (@$accts) { | |
| 373 | my $h_handle = _h($a->{account_handle} // ''); | |
| 374 | my $h_status = _h($a->{status} // ''); | |
| 375 | my $sc = $a->{status} eq 'connected' ? '#10b981' : ($a->{status} eq 'error' ? '#fca5a5' : '#7e92b6'); | |
| 376 | $multi_html .= qq~<div style="display:flex;justify-content:space-between;padding:4px 0"> | |
| 377 | <span style="font-family:ui-monospace,Menlo,monospace;color:#e6ecf6">$h_handle</span> | |
| 378 | <span style="color:$sc;font-weight:600;font-size:11px">$h_status</span> | |
| 379 | </div>~; | |
| 380 | } | |
| 381 | $multi_html .= '</div>'; | |
| 382 | } | |
| 383 | ||
| 384 | my $icon = _h($p->{icon} // '?'); | |
| 385 | my $accent = _h($p->{accent} // '#14b8a6'); | |
| 386 | my $tag = _h($p->{tagline} // ''); | |
| 387 | my $name = _h($p->{name}); | |
| 388 | my $plan = _h($p->{seller_plan_required} // ''); | |
| 389 | my $signup = _h($p->{signup_url} // ''); | |
| 390 | my $docs = _h($p->{developer_url} // ''); | |
| 391 | ||
| 392 | $cards .= <<HTML; | |
| 393 | <section style="background:#0b1226;border:1px solid #1f2a4a;border-radius:12px;padding:18px 20px;margin-bottom:14px"> | |
| 394 | <div style="display:flex;align-items:center;gap:12px"> | |
| 395 | <div style="width:38px;height:38px;border-radius:9px;background:${accent}1a;color:$accent;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px">$icon</div> | |
| 396 | <div style="flex:1"> | |
| 397 | <div style="font-size:18px;font-weight:600;color:#fff">$name</div> | |
| 398 | <div style="font-size:13px;color:#7e92b6">$tag</div> | |
| 399 | </div> | |
| 400 | <span class="status-pill $status_class">$status_label</span> | |
| 401 | </div> | |
| 402 | <div style="font-size:12px;color:#7e92b6;margin-top:10px">Seller plan needed: $plan</div> | |
| 403 | $handle_html | |
| 404 | $last_used_html | |
| 405 | $err_html | |
| 406 | $multi_html | |
| 407 | $controls | |
| 408 | $actions_row | |
| 409 | <div style="font-size:11px;color:#5f719b;margin-top:8px"> | |
| 410 | <a href="$signup" target="_blank" rel="noopener" style="color:#7e92b6">Sign up for $name →</a> | |
| 411 | · | |
| 412 | <a href="$docs" target="_blank" rel="noopener" style="color:#7e92b6">$name API docs →</a> | |
| 413 | </div> | |
| 414 | </section> | |
| 415 | HTML | |
| 416 | } | |
| 417 | ||
| 418 | my $flash_html = ''; | |
| 419 | if (length $flash) { | |
| 420 | my %color = (success => '#10b981', warn => '#fbbf24', error => '#fca5a5', info => '#10b981'); | |
| 421 | my $c = $color{$flash_kind} || $color{info}; | |
| 422 | $flash_html = qq~<div style="background:${c}1a;border-left:3px solid $c;color:$c;padding:12px 16px;border-radius:8px;margin-bottom:18px;font-size:14px">$flash</div>~; | |
| 423 | } | |
| 424 | ||
| 425 | my $body = <<HTML; | |
| 426 | <style> | |
| 427 | .status-pill { font-size:11px; letter-spacing:1.5px; text-transform:uppercase; padding:3px 9px; border-radius:999px; font-weight:600; } | |
| 428 | .status-pill.green { background:#10b98122; color:#10b981; } | |
| 429 | .status-pill.amber { background:#fbbf2422; color:#fbbf24; } | |
| 430 | .status-pill.red { background:#fca5a522; color:#fca5a5; } | |
| 431 | .status-pill.gray { background:#33415522; color:#94a3b8; } | |
| 432 | .btn-primary { background:#064e3b; color:#a7f3d0; border:0; padding:7px 14px; border-radius:6px; font-weight:600; cursor:pointer; } | |
| 433 | .btn-primary:hover { background:#67e8f9; } | |
| 434 | .btn-secondary { background:#1f2a4a; color:#e6ecf6; border:0; padding:7px 14px; border-radius:6px; cursor:pointer; } | |
| 435 | .btn-ghost { background:transparent; color:#94a3b8; border:0; padding:7px 14px; border-radius:6px; cursor:pointer; } | |
| 436 | .btn-sm { font-size:13px; } | |
| 437 | </style> | |
| 438 | ||
| 439 | <div style="max-width:820px;margin:0 auto;padding:24px 20px"> | |
| 440 | <div style="margin-bottom:18px"> | |
| 441 | <div style="font-size:11px;letter-spacing:2px;color:#14b8a6;text-transform:uppercase">Marketplace Integrations</div> | |
| 442 | <h1 style="font-size:26px;color:#fff;margin:6px 0 4px;font-weight:700">Connect your marketplaces</h1> | |
| 443 | <p style="color:#7e92b6;font-size:14px;margin:0">Authorise RePricer to read your competitor offers and update your prices. We use OAuth or API keys -- we never store your marketplace login.</p> | |
| 444 | </div> | |
| 445 | ||
| 446 | $flash_html | |
| 447 | $cards | |
| 448 | ||
| 449 | <div style="margin-top:24px;padding:16px 18px;background:#0a0f1f;border:1px dashed #1f2a4a;border-radius:10px;font-size:13px;color:#7e92b6"> | |
| 450 | <b style="color:#e6ecf6">How it works.</b> | |
| 451 | For Amazon and eBay you'll click "Connect" and authorize RePricer on the marketplace's own consent screen. | |
| 452 | For Walmart, paste your Consumer ID + Private Key from Seller Center -- Walmart doesn't use OAuth. | |
| 453 | After connecting, head to <a href="/products.cgi" style="color:#14b8a6">Products</a> to import your SKUs, then set up | |
| 454 | <a href="/repricing_rules.cgi" style="color:#14b8a6">repricing rules</a> to start winning the buy box. | |
| 455 | </div> | |
| 456 | </div> | |
| 457 | HTML | |
| 458 | ||
| 459 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 460 | $wrap->render({ | |
| 461 | userinfo => $userinfo, | |
| 462 | page_key => 'marketplaces', | |
| 463 | title => 'Marketplaces', | |
| 464 | body => $body, | |
| 465 | }); | |
| 466 | exit; | |
| 467 | ||
| 468 | #====================================================================== | |
| 469 | # oauth_amazon.cgi -- Amazon SP-API OAuth callback (ex-/oauth_amazon.cgi) | |
| 470 | # | |
| 471 | # Amazon redirects the seller back here after they grant consent in | |
| 472 | # Seller Central. We verify state, exchange the one-time code for an | |
| 473 | # LWA refresh token, upsert the marketplace_accounts row, and 302 back | |
| 474 | # to /marketplaces.cgi with a success/error flash. | |
| 475 | #====================================================================== | |
| 476 | sub _oauth_amazon_cb { | |
| 477 | require MODS::RePricer::Marketplaces::Amazon; | |
| 478 | ||
| 479 | # User-cancel on the Amazon consent page comes back with error / error_description. | |
| 480 | if ($form->{error} || $form->{error_description}) { | |
| 481 | my $msg = $form->{error_description} || $form->{error} || 'Amazon consent cancelled'; | |
| 482 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" . _e($msg) . "&flash_kind=warn\n\n"; | |
| 483 | return; | |
| 484 | } | |
| 485 | ||
| 486 | my $code = $form->{spapi_oauth_code} || ''; | |
| 487 | my $state = $form->{state} || ''; | |
| 488 | my $sp_id = $form->{selling_partner_id} || ''; | |
| 489 | ||
| 490 | unless ($code && $sp_id) { | |
| 491 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 492 | . _e('Amazon callback missing spapi_oauth_code or selling_partner_id') . "&flash_kind=error\n\n"; | |
| 493 | return; | |
| 494 | } | |
| 495 | ||
| 496 | my $dbh = $db->db_connect(); | |
| 497 | my $row = $db->db_readwrite($dbh, qq~ | |
| 498 | SELECT oauth_state, oauth_marketplace_id FROM `${DB}`.users WHERE id='$uid' LIMIT 1 | |
| 499 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 500 | my $expected_state = ($row && $row->{oauth_state}) || ''; | |
| 501 | my $marketplace_id = ($row && $row->{oauth_marketplace_id}) || 'ATVPDKIKX0DER'; | |
| 502 | unless (length $state && $state eq $expected_state) { | |
| 503 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 504 | . _e('OAuth state mismatch -- please try connecting again') . "&flash_kind=error\n\n"; | |
| 505 | return; | |
| 506 | } | |
| 507 | ||
| 508 | my $tok = MODS::RePricer::Marketplaces::Amazon->exchange_code_for_token($code); | |
| 509 | unless ($tok->{ok}) { | |
| 510 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 511 | . _e('Amazon token exchange failed: ' . $tok->{error}) . "&flash_kind=error\n\n"; | |
| 512 | return; | |
| 513 | } | |
| 514 | ||
| 515 | my $refresh = $tok->{refresh_token}; $refresh =~ s/'/''/g; | |
| 516 | my $access = $tok->{access_token} || ''; $access =~ s/'/''/g; | |
| 517 | my $exp_at = $tok->{expires_in} ? (time() + $tok->{expires_in}) : 0; | |
| 518 | my $sp_id_q = $sp_id; $sp_id_q =~ s/'/''/g; | |
| 519 | my $meta_json = JSON::PP::encode_json({ | |
| 520 | seller_id => $sp_id, | |
| 521 | marketplace_id => $marketplace_id, | |
| 522 | access_token => $tok->{access_token}, | |
| 523 | access_token_expires_at=> $exp_at, | |
| 524 | }); | |
| 525 | $meta_json =~ s/'/''/g; | |
| 526 | ||
| 527 | my $existing = $db->db_readwrite($dbh, qq~ | |
| 528 | SELECT id FROM `${DB}`.marketplace_accounts | |
| 529 | WHERE user_id='$uid' AND platform='amazon' LIMIT 1 | |
| 530 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 531 | if ($existing && $existing->{id}) { | |
| 532 | my $id = $existing->{id}; $id =~ s/[^0-9]//g; | |
| 533 | $db->db_readwrite($dbh, qq~ | |
| 534 | UPDATE `${DB}`.marketplace_accounts | |
| 535 | SET access_token='$refresh', | |
| 536 | status='connected', | |
| 537 | account_handle='$sp_id_q', | |
| 538 | metadata='$meta_json', | |
| 539 | last_error=NULL, | |
| 540 | connected_at=COALESCE(connected_at, NOW()), | |
| 541 | last_used_at=NOW(), | |
| 542 | updated_at=NOW() | |
| 543 | WHERE id='$id' | |
| 544 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 545 | } else { | |
| 546 | $db->db_readwrite($dbh, qq~ | |
| 547 | INSERT INTO `${DB}`.marketplace_accounts | |
| 548 | (user_id, platform, status, access_token, account_handle, | |
| 549 | metadata, connected_at, last_used_at, created_at) | |
| 550 | VALUES ('$uid', 'amazon', 'connected', '$refresh', '$sp_id_q', | |
| 551 | '$meta_json', NOW(), NOW(), NOW()) | |
| 552 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 553 | } | |
| 554 | ||
| 555 | # Wipe the one-time CSRF state. | |
| 556 | $db->db_readwrite($dbh, qq~ | |
| 557 | UPDATE `${DB}`.users SET oauth_state=NULL, oauth_marketplace_id=NULL WHERE id='$uid' | |
| 558 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 559 | ||
| 560 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 561 | . _e("Amazon connected -- seller $sp_id") . "&flash_kind=success\n\n"; | |
| 562 | } | |
| 563 | ||
| 564 | #====================================================================== | |
| 565 | # oauth_ebay.cgi -- eBay OAuth2 callback (ex-/oauth_ebay.cgi) | |
| 566 | # | |
| 567 | # eBay redirects here after consent. We verify state, exchange the | |
| 568 | # code for { access_token, refresh_token }, upsert the row, and 302 | |
| 569 | # back to /marketplaces.cgi. | |
| 570 | #====================================================================== | |
| 571 | sub _oauth_ebay_cb { | |
| 572 | require MODS::RePricer::Marketplaces::EBay; | |
| 573 | ||
| 574 | if ($form->{error} || $form->{error_description}) { | |
| 575 | my $msg = $form->{error_description} || $form->{error} || 'eBay consent cancelled'; | |
| 576 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" . _e($msg) . "&flash_kind=warn\n\n"; | |
| 577 | return; | |
| 578 | } | |
| 579 | ||
| 580 | my $code = $form->{code} || ''; | |
| 581 | my $state = $form->{state} || ''; | |
| 582 | unless ($code) { | |
| 583 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 584 | . _e('eBay callback missing code') . "&flash_kind=error\n\n"; | |
| 585 | return; | |
| 586 | } | |
| 587 | ||
| 588 | my $dbh = $db->db_connect(); | |
| 589 | my $row = $db->db_readwrite($dbh, qq~ | |
| 590 | SELECT oauth_state, oauth_marketplace_id FROM `${DB}`.users WHERE id='$uid' LIMIT 1 | |
| 591 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 592 | my $expected_state = ($row && $row->{oauth_state}) || ''; | |
| 593 | my $marketplace_id = ($row && $row->{oauth_marketplace_id}) || 'EBAY_US'; | |
| 594 | unless (length $state && $state eq $expected_state) { | |
| 595 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 596 | . _e('OAuth state mismatch -- please try connecting again') . "&flash_kind=error\n\n"; | |
| 597 | return; | |
| 598 | } | |
| 599 | ||
| 600 | my $tok = MODS::RePricer::Marketplaces::EBay->exchange_code_for_token($code); | |
| 601 | unless ($tok->{ok}) { | |
| 602 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 603 | . _e('eBay token exchange failed: ' . $tok->{error}) . "&flash_kind=error\n\n"; | |
| 604 | return; | |
| 605 | } | |
| 606 | ||
| 607 | my $refresh = $tok->{refresh_token}; $refresh =~ s/'/''/g; | |
| 608 | my $access = $tok->{access_token} || ''; | |
| 609 | my $exp_at = $tok->{expires_in} ? (time() + $tok->{expires_in}) : 0; | |
| 610 | my $meta_json = JSON::PP::encode_json({ | |
| 611 | marketplace_id => $marketplace_id, | |
| 612 | access_token => $access, | |
| 613 | access_token_expires_at => $exp_at, | |
| 614 | }); | |
| 615 | $meta_json =~ s/'/''/g; | |
| 616 | ||
| 617 | my $existing = $db->db_readwrite($dbh, qq~ | |
| 618 | SELECT id FROM `${DB}`.marketplace_accounts | |
| 619 | WHERE user_id='$uid' AND platform='ebay' LIMIT 1 | |
| 620 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 621 | if ($existing && $existing->{id}) { | |
| 622 | my $id = $existing->{id}; $id =~ s/[^0-9]//g; | |
| 623 | $db->db_readwrite($dbh, qq~ | |
| 624 | UPDATE `${DB}`.marketplace_accounts | |
| 625 | SET refresh_token='$refresh', | |
| 626 | status='connected', | |
| 627 | metadata='$meta_json', | |
| 628 | last_error=NULL, | |
| 629 | connected_at=COALESCE(connected_at, NOW()), | |
| 630 | last_used_at=NOW(), | |
| 631 | updated_at=NOW() | |
| 632 | WHERE id='$id' | |
| 633 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 634 | } else { | |
| 635 | $db->db_readwrite($dbh, qq~ | |
| 636 | INSERT INTO `${DB}`.marketplace_accounts | |
| 637 | (user_id, platform, status, refresh_token, | |
| 638 | metadata, connected_at, last_used_at, created_at) | |
| 639 | VALUES ('$uid', 'ebay', 'connected', '$refresh', | |
| 640 | '$meta_json', NOW(), NOW(), NOW()) | |
| 641 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 642 | } | |
| 643 | ||
| 644 | $db->db_readwrite($dbh, qq~ | |
| 645 | UPDATE `${DB}`.users SET oauth_state=NULL, oauth_marketplace_id=NULL WHERE id='$uid' | |
| 646 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 647 | ||
| 648 | print "Status: 302 Found\nLocation: /marketplaces.cgi?flash=" | |
| 649 | . _e('eBay connected.') . "&flash_kind=success\n\n"; | |
| 650 | } |