added on local at 2026-07-01 21:47:35
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- printable shipping label + packing slip. | |
| 4 | # | |
| 5 | # /print_label.cgi?label_id=N | |
| 6 | # | |
| 7 | # Renders a print-ready HTML page (no app chrome) the seller can hit | |
| 8 | # Ctrl+P on. Designed to fit on US Letter or A4. Includes: | |
| 9 | # - From / To addresses | |
| 10 | # - Carrier + service + tracking number with a CODE128 barcode | |
| 11 | # - Weight + dimensions | |
| 12 | # - A packing slip listing the order items | |
| 13 | # | |
| 14 | # Until a real carrier-API label PDF is available, this is the | |
| 15 | # label. It's not for carrier acceptance (no postage paid) -- it's | |
| 16 | # what the seller staples on the box for their own records / to | |
| 17 | # stick on top of a carrier-issued label they bought elsewhere. | |
| 18 | #====================================================================== | |
| 19 | use strict; | |
| 20 | use warnings; | |
| 21 | ||
| 22 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 23 | use CGI; | |
| 24 | use MODS::DBConnect; | |
| 25 | use MODS::Login; | |
| 26 | use MODS::RePricer::Config; | |
| 27 | use MODS::RePricer::Permissions; | |
| 28 | use MODS::RePricer::Shipping; | |
| 29 | ||
| 30 | $| = 1; | |
| 31 | ||
| 32 | my $q = CGI->new; | |
| 33 | my $form = $q->Vars; | |
| 34 | my $auth = MODS::Login->new; | |
| 35 | my $db = MODS::DBConnect->new; | |
| 36 | my $cfg = MODS::RePricer::Config->new; | |
| 37 | my $perm = MODS::RePricer::Permissions->new; | |
| 38 | my $ship = MODS::RePricer::Shipping->new; | |
| 39 | my $DB = $cfg->settings('database_name'); | |
| 40 | ||
| 41 | my $userinfo = $auth->login_verify(); | |
| 42 | unless ($userinfo) { | |
| 43 | print "Status: 302 Found\nLocation: /login.cgi?return=/fulfill.cgi\n\n"; exit; | |
| 44 | } | |
| 45 | $perm->require_feature($userinfo, 'view_orders'); | |
| 46 | ||
| 47 | my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g; | |
| 48 | my $owner_uid = $userinfo->{owner_user_id} || $uid; | |
| 49 | $owner_uid =~ s/[^0-9]//g; | |
| 50 | ||
| 51 | my $lid = $form->{label_id} || 0; $lid =~ s/[^0-9]//g; | |
| 52 | unless ($lid) { | |
| 53 | print "Status: 302 Found\nLocation: /fulfill.cgi\n\n"; exit; | |
| 54 | } | |
| 55 | ||
| 56 | my $dbh = $db->db_connect(); | |
| 57 | my $label = $ship->get_label($db, $dbh, $DB, $lid, $owner_uid); | |
| 58 | unless ($label) { | |
| 59 | $db->db_disconnect($dbh); | |
| 60 | print "Status: 302 Found\nLocation: /fulfill.cgi\n\n"; exit; | |
| 61 | } | |
| 62 | my $order = $ship->get_order($db, $dbh, $DB, $label->{order_id}, $owner_uid); | |
| 63 | my $items = $ship->get_order_items($db, $dbh, $DB, $label->{order_id}, $owner_uid); | |
| 64 | my $settings = $ship->get_settings($db, $dbh, $DB, $owner_uid) || {}; | |
| 65 | $db->db_disconnect($dbh); | |
| 66 | ||
| 67 | my $brand = $cfg->settings('brand_name') || 'RePricer'; | |
| 68 | my $tracking = $label->{tracking_number} || ''; | |
| 69 | ||
| 70 | print "Content-Type: text/html; charset=utf-8\n\n"; | |
| 71 | print qq~<!DOCTYPE html> | |
| 72 | <html lang="en"> | |
| 73 | <head> | |
| 74 | <meta charset="utf-8"> | |
| 75 | <title>Label #$lid — Order #$label->{order_id}</title> | |
| 76 | <style> | |
| 77 | \@page { size: letter; margin: 0.4in; } | |
| 78 | * { box-sizing: border-box; } | |
| 79 | body { margin:0; padding:24px; font-family: -apple-system, "Segoe UI", system-ui, Arial, sans-serif; color:#111; background:#fff; -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| 80 | .pl-noprint { background:#f4f4f5; border:1px dashed #cbd5e1; padding:10px 14px; border-radius:6px; margin-bottom:18px; font-size:13px; color:#475569; display:flex; gap:10px; align-items:center; justify-content:space-between; } | |
| 81 | .pl-noprint button { padding:6px 14px; font-size:13px; background:#111; color:#fff; border:none; border-radius:5px; cursor:pointer; } | |
| 82 | \@media print { .pl-noprint { display:none !important; } } | |
| 83 | ||
| 84 | .pl-card { border:2px solid #111; border-radius:6px; overflow:hidden; max-width:7.5in; } | |
| 85 | .pl-row { display:flex; } | |
| 86 | .pl-cell { flex:1; padding:14px 16px; } | |
| 87 | .pl-cell + .pl-cell { border-left:1.5px solid #111; } | |
| 88 | .pl-row + .pl-row { border-top:1.5px solid #111; } | |
| 89 | ||
| 90 | .pl-lbl { font-size:9px; text-transform:uppercase; letter-spacing:2px; color:#000; font-weight:700; margin-bottom:6px; } | |
| 91 | .pl-addr { font-size:14px; line-height:1.45; } | |
| 92 | .pl-big { font-size:22px; font-weight:700; } | |
| 93 | .pl-carrier { font-size:32px; font-weight:900; letter-spacing:2px; text-transform:uppercase; } | |
| 94 | .pl-service { font-size:13px; color:#444; } | |
| 95 | ||
| 96 | .pl-track { padding:14px 16px; border-top:1.5px solid #111; } | |
| 97 | .pl-track-num { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size:18px; font-weight:700; letter-spacing:1px; } | |
| 98 | .pl-barcode { display:flex; gap:1px; height:60px; margin-top:6px; } | |
| 99 | .pl-barcode span { display:block; background:#111; } | |
| 100 | ||
| 101 | .pl-meta { display:flex; gap:24px; padding:10px 16px; font-size:11px; color:#444; } | |
| 102 | .pl-meta strong { color:#111; font-size:12px; } | |
| 103 | ||
| 104 | /* Packing slip below the label */ | |
| 105 | .pl-slip { max-width:7.5in; margin-top:24px; } | |
| 106 | .pl-slip h2 { font-size:18px; margin:0 0 4px; } | |
| 107 | .pl-slip .pl-sub { font-size:12px; color:#666; margin-bottom:14px; } | |
| 108 | table.pl-items { width:100%; border-collapse:collapse; font-size:13px; } | |
| 109 | .pl-items th { text-align:left; padding:8px 6px; border-bottom:2px solid #111; font-size:11px; text-transform:uppercase; letter-spacing:1px; } | |
| 110 | .pl-items td { padding:6px; border-bottom:1px solid #d4d4d8; } | |
| 111 | .pl-items td.r { text-align:right; font-variant-numeric: tabular-nums; } | |
| 112 | .pl-thanks { margin-top:16px; padding:12px 14px; background:#f4f4f5; border-radius:6px; font-size:13px; color:#444; } | |
| 113 | </style> | |
| 114 | </head> | |
| 115 | <body> | |
| 116 | ||
| 117 | <div class="pl-noprint"> | |
| 118 | <span>This is a print preview — tap Print or hit Ctrl/Cmd + P.</span> | |
| 119 | <button onclick="window.print()">Print</button> | |
| 120 | </div> | |
| 121 | ||
| 122 | <div class="pl-card"> | |
| 123 | <div class="pl-row"> | |
| 124 | <div class="pl-cell" style="flex:1.2"> | |
| 125 | <div class="pl-lbl">From</div> | |
| 126 | <div class="pl-addr"> | |
| 127 | ~; | |
| 128 | ||
| 129 | my $from_html = _addr_block( | |
| 130 | $settings->{origin_name}, $settings->{origin_company}, | |
| 131 | $settings->{origin_line1}, $settings->{origin_line2}, | |
| 132 | $settings->{origin_city}, $settings->{origin_region}, $settings->{origin_postal}, | |
| 133 | $settings->{origin_country}, $settings->{origin_phone}, | |
| 134 | ); | |
| 135 | print $from_html; | |
| 136 | print qq~ | |
| 137 | </div> | |
| 138 | </div> | |
| 139 | <div class="pl-cell" style="flex:0.8;display:flex;flex-direction:column;align-items:flex-end;justify-content:space-between"> | |
| 140 | <div class="pl-carrier">~ . uc(_h($label->{carrier} || 'manual')) . qq~</div> | |
| 141 | <div class="pl-service">~ . _h($label->{service} || '') . qq~</div> | |
| 142 | </div> | |
| 143 | </div> | |
| 144 | ||
| 145 | <div class="pl-row"> | |
| 146 | <div class="pl-cell"> | |
| 147 | <div class="pl-lbl">Ship to</div> | |
| 148 | <div class="pl-addr pl-big"> | |
| 149 | ~; | |
| 150 | ||
| 151 | my $to_html = _addr_block( | |
| 152 | $order->{ship_name}, undef, | |
| 153 | $order->{ship_addr1}, $order->{ship_addr2}, | |
| 154 | $order->{ship_city}, $order->{ship_region}, $order->{ship_postal}, | |
| 155 | $order->{ship_country}, $order->{ship_phone}, | |
| 156 | ); | |
| 157 | print $to_html; | |
| 158 | print qq~ | |
| 159 | </div> | |
| 160 | </div> | |
| 161 | </div> | |
| 162 | ~; | |
| 163 | ||
| 164 | if (length $tracking) { | |
| 165 | my $barcode = _barcode_strips($tracking); | |
| 166 | print qq~ | |
| 167 | <div class="pl-track"> | |
| 168 | <div class="pl-lbl">Tracking number</div> | |
| 169 | <div class="pl-track-num">$tracking</div> | |
| 170 | <div class="pl-barcode">$barcode</div> | |
| 171 | </div> | |
| 172 | ~; | |
| 173 | } | |
| 174 | ||
| 175 | print qq~ | |
| 176 | <div class="pl-meta"> | |
| 177 | <div><strong>Order</strong> #$label->{order_id}</div> | |
| 178 | <div><strong>Label</strong> #$lid</div> | |
| 179 | ~; | |
| 180 | if ($label->{weight_grams}) { | |
| 181 | print qq~<div><strong>Weight</strong> $label->{weight_grams} g</div>~; | |
| 182 | } | |
| 183 | if (defined $label->{cost_cents}) { | |
| 184 | my $c = sprintf('%.2f', $label->{cost_cents} / 100); | |
| 185 | print qq~<div><strong>Postage</strong> \$$c</div>~; | |
| 186 | } | |
| 187 | print qq~ </div> | |
| 188 | </div> | |
| 189 | ||
| 190 | <div class="pl-slip"> | |
| 191 | <h2>Packing slip — Order #$label->{order_id}</h2> | |
| 192 | <div class="pl-sub">From $brand · ~ . _h($order->{paid_at} || $order->{created_at} || '') . qq~</div> | |
| 193 | <table class="pl-items"> | |
| 194 | <thead> | |
| 195 | <tr> | |
| 196 | <th style="width:50%">Item</th> | |
| 197 | <th>Variant</th> | |
| 198 | <th class="r">Qty</th> | |
| 199 | <th class="r">Price</th> | |
| 200 | </tr> | |
| 201 | </thead> | |
| 202 | <tbody> | |
| 203 | ~; | |
| 204 | ||
| 205 | my $st_subtotal = 0; | |
| 206 | foreach my $it (@$items) { | |
| 207 | next unless ($it->{purchase_kind} || '') eq 'physical'; | |
| 208 | my $variant = ''; | |
| 209 | if (length($it->{selected_color} || '')) { $variant .= _h($it->{selected_color}); } | |
| 210 | if (length($it->{selected_material} || '')) { | |
| 211 | $variant .= ' · ' if length $variant; | |
| 212 | $variant .= _h($it->{selected_material}); | |
| 213 | } | |
| 214 | my $line = ($it->{price_paid_cents} || 0) * ($it->{quantity} || 1); | |
| 215 | $st_subtotal += $line; | |
| 216 | print qq~ | |
| 217 | <tr> | |
| 218 | <td>~ . _h($it->{model_title} || '') . qq~</td> | |
| 219 | <td>$variant</td> | |
| 220 | <td class="r">$it->{quantity}</td> | |
| 221 | <td class="r">\$~ . sprintf('%.2f', $line / 100) . qq~</td> | |
| 222 | </tr>~; | |
| 223 | } | |
| 224 | ||
| 225 | my $total_disp = sprintf('%.2f', ($order->{total_amount_cents} || 0) / 100); | |
| 226 | my $sub_disp = sprintf('%.2f', $st_subtotal / 100); | |
| 227 | my $shp_disp = sprintf('%.2f', ($order->{shipping_cents} || 0) / 100); | |
| 228 | ||
| 229 | print qq~ | |
| 230 | </tbody> | |
| 231 | <tfoot> | |
| 232 | <tr><td colspan="3" class="r" style="border-bottom:none;color:#666">Items subtotal</td><td class="r" style="border-bottom:none">\$$sub_disp</td></tr> | |
| 233 | <tr><td colspan="3" class="r" style="border-bottom:none;color:#666">Shipping</td><td class="r" style="border-bottom:none">\$$shp_disp</td></tr> | |
| 234 | <tr><td colspan="3" class="r" style="border-bottom:none;font-weight:700">Total</td><td class="r" style="border-bottom:none;font-weight:700">\$$total_disp ~ . _h($order->{currency} || 'USD') . qq~</td></tr> | |
| 235 | </tfoot> | |
| 236 | </table> | |
| 237 | <div class="pl-thanks">Thank you for your order! Questions? Reply to the email on your receipt.</div> | |
| 238 | </div> | |
| 239 | ||
| 240 | </body> | |
| 241 | </html> | |
| 242 | ~; | |
| 243 | exit; | |
| 244 | ||
| 245 | sub _h { | |
| 246 | my $s = shift // ''; | |
| 247 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 248 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 249 | return $s; | |
| 250 | } | |
| 251 | sub _addr_block { | |
| 252 | my ($name, $company, $l1, $l2, $city, $region, $postal, $country, $phone) = @_; | |
| 253 | my @lines; | |
| 254 | push @lines, _h($name) if length($name || ''); | |
| 255 | push @lines, _h($company) if length($company || ''); | |
| 256 | push @lines, _h($l1) if length($l1 || ''); | |
| 257 | push @lines, _h($l2) if length($l2 || ''); | |
| 258 | my $csz = join(' ', grep { length } (_h($city || ''), _h($region || ''), _h($postal || ''))); | |
| 259 | push @lines, $csz if length $csz; | |
| 260 | push @lines, _h($country) if length($country || ''); | |
| 261 | return @lines ? join('<br>', @lines) : '(no address)'; | |
| 262 | } | |
| 263 | # Pseudo-barcode: not a real CODE128 -- a visual representation that | |
| 264 | # matches the digits/characters in the tracking number. Carriers will | |
| 265 | # only scan their own printed barcodes, but this gives the label a | |
| 266 | # legitimate look and lets a seller eyeball-check a tracking number. | |
| 267 | sub _barcode_strips { | |
| 268 | my ($s) = @_; | |
| 269 | my $bars = ''; | |
| 270 | foreach my $ch (split //, $s) { | |
| 271 | my $w = (ord($ch) % 4) + 1; # 1..4 px | |
| 272 | my $g = (ord($ch) % 3) + 1; # 1..3 px gap | |
| 273 | $bars .= qq~<span style="width:${w}px"></span><span style="width:${g}px;background:#fff"></span>~; | |
| 274 | } | |
| 275 | return $bars; | |
| 276 | } |