Mbonea commited on
Commit
4465ca0
·
1 Parent(s): 2f97687

Add captive portal free promo claims

Browse files
public/engine.js CHANGED
@@ -48,6 +48,7 @@
48
  // ── State ─────────────────────────────────────────────────────────────────
49
  let selectedPlanId = null;
50
  let selectedPlanPrice = null;
 
51
  let pollTimer = null;
52
  let currentReference = null;
53
  let purchaseInFlight = false;
@@ -64,7 +65,9 @@
64
  function updatePayBtn() {
65
  if (!payBtn) return;
66
  if (selectedPlanId && isPhoneValid()) {
67
- payBtn.textContent = `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
 
 
68
  payBtn.disabled = false;
69
  } else if (selectedPlanId) {
70
  payBtn.textContent = 'Enter your phone number';
@@ -79,6 +82,7 @@
79
  radio.addEventListener('change', () => {
80
  selectedPlanId = radio.dataset.planId;
81
  selectedPlanPrice = radio.dataset.planPrice;
 
82
  updatePayBtn();
83
  });
84
  });
@@ -106,24 +110,38 @@
106
  if (!phone || phone.replace(/\D/g, '').length < 9) {
107
  return showError('select-error', 'Enter a valid phone number.');
108
  }
 
 
 
109
 
110
  purchaseInFlight = true;
111
  payBtn.disabled = true;
112
  showScreen('waiting');
113
 
114
  try {
115
- const res = await fetch(`/portal/${P.siteId}/purchase`, {
 
 
 
 
116
  method: 'POST',
117
  headers: { 'Content-Type': 'application/json' },
118
- body: JSON.stringify({ planId: selectedPlanId, phone }),
119
  });
120
- const data = await res.json();
 
 
 
 
 
 
 
121
 
122
  if (!res.ok || !data.reference) {
123
  showScreen('select');
124
  purchaseInFlight = false;
125
  payBtn.disabled = false;
126
- return showError('select-error', data.error || 'Purchase failed. Try again.');
127
  }
128
 
129
  currentReference = data.reference;
 
48
  // ── State ─────────────────────────────────────────────────────────────────
49
  let selectedPlanId = null;
50
  let selectedPlanPrice = null;
51
+ let selectedPlanIsPromo = false;
52
  let pollTimer = null;
53
  let currentReference = null;
54
  let purchaseInFlight = false;
 
65
  function updatePayBtn() {
66
  if (!payBtn) return;
67
  if (selectedPlanId && isPhoneValid()) {
68
+ payBtn.textContent = selectedPlanIsPromo
69
+ ? 'Claim promo'
70
+ : `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
71
  payBtn.disabled = false;
72
  } else if (selectedPlanId) {
73
  payBtn.textContent = 'Enter your phone number';
 
82
  radio.addEventListener('change', () => {
83
  selectedPlanId = radio.dataset.planId;
84
  selectedPlanPrice = radio.dataset.planPrice;
85
+ selectedPlanIsPromo = radio.dataset.planPromo === '1' || Number(selectedPlanPrice) === 0;
86
  updatePayBtn();
87
  });
88
  });
 
110
  if (!phone || phone.replace(/\D/g, '').length < 9) {
111
  return showError('select-error', 'Enter a valid phone number.');
112
  }
113
+ if (selectedPlanIsPromo && !P.clientMac) {
114
+ return showError('select-error', 'Open this portal from the WiFi login page on the device you want to connect.');
115
+ }
116
 
117
  purchaseInFlight = true;
118
  payBtn.disabled = true;
119
  showScreen('waiting');
120
 
121
  try {
122
+ const endpoint = selectedPlanIsPromo ? 'promo' : 'purchase';
123
+ const payload = selectedPlanIsPromo
124
+ ? { planId: selectedPlanId, phone, clientMac: P.clientMac }
125
+ : { planId: selectedPlanId, phone };
126
+ const res = await fetch(`/portal/${P.siteId}/${endpoint}`, {
127
  method: 'POST',
128
  headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify(payload),
130
  });
131
+ const data = await readJsonSafe(res);
132
+
133
+ if (selectedPlanIsPromo && res.ok && data.code) {
134
+ purchaseInFlight = false;
135
+ payBtn.disabled = false;
136
+ showCodeEntry(data.code);
137
+ return;
138
+ }
139
 
140
  if (!res.ok || !data.reference) {
141
  showScreen('select');
142
  purchaseInFlight = false;
143
  payBtn.disabled = false;
144
+ return showError('select-error', data.error || (selectedPlanIsPromo ? 'Could not issue promo code. Please ask the attendant.' : 'Purchase failed. Try again.'));
145
  }
146
 
147
  currentReference = data.reference;
src/routes/plans.routes.js CHANGED
@@ -11,6 +11,12 @@ function formatVoucherSyncError(err) {
11
  return `${code}${err.message || 'Voucher group sync failed'}${detail}`.slice(0, 255);
12
  }
13
 
 
 
 
 
 
 
14
  async function syncPlanVoucherGroup(plan) {
15
  const device = await db.queryOne(
16
  `SELECT id, omada_site_id FROM devices WHERE id = ? AND client_id = ? LIMIT 1`,
@@ -171,10 +177,18 @@ router.get('/', async (req, res) => {
171
  // POST /api/plans
172
  router.post('/', async (req, res) => {
173
  const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
 
 
174
 
175
- if (!device_id || !name || !duration_seconds || !price) {
176
  return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
177
  }
 
 
 
 
 
 
178
 
179
  try {
180
  const device = await db.query(
@@ -190,7 +204,7 @@ router.post('/', async (req, res) => {
190
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
191
  [
192
  req.client.id, device_id, name,
193
- parseInt(duration_seconds), parseFloat(price),
194
  down_limit || 1, down_unit || 2,
195
  up_limit || 5, up_unit || 2,
196
  display_order || 0,
@@ -208,6 +222,15 @@ router.post('/', async (req, res) => {
208
  // PUT /api/plans/:id
209
  router.put('/:id', async (req, res) => {
210
  const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
 
 
 
 
 
 
 
 
 
211
 
212
  try {
213
  const plan = await db.query(
@@ -229,7 +252,7 @@ router.put('/:id', async (req, res) => {
229
  is_active = COALESCE(?, is_active)
230
  WHERE id = ?`,
231
  [
232
- name ?? null, duration_seconds ?? null, price ?? null,
233
  down_limit ?? null, down_unit ?? null,
234
  up_limit ?? null, up_unit ?? null,
235
  display_order !== undefined ? display_order : null,
 
11
  return `${code}${err.message || 'Voucher group sync failed'}${detail}`.slice(0, 255);
12
  }
13
 
14
+ function parsePlanNumber(value) {
15
+ if (value === undefined || value === null || value === '') return null;
16
+ const parsed = Number(value);
17
+ return Number.isFinite(parsed) ? parsed : null;
18
+ }
19
+
20
  async function syncPlanVoucherGroup(plan) {
21
  const device = await db.queryOne(
22
  `SELECT id, omada_site_id FROM devices WHERE id = ? AND client_id = ? LIMIT 1`,
 
177
  // POST /api/plans
178
  router.post('/', async (req, res) => {
179
  const { device_id, name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order } = req.body;
180
+ const durationValue = parsePlanNumber(duration_seconds);
181
+ const priceValue = parsePlanNumber(price);
182
 
183
+ if (!device_id || !name || durationValue === null || priceValue === null) {
184
  return res.status(400).json({ error: 'device_id, name, duration_seconds, price required' });
185
  }
186
+ if (durationValue <= 0) {
187
+ return res.status(400).json({ error: 'duration_seconds must be greater than 0' });
188
+ }
189
+ if (priceValue < 0) {
190
+ return res.status(400).json({ error: 'price cannot be negative' });
191
+ }
192
 
193
  try {
194
  const device = await db.query(
 
204
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
205
  [
206
  req.client.id, device_id, name,
207
+ parseInt(durationValue, 10), priceValue,
208
  down_limit || 1, down_unit || 2,
209
  up_limit || 5, up_unit || 2,
210
  display_order || 0,
 
222
  // PUT /api/plans/:id
223
  router.put('/:id', async (req, res) => {
224
  const { name, duration_seconds, price, down_limit, down_unit, up_limit, up_unit, display_order, is_active } = req.body;
225
+ const durationValue = parsePlanNumber(duration_seconds);
226
+ const priceValue = parsePlanNumber(price);
227
+
228
+ if (duration_seconds !== undefined && (durationValue === null || durationValue <= 0)) {
229
+ return res.status(400).json({ error: 'duration_seconds must be greater than 0' });
230
+ }
231
+ if (price !== undefined && (priceValue === null || priceValue < 0)) {
232
+ return res.status(400).json({ error: 'price cannot be negative' });
233
+ }
234
 
235
  try {
236
  const plan = await db.query(
 
252
  is_active = COALESCE(?, is_active)
253
  WHERE id = ?`,
254
  [
255
+ name ?? null, duration_seconds !== undefined ? parseInt(durationValue, 10) : null, price !== undefined ? priceValue : null,
256
  down_limit ?? null, down_unit ?? null,
257
  up_limit ?? null, up_unit ?? null,
258
  display_order !== undefined ? display_order : null,
src/routes/portal.routes.js CHANGED
@@ -5,7 +5,8 @@ const db = require('../config/db');
5
  const omada = require('../services/omada');
6
  const snippe = require('../services/snippe');
7
  const sms = require('../services/sms');
8
- const { generateCode } = require('../utils/generateCode');
 
9
  const { v4: uuidv4 } = require('uuid');
10
  const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
11
  const { alertAdmin } = require('../utils/adminAlert');
@@ -17,6 +18,27 @@ const { alertWifiClient } = require('../utils/tokenClientAlerts');
17
  const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
18
  const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const omadaPortalErrorMap = {
21
  '-41501': 'Failed to authenticate.',
22
  '-41502': 'Voucher code is incorrect.',
@@ -190,13 +212,18 @@ async function recordOmadaVoucherUse({ siteId, code, mac }) {
190
  }
191
 
192
  if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
193
- console.warn('[portal/omada-auth-record] Omada accepted token for a different MAC; syncing DB lock to Omada result:', {
 
194
  siteId,
195
  code,
196
  tokenId: token.id,
197
- previousMac: token.locked_mac,
198
  acceptedMac: mac,
199
  });
 
 
 
 
200
  }
201
 
202
  if (!expiresAt || expiresAt < now) {
@@ -601,6 +628,172 @@ router.post('/:siteId/purchase', purchaseLimiter, async (req, res) => {
601
  });
602
 
603
  // ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  router.get('/:siteId/check/:reference', async (req, res) => {
605
  try {
606
  const payment = await db.query(
@@ -836,14 +1029,14 @@ router.post('/:siteId/auth', portalAuthLimiter, async (req, res) => {
836
  return res.status(410).json({ error: 'Code has expired' });
837
  }
838
 
 
 
 
 
 
839
  if (token.status === 'active') {
840
  trace.step('active_token_validation_start');
841
 
842
- if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
843
- trace.step('token_rejected_locked_mac', { lockedMac: token.locked_mac });
844
- return res.status(403).json({ error: 'Code is locked to another device' });
845
- }
846
-
847
  if (token.expires_at && new Date(token.expires_at) < new Date()) {
848
  await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
849
  trace.step('token_marked_expired');
 
5
  const omada = require('../services/omada');
6
  const snippe = require('../services/snippe');
7
  const sms = require('../services/sms');
8
+ const messages = require('../utils/messages');
9
+ const { createAccessTokenForPayment } = require('../utils/tokenSales');
10
  const { v4: uuidv4 } = require('uuid');
11
  const { purchaseLimiter, portalAuthLimiter } = require('../middleware/rateLimiter');
12
  const { alertAdmin } = require('../utils/adminAlert');
 
18
  const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
19
  const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
20
 
21
+ function formatDuration(seconds) {
22
+ if (seconds >= 86400) {
23
+ const days = Math.round(seconds / 86400);
24
+ return `${days} day${days === 1 ? '' : 's'}`;
25
+ }
26
+ if (seconds >= 3600) {
27
+ const hours = Math.round(seconds / 3600);
28
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
29
+ }
30
+ const mins = Math.round(seconds / 60);
31
+ return `${mins} min`;
32
+ }
33
+
34
+ function normalizePhoneForPromo(phone) {
35
+ const digits = String(phone || '').replace(/\D/g, '');
36
+ if (digits.length === 9) return `255${digits}`;
37
+ if (digits.length === 10 && digits.startsWith('0')) return `255${digits.slice(1)}`;
38
+ if (digits.length === 12 && digits.startsWith('255')) return digits;
39
+ return digits;
40
+ }
41
+
42
  const omadaPortalErrorMap = {
43
  '-41501': 'Failed to authenticate.',
44
  '-41502': 'Voucher code is incorrect.',
 
212
  }
213
 
214
  if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
215
+ await conn.rollback();
216
+ console.warn('[portal/omada-auth-record] Omada accepted token for a different MAC; rejecting DB sync:', {
217
  siteId,
218
  code,
219
  tokenId: token.id,
220
+ lockedMac: token.locked_mac,
221
  acceptedMac: mac,
222
  });
223
+ const err = new Error('Code is locked to another device');
224
+ err.status = 403;
225
+ err.code = 'TOKEN_LOCKED_TO_ANOTHER_DEVICE';
226
+ throw err;
227
  }
228
 
229
  if (!expiresAt || expiresAt < now) {
 
628
  });
629
 
630
  // ── GET /portal/:siteId/check/:reference — poll payment status ───────────────
631
+ // POST /portal/:siteId/promo - claim a free promo voucher instantly
632
+ router.post('/:siteId/promo', purchaseLimiter, async (req, res) => {
633
+ const { siteId } = req.params;
634
+ const { planId, phone, clientMac } = req.body;
635
+ const cleanPhone = normalizePhoneForPromo(phone);
636
+ const mac = normalizePortalMac(clientMac);
637
+
638
+ if (!planId || !cleanPhone || cleanPhone.length < 12 || !mac) {
639
+ return res.status(400).json({ error: 'planId, phone and clientMac required' });
640
+ }
641
+
642
+ const connection = await db.pool.getConnection();
643
+
644
+ try {
645
+ await connection.beginTransaction();
646
+
647
+ const [deviceRows] = await connection.execute(
648
+ `SELECT d.*, c.business_name, c.commission_rate
649
+ FROM devices d JOIN clients c ON c.id = d.client_id
650
+ WHERE d.omada_site_id = ? LIMIT 1`,
651
+ [siteId]
652
+ );
653
+ const device = deviceRows[0];
654
+
655
+ if (!device) {
656
+ await connection.rollback();
657
+ return res.status(404).json({ error: 'Device not found' });
658
+ }
659
+ if (!['trial', 'active'].includes(device.billing_status)) {
660
+ await connection.rollback();
661
+ return res.status(403).json({ error: 'WiFi service unavailable - billing suspended' });
662
+ }
663
+
664
+ const [planRows] = await connection.execute(
665
+ `SELECT *
666
+ FROM wifi_plans
667
+ WHERE id = ? AND device_id = ? AND is_active = 1 LIMIT 1
668
+ FOR UPDATE`,
669
+ [planId, device.id]
670
+ );
671
+ const plan = planRows[0];
672
+
673
+ if (!plan) {
674
+ await connection.rollback();
675
+ return res.status(404).json({ error: 'Promo is not available right now.' });
676
+ }
677
+ if (Number(plan.price) !== 0) {
678
+ await connection.rollback();
679
+ return res.status(400).json({ error: 'Selected plan is not a free promo.' });
680
+ }
681
+ if (!plan.omada_voucher_group_id) {
682
+ await connection.rollback();
683
+ return res.status(409).json({ error: 'Promo is not available yet. Please ask the attendant.' });
684
+ }
685
+
686
+ const [phoneClaimRows] = await connection.execute(
687
+ `SELECT p.id
688
+ FROM payments p
689
+ WHERE p.device_id = ?
690
+ AND p.plan_id = ?
691
+ AND p.phone = ?
692
+ AND p.status = 'completed'
693
+ AND p.gross_amount = 0
694
+ LIMIT 1`,
695
+ [device.id, plan.id, cleanPhone]
696
+ );
697
+ const [macClaimRows] = await connection.execute(
698
+ `SELECT t.id
699
+ FROM access_tokens t
700
+ JOIN payments p ON p.id = t.payment_id
701
+ WHERE t.device_id = ?
702
+ AND t.plan_id = ?
703
+ AND t.locked_mac = ?
704
+ AND p.status = 'completed'
705
+ AND p.gross_amount = 0
706
+ LIMIT 1`,
707
+ [device.id, plan.id, mac]
708
+ );
709
+
710
+ if (phoneClaimRows.length || macClaimRows.length) {
711
+ await connection.rollback();
712
+ return res.status(409).json({ error: 'This promo has already been claimed for this phone or device.' });
713
+ }
714
+
715
+ const [issuedRows] = await connection.execute(
716
+ `SELECT code, omada_voucher_id
717
+ FROM access_tokens
718
+ WHERE omada_voucher_group_id = ?`,
719
+ [plan.omada_voucher_group_id]
720
+ );
721
+ const voucher = await omada.issueVoucherFromGroup(device.omada_site_id, plan.omada_voucher_group_id, {
722
+ excludeCodes: issuedRows.map(row => row.code),
723
+ excludeVoucherIds: issuedRows.map(row => row.omada_voucher_id),
724
+ });
725
+
726
+ const reference = `PROMO-${uuidv4().replace(/-/g, '').slice(0, 18).toUpperCase()}`;
727
+ const [paymentResult] = await connection.execute(
728
+ `INSERT INTO payments
729
+ (reference, client_id, device_id, plan_id, phone, phone_provider,
730
+ payment_channel, provider_account_type, verification_source, counts_toward_balance,
731
+ confirmed_at, notes, gross_amount, snippe_fee, commission_rate,
732
+ commission_amount, client_credit, status, completed_at)
733
+ VALUES (?, ?, ?, ?, ?, 'other',
734
+ 'tenant_direct_manual', 'platform', 'tenant_confirmed', 0,
735
+ NOW(), 'Captive portal free promo claim', 0.00, 0.00, ?,
736
+ 0.00, 0.00, 'completed', NOW())`,
737
+ [
738
+ reference,
739
+ device.client_id,
740
+ device.id,
741
+ plan.id,
742
+ cleanPhone,
743
+ device.commission_rate,
744
+ ]
745
+ );
746
+
747
+ const { tokenId, code } = await createAccessTokenForPayment({
748
+ connection,
749
+ paymentId: paymentResult.insertId,
750
+ clientId: device.client_id,
751
+ deviceId: device.id,
752
+ planId: plan.id,
753
+ durationSeconds: plan.duration_seconds,
754
+ downLimit: plan.down_limit,
755
+ downUnit: plan.down_unit,
756
+ upLimit: plan.up_limit,
757
+ upUnit: plan.up_unit,
758
+ code: voucher.code,
759
+ omadaVoucherId: voucher.voucherId,
760
+ omadaVoucherGroupId: plan.omada_voucher_group_id,
761
+ requirePreferredCode: true,
762
+ });
763
+
764
+ await connection.execute(
765
+ `UPDATE access_tokens
766
+ SET locked_mac = ?
767
+ WHERE id = ?`,
768
+ [mac, tokenId]
769
+ );
770
+
771
+ await connection.commit();
772
+
773
+ const duration = formatDuration(plan.duration_seconds);
774
+ const smsSent = Boolean(await sms.sendSMS(
775
+ cleanPhone,
776
+ messages.promoWifiCode(code, plan.name, duration, device.name)
777
+ ).catch(() => false));
778
+
779
+ res.status(201).json({
780
+ reference,
781
+ token_id: tokenId,
782
+ code,
783
+ plan_name: plan.name,
784
+ duration_seconds: plan.duration_seconds,
785
+ sms_sent: smsSent,
786
+ message: smsSent ? 'Promo code sent successfully' : 'Promo code generated successfully',
787
+ });
788
+ } catch (err) {
789
+ await connection.rollback();
790
+ console.error('[portal/promo]', err.message);
791
+ res.status(500).json({ error: 'Could not issue promo code. Please ask the attendant.' });
792
+ } finally {
793
+ connection.release();
794
+ }
795
+ });
796
+
797
  router.get('/:siteId/check/:reference', async (req, res) => {
798
  try {
799
  const payment = await db.query(
 
1029
  return res.status(410).json({ error: 'Code has expired' });
1030
  }
1031
 
1032
+ if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
1033
+ trace.step('token_rejected_locked_mac', { lockedMac: token.locked_mac });
1034
+ return res.status(403).json({ error: 'Code is locked to another device' });
1035
+ }
1036
+
1037
  if (token.status === 'active') {
1038
  trace.step('active_token_validation_start');
1039
 
 
 
 
 
 
1040
  if (token.expires_at && new Date(token.expires_at) < new Date()) {
1041
  await db.query(`UPDATE access_tokens SET status = 'expired' WHERE id = ?`, [token.id]);
1042
  trace.step('token_marked_expired');
src/utils/messages.js CHANGED
@@ -12,6 +12,10 @@ module.exports = {
12
  manualWifiCode: (code, planName, duration, deviceName, expiryPreview) =>
13
  `WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nValid for ${duration} after first connection. If used now, access ends around ${expiryPreview}.`,
14
 
 
 
 
 
15
  // Guest receives this when their WiFi token expires
16
  tokenExpired: (deviceName, planName = null) =>
17
  planName
 
12
  manualWifiCode: (code, planName, duration, deviceName, expiryPreview) =>
13
  `WiFi Code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nValid for ${duration} after first connection. If used now, access ends around ${expiryPreview}.`,
14
 
15
+ // Guest receives this after claiming a free promo on the captive portal
16
+ promoWifiCode: (code, planName, duration, deviceName) =>
17
+ `Free WiFi promo code: ${code}\nPlan: ${planName} (${duration})\nDevice: ${deviceName}\nEnter this code on the WiFi login page. Valid for ${duration} after connecting.`,
18
+
19
  // Guest receives this when their WiFi token expires
20
  tokenExpired: (deviceName, planName = null) =>
21
  planName
src/views/screens/portal.ejs CHANGED
@@ -777,6 +777,7 @@
777
 
778
  <!-- SCREEN 1: Plan selection & phone -->
779
  <div id="screen-select" class="screen active">
 
780
  <% if (portal.plans.length === 0) { %>
781
  <div class="empty-state">No plans available yet.<br>Check back soon.</div>
782
  <% } else { %>
@@ -786,16 +787,17 @@
786
  const hours = Math.floor(plan.duration_seconds / 3600);
787
  const days = Math.floor(plan.duration_seconds / 86400);
788
  const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hr' : ' Hrs');
 
789
  %>
790
  <div class="plan-item">
791
  <input type="radio" name="plan" id="plan-<%= plan.id %>"
792
- data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>">
793
  <label class="plan-btn" for="plan-<%= plan.id %>">
794
  <div class="plan-row">
795
  <span class="plan-name"><%= plan.name %></span>
796
- <span class="plan-price"><%= parseInt(plan.price).toLocaleString() %> TZS</span>
797
  </div>
798
- <div class="plan-meta"><%= dur %></div>
799
  </label>
800
  </div>
801
  <% }); %>
@@ -840,9 +842,9 @@
840
  <% } %>
841
 
842
  <div class="divider" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>><span>Pay via Mobile Money</span></div>
843
- <p class="payment-note" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>Prefer instant activation? Enter your own number below to pay online and receive an Omada voucher automatically.</p>
844
 
845
- <div class="input-wrap" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>
846
  <label class="field-label" for="phone-input">Phone Number</label>
847
  <div class="phone-group">
848
  <div class="phone-prefix">
@@ -856,7 +858,7 @@
856
  </div>
857
 
858
  <div class="error-msg" id="select-error"></div>
859
- <button class="btn-primary" id="pay-btn" disabled <% if (!showOnlinePayment) { %>style="display:none;"<% } %>>Select a plan to continue</button>
860
 
861
  <% if (!showOnlinePayment) { %>
862
  <div class="quick-access-note" style="margin-top:18px;">
 
777
 
778
  <!-- SCREEN 1: Plan selection & phone -->
779
  <div id="screen-select" class="screen active">
780
+ <% const hasPromoPlans = portal.plans.some(function(plan) { return Number(plan.price) === 0; }); %>
781
  <% if (portal.plans.length === 0) { %>
782
  <div class="empty-state">No plans available yet.<br>Check back soon.</div>
783
  <% } else { %>
 
787
  const hours = Math.floor(plan.duration_seconds / 3600);
788
  const days = Math.floor(plan.duration_seconds / 86400);
789
  const dur = days >= 1 ? days + (days === 1 ? ' Day' : ' Days') : hours + (hours === 1 ? ' Hr' : ' Hrs');
790
+ const isPromo = Number(plan.price) === 0;
791
  %>
792
  <div class="plan-item">
793
  <input type="radio" name="plan" id="plan-<%= plan.id %>"
794
+ data-plan-id="<%= plan.id %>" data-plan-price="<%= plan.price %>" data-plan-promo="<%= isPromo ? '1' : '0' %>">
795
  <label class="plan-btn" for="plan-<%= plan.id %>">
796
  <div class="plan-row">
797
  <span class="plan-name"><%= plan.name %></span>
798
+ <span class="plan-price"><%= isPromo ? 'Free' : parseInt(plan.price).toLocaleString() + ' TZS' %></span>
799
  </div>
800
+ <div class="plan-meta"><%= dur %><%= isPromo ? ' · Promo' : '' %></div>
801
  </label>
802
  </div>
803
  <% }); %>
 
842
  <% } %>
843
 
844
  <div class="divider" <% if (!showOnlinePayment) { %>style="display:none;"<% } %>><span>Pay via Mobile Money</span></div>
845
+ <p class="payment-note" <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>Prefer instant activation? Enter your own number below to pay online or claim an available free promo.</p>
846
 
847
+ <div class="input-wrap" <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>
848
  <label class="field-label" for="phone-input">Phone Number</label>
849
  <div class="phone-group">
850
  <div class="phone-prefix">
 
858
  </div>
859
 
860
  <div class="error-msg" id="select-error"></div>
861
+ <button class="btn-primary" id="pay-btn" disabled <% if (!showOnlinePayment && !hasPromoPlans) { %>style="display:none;"<% } %>>Select a plan to continue</button>
862
 
863
  <% if (!showOnlinePayment) { %>
864
  <div class="quick-access-note" style="margin-top:18px;">