@goweekdays/layer-common 0.0.12 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,72 +1,208 @@
1
1
  <template>
2
2
  <v-row no-gutters>
3
3
  <v-col cols="12">
4
- <slot name="header">
5
- <v-row no-gutters>
6
- <v-col cols="12" class="text-h6 font-weight-bold">
7
- Payment Method
8
- </v-col>
9
- </v-row>
10
- </slot>
11
- </v-col>
4
+ <v-card variant="outlined" border="thin" rounded="md">
5
+ <v-expansion-panels
6
+ v-model="expansionPanel"
7
+ elevation="0"
8
+ variant="accordion"
9
+ focusable
10
+ >
11
+ <v-expansion-panel>
12
+ <template #title>
13
+ <span class="font-weight-bold py-2">E-Wallet</span>
14
+ </template>
15
+
16
+ <template #text>
17
+ <v-item-group
18
+ v-model.number="selectedPaymentMethod"
19
+ selected-class="bg-cyan-accent-1"
20
+ mandatory
21
+ >
22
+ <v-row class="pt-4 pb-3">
23
+ <template v-for="eWalletItem in supportedEwallets">
24
+ <v-col cols="12" lg="3" md="4" sm="4">
25
+ <v-item v-slot="{ toggle, selectedClass }">
26
+ <v-card
27
+ variant="outlined"
28
+ border="thin"
29
+ @click="toggle"
30
+ :class="['px-4', selectedClass]"
31
+ >
32
+ <v-img
33
+ :src="eWalletItem.logo"
34
+ width="100%"
35
+ height="60px"
36
+ >
37
+ </v-img>
38
+ </v-card>
39
+ </v-item>
40
+ </v-col>
41
+ </template>
42
+
43
+ <slot name="ewallet-action"></slot>
44
+ </v-row>
45
+ </v-item-group>
46
+ </template>
47
+ </v-expansion-panel>
48
+
49
+ <v-expansion-panel disabled>
50
+ <template #title>
51
+ <span class="font-weight-bold py-2">Credit/Debit Card</span>
52
+ </template>
53
+
54
+ <template #text>
55
+ <v-row no-gutters>
56
+ <v-col cols="12" lg="8" md="9" class="mt-2">
57
+ <v-row no-gutters>
58
+ <v-col cols="12">
59
+ <v-row no-gutters>
60
+ <v-col cols="12">
61
+ <InputLabel title="Card Number" />
62
+ <v-text-field
63
+ v-model="cardNumber"
64
+ @input="formatCardNumber"
65
+ :rules="[validateCardNumber, validateAcceptedCard]"
66
+ >
67
+ <template #prepend-inner>
68
+ <component :is="cardLogo" />
69
+ </template>
70
+ </v-text-field>
71
+ </v-col>
72
+ </v-row>
73
+ </v-col>
12
74
 
