feat: send ticket receipts via WhatsApp and normalize phone numbers
Add WhatsApp API integration for automated receipt delivery Enforce country codes for all phone number inputs (defaults to +60)
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
|
||||
NUXT_REDIS_URL=redis://127.0.0.1:6379
|
||||
NUXT_SESSION_COOKIE_NAME=dinner_ticket_session
|
||||
NUXT_WHATSAPP_ACCESS_TOKEN=
|
||||
NUXT_WHATSAPP_PHONE_NUMBER_ID=
|
||||
NUXT_WHATSAPP_API_VERSION=v23.0
|
||||
|
||||
# Use your deployed HTTPS origin in production so WebAuthn/passkeys validate correctly.
|
||||
NUXT_PUBLIC_APP_URL=http://localhost:20013
|
||||
|
||||
@@ -17,10 +17,14 @@ Create `.env` from `.env.example` and set:
|
||||
```bash
|
||||
NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system
|
||||
NUXT_REDIS_URL=redis://127.0.0.1:6379
|
||||
NUXT_WHATSAPP_ACCESS_TOKEN=
|
||||
NUXT_WHATSAPP_PHONE_NUMBER_ID=
|
||||
NUXT_WHATSAPP_API_VERSION=v23.0
|
||||
NUXT_PUBLIC_APP_URL=http://localhost:20013
|
||||
```
|
||||
|
||||
`NUXT_PUBLIC_APP_URL` should be your final HTTPS origin in production. Passkeys rely on the RP origin being stable and correct.
|
||||
Set the WhatsApp variables to enable automatic ticket receipt delivery after PIC confirmation. Without them, confirmation still succeeds and the UI reports that WhatsApp delivery was skipped.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PublicBooking } from '~~/shared/booking'
|
||||
import type { ConfirmBookingResponse, PublicBooking } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
formatBookingCurrency,
|
||||
@@ -85,7 +85,7 @@ async function confirmBooking() {
|
||||
confirming.value = true
|
||||
|
||||
try {
|
||||
const response = await apiClient<{ booking: PublicBooking, alreadyConfirmed: boolean }>(
|
||||
const response = await apiClient<ConfirmBookingResponse>(
|
||||
`/api/public/bookings/${token}/confirm`,
|
||||
{
|
||||
method: 'POST'
|
||||
@@ -98,8 +98,10 @@ async function confirmBooking() {
|
||||
title: response.alreadyConfirmed ? 'Booking already confirmed' : 'Booking confirmed',
|
||||
description: response.alreadyConfirmed
|
||||
? 'This booking had already been confirmed earlier.'
|
||||
: 'The booking details have been confirmed successfully.',
|
||||
color: 'success',
|
||||
: response.ticketReceiptWhatsApp.sent
|
||||
? `Ticket receipt was sent to ${response.ticketReceiptWhatsApp.recipientPhone}.`
|
||||
: `Booking confirmed, but the ticket receipt WhatsApp was not sent: ${response.ticketReceiptWhatsApp.error}`,
|
||||
color: response.alreadyConfirmed || response.ticketReceiptWhatsApp.sent ? 'success' : 'warning',
|
||||
icon: 'i-lucide-check-circle-2'
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import { isValidPhoneNumber, type PublicContact } from '~~/shared/auth'
|
||||
import {
|
||||
DEFAULT_PHONE_COUNTRY_CODE,
|
||||
isValidPhoneNumber,
|
||||
normalizePhoneNumber,
|
||||
type PublicContact
|
||||
} from '~~/shared/auth'
|
||||
import type { CreateBookingResponse } from '~~/shared/booking'
|
||||
import {
|
||||
BOOKING_MODE_OPTIONS,
|
||||
@@ -51,7 +56,7 @@ const personInCharge = computed(() => {
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
phone: DEFAULT_PHONE_COUNTRY_CODE,
|
||||
bookingMode: 'table' as BookingMode,
|
||||
quantity: 1,
|
||||
ticketType: 'vip' as TicketType
|
||||
@@ -85,7 +90,7 @@ function validateBooking(state: typeof form): FormError[] {
|
||||
if (!state.phone.trim()) {
|
||||
errors.push({ name: 'phone', message: 'Please enter a contact number.' })
|
||||
} else if (!isValidPhoneNumber(state.phone.trim())) {
|
||||
errors.push({ name: 'phone', message: 'Use a valid phone number with 8 to 15 digits.' })
|
||||
errors.push({ name: 'phone', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
|
||||
}
|
||||
|
||||
if (state.quantity < 1) {
|
||||
@@ -117,7 +122,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customerName: form.name.trim(),
|
||||
customerPhone: form.phone.trim(),
|
||||
customerPhone: normalizePhoneNumber(form.phone),
|
||||
bookingMode: form.bookingMode,
|
||||
quantity: form.quantity,
|
||||
ticketType: form.ticketType,
|
||||
@@ -181,7 +186,7 @@ async function bookTicket(event: FormSubmitEvent<typeof form>) {
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="phone" label="Phone Number" required>
|
||||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. 0123456789" />
|
||||
<UInput v-model="form.phone" size="xl" type="tel" class="w-full" placeholder="e.g. +60123456789" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
size="lg"
|
||||
type="tel"
|
||||
class="w-full"
|
||||
placeholder="e.g. 0123456789"
|
||||
placeholder="e.g. +60123456789"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
import {
|
||||
DEFAULT_PHONE_COUNTRY_CODE,
|
||||
hasValidFullName,
|
||||
isValidPhoneNumber,
|
||||
isValidUsername,
|
||||
@@ -242,7 +243,7 @@ const editingUserId = ref<string | null>(null)
|
||||
const userForm = reactive({
|
||||
fullName: '',
|
||||
username: '',
|
||||
phoneNumber: '',
|
||||
phoneNumber: DEFAULT_PHONE_COUNTRY_CODE,
|
||||
role: 'staff' as UserRole
|
||||
})
|
||||
|
||||
@@ -287,7 +288,7 @@ await refreshUsers()
|
||||
function resetUserForm() {
|
||||
userForm.fullName = ''
|
||||
userForm.username = ''
|
||||
userForm.phoneNumber = ''
|
||||
userForm.phoneNumber = DEFAULT_PHONE_COUNTRY_CODE
|
||||
userForm.role = 'staff'
|
||||
editingUserId.value = null
|
||||
}
|
||||
@@ -303,7 +304,7 @@ function openEditModal(user: ManagedUser) {
|
||||
editingUserId.value = user.id
|
||||
userForm.fullName = user.fullName
|
||||
userForm.username = user.username
|
||||
userForm.phoneNumber = user.phoneNumber || ''
|
||||
userForm.phoneNumber = user.phoneNumber ? normalizePhoneNumber(user.phoneNumber) : DEFAULT_PHONE_COUNTRY_CODE
|
||||
userForm.role = user.role
|
||||
editorOpen.value = true
|
||||
}
|
||||
@@ -329,7 +330,7 @@ function validateUserForm(state: typeof userForm): FormError[] {
|
||||
}
|
||||
|
||||
if (!isValidPhoneNumber(state.phoneNumber)) {
|
||||
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with 8 to 15 digits.' })
|
||||
errors.push({ name: 'phoneNumber', message: 'Use a valid phone number with country code, e.g. +60123456789.' })
|
||||
}
|
||||
|
||||
return errors
|
||||
|
||||
@@ -607,7 +607,7 @@ async function openBatchShare() {
|
||||
<UInput
|
||||
v-model="shareForm.recipientName"
|
||||
class="w-full"
|
||||
placeholder="Optional"
|
||||
placeholder="Optional, e.g. +60123456789"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
NUXT_DATABASE_URL: ${NUXT_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/dinner_ticket_system}
|
||||
NUXT_REDIS_URL: ${NUXT_REDIS_URL:-redis://redis:6379}
|
||||
NUXT_SESSION_COOKIE_NAME: ${NUXT_SESSION_COOKIE_NAME:-dinner_ticket_session}
|
||||
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
||||
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
||||
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
||||
NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
|
||||
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
|
||||
volumes:
|
||||
|
||||
@@ -48,6 +48,9 @@ services:
|
||||
NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system
|
||||
NUXT_REDIS_URL: redis://redis:6379
|
||||
NUXT_SESSION_COOKIE_NAME: dinner_ticket_session
|
||||
NUXT_WHATSAPP_ACCESS_TOKEN: ${NUXT_WHATSAPP_ACCESS_TOKEN:-}
|
||||
NUXT_WHATSAPP_PHONE_NUMBER_ID: ${NUXT_WHATSAPP_PHONE_NUMBER_ID:-}
|
||||
NUXT_WHATSAPP_API_VERSION: ${NUXT_WHATSAPP_API_VERSION:-v23.0}
|
||||
NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013}
|
||||
NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System}
|
||||
ports:
|
||||
|
||||
@@ -10,6 +10,9 @@ export default defineNuxtConfig({
|
||||
databaseUrl: '',
|
||||
redisUrl: '',
|
||||
sessionCookieName: 'dinner_ticket_session',
|
||||
whatsappAccessToken: '',
|
||||
whatsappPhoneNumberId: '',
|
||||
whatsappApiVersion: 'v23.0',
|
||||
public: {
|
||||
appUrl: '',
|
||||
rpName: 'Dinner Ticket System'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createBooking } from '../../utils/booking-repository'
|
||||
import { buildBookingMessage, parseCreateBookingInput } from '../../utils/bookings'
|
||||
import { assertBadRequest } from '../../utils/http'
|
||||
import { getPublicContactById } from '../../utils/user-repository'
|
||||
import { buildWhatsAppDeepLink } from '../../utils/whatsapp'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CreateBookingResponse> => {
|
||||
const body = await readBody<{
|
||||
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event): Promise<CreateBookingResponse>
|
||||
|
||||
const confirmationUrl = buildAppUrl(event, `/confirmation/${confirmationToken}`)
|
||||
const whatsappMessage = buildBookingMessage(booking, confirmationUrl)
|
||||
const whatsappUrl = `https://wa.me/${booking.personInChargePhoneNumber}?text=${encodeURIComponent(whatsappMessage)}`
|
||||
const whatsappUrl = buildWhatsAppDeepLink(booking.personInChargePhoneNumber, whatsappMessage)
|
||||
|
||||
return {
|
||||
booking,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ConfirmBookingResponse } from '~~/shared/booking'
|
||||
|
||||
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
|
||||
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
|
||||
import { sendBookingTicketReceiptViaWhatsApp } from '../../../../utils/whatsapp'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
export default defineEventHandler(async (event): Promise<ConfirmBookingResponse> => {
|
||||
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
|
||||
const existingBooking = await getBookingByConfirmationToken(token)
|
||||
|
||||
@@ -12,7 +15,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (existingBooking.status === 'confirmed') {
|
||||
return {
|
||||
booking: existingBooking,
|
||||
alreadyConfirmed: true
|
||||
alreadyConfirmed: true,
|
||||
ticketReceiptWhatsApp: {
|
||||
sent: false,
|
||||
skipped: true,
|
||||
recipientPhone: existingBooking.customerPhone,
|
||||
apiRecipientPhone: existingBooking.customerPhone.replace(/\D/g, ''),
|
||||
error: 'Booking was already confirmed earlier.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +38,11 @@ export default defineEventHandler(async (event) => {
|
||||
httpError(404, 'Booking not found')
|
||||
}
|
||||
|
||||
const ticketReceiptWhatsApp = await sendBookingTicketReceiptViaWhatsApp(event, booking)
|
||||
|
||||
return {
|
||||
booking,
|
||||
alreadyConfirmed: false
|
||||
alreadyConfirmed: false,
|
||||
ticketReceiptWhatsApp
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ export function parseCreateBookingInput(body: {
|
||||
const personInChargeId = (body.personInChargeId || '').trim()
|
||||
|
||||
assertBadRequest(hasValidFullName(customerName), 'Guest or organizer name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(isValidPhoneNumber(customerPhone), 'Phone number must include a country code, e.g. +60123456789')
|
||||
assertBadRequest(isBookingMode(bookingMode), 'Booking mode is invalid')
|
||||
assertBadRequest(Number.isInteger(quantity) && quantity >= 1, 'Quantity must be a whole number of at least 1')
|
||||
assertBadRequest(isTicketType(ticketType), 'Ticket category is invalid')
|
||||
@@ -73,7 +73,7 @@ export function parseSeatShareInput(body: {
|
||||
|
||||
if (shared) {
|
||||
assertBadRequest(!recipientName || hasValidFullName(recipientName), 'Recipient name must be at least 2 characters')
|
||||
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(!recipientPhone || isValidPhoneNumber(recipientPhone), 'Recipient phone number must include a country code, e.g. +60123456789')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -279,7 +279,7 @@ async function initializeDatabase() {
|
||||
await sql`
|
||||
update users
|
||||
set
|
||||
phone_number = '601157753558',
|
||||
phone_number = '+601157753558',
|
||||
updated_at = now()
|
||||
where username = 'xiaomai'
|
||||
and (phone_number is null or phone_number = '')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server'
|
||||
|
||||
import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth'
|
||||
import { normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
import { encodeBase64Url, toIsoString } from './base64url'
|
||||
import { ensureDatabaseReady } from './db-init'
|
||||
@@ -80,7 +81,7 @@ function mapAuthUser(row: DbUserRow): AuthUser {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number,
|
||||
phoneNumber: row.phone_number ? normalizePhoneNumber(row.phone_number) : null,
|
||||
role: row.role,
|
||||
isActive: row.is_active,
|
||||
mustChangePassword: row.must_change_password,
|
||||
@@ -251,7 +252,7 @@ export async function listPublicContacts(): Promise<PublicContact[]> {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number || '',
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
}))
|
||||
}
|
||||
@@ -281,7 +282,7 @@ export async function getPublicContactById(contactId: string): Promise<PublicCon
|
||||
return {
|
||||
id: row.id,
|
||||
fullName: row.full_name,
|
||||
phoneNumber: row.phone_number || '',
|
||||
phoneNumber: normalizePhoneNumber(row.phone_number || ''),
|
||||
role: row.role
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function parseCreateUserInput(body: {
|
||||
'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore'
|
||||
)
|
||||
assertBadRequest(hasValidFullName(fullName), 'Full name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must include a country code, e.g. +60123456789')
|
||||
|
||||
return {
|
||||
username,
|
||||
@@ -64,7 +64,7 @@ export function parseUserProfileInput(body: {
|
||||
const role = body.role
|
||||
|
||||
assertBadRequest(hasValidFullName(fullName), 'Display name must be at least 2 characters')
|
||||
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must contain 8 to 15 digits')
|
||||
assertBadRequest(isValidPhoneNumber(phoneNumber), 'Phone number must include a country code, e.g. +60123456789')
|
||||
assertBadRequest(isUserRole(role), 'Role is invalid')
|
||||
|
||||
return {
|
||||
|
||||
111
server/utils/whatsapp.ts
Normal file
111
server/utils/whatsapp.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
import type { PublicBooking, WhatsAppDeliveryResult } from '~~/shared/booking'
|
||||
|
||||
import {
|
||||
DINNER_EVENT_DATE_LABEL,
|
||||
DINNER_EVENT_TIME_LABEL,
|
||||
DINNER_EVENT_TITLE,
|
||||
DINNER_EVENT_VENUE,
|
||||
formatBookingCurrency,
|
||||
getTicketCatalogItem
|
||||
} from '~~/shared/booking'
|
||||
import { normalizePhoneNumber } from '~~/shared/auth'
|
||||
|
||||
import { buildAppUrl } from './app-url'
|
||||
|
||||
type WhatsAppMessagesResponse = {
|
||||
messages?: Array<{
|
||||
id?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function toWhatsAppPhoneNumber(phoneNumber: string) {
|
||||
return normalizePhoneNumber(phoneNumber).replace(/\D/g, '')
|
||||
}
|
||||
|
||||
export function buildWhatsAppDeepLink(phoneNumber: string, message: string) {
|
||||
return `https://wa.me/${toWhatsAppPhoneNumber(phoneNumber)}?text=${encodeURIComponent(message)}`
|
||||
}
|
||||
|
||||
export function buildBookingTicketReceiptMessage(event: H3Event, booking: PublicBooking) {
|
||||
const ticket = getTicketCatalogItem(booking.ticketType)
|
||||
const ticketLabel = ticket?.label || booking.ticketType.toUpperCase()
|
||||
const receiptUrl = buildAppUrl(event, `/receipt/${booking.receiptToken}`)
|
||||
|
||||
return [
|
||||
DINNER_EVENT_TITLE,
|
||||
'',
|
||||
`Hi ${booking.customerName}, your ticket receipt has been confirmed.`,
|
||||
'',
|
||||
`Receipt: ${receiptUrl}`,
|
||||
`Seats: ${booking.seatCount}`,
|
||||
`Ticket Category: ${ticketLabel}`,
|
||||
`Total Price: ${formatBookingCurrency(booking.totalPrice)}`,
|
||||
`Date: ${DINNER_EVENT_DATE_LABEL}`,
|
||||
`Time: ${DINNER_EVENT_TIME_LABEL}`,
|
||||
`Venue: ${DINNER_EVENT_VENUE}`,
|
||||
'',
|
||||
'Please present the QR code from the receipt at the event.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function sendBookingTicketReceiptViaWhatsApp(
|
||||
event: H3Event,
|
||||
booking: PublicBooking
|
||||
): Promise<WhatsAppDeliveryResult> {
|
||||
const recipientPhone = normalizePhoneNumber(booking.customerPhone)
|
||||
const to = toWhatsAppPhoneNumber(recipientPhone)
|
||||
const config = useRuntimeConfig()
|
||||
const accessToken = String(config.whatsappAccessToken || '')
|
||||
const phoneNumberId = String(config.whatsappPhoneNumberId || '')
|
||||
const apiVersion = String(config.whatsappApiVersion || 'v23.0')
|
||||
|
||||
if (!accessToken || !phoneNumberId) {
|
||||
return {
|
||||
sent: false,
|
||||
skipped: true,
|
||||
recipientPhone,
|
||||
apiRecipientPhone: to,
|
||||
error: 'WhatsApp API credentials are not configured.'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<WhatsAppMessagesResponse>(
|
||||
`https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
},
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'text',
|
||||
text: {
|
||||
preview_url: true,
|
||||
body: buildBookingTicketReceiptMessage(event, booking)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
skipped: false,
|
||||
recipientPhone,
|
||||
apiRecipientPhone: to,
|
||||
messageId: response.messages?.[0]?.id
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
sent: false,
|
||||
skipped: false,
|
||||
recipientPhone,
|
||||
apiRecipientPhone: to,
|
||||
error: error?.data?.error?.message || error?.message || 'WhatsApp API request failed.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export const MIN_PASSWORD_LENGTH = 8
|
||||
export const MIN_FULL_NAME_LENGTH = 2
|
||||
export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/
|
||||
export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/
|
||||
export const DEFAULT_PHONE_COUNTRY_CODE = '+60'
|
||||
|
||||
export type UserRole = 'super_admin' | 'staff'
|
||||
|
||||
@@ -24,7 +25,21 @@ export function normalizePhoneNumber(value: string) {
|
||||
const hasPlusPrefix = trimmed.startsWith('+')
|
||||
const digitsOnly = trimmed.replace(/\D/g, '')
|
||||
|
||||
return hasPlusPrefix ? `+${digitsOnly}` : digitsOnly
|
||||
if (!digitsOnly) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (hasPlusPrefix) {
|
||||
return `+${digitsOnly}`
|
||||
}
|
||||
|
||||
const defaultCountryDigits = DEFAULT_PHONE_COUNTRY_CODE.replace(/\D/g, '')
|
||||
|
||||
if (digitsOnly.startsWith(defaultCountryDigits)) {
|
||||
return `+${digitsOnly}`
|
||||
}
|
||||
|
||||
return `+${defaultCountryDigits}${digitsOnly.replace(/^0+/, '')}`
|
||||
}
|
||||
|
||||
export function isValidPhoneNumber(value: string) {
|
||||
|
||||
@@ -116,6 +116,21 @@ export interface CreateBookingResponse {
|
||||
whatsappUrl: string
|
||||
}
|
||||
|
||||
export interface WhatsAppDeliveryResult {
|
||||
sent: boolean
|
||||
skipped: boolean
|
||||
recipientPhone: string
|
||||
apiRecipientPhone: string
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConfirmBookingResponse {
|
||||
booking: PublicBooking
|
||||
alreadyConfirmed: boolean
|
||||
ticketReceiptWhatsApp: WhatsAppDeliveryResult
|
||||
}
|
||||
|
||||
export function isBookingMode(value: string | null | undefined): value is BookingMode {
|
||||
return value === 'table' || value === 'seat'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user