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