13
- <v-col cols="12" class="mt-2">
14
- <v-row no-gutters>
15
- <v-col cols="12">
16
- <v-item-group
17
- v-model.number="selectedPaymentMethod"
18
- selected-class="bg-cyan-accent-1"
19
- mandatory
20
- >
21
- <v-row>
22
- <v-col cols="12">
23
- <InputLabel title="E-Wallet" />
24
- </v-col>
25
- <template v-for="eWalletItem in props.supportedEwallets">
26
- <v-col cols="12" lg="3" md="4" sm="4">
27
- <v-item v-slot="{ toggle, selectedClass }">
28
- <v-card
29
- variant="outlined"
30
- border="thin"
31
- @click="toggle"
32
- :class="['px-4', selectedClass]"
33
- >
34
- <v-img :src="eWalletItem.logo" width="100%" height="60px">
35
- </v-img>
36
- </v-card>
37
- </v-item>
75
+ <v-col cols="12" class="mt-2">
76
+ <v-row no-gutters>
77
+ <v-sheet width="110px" class="mr-4">
78
+ <InputLabel title="Card Validity" />
79
+ <v-text-field
80
+ v-model="cardExpiration"
81
+ @input="formatExpiry"
82
+ :rules="[validateExpiry]"
83
+ >
84
+ <template #prepend-inner>
85
+ <v-img
86
+ :src="`${CARD_LOGO}#calendar`"
87
+ width="30"
88
+ height="30"
89
+ />
90
+ </template>
91
+ </v-text-field>
92
+ </v-sheet>
93
+
94
+ <v-sheet width="100px">
95
+ <InputLabel title="Card Validity" />
96
+ <v-text-field
97
+ v-model="cardSecurityCode"
98
+ @input="formatCardSecurityCode"
99
+ :rules="[validateCVV]"
100
+ >
101
+ <template #prepend-inner>
102
+ <v-img
103
+ :src="`${CARD_LOGO}#protected-registration`"
104
+ width="30"
105
+ height="30"
106
+ />
107
+ </template>
108
+ </v-text-field>
109
+ </v-sheet>
110
+ </v-row>
111
+ </v-col>
112
+
113
+ <v-col cols="12">
114
+ <v-row no-gutters>
115
+ <v-col cols="12">
116
+ <InputLabel title="Cardholder Name" />
117
+ <v-text-field
118
+ v-model="cardholderName"
119
+ :rules="[requiredRule]"
120
+ >
121
+ <template #prepend-inner>
122
+ <v-img
123
+ :src="`${CARD_LOGO}#discount-domain-club`"
124
+ width="30"
125
+ height="30"
126
+ />
127
+ </template>
128
+ </v-text-field>
129
+ </v-col>
130
+ </v-row>
131
+ </v-col>
132
+
133
+ <v-col cols="12" class="mt-2">
134
+ <v-row no-gutters>
135
+ <v-col cols="12" lg="9">
136
+ <InputLabel title="Email" />
137
+ <v-text-field
138
+ v-model="cardholderEmail"
139
+ :rules="[emailRule, requiredRule]"
140
+ >
141
+ </v-text-field>
142
+ </v-col>
143
+ </v-row>
144
+ </v-col>
145
+
146
+ <v-col cols="12" class="mt-2">
147
+ <v-row no-gutters>
148
+ <v-col cols="12" lg="9">
149
+ <InputLabel title="Mobile Number" />
150
+ <v-text-field
151
+ v-model="cardholderMobileNumber"
152
+ @input="formatContactNumber"
153
+ :rules="[requiredRule, validateContactNumber]"
154
+ placeholder="+XXX XXXX XXX XXX"
155
+ ></v-text-field>
156
+ </v-col>
157
+ </v-row>
158
+ </v-col>
159
+ </v-row>
38
160
  </v-col>
39
- </template>
40
161
 
41
- <v-col cols="12">
42
- <InputLabel title="Direct Debit" />
43
- </v-col>
162
+ <slot name="card-action"></slot>
163
+ </v-row>
164
+ </template>
165
+ </v-expansion-panel>
166
+
167
+ <v-expansion-panel disabled>
168
+ <template #title>
169
+ <span class="font-weight-bold py-2">Direct Debit</span>
170
+ </template>
44
171
 
45
- <template
46
- v-for="supportedDirectDebitItem in props.supportedDirectDebit"
172
+ <template #text>
173
+ <v-item-group
174
+ v-model.number="selectedPaymentMethod"
175
+ selected-class="bg-cyan-accent-1"
176
+ mandatory
47
177
  >
48
- <v-col cols="12" lg="3" md="4" sm="4">
49
- <v-item v-slot="{ toggle, selectedClass }">
50
- <v-card
51
- variant="outlined"
52
- border="thin"
53
- @click="toggle"
54
- :class="['px-4', selectedClass]"
55
- >
56
- <v-img
57
- :src="supportedDirectDebitItem.logo"
58
- width="100%"
59
- height="60px"
60
- >
61
- </v-img>
62
- </v-card>
63
- </v-item>
64
- </v-col>
65
- </template>
66
- </v-row>
67
- </v-item-group>
68
- </v-col>
69
- </v-row>
178
+ <v-row class="pt-4 pb-3">
179
+ <template v-for="directDebit in supportedDirectDebit">
180
+ <v-col cols="12" lg="3" md="4" sm="4">
181
+ <v-item v-slot="{ toggle, selectedClass }">
182
+ <v-card
183
+ variant="outlined"
184
+ border="thin"
185
+ @click="toggle"
186
+ :class="['px-4', selectedClass]"
187
+ >
188
+ <v-img
189
+ :src="directDebit.logo"
190
+ width="100%"
191
+ height="60px"
192
+ >
193
+ </v-img>
194
+ </v-card>
195
+ </v-item>
196
+ </v-col>
197
+ </template>
198
+
199
+ <slot name="ewallet-action"></slot>
200
+ </v-row>
201
+ </v-item-group>
202
+ </template>
203
+ </v-expansion-panel>
204
+ </v-expansion-panels>
205
+ </v-card>
70
206
  </v-col>
71
207
  </v-row>
72
208
  </template>
@@ -79,93 +215,371 @@ const channel = defineModel<string | null>("channel", {
79
215
  const type = defineModel<string | null>("type", {
80
216
  default: null,
81
217
  });
82
- const props = defineProps({
83
- supportedEwallets: {
84
- type: Array as PropType<
85
- {
86
- channel: string;
87
- logo: string;
88
- type: string;
89
- }[]
90
- >,
91
- default() {
92
- return [
93
- {
94
- channel: "GCASH",
95
- logo: "/gcash-logo.svg",
96
- type: "EWALLET",
97
- },
98
- {
99
- channel: "PAYMAYA",
100
- logo: "/paymaya-logo.svg",
101
- type: "EWALLET",
102
- },
103
- {
104
- channel: "GRABPAY",
105
- logo: "/grabpay-logo.svg",
106
- type: "EWALLET",
107
- },
108
- {
109
- channel: "SHOPEEPAY",
110
- logo: "/shopeepay-logo.svg",
111
- type: "EWALLET",
112
- },
113
- ];
114
- },
115
- },
116
- supportedDirectDebit: {
117
- type: Array as PropType<
118
- {
119
- channel: string;
120
- logo: string;
121
- type: string;
122
- }[]
123
- >,
124
- default() {
125
- return [
126
- {
127
- channel: "UBP",
128
- logo: "/ubp-logo.svg",
129
- type: "DIRECT_DEBIT",
130
- },
131
- {
132
- channel: "BDO",
133
- logo: "/bdo-logo.svg",
134
- type: "DIRECT_DEBIT",
135
- },
136
- {
137
- channel: "BPI",
138
- logo: "/bpi-logo.svg",
139
- type: "DIRECT_DEBIT",
140
- },
141
- {
142
- channel: "RCBC",
143
- logo: "/rcbc-logo.svg",
144
- type: "DIRECT_DEBIT",
145
- },
146
- {
147
- channel: "Chinabank",
148
- logo: "/chinabank-logo.svg",
149
- type: "DIRECT_DEBIT",
150
- },
151
- ];
152
- },
153
- },
218
+
219
+ const expansionPanel = ref();
220
+
221
+ watch(expansionPanel, (value) => {
222
+ channel.value = null;
223
+ type.value = null;
224
+ if (value === 0) {
225
+ type.value = "EWALLET";
226
+ } else if (value === 1) {
227
+ type.value = "CARD";
228
+ } else if (value === 2) {
229
+ type.value = "DIRECT_DEBIT";
230
+ }
154
231
  });
155
232
 
156
- const supportedPaymentMethods = [
157
- ...props.supportedEwallets,
158
- ...props.supportedDirectDebit,
159
- ];
233
+ const { requiredRule, emailRule } = useUtils();
234
+
235
+ const {
236
+ supportedPaymentMethods,
237
+ supportedEwallets,
238
+ supportedDirectDebit,
239
+ cardNumber,
240
+ cardExpiration,
241
+ cardSecurityCode,
242
+ cardholderName,
243
+ cardholderMobileNumber,
244
+ cardholderEmail,
245
+ } = usePaymentMethod();
160
246
 
161
247
  watch(selectedPaymentMethod, (value: number | null) => {
162
248
  if (value !== null) {
163
- const selectedPayment = supportedPaymentMethods[value];
249
+ const paymentMethods =
250
+ type.value === "DIRECT_DEBIT"
251
+ ? supportedDirectDebit
252
+ : type.value === "EWALLET"
253
+ ? supportedEwallets
254
+ : [];
255
+ const selectedPayment = paymentMethods[value];
164
256
  channel.value = selectedPayment.channel;
165
- type.value = selectedPayment.type;
166
257
  } else {
167
258
  channel.value = null;
168
- type.value = null;
169
259
  }
170
260
  });
261
+
262
+ // Extended card providers with regex patterns
263
+ const cardTypes = [
264
+ { name: "Visa", regex: /^4/ },
265
+ {
266
+ name: "MasterCard",
267
+ regex: /^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)/,
268
+ },
269
+ { name: "American Express", regex: /^3[47]/ },
270
+ {
271
+ name: "Discover",
272
+ regex: /^(6011|622(12[6-9]|1[3-9]\d|[2-8]\d\d|92[0-5])|64[4-9]|65)/,
273
+ },
274
+ { name: "Diners Club", regex: /^3(0[0-5]|[68])/ },
275
+ { name: "JCB", regex: /^35/ },
276
+ { name: "UnionPay", regex: /^62/ },
277
+ { name: "Maestro", regex: /^(50|5[6-9]|6\d)/ },
278
+ ];
279
+
280
+ function detectCard(value: string) {
281
+ if (!value) return "Unknown";
282
+ return cardTypes.find((card) => card.regex.test(value))?.name || "Unknown";
283
+ }
284
+
285
+ function validateAcceptedCard(value: string): boolean | string {
286
+ const acceptedCards = ["JCB", "Visa", "MasterCard"];
287
+ if (!acceptedCards.includes(detectCard(value))) {
288
+ return "Card type not accepted";
289
+ }
290
+ return true;
291
+ }
292
+
293
+ const CARD_LOGO = useRuntimeConfig().public.CARD_LOGO;
294
+
295
+ const cardLogo = computed(() =>
296
+ getPaymentMethodLogo(detectCard(cardNumber.value))
297
+ );
298
+
299
+ import { h } from "vue";
300
+ import { VImg } from "vuetify/components";
301
+
302
+ function getPaymentMethodLogo(type: string) {
303
+ const width = "30";
304
+ const height = "30";
305
+ switch (type) {
306
+ case "Visa":
307
+ return h(VImg, {
308
+ src: `${CARD_LOGO}#visa`,
309
+ width,
310
+ height,
311
+ });
312
+ case "MasterCard":
313
+ return h(VImg, {
314
+ src: `${CARD_LOGO}#mastercard`,
315
+ width,
316
+ height,
317
+ });
318
+ case "American Express":
319
+ return h(VImg, {
320
+ src: `${CARD_LOGO}#amex`,
321
+ width,
322
+ height,
323
+ });
324
+ case "Discover":
325
+ return h(VImg, {
326
+ src: `${CARD_LOGO}#discover`,
327
+ width,
328
+ height,
329
+ });
330
+ case "Diners Club":
331
+ return h(VImg, {
332
+ src: `${CARD_LOGO}#diners`,
333
+ width,
334
+ height,
335
+ });
336
+ case "JCB":
337
+ return h(VImg, {
338
+ src: `${CARD_LOGO}#jcb`,
339
+ width,
340
+ height,
341
+ });
342
+ case "UnionPay":
343
+ return h(VImg, {
344
+ src: `${CARD_LOGO}#unionpay`,
345
+ width,
346
+ height,
347
+ });
348
+ case "Maestro":
349
+ return h(VImg, {
350
+ src: `${CARD_LOGO}#maestro`,
351
+ width,
352
+ height,
353
+ });
354
+ case "GCASH":
355
+ return h(VImg, {
356
+ src: `${CARD_LOGO}#gcash`,
357
+ width,
358
+ height,
359
+ });
360
+ case "PAYMAYA":
361
+ return h(VImg, {
362
+ src: "/public/paymaya-logo.png",
363
+ width,
364
+ height,
365
+ });
366
+ default:
367
+ return h(VImg, {
368
+ src: `${CARD_LOGO}#guide_code`,
369
+ width,
370
+ height,
371
+ });
372
+ }
373
+ }
374
+
375
+ const formatExpiry = (event: Event) => {
376
+ const input = event.target as HTMLInputElement;
377
+ let value = input.value.replace(/\D/g, ""); // Remove non-numeric characters
378
+
379
+ if (value.length > 2) {
380
+ value = value.replace(/(\d{2})(\d{0,2})/, "$1/$2"); // Insert slash after MM
381
+ }
382
+
383
+ value = value.substring(0, 5); // ✨ Limit to 5 characters
384
+
385
+ cardExpiration.value = value;
386
+ };
387
+
388
+ // Expiry date validation
389
+ const validateExpiry = (value: string): boolean | string => {
390
+ if (!value.match(/^\d{2}\/\d{2}$/)) return "Invalid"; // Check format
391
+
392
+ const [monthStr, yearStr] = value.split("/");
393
+ const month = parseInt(monthStr, 10);
394
+ const year = parseInt("20" + yearStr, 10); // Convert YY to YYYY
395
+
396
+ const now = new Date();
397
+ const currentMonth = now.getMonth() + 1; // JavaScript months are 0-based
398
+ const currentYear = now.getFullYear();
399
+
400
+ if (month < 1 || month > 12) return "Invalid";
401
+ if (year < currentYear || (year === currentYear && month < currentMonth))
402
+ return "Expired";
403
+
404
+ return true;
405
+ };
406
+
407
+ // Format card number as "#### #### #### ####"
408
+ const formatCardNumber = (event: Event) => {
409
+ const input = event.target as HTMLInputElement;
410
+ let value = input.value.replace(/\D/g, ""); // Remove non-numeric characters
411
+
412
+ // Insert spaces after every 4 digits
413
+ let formattedValue = value
414
+ .slice(0, 19)
415
+ .replace(/(\d{4})/g, "$1 ")
416
+ .trim(); // Ensures no extra spaces at the end
417
+
418
+ // Preserve cursor position while formatting
419
+ const cursorPosition = input.selectionStart ?? 0;
420
+ const spaceCountBefore = (cardNumber.value.match(/ /g) || []).length;
421
+ const spaceCountAfter = (formattedValue.match(/ /g) || []).length;
422
+ const cursorOffset = spaceCountAfter - spaceCountBefore;
423
+
424
+ cardNumber.value = formattedValue;
425
+
426
+ // Restore correct cursor position
427
+ setTimeout(() => {
428
+ input.setSelectionRange(
429
+ cursorPosition + cursorOffset,
430
+ cursorPosition + cursorOffset
431
+ );
432
+ });
433
+ };
434
+
435
+ // Validate card number using Luhn Algorithm
436
+ const validateCardNumber = (value: string): boolean | string => {
437
+ const sanitizedValue = value.replace(/\s/g, ""); // Remove spaces
438
+
439
+ if (!/^\d{15,19}$/.test(sanitizedValue)) return "Invalid card number";
440
+
441
+ if (!luhnCheck(sanitizedValue)) return "Invalid card";
442
+
443
+ return true;
444
+ };
445
+
446
+ // Luhn Algorithm for card validation
447
+ const luhnCheck = (num: string): boolean => {
448
+ let sum = 0;
449
+ let shouldDouble = false;
450
+
451
+ for (let i = num.length - 1; i >= 0; i--) {
452
+ let digit = parseInt(num.charAt(i), 10);
453
+
454
+ if (shouldDouble) {
455
+ digit *= 2;
456
+ if (digit > 9) digit -= 9;
457
+ }
458
+
459
+ sum += digit;
460
+ shouldDouble = !shouldDouble;
461
+ }
462
+
463
+ return sum % 10 === 0;
464
+ };
465
+
466
+ function validateCVV() {
467
+ const type = detectCard(cardNumber.value);
468
+ const cvvLength = type === "American Express" ? 4 : 3;
469
+ const cvvRegex = new RegExp(`^\\d{${cvvLength}}$`);
470
+ if (!cvvRegex.test(cardSecurityCode.value)) {
471
+ return "Invalid";
472
+ }
473
+ return true;
474
+ }
475
+
476
+ function formatCardSecurityCode(event: Event) {
477
+ const input = event.target as HTMLInputElement;
478
+ let value = input.value.replace(/\D/g, ""); // Remove non-numeric characters
479
+
480
+ const maxLength = detectCard(cardNumber.value) === "American Express" ? 4 : 3;
481
+ value = value.substring(0, maxLength);
482
+
483
+ input.value = value; // ✨ update input field
484
+ cardSecurityCode.value = value; // ✨ update bound variable
485
+
486
+ // Preserve cursor position while formatting
487
+ const cursorPosition = input.selectionStart ?? 0;
488
+ const cursorOffset = value.length - input.value.length;
489
+ setTimeout(() => {
490
+ input.setSelectionRange(
491
+ cursorPosition + cursorOffset,
492
+ cursorPosition + cursorOffset
493
+ );
494
+ });
495
+ }
496
+
497
+ const countryRules: { [key: string]: number } = {
498
+ "1": 11, // 🇺🇸 USA, 🇨🇦 Canada (1 XXX XXX XXXX)
499
+ "44": 12, // 🇬🇧 UK (44 XXXX XXXXXX)
500
+ "971": 12, // 🇦🇪 UAE (971 XX XXX XXXX)
501
+ "234": 13, // 🇳🇬 Nigeria (234 XXX XXX XXXX)
502
+ "63": 12, // 🇵🇭 Philippines (63 XXX XXX XXXX)
503
+ "91": 12, // 🇮🇳 India (91 XXXXX XXXXX)
504
+ "61": 12, // 🇦🇺 Australia (61 XXXX XXX XXX)
505
+ "81": 12, // 🇯🇵 Japan (81 XX XXXX XXXX)
506
+ "65": 11, // 🇸🇬 Singapore (65 XXXX XXXX)
507
+ "62": 12, // 🇮🇩 Indonesia (62 XX XXX XXXX)
508
+ "60": 12, // 🇲🇾 Malaysia (60 XX XXX XXXX)
509
+ "49": 13, // 🇩🇪 Germany (49 XXXX XXXXXX)
510
+ "33": 12, // 🇫🇷 France (33 X XX XX XX XX)
511
+ "39": 13, // 🇮🇹 Italy (39 XXX XXX XXXX)
512
+ };
513
+
514
+ const formatContactNumber = (event: Event) => {
515
+ const input = event.target as HTMLInputElement;
516
+ let rawValue = input.value.replace(/\D/g, ""); // Remove all non-numeric characters
517
+
518
+ if (!rawValue) {
519
+ cardholderMobileNumber.value = "";
520
+ return;
521
+ }
522
+
523
+ let countryCode = "";
524
+ let localNumber = "";
525
+ let maxLength = 15; // Default max length
526
+ let cursorPosition = input.selectionStart || 0;
527
+ let convertedToPH = false;
528
+
529
+ // Detect if the user types a Philippine number (09...)
530
+ if (rawValue.startsWith("09")) {
531
+ rawValue = "63" + rawValue.slice(1); // Convert 09xxxxxxx to +63 9xxxxxxx
532
+ convertedToPH = true; // Mark that conversion happened
533
+ }
534
+
535
+ // Detect country code
536
+ for (let code in countryRules) {
537
+ if (rawValue.startsWith(code)) {
538
+ countryCode = code;
539
+ maxLength = countryRules[code];
540
+ break;
541
+ }
542
+ }
543
+
544
+ // If no valid country code is detected, allow free typing
545
+ if (!countryCode) {
546
+ cardholderMobileNumber.value = `+${rawValue}`;
547
+ return;
548
+ }
549
+
550
+ // Extract local number
551
+ localNumber = rawValue.slice(countryCode.length);
552
+ rawValue = rawValue.slice(0, maxLength); // Enforce max length
553
+
554
+ // Format number
555
+ let formattedValue = `+${countryCode} ${localNumber}`;
556
+
557
+ // Set formatted value
558
+ cardholderMobileNumber.value = formattedValue;
559
+
560
+ // Move cursor to the end if `09` was converted to `+63 9`
561
+ setTimeout(() => {
562
+ if (convertedToPH) {
563
+ input.setSelectionRange(formattedValue.length, formattedValue.length);
564
+ } else {
565
+ input.setSelectionRange(cursorPosition, cursorPosition);
566
+ }
567
+ }, 0);
568
+ };
569
+
570
+ // ✅ **Validation Function**
571
+ const validateContactNumber = (value: string): boolean | string => {
572
+ const cleanValue = value.replace(/\D/g, ""); // Remove spaces and non-numeric characters
573
+
574
+ for (let code in countryRules) {
575
+ if (
576
+ cleanValue.startsWith(code) &&
577
+ cleanValue.length === countryRules[code]
578
+ ) {
579
+ return true; // ✅ Valid if country code matches and length is correct
580
+ }
581
+ }
582
+
583
+ return "Invalid contact number"; // ❌ Fallback error
584
+ };
171
585
  </script>