Mbonea commited on
Commit
c545977
Β·
1 Parent(s): 1daf642

Improve portal payment and success UX

Browse files
mockups/portal-redesign.html ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>WiFi Portal Mock</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ :root {
10
+ --ink: #101418;
11
+ --muted: #5f6b76;
12
+ --line: #dfe5ea;
13
+ --soft: #f3f6f8;
14
+ --brand: #1463ff;
15
+ --good: #0f9f6e;
16
+ --warn: #c2410c;
17
+ --card: #ffffff;
18
+ --radius: 8px;
19
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ }
21
+ body {
22
+ margin: 0;
23
+ min-height: 100vh;
24
+ background: linear-gradient(180deg, #eef4f7 0%, #f9fbfc 48%, #edf2f5 100%);
25
+ color: var(--ink);
26
+ display: grid;
27
+ place-items: center;
28
+ padding: 20px;
29
+ }
30
+ .phone {
31
+ width: min(390px, 100%);
32
+ background: var(--card);
33
+ border: 1px solid rgba(16, 20, 24, 0.08);
34
+ border-radius: 18px;
35
+ box-shadow: 0 24px 70px rgba(20, 35, 48, 0.16);
36
+ overflow: hidden;
37
+ }
38
+ .top {
39
+ padding: 22px 22px 16px;
40
+ border-bottom: 1px solid var(--line);
41
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
42
+ }
43
+ .brand-row {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ gap: 14px;
48
+ margin-bottom: 18px;
49
+ }
50
+ .mark {
51
+ width: 38px;
52
+ height: 38px;
53
+ border-radius: 8px;
54
+ background: var(--ink);
55
+ display: grid;
56
+ place-items: center;
57
+ color: #fff;
58
+ font-weight: 800;
59
+ letter-spacing: 0;
60
+ }
61
+ .network-pill {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 8px;
65
+ padding: 7px 10px;
66
+ border: 1px solid #cde2ff;
67
+ color: #0d4cbf;
68
+ background: #f2f7ff;
69
+ border-radius: 999px;
70
+ font-size: 11px;
71
+ font-weight: 700;
72
+ }
73
+ .pulse {
74
+ width: 7px;
75
+ height: 7px;
76
+ border-radius: 99px;
77
+ background: var(--good);
78
+ box-shadow: 0 0 0 4px rgba(15, 159, 110, 0.14);
79
+ }
80
+ h1 {
81
+ font-size: 27px;
82
+ line-height: 1.05;
83
+ letter-spacing: 0;
84
+ margin: 0 0 7px;
85
+ }
86
+ .lead {
87
+ margin: 0;
88
+ color: var(--muted);
89
+ font-size: 14px;
90
+ line-height: 1.45;
91
+ }
92
+ .section {
93
+ padding: 18px 22px 22px;
94
+ }
95
+ .label {
96
+ display: flex;
97
+ justify-content: space-between;
98
+ gap: 12px;
99
+ color: var(--muted);
100
+ font-size: 11px;
101
+ font-weight: 800;
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.08em;
104
+ margin-bottom: 9px;
105
+ }
106
+ .plans {
107
+ display: grid;
108
+ gap: 8px;
109
+ margin-bottom: 16px;
110
+ }
111
+ .plan {
112
+ display: grid;
113
+ grid-template-columns: 1fr auto;
114
+ gap: 10px;
115
+ padding: 13px 14px;
116
+ border: 1px solid var(--line);
117
+ border-radius: var(--radius);
118
+ background: #fff;
119
+ align-items: center;
120
+ }
121
+ .plan.selected {
122
+ border-color: var(--brand);
123
+ background: #f5f9ff;
124
+ box-shadow: 0 0 0 3px rgba(20, 99, 255, 0.09);
125
+ }
126
+ .plan strong {
127
+ display: block;
128
+ font-size: 14px;
129
+ margin-bottom: 3px;
130
+ }
131
+ .plan span {
132
+ color: var(--muted);
133
+ font-size: 12px;
134
+ }
135
+ .price {
136
+ font-size: 15px;
137
+ font-weight: 800;
138
+ white-space: nowrap;
139
+ }
140
+ .field {
141
+ margin-bottom: 14px;
142
+ }
143
+ .input {
144
+ width: 100%;
145
+ height: 46px;
146
+ border: 1px solid var(--line);
147
+ border-radius: var(--radius);
148
+ padding: 0 13px;
149
+ font-size: 15px;
150
+ font-weight: 600;
151
+ outline: none;
152
+ background: #fff;
153
+ }
154
+ .input:focus {
155
+ border-color: var(--brand);
156
+ box-shadow: 0 0 0 3px rgba(20, 99, 255, 0.12);
157
+ }
158
+ .pay-row {
159
+ display: grid;
160
+ grid-template-columns: 1fr;
161
+ gap: 9px;
162
+ }
163
+ .btn {
164
+ height: 46px;
165
+ border: 0;
166
+ border-radius: var(--radius);
167
+ background: var(--ink);
168
+ color: #fff;
169
+ font-size: 13px;
170
+ font-weight: 800;
171
+ cursor: default;
172
+ }
173
+ .alt {
174
+ background: var(--soft);
175
+ color: var(--ink);
176
+ border: 1px solid var(--line);
177
+ }
178
+ .notice {
179
+ display: flex;
180
+ gap: 10px;
181
+ padding: 12px;
182
+ border-radius: var(--radius);
183
+ background: #fff7ed;
184
+ border: 1px solid #fed7aa;
185
+ color: #7c2d12;
186
+ font-size: 12px;
187
+ line-height: 1.4;
188
+ margin-top: 14px;
189
+ }
190
+ .success {
191
+ border-top: 1px solid var(--line);
192
+ background: #fbfdfc;
193
+ padding: 20px 22px 22px;
194
+ }
195
+ .success-card {
196
+ border: 1px solid #b9efd8;
197
+ background: linear-gradient(180deg, #f5fffa 0%, #ffffff 100%);
198
+ border-radius: 10px;
199
+ padding: 17px;
200
+ }
201
+ .status {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 9px;
205
+ color: #066046;
206
+ font-size: 12px;
207
+ font-weight: 800;
208
+ text-transform: uppercase;
209
+ letter-spacing: 0.07em;
210
+ margin-bottom: 12px;
211
+ }
212
+ .check {
213
+ width: 23px;
214
+ height: 23px;
215
+ border-radius: 99px;
216
+ background: var(--good);
217
+ color: #fff;
218
+ display: grid;
219
+ place-items: center;
220
+ font-weight: 900;
221
+ font-size: 13px;
222
+ }
223
+ .success h2 {
224
+ margin: 0 0 6px;
225
+ font-size: 23px;
226
+ letter-spacing: 0;
227
+ }
228
+ .success p {
229
+ margin: 0 0 16px;
230
+ color: var(--muted);
231
+ font-size: 13px;
232
+ line-height: 1.45;
233
+ }
234
+ .voucher {
235
+ border: 1px dashed #94a3b8;
236
+ border-radius: 8px;
237
+ padding: 13px;
238
+ background: #fff;
239
+ margin-bottom: 12px;
240
+ }
241
+ .voucher small {
242
+ display: block;
243
+ color: var(--muted);
244
+ font-size: 10px;
245
+ font-weight: 800;
246
+ text-transform: uppercase;
247
+ letter-spacing: 0.08em;
248
+ margin-bottom: 7px;
249
+ }
250
+ .voucher code {
251
+ font-family: "SFMono-Regular", Consolas, monospace;
252
+ font-size: 26px;
253
+ font-weight: 900;
254
+ letter-spacing: 0.08em;
255
+ color: var(--ink);
256
+ }
257
+ .stats {
258
+ display: grid;
259
+ grid-template-columns: 1fr 1fr;
260
+ gap: 8px;
261
+ margin-bottom: 14px;
262
+ }
263
+ .stat {
264
+ border: 1px solid var(--line);
265
+ border-radius: 8px;
266
+ padding: 10px;
267
+ background: #fff;
268
+ }
269
+ .stat span {
270
+ display: block;
271
+ color: var(--muted);
272
+ font-size: 10px;
273
+ font-weight: 800;
274
+ text-transform: uppercase;
275
+ letter-spacing: 0.07em;
276
+ margin-bottom: 4px;
277
+ }
278
+ .stat strong {
279
+ font-size: 14px;
280
+ }
281
+ .success-actions {
282
+ display: grid;
283
+ grid-template-columns: 1fr 1fr;
284
+ gap: 8px;
285
+ }
286
+ @media (max-width: 360px) {
287
+ body { padding: 0; align-items: stretch; }
288
+ .phone { border-radius: 0; min-height: 100vh; }
289
+ .success-actions, .stats { grid-template-columns: 1fr; }
290
+ h1 { font-size: 24px; }
291
+ }
292
+ </style>
293
+ </head>
294
+ <body>
295
+ <main class="phone">
296
+ <section class="top">
297
+ <div class="brand-row">
298
+ <div class="mark">W</div>
299
+ <div class="network-pill"><span class="pulse"></span> Sunset Guest WiFi</div>
300
+ </div>
301
+ <h1>Choose your WiFi pass</h1>
302
+ <p class="lead">Pay by mobile money or enter a voucher from the attendant.</p>
303
+ </section>
304
+
305
+ <section class="section">
306
+ <div class="label"><span>Plans</span><span>5 Mbps up / 2 Mbps down</span></div>
307
+ <div class="plans">
308
+ <div class="plan selected">
309
+ <div><strong>1 Hour</strong><span>Good for quick browsing</span></div>
310
+ <div class="price">500 TZS</div>
311
+ </div>
312
+ <div class="plan">
313
+ <div><strong>All Day</strong><span>Valid until midnight</span></div>
314
+ <div class="price">2,000 TZS</div>
315
+ </div>
316
+ </div>
317
+ <div class="field">
318
+ <div class="label"><span>M-Pesa number</span></div>
319
+ <input class="input" value="0765 123 456" aria-label="M-Pesa number">
320
+ </div>
321
+ <div class="pay-row">
322
+ <button class="btn">Pay 500 TZS</button>
323
+ <button class="btn alt">Enter voucher code</button>
324
+ </div>
325
+ <div class="notice">
326
+ <strong>!</strong>
327
+ <span>If payment starts while the AP is not reporting online, the voucher is still created. Omada validates the code when the guest connects.</span>
328
+ </div>
329
+ </section>
330
+
331
+ <section class="success">
332
+ <div class="success-card">
333
+ <div class="status"><span class="check">βœ“</span> Connected</div>
334
+ <h2>You are online</h2>
335
+ <p>Save this voucher. You can use it again on this device until it expires.</p>
336
+ <div class="voucher">
337
+ <small>Voucher code</small>
338
+ <code>VqG00O</code>
339
+ </div>
340
+ <div class="stats">
341
+ <div class="stat"><span>Time left</span><strong>59 minutes</strong></div>
342
+ <div class="stat"><span>Speed</span><strong>2 Mbps down</strong></div>
343
+ <div class="stat"><span>Upload</span><strong>5 Mbps up</strong></div>
344
+ <div class="stat"><span>Network</span><strong>Sunset Guest</strong></div>
345
+ </div>
346
+ <div class="success-actions">
347
+ <button class="btn">Continue</button>
348
+ <button class="btn alt">Copy code</button>
349
+ </div>
350
+ </div>
351
+ </section>
352
+ </main>
353
+ </body>
354
+ </html>
public/engine.js CHANGED
@@ -202,15 +202,45 @@
202
  const metaEl = $('success-meta');
203
  if (metaEl) {
204
  const hours = data.expiresIn ? Math.ceil(data.expiresIn / 3600) : '?';
 
 
 
 
 
205
  metaEl.innerHTML = `
206
- <div class="meta-row"><span>Speed</span><strong>${data.speedDown} ↓ / ${data.speedUp} ↑</strong></div>
207
- <div class="meta-row"><span>Valid for</span><strong>${hours < 24 ? hours + 'h' : Math.ceil(hours/24) + 'd'}</strong></div>
208
- <div class="meta-row"><span>Reconnect?</span><strong>Enter code above on login page</strong></div>
 
209
  `;
210
  }
211
  showScreen('success');
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  function omadaAuthPayload(code) {
215
  return {
216
  code,
 
202
  const metaEl = $('success-meta');
203
  if (metaEl) {
204
  const hours = data.expiresIn ? Math.ceil(data.expiresIn / 3600) : '?';
205
+ const validFor = hours === '?'
206
+ ? 'Available'
207
+ : hours < 24
208
+ ? `${hours} ${hours === 1 ? 'hour' : 'hours'}`
209
+ : `${Math.ceil(hours / 24)} ${Math.ceil(hours / 24) === 1 ? 'day' : 'days'}`;
210
  metaEl.innerHTML = `
211
+ <div class="meta-row"><span class="meta-key">Time left</span><strong class="meta-val">${validFor}</strong></div>
212
+ <div class="meta-row"><span class="meta-key">Download</span><strong class="meta-val">${data.speedDown || 'Active'}</strong></div>
213
+ <div class="meta-row"><span class="meta-key">Upload</span><strong class="meta-val">${data.speedUp || 'Active'}</strong></div>
214
+ <div class="meta-row"><span class="meta-key">Reconnect</span><strong class="meta-val">Use this code</strong></div>
215
  `;
216
  }
217
  showScreen('success');
218
  }
219
 
220
+ const successCopyBtn = $('success-copy-btn');
221
+ if (successCopyBtn) {
222
+ successCopyBtn.addEventListener('click', async () => {
223
+ const code = $('success-code')?.textContent?.trim();
224
+ if (!code || code === 'β€”') return;
225
+ try {
226
+ await navigator.clipboard.writeText(code);
227
+ successCopyBtn.textContent = 'Copied';
228
+ setTimeout(() => { successCopyBtn.textContent = 'Copy code'; }, 1800);
229
+ } catch {
230
+ successCopyBtn.textContent = 'Select code';
231
+ setTimeout(() => { successCopyBtn.textContent = 'Copy code'; }, 1800);
232
+ }
233
+ });
234
+ }
235
+
236
+ const successContinueBtn = $('success-continue-btn');
237
+ if (successContinueBtn) {
238
+ successContinueBtn.addEventListener('click', () => {
239
+ const target = P.originUrl && /^https?:\/\//i.test(P.originUrl) ? P.originUrl : 'http://example.com';
240
+ window.location.href = target;
241
+ });
242
+ }
243
+
244
  function omadaAuthPayload(code) {
245
  return {
246
  code,
src/routes/portal.routes.js CHANGED
@@ -450,20 +450,34 @@ router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
450
 
451
  if (!device) return res.status(404).json({ error: 'Device not found' });
452
 
453
- const siteDevices = await omada.getSiteDevices(siteId);
454
- const omadaDevice = siteDevices.find(d => normalizeMac(d.mac) === normalizeMac(device.mac));
455
- const liveStatus = mapOmadaDeviceStatus(omadaDevice?.statusCategory);
456
-
457
- if (liveStatus && liveStatus !== device.status) {
458
- await db.query(
459
- `UPDATE devices SET status = ?, last_seen_at = NOW() WHERE id = ?`,
460
- [liveStatus, device.id]
461
- );
462
- device.status = liveStatus;
463
- }
 
464
 
465
- if (!omadaDevice || liveStatus !== 'online') {
466
- return res.status(404).json({ error: 'Device not found or offline' });
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  }
468
  if (!['trial', 'active'].includes(device.billing_status)) {
469
  return res.status(403).json({ error: 'WiFi service unavailable β€” billing suspended' });
 
450
 
451
  if (!device) return res.status(404).json({ error: 'Device not found' });
452
 
453
+ try {
454
+ const siteDevices = await omada.getSiteDevices(siteId);
455
+ const omadaDevice = siteDevices.find(d => normalizeMac(d.mac) === normalizeMac(device.mac));
456
+ const liveStatus = mapOmadaDeviceStatus(omadaDevice?.statusCategory);
457
+
458
+ if (liveStatus && liveStatus !== device.status) {
459
+ await db.query(
460
+ `UPDATE devices SET status = ?, last_seen_at = NOW() WHERE id = ?`,
461
+ [liveStatus, device.id]
462
+ );
463
+ device.status = liveStatus;
464
+ }
465
 
466
+ if (!omadaDevice || liveStatus !== 'online') {
467
+ console.warn('[portal/purchase] starting payment while Omada AP is not reported online:', {
468
+ siteId,
469
+ deviceId: device.id,
470
+ dbStatus: device.status,
471
+ liveStatus: liveStatus || null,
472
+ omadaDeviceFound: Boolean(omadaDevice),
473
+ });
474
+ }
475
+ } catch (omadaErr) {
476
+ console.warn('[portal/purchase] Omada live status check skipped:', {
477
+ siteId,
478
+ deviceId: device.id,
479
+ error: omadaErr.message,
480
+ });
481
  }
482
  if (!['trial', 'active'].includes(device.billing_status)) {
483
  return res.status(403).json({ error: 'WiFi service unavailable β€” billing suspended' });
src/views/screens/portal.ejs CHANGED
@@ -626,75 +626,105 @@
626
  }
627
 
628
  /* ── Success ── */
 
 
 
 
 
 
629
  .success-badge {
630
  display: inline-flex;
631
  align-items: center;
632
- gap: 6px;
633
- background: var(--black);
634
- color: #fff;
635
- border-radius: 20px;
636
- padding: 5px 12px;
637
- font-size: 10px;
638
- font-weight: 700;
639
  text-transform: uppercase;
640
- letter-spacing: 0.12em;
641
- margin-bottom: 20px;
642
  }
643
  .success-badge::before {
644
- content: '';
645
- width: 6px; height: 6px;
646
- background: #22C55E;
 
 
 
 
 
647
  border-radius: 50%;
 
 
 
648
  }
649
-
650
  .success-headline {
651
- font-size: 26px;
652
  font-weight: 800;
653
- letter-spacing: -0.05em;
654
  color: var(--text-primary);
655
- margin-bottom: 20px;
656
  line-height: 1.1;
657
  }
658
-
 
 
 
 
 
659
  .code-panel {
660
- background: var(--nest);
661
- border-radius: var(--radius-card);
662
- padding: 18px 20px;
663
- margin-bottom: 18px;
 
664
  }
665
  .code-panel .field-label { margin-bottom: 8px; }
666
  .code-value {
667
  font-size: 26px;
668
- font-weight: 800;
669
  color: var(--text-primary);
670
- letter-spacing: 4px;
671
  font-family: 'Courier New', monospace;
 
672
  }
673
-
674
  .meta-grid {
675
- display: flex;
676
- flex-direction: column;
677
- gap: 0;
 
678
  }
679
  .meta-row {
680
- display: flex;
681
- justify-content: space-between;
682
- align-items: center;
683
- padding: 10px 0;
684
- border-bottom: 1px solid var(--nest);
685
  }
686
- .meta-row:last-child { border-bottom: none; }
687
  .meta-key {
 
 
688
  font-size: 10px;
689
- font-weight: 700;
690
  text-transform: uppercase;
691
- letter-spacing: 0.1em;
692
  color: var(--text-disabled);
693
  }
694
  .meta-val {
 
695
  font-size: 13px;
696
- font-weight: 700;
697
  color: var(--text-primary);
 
 
 
 
 
 
 
 
 
 
 
 
698
  }
699
 
700
  /* ── Empty state ── */
@@ -895,13 +925,20 @@
895
 
896
  <!-- SCREEN 4: Connected! -->
897
  <div id="screen-success" class="screen">
898
- <div class="success-badge">Connected</div>
899
- <h2 class="success-headline">You're online.</h2>
900
- <div class="code-panel">
901
- <span class="field-label">Your Omada Voucher Code - Save to Reconnect</span>
902
- <div class="code-value" id="success-code">β€”</div>
 
 
 
 
 
 
 
 
903
  </div>
904
- <div class="meta-grid" id="success-meta"></div>
905
  </div>
906
 
907
  </div>
 
626
  }
627
 
628
  /* ── Success ── */
629
+ .success-card {
630
+ border: 1px solid #B9EFD8;
631
+ background: linear-gradient(180deg, #F5FFFA 0%, #FFFFFF 100%);
632
+ border-radius: 10px;
633
+ padding: 18px;
634
+ }
635
  .success-badge {
636
  display: inline-flex;
637
  align-items: center;
638
+ gap: 9px;
639
+ color: #066046;
640
+ font-size: 11px;
641
+ font-weight: 800;
 
 
 
642
  text-transform: uppercase;
643
+ letter-spacing: 0.08em;
644
+ margin-bottom: 14px;
645
  }
646
  .success-badge::before {
647
+ content: 'βœ“';
648
+ width: 23px;
649
+ height: 23px;
650
+ display: inline-flex;
651
+ align-items: center;
652
+ justify-content: center;
653
+ background: #0F9F6E;
654
+ color: #FFFFFF;
655
  border-radius: 50%;
656
+ font-size: 13px;
657
+ font-weight: 900;
658
+ letter-spacing: 0;
659
  }
 
660
  .success-headline {
661
+ font-size: 24px;
662
  font-weight: 800;
663
+ letter-spacing: 0;
664
  color: var(--text-primary);
665
+ margin-bottom: 7px;
666
  line-height: 1.1;
667
  }
668
+ .success-copy {
669
+ margin-bottom: 16px;
670
+ font-size: 13px;
671
+ line-height: 1.5;
672
+ color: var(--text-secondary);
673
+ }
674
  .code-panel {
675
+ background: var(--card);
676
+ border: 1px dashed rgba(0,0,0,0.34);
677
+ border-radius: 8px;
678
+ padding: 14px;
679
+ margin-bottom: 12px;
680
  }
681
  .code-panel .field-label { margin-bottom: 8px; }
682
  .code-value {
683
  font-size: 26px;
684
+ font-weight: 900;
685
  color: var(--text-primary);
686
+ letter-spacing: 0.08em;
687
  font-family: 'Courier New', monospace;
688
+ overflow-wrap: anywhere;
689
  }
 
690
  .meta-grid {
691
+ display: grid;
692
+ grid-template-columns: 1fr 1fr;
693
+ gap: 8px;
694
+ margin-bottom: 14px;
695
  }
696
  .meta-row {
697
+ padding: 10px;
698
+ border: 1px solid rgba(0,0,0,0.08);
699
+ border-radius: 8px;
700
+ background: var(--card);
 
701
  }
 
702
  .meta-key {
703
+ display: block;
704
+ margin-bottom: 4px;
705
  font-size: 10px;
706
+ font-weight: 800;
707
  text-transform: uppercase;
708
+ letter-spacing: 0.08em;
709
  color: var(--text-disabled);
710
  }
711
  .meta-val {
712
+ display: block;
713
  font-size: 13px;
714
+ font-weight: 800;
715
  color: var(--text-primary);
716
+ line-height: 1.3;
717
+ }
718
+ .success-actions {
719
+ display: grid;
720
+ grid-template-columns: 1fr 1fr;
721
+ gap: 8px;
722
+ }
723
+ @media (max-width: 360px) {
724
+ .meta-grid,
725
+ .success-actions {
726
+ grid-template-columns: 1fr;
727
+ }
728
  }
729
 
730
  /* ── Empty state ── */
 
925
 
926
  <!-- SCREEN 4: Connected! -->
927
  <div id="screen-success" class="screen">
928
+ <div class="success-card">
929
+ <div class="success-badge">Connected</div>
930
+ <h2 class="success-headline">You are online</h2>
931
+ <p class="success-copy">Save this voucher. You can use it again on this device until it expires.</p>
932
+ <div class="code-panel">
933
+ <span class="field-label">Voucher Code</span>
934
+ <div class="code-value" id="success-code">β€”</div>
935
+ </div>
936
+ <div class="meta-grid" id="success-meta"></div>
937
+ <div class="success-actions">
938
+ <button class="btn-primary" id="success-continue-btn" type="button">Continue</button>
939
+ <button class="btn-ghost" id="success-copy-btn" type="button">Copy code</button>
940
+ </div>
941
  </div>
 
942
  </div>
943
 
944
  </div>