added on local at 2026-07-01 13:48:00
| 1 | <!-- ===================================================================== | |
| 2 | Manage card on file. | |
| 3 | 1. Flash message strip | |
| 4 | 2. Saved cards list (set default / remove) | |
| 5 | 3. Stripe Elements card-entry form (SetupIntent) | |
| 6 | Backed by /billing_payment.cgi. PAN never reaches our server -- | |
| 7 | Stripe.js handles the card field; we only see the resulting | |
| 8 | pm_xxx id that we attach + persist. | |
| 9 | The whole interactive surface is wrapped with [ptag:manage_billing] | |
| 10 | so a team member without the grant lands on a read-only "not | |
| 11 | authorized" notice from require_feature's redirect upstream. | |
| 12 | ===================================================================== --> | |
| 13 | ||
| 14 | <style> | |
| 15 | .pay-wrap { max-width: 720px; margin: 0 auto; } | |
| 16 | .pay-card { | |
| 17 | background: var(--col-surface-1); | |
| 18 | border: 1px solid var(--col-border); | |
| 19 | border-radius: 12px; | |
| 20 | padding: 18px 20px; | |
| 21 | margin-bottom: 18px; | |
| 22 | } | |
| 23 | .pay-card h2 { | |
| 24 | font-size: 16px; font-weight: 600; color: var(--col-text); | |
| 25 | margin: 0 0 12px; | |
| 26 | } | |
| 27 | .pay-row { | |
| 28 | display: flex; align-items: center; justify-content: space-between; | |
| 29 | gap: 14px; padding: 10px 0; border-top: 1px solid var(--col-border); | |
| 30 | } | |
| 31 | .pay-row:first-of-type { border-top: 0; } | |
| 32 | .pay-pill { | |
| 33 | display: inline-block; font-size: 11px; font-weight: 600; | |
| 34 | background: var(--col-brand); color: #fff; padding: 2px 8px; | |
| 35 | border-radius: 999px; margin-left: 8px; | |
| 36 | } | |
| 37 | .pay-brand-tile { | |
| 38 | width: 48px; height: 32px; border-radius: 6px; | |
| 39 | background: linear-gradient(135deg,#1e293b,#334155); | |
| 40 | display: grid; place-items: center; | |
| 41 | font-size: 11px; font-weight: 700; color: #fff; | |
| 42 | } | |
| 43 | #card-element { | |
| 44 | background: #fff; padding: 12px 14px; border-radius: 8px; | |
| 45 | border: 1px solid var(--col-border); | |
| 46 | } | |
| 47 | #card-errors { | |
| 48 | color: #f87171; font-size: 13px; margin-top: 8px; min-height: 18px; | |
| 49 | } | |
| 50 | .pay-flash { | |
| 51 | border-radius: 8px; padding: 10px 14px; font-size: 13px; | |
| 52 | margin-bottom: 16px; | |
| 53 | } | |
| 54 | .pay-flash.ok { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.35); } | |
| 55 | .pay-flash.danger { background: rgba(239,68,68,0.15); color: #fca5a5; border: 1px solid rgba(239,68,68,0.35); } | |
| 56 | .pay-not-config { | |
| 57 | border: 1px dashed var(--col-border); border-radius: 10px; | |
| 58 | padding: 20px; text-align: center; color: var(--col-text-2); | |
| 59 | } | |
| 60 | </style> | |
| 61 | ||
| 62 | <div class="pay-wrap"> | |
| 63 | ||
| 64 | <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px"> | |
| 65 | <h1 style="font-size:22px;color:var(--col-text);margin:0">Card on file</h1> | |
| 66 | <a href="/billing.cgi" class="btn btn-secondary btn-sm">Back to billing</a> | |
| 67 | </div> | |
| 68 | <p class="text-sm text-dim" style="margin:0 0 18px"> | |
| 69 | Cards are stored at Stripe; we only keep the brand, last 4 digits, and expiration on our side for display. The card number never reaches our servers. | |
| 70 | </p> | |
| 71 | ||
| 72 | [if:$has_flash] | |
| 73 | <div class="pay-flash $flash_kind">$flash_msg</div> | |
| 74 | [/if] | |
| 75 | ||
| 76 | [ptag:manage_billing] | |
| 77 | ||
| 78 | <!-- Saved cards --> | |
| 79 | <div class="pay-card"> | |
| 80 | <h2>Saved cards</h2> | |
| 81 | [if:$has_cards] | |
| 82 | [loop:@cards] | |
| 83 | <div class="pay-row"> | |
| 84 | <div style="display:flex;align-items:center;gap:14px"> | |
| 85 | <div class="pay-brand-tile">CARD</div> | |
| 86 | <div> | |
| 87 | <div class="text-sm fw-600" style="color:var(--col-text)"> | |
| 88 | $brand_label ···· $last4 | |
| 89 | [if:$is_default]<span class="pay-pill">Default</span>[/if] | |
| 90 | </div> | |
| 91 | <div class="text-xs text-dim">Expires $exp_display</div> | |
| 92 | </div> | |
| 93 | </div> | |
| 94 | <div style="display:flex;gap:8px"> | |
| 95 | [if:$not_default] | |
| 96 | <form method="POST" action="/billing_payment.cgi" style="margin:0"> | |
| 97 | <input type="hidden" name="act" value="set_default"> | |
| 98 | <input type="hidden" name="pm_id" value="$id"> | |
| 99 | <button class="btn btn-secondary btn-sm" type="submit">Make default</button> | |
| 100 | </form> | |
| 101 | [/if] | |
| 102 | <form method="POST" action="/billing_payment.cgi" style="margin:0" | |
| 103 | data-confirm-title="Remove this card?" | |
| 104 | data-confirm-message="Future invoices will fail until you add a replacement card. If this is your only card on file, paid features may pause." | |
| 105 | data-confirm-label="Remove card" | |
| 106 | data-confirm-style="danger"> | |
| 107 | <input type="hidden" name="act" value="delete_card"> | |
| 108 | <input type="hidden" name="pm_id" value="$id"> | |
| 109 | <button class="btn btn-danger btn-sm" type="submit">Remove</button> | |
| 110 | </form> | |
| 111 | </div> | |
| 112 | </div> | |
| 113 | [/loop] | |
| 114 | [/if] | |
| 115 | [if:$no_cards] | |
| 116 | <div class="text-sm text-dim" style="padding:8px 0"> | |
| 117 | No cards on file yet. Add one below to enable paid plans and automatic renewals. | |
| 118 | </div> | |
| 119 | [/if] | |
| 120 | </div> | |
| 121 | ||
| 122 | <!-- Add a card --> | |
| 123 | <div class="pay-card"> | |
| 124 | <h2>Add a card</h2> | |
| 125 | ||
| 126 | [if:$stripe_missing] | |
| 127 | <div class="pay-not-config"> | |
| 128 | Stripe is not configured for this install yet. Once the platform admin adds Stripe keys, this form will accept new cards. | |
| 129 | </div> | |
| 130 | [/if] | |
| 131 | ||
| 132 | [if:$has_setup] | |
| 133 | <form id="pay-form"> | |
| 134 | <label class="text-xs text-dim" style="display:block;margin-bottom:6px">Card details</label> | |
| 135 | <div id="card-element"></div> | |
| 136 | <div id="card-errors" role="alert"></div> | |
| 137 | <button id="pay-submit" type="submit" class="btn btn-primary" style="margin-top:14px"> | |
| 138 | <span id="pay-btn-text">Save card</span> | |
| 139 | </button> | |
| 140 | </form> | |
| 141 | ||
| 142 | <p class="text-xs text-dim" style="margin:14px 0 0"> | |
| 143 | We use Stripe Elements (Stripe's PCI-compliant hosted card field) so the card number is sent directly to Stripe over TLS. We receive only a token we use to charge future invoices. | |
| 144 | </p> | |
| 145 | ||
| 146 | <script src="https://js.stripe.com/v3/"></script> | |
| 147 | <script> | |
| 148 | (function () { | |
| 149 | var pubKey = '$publishable_key'; | |
| 150 | var clientSecret = '$client_secret'; | |
| 151 | if (!pubKey || !clientSecret) { return; } | |
| 152 | var stripe = Stripe(pubKey); | |
| 153 | var elements = stripe.elements(); | |
| 154 | var card = elements.create('card', { | |
| 155 | style: { | |
| 156 | base: { fontSize: '16px', color: '#0f172a' }, | |
| 157 | invalid: { color: '#dc2626' } | |
| 158 | } | |
| 159 | }); | |
| 160 | card.mount('#card-element'); | |
| 161 | ||
| 162 | var errBox = document.getElementById('card-errors'); | |
| 163 | card.on('change', function (ev) { | |
| 164 | errBox.textContent = ev.error ? ev.error.message : ''; | |
| 165 | }); | |
| 166 | ||
| 167 | var form = document.getElementById('pay-form'); | |
| 168 | var btn = document.getElementById('pay-submit'); | |
| 169 | var txt = document.getElementById('pay-btn-text'); | |
| 170 | form.addEventListener('submit', function (ev) { | |
| 171 | ev.preventDefault(); | |
| 172 | btn.disabled = true; | |
| 173 | txt.textContent = 'Saving...'; | |
| 174 | stripe.confirmCardSetup(clientSecret, { | |
| 175 | payment_method: { card: card } | |
| 176 | }).then(function (result) { | |
| 177 | if (result.error) { | |
| 178 | errBox.textContent = result.error.message || 'Could not save card.'; | |
| 179 | btn.disabled = false; | |
| 180 | txt.textContent = 'Save card'; | |
| 181 | return; | |
| 182 | } | |
| 183 | var pmId = result.setupIntent && result.setupIntent.payment_method; | |
| 184 | if (!pmId) { | |
| 185 | errBox.textContent = 'Card setup did not return a payment method.'; | |
| 186 | btn.disabled = false; | |
| 187 | txt.textContent = 'Save card'; | |
| 188 | return; | |
| 189 | } | |
| 190 | var f = document.createElement('form'); | |
| 191 | f.method = 'POST'; | |
| 192 | f.action = '/billing_payment.cgi'; | |
| 193 | function add(name, value) { | |
| 194 | var el = document.createElement('input'); | |
| 195 | el.type = 'hidden'; el.name = name; el.value = value; | |
| 196 | f.appendChild(el); | |
| 197 | } | |
| 198 | add('act', 'save_card'); | |
| 199 | add('stripe_payment_method_id', pmId); | |
| 200 | document.body.appendChild(f); | |
| 201 | f.submit(); | |
| 202 | }); | |
| 203 | }); | |
| 204 | })(); | |
| 205 | </script> | |
| 206 | [/if] | |
| 207 | ||
| 208 | [if:!$has_setup] | |
| 209 | [if:$stripe_ready] | |
| 210 | <div class="pay-not-config"> | |
| 211 | We could not start a Stripe SetupIntent right now. Refresh the page in a moment, or contact support if it keeps failing. | |
| 212 | </div> | |
| 213 | [/if] | |
| 214 | [/if] | |
| 215 | ||
| 216 | </div> | |
| 217 | ||
| 218 | [/ptag:manage_billing] | |
| 219 | ||
| 220 | </div> |