added on local at 2026-07-01 13:48:02
| 1 | <!-- ===================================================================== | |
| 2 | Seller-side Customers page: list of buyers + per-buyer detail. | |
| 3 | One template handles both views, switched by $is_list_view / | |
| 4 | $is_detail_view from customers.cgi. | |
| 5 | ===================================================================== --> | |
| 6 | <style> | |
| 7 | .cu-flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; } | |
| 8 | .cu-flash.ok { background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.40); color: #4ade80; } | |
| 9 | .cu-flash.danger { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.40); color: #f87171; } | |
| 10 | ||
| 11 | .cu-card { | |
| 12 | background: var(--col-surface-1); | |
| 13 | border: 1px solid var(--col-border); | |
| 14 | border-radius: 12px; | |
| 15 | padding: 18px; | |
| 16 | margin-bottom: 14px; | |
| 17 | } | |
| 18 | .cu-card-title { | |
| 19 | font-size: 10px; font-weight: 700; | |
| 20 | letter-spacing: 1.5px; text-transform: uppercase; | |
| 21 | color: var(--col-text-3); | |
| 22 | margin-bottom: 12px; | |
| 23 | } | |
| 24 | ||
| 25 | .cu-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; } | |
| 26 | .cu-table th { text-align: left; font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); padding: 9px 10px; font-weight: 700; border-bottom: 1px solid var(--col-border); } | |
| 27 | .cu-table td { padding: 11px 10px; border-bottom: 1px solid var(--col-border); color: var(--col-text-2); } | |
| 28 | .cu-table tr:last-child td { border-bottom: none; } | |
| 29 | .cu-table tr.cu-clickable { cursor: pointer; } | |
| 30 | .cu-table tr.cu-clickable:hover td { background: var(--col-surface-2); } | |
| 31 | .cu-table .mono { font-family: var(--font-mono, monospace); color: var(--col-text); font-size: 12px; } | |
| 32 | .cu-table .num { text-align: right; font-family: var(--font-mono, monospace); } | |
| 33 | ||
| 34 | .cu-pill { font-size: 9px; font-weight: 700; padding: 3px 8px; border-radius: 6px; letter-spacing: 1px; text-transform: uppercase; } | |
| 35 | .cu-pill.ok { background: rgba(34,197,94,0.18); color: #4ade80; } | |
| 36 | .cu-pill.warn { background: rgba(245,158,11,0.20); color: #fbbf24; } | |
| 37 | .cu-pill.zero { background: var(--col-surface-2); color: var(--col-text-3); } | |
| 38 | .cu-pill.neg { background: rgba(239,68,68,0.20); color: #f87171; } | |
| 39 | ||
| 40 | .cu-bal { | |
| 41 | display: flex; align-items: baseline; gap: 14px; | |
| 42 | padding: 18px 22px; | |
| 43 | background: var(--col-surface-2); | |
| 44 | border: 1px solid var(--col-border); | |
| 45 | border-radius: 12px; | |
| 46 | } | |
| 47 | .cu-bal .amt { font-size: 30px; font-weight: 800; font-family: var(--font-mono, monospace); color: var(--col-text); } | |
| 48 | .cu-bal .amt.pos { color: #4ade80; } | |
| 49 | .cu-bal .amt.neg { color: #f87171; } | |
| 50 | .cu-bal .lbl { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); } | |
| 51 | ||
| 52 | .cu-detail-grid { | |
| 53 | display: grid; | |
| 54 | grid-template-columns: 320px 1fr; | |
| 55 | gap: 16px; | |
| 56 | margin-top: 14px; | |
| 57 | } | |
| 58 | \@media (max-width: 1000px) { .cu-detail-grid { grid-template-columns: 1fr; } } | |
| 59 | ||
| 60 | .cu-meta-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: 13px; border-bottom: 1px solid var(--col-border); } | |
| 61 | .cu-meta-row:last-child { border-bottom: none; } | |
| 62 | .cu-meta-row .lbl { color: var(--col-text-3); } | |
| 63 | .cu-meta-row .val { color: var(--col-text); font-weight: 600; text-align: right; word-break: break-word; } | |
| 64 | ||
| 65 | .cu-grant-btn { | |
| 66 | width: 100%; | |
| 67 | background: var(--grad-brand); | |
| 68 | border: 0; | |
| 69 | color: #fff; | |
| 70 | padding: 11px 14px; | |
| 71 | border-radius: 9px; | |
| 72 | cursor: pointer; | |
| 73 | font-size: 13px; | |
| 74 | font-weight: 700; | |
| 75 | margin-top: 10px; | |
| 76 | } | |
| 77 | .cu-grant-btn:hover { filter: brightness(1.08); } | |
| 78 | </style> | |
| 79 | ||
| 80 | [if:$has_flash] | |
| 81 | <div class="cu-flash $flash_kind">$flash_msg</div> | |
| 82 | [/if] | |
| 83 | ||
| 84 | [if:$is_list_view] | |
| 85 | <div class="page-head"> | |
| 86 | <div> | |
| 87 | <span class="page-eyebrow"><span class="dot"></span> Studio · Customers</span> | |
| 88 | <h1 class="page-title">Customers</h1> | |
| 89 | <p class="page-subtitle">Every buyer who has ordered from your storefronts, with their current store-credit balance.</p> | |
| 90 | </div> | |
| 91 | </div> | |
| 92 | ||
| 93 | <div class="cu-card"> | |
| 94 | <div class="cu-card-title">All buyers</div> | |
| 95 | [if:$has_rows] | |
| 96 | <table class="cu-table"> | |
| 97 | <thead> | |
| 98 | <tr> | |
| 99 | <th>Buyer</th> | |
| 100 | <th>Storefront</th> | |
| 101 | <th class="num">Orders</th> | |
| 102 | <th class="num">Lifetime spend</th> | |
| 103 | <th class="num">Store credit</th> | |
| 104 | <th>Last order</th> | |
| 105 | </tr> | |
| 106 | </thead> | |
| 107 | <tbody> | |
| 108 | [loop:@rows] | |
| 109 | <tr class="cu-clickable" data-href="/customers.cgi?$loop1.detail_qs"> | |
| 110 | <td> | |
| 111 | [if:$loop1.display_name]<strong>$loop1.display_name</strong><br>[/if] | |
| 112 | <span class="mono">$loop1.buyer_email</span> | |
| 113 | [if:!$loop1.has_account] <span class="cu-pill warn" title="Guest checkout - no buyer account">Guest</span>[/if] | |
| 114 | </td> | |
| 115 | <td>$loop1.store_name</td> | |
| 116 | <td class="num">$loop1.order_count</td> | |
| 117 | <td class="num mono">$loop1.spent_dollars</td> | |
| 118 | <td class="num"> | |
| 119 | [if:$loop1.has_credit]<span class="cu-pill ok mono">$loop1.balance_dollars</span>[/if] | |
| 120 | [if:$loop1.no_credit]<span class="cu-pill zero mono">$loop1.balance_dollars</span>[/if] | |
| 121 | [if:$loop1.has_debit]<span class="cu-pill neg mono">$loop1.balance_dollars</span>[/if] | |
| 122 | </td> | |
| 123 | <td class="mono">$loop1.last_order_at</td> | |
| 124 | </tr> | |
| 125 | [/loop] | |
| 126 | </tbody> | |
| 127 | </table> | |
| 128 | <script> | |
| 129 | // Whole-row click navigation. Avoids putting an anchor in every cell. | |
| 130 | document.querySelectorAll('.cu-clickable').forEach(function (tr) { | |
| 131 | tr.addEventListener('click', function () { | |
| 132 | var h = tr.getAttribute('data-href'); | |
| 133 | if (h) window.location.href = h; | |
| 134 | }); | |
| 135 | }); | |
| 136 | </script> | |
| 137 | [/if] | |
| 138 | [if:$no_rows] | |
| 139 | <div style="text-align:center;padding:40px 18px;color:var(--col-text-3)"> | |
| 140 | <div style="font-size:14px;font-weight:600;color:var(--col-text);margin-bottom:6px">No customers yet</div> | |
| 141 | <div style="font-size:12px">As soon as your first buyer places an order they will appear here.</div> | |
| 142 | </div> | |
| 143 | [/if] | |
| 144 | </div> | |
| 145 | [/if] | |
| 146 | ||
| 147 | [if:$is_detail_view] | |
| 148 | <div class="page-head"> | |
| 149 | <div> | |
| 150 | <span class="page-eyebrow"><span class="dot"></span> Customers · $store_name</span> | |
| 151 | <h1 class="page-title">$buyer_name</h1> | |
| 152 | <p class="page-subtitle">$buyer_email[if:$buyer_no_account] · guest checkout (no account)[/if]</p> | |
| 153 | </div> | |
| 154 | <div class="page-actions"> | |
| 155 | <a href="/customers.cgi" class="btn btn-secondary"> | |
| 156 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg> | |
| 157 | Back to customers | |
| 158 | </a> | |
| 159 | </div> | |
| 160 | </div> | |
| 161 | ||
| 162 | <div class="cu-detail-grid"> | |
| 163 | <!-- LEFT: profile + credit --> | |
| 164 | <div> | |
| 165 | <div class="cu-card"> | |
| 166 | <div class="cu-card-title">Buyer</div> | |
| 167 | <div class="cu-meta-row"><span class="lbl">Email</span><span class="val">$buyer_email</span></div> | |
| 168 | [if:$buyer_has_account] | |
| 169 | <div class="cu-meta-row"><span class="lbl">Display name</span><span class="val">$buyer_name</span></div> | |
| 170 | <div class="cu-meta-row"><span class="lbl">Joined</span><span class="val">$buyer_joined</span></div> | |
| 171 | <div class="cu-meta-row"><span class="lbl">Last login</span><span class="val">$buyer_last_login</span></div> | |
| 172 | [/if] | |
| 173 | [if:$buyer_no_account] | |
| 174 | <div class="cu-meta-row"><span class="lbl">Account</span><span class="val">Guest checkout</span></div> | |
| 175 | [/if] | |
| 176 | <div class="cu-meta-row"><span class="lbl">Orders</span><span class="val">$buyer_order_count</span></div> | |
| 177 | <div class="cu-meta-row"><span class="lbl">Lifetime spend</span><span class="val">$buyer_lifetime</span></div> | |
| 178 | </div> | |
| 179 | ||
| 180 | <div class="cu-card"> | |
| 181 | <div class="cu-card-title">Store credit balance</div> | |
| 182 | <div class="cu-bal"> | |
| 183 | <span class="amt[if:$has_credit] pos[/if][if:$has_debit] neg[/if]">$balance_dollars</span> | |
| 184 | <span class="lbl">applied to next order</span> | |
| 185 | </div> | |
| 186 | [if:$can_grant] | |
| 187 | <form id="cu-grant-form" method="POST" action="/customer_action.cgi"> | |
| 188 | <input type="hidden" name="act" value="grant_credit"> | |
| 189 | <input type="hidden" name="storefront_id" value="$store_id"> | |
| 190 | [if:$buyer_has_account]<input type="hidden" name="buyer_account_id" value="$buyer_id">[/if] | |
| 191 | <input type="hidden" name="buyer_email" value="$buyer_email"> | |
| 192 | <input type="hidden" name="amount_cents" value=""> | |
| 193 | <input type="hidden" name="note" value=""> | |
| 194 | <input type="hidden" name="return_to" value=""> | |
| 195 | <button type="button" class="cu-grant-btn js-cu-grant">Grant credit to this buyer</button> | |
| 196 | </form> | |
| 197 | [/if] | |
| 198 | [if:!$can_grant] | |
| 199 | <div style="font-size:11px;color:var(--col-text-3);margin-top:10px">You do not have the "Issue refunds" permission required to grant credit. Ask your storefront owner to enable it for your role.</div> | |
| 200 | [/if] | |
| 201 | </div> | |
| 202 | </div> | |
| 203 | ||
| 204 | <!-- RIGHT: orders + credit history --> | |
| 205 | <div> | |
| 206 | <div class="cu-card"> | |
| 207 | <div class="cu-card-title">Recent orders</div> | |
| 208 | [if:$has_orders] | |
| 209 | <table class="cu-table"> | |
| 210 | <thead><tr><th>Order</th><th>Status</th><th class="num">Total</th><th>Credit used</th><th>Placed</th></tr></thead> | |
| 211 | <tbody> | |
| 212 | [loop:@orders] | |
| 213 | <tr> | |
| 214 | <td class="mono">#$loop1.id</td> | |
| 215 | <td> | |
| 216 | [if:$loop1.is_paid]<span class="cu-pill ok">$loop1.status_label</span>[/if] | |
| 217 | [if:$loop1.is_refunded]<span class="cu-pill neg">$loop1.status_label</span>[/if] | |
| 218 | [if:!$loop1.is_paid][if:!$loop1.is_refunded]$loop1.status_label[/if][/if] | |
| 219 | </td> | |
| 220 | <td class="num mono">$loop1.total_dollars</td> | |
| 221 | <td class="mono"> | |
| 222 | [if:$loop1.has_credit_used]$loop1.credit_used_dollars[/if] | |
| 223 | [if:!$loop1.has_credit_used]—[/if] | |
| 224 | </td> | |
| 225 | <td class="mono">$loop1.placed_at</td> | |
| 226 | </tr> | |
| 227 | [/loop] | |
| 228 | </tbody> | |
| 229 | </table> | |
| 230 | [/if] | |
| 231 | [if:$no_orders] | |
| 232 | <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No orders from this buyer yet.</div> | |
| 233 | [/if] | |
| 234 | </div> | |
| 235 | ||
| 236 | <div class="cu-card"> | |
| 237 | <div class="cu-card-title">Credit history</div> | |
| 238 | [if:$has_history] | |
| 239 | <table class="cu-table"> | |
| 240 | <thead><tr><th class="num">Amount</th><th>Reason</th><th>Order</th><th>Note</th><th>When</th></tr></thead> | |
| 241 | <tbody> | |
| 242 | [loop:@history] | |
| 243 | <tr> | |
| 244 | <td class="num mono" style="color:[if:$loop1.is_positive]#4ade80[/if][if:$loop1.is_negative]#f87171[/if]">$loop1.amount_dollars</td> | |
| 245 | <td>$loop1.reason_label</td> | |
| 246 | <td class="mono">[if:$loop1.has_related]#$loop1.related_order_id[/if][if:!$loop1.has_related]—[/if]</td> | |
| 247 | <td>$loop1.note</td> | |
| 248 | <td class="mono">$loop1.created_at</td> | |
| 249 | </tr> | |
| 250 | [/loop] | |
| 251 | </tbody> | |
| 252 | </table> | |
| 253 | [/if] | |
| 254 | [if:$no_history] | |
| 255 | <div style="color:var(--col-text-3);font-size:12px;padding:6px 0">No credit activity yet.</div> | |
| 256 | [/if] | |
| 257 | </div> | |
| 258 | </div> | |
| 259 | </div> | |
| 260 | ||
| 261 | [if:$can_grant] | |
| 262 | <script> | |
| 263 | // Grant-credit flow: amount prompt -> note prompt -> confirm -> submit. | |
| 264 | document.addEventListener('click', function (e) { | |
| 265 | var btn = e.target.closest('.js-cu-grant'); | |
| 266 | if (!btn) return; | |
| 267 | e.preventDefault(); | |
| 268 | var form = document.getElementById('cu-grant-form'); | |
| 269 | if (!form) return; | |
| 270 | appPrompt({ | |
| 271 | title: 'Grant credit', | |
| 272 | message: 'Enter the amount in cents. Example: 500 for \$5.00.', | |
| 273 | placeholder: '500', | |
| 274 | input_type: 'number', | |
| 275 | label: 'Next', | |
| 276 | required: true, | |
| 277 | validate: function (v) { | |
| 278 | if (!/^[0-9]+\$/.test(v)) return 'Whole-cent integer required.'; | |
| 279 | if (parseInt(v, 10) <= 0) return 'Must be greater than zero.'; | |
| 280 | return ''; | |
| 281 | } | |
| 282 | }).then(function (amt) { | |
| 283 | if (amt === null) return; | |
| 284 | appPrompt({ | |
| 285 | title: 'Note for the buyer', | |
| 286 | message: 'Shown to the buyer on their credit history. Keep it short and clear.', | |
| 287 | placeholder: 'Sorry for the wait!', | |
| 288 | default_value: 'Store credit', | |
| 289 | label: 'Next', | |
| 290 | required: true | |
| 291 | }).then(function (note) { | |
| 292 | if (note === null) return; | |
| 293 | var dollars = (parseInt(amt, 10) / 100).toFixed(2); | |
| 294 | appConfirm({ | |
| 295 | title: 'Grant \$' + dollars + ' credit?', | |
| 296 | message: 'Goes into the buyer ledger immediately and applies on their next order.', | |
| 297 | confirm_label: 'Grant credit', | |
| 298 | confirm_style: 'success' | |
| 299 | }).then(function (ok) { | |
| 300 | if (!ok) return; | |
| 301 | form.querySelector('input[name="amount_cents"]').value = amt; | |
| 302 | form.querySelector('input[name="note"]').value = note; | |
| 303 | form.querySelector('input[name="return_to"]').value = window.location.pathname + window.location.search; | |
| 304 | form.submit(); | |
| 305 | }); | |
| 306 | }); | |
| 307 | }); | |
| 308 | }); | |
| 309 | </script> | |
| 310 | [/if] | |
| 311 | [/if] | |
| 312 | ||
| 313 | <!-- Trailing non-conditional content: MODS::Template returns the eval's | |
| 314 | final value, so a template that ends with [/if] silently renders empty. --> | |
| 315 | <div style="height:1px"></div> |