From 34f97f998cde7887907d17c17af5574bbbe29a7b Mon Sep 17 00:00:00 2001 From: Josh Torre Date: Thu, 16 Apr 2026 06:43:41 -0400 Subject: [PATCH 1/2] Init --- frontend/apps/mobile/app/(app)/event/[id].tsx | 5 +- .../mobile/app/(app)/org/[id]/schedule.tsx | 55 +- .../mobile/components/ReservationModal.tsx | 474 +++++++++++------- .../components/ReservationSuccessModal.tsx | 117 +++++ .../mobile/components/StaticBackground.tsx | 8 +- .../components/TermsAndConditionsModal.tsx | 124 +++++ frontend/apps/mobile/i18n/en.json | 1 + frontend/apps/mobile/i18n/th.json | 1 + frontend/apps/mobile/utils/format.ts | 4 + 9 files changed, 578 insertions(+), 211 deletions(-) create mode 100644 frontend/apps/mobile/components/ReservationSuccessModal.tsx create mode 100644 frontend/apps/mobile/components/TermsAndConditionsModal.tsx diff --git a/frontend/apps/mobile/app/(app)/event/[id].tsx b/frontend/apps/mobile/app/(app)/event/[id].tsx index a8416e19..0f35ef46 100644 --- a/frontend/apps/mobile/app/(app)/event/[id].tsx +++ b/frontend/apps/mobile/app/(app)/event/[id].tsx @@ -46,7 +46,7 @@ function EventOccurrenceDetail({ const avgRating = aggregate?.average_rating ?? 0; const totalReviews = aggregate?.total_reviews ?? 0; const ratingMatch = RATING_OPTIONS.find( - (r) => r.rating === Math.round(avgRating), + (r) => r.rating === Math.round(avgRating) ); const cardShadow = { @@ -285,8 +285,7 @@ function EventOccurrenceDetail({ router.push({ pathname: "/org/[id]/schedule", diff --git a/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx b/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx index 8daefd46..4767ed41 100644 --- a/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx +++ b/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx @@ -19,7 +19,7 @@ import { AppColors, FontFamilies } from "@/constants/theme"; import { useThemeColor } from "@/hooks/use-theme-color"; import { useTranslation } from "react-i18next"; import { OccurrenceCard } from "./OccurrenceCard"; -import { formatSectionDate } from "@/utils/format"; +import { formatSectionDate, formatSectionMonth } from "@/utils/format"; import { useOrgScheduleFilters } from "@/hooks/use-org-schedule-filters"; export default function OrgScheduleScreen() { @@ -88,8 +88,10 @@ export default function OrgScheduleScreen() { if (min_price !== undefined && o.price < min_price) return false; if (max_price !== undefined && o.price > max_price) return false; - if (min_age !== undefined && o.event.age_range_max < min_age) return false; - if (max_age !== undefined && o.event.age_range_min > max_age) return false; + if (min_age !== undefined && o.event.age_range_max < min_age) + return false; + if (max_age !== undefined && o.event.age_range_min > max_age) + return false; return true; }); @@ -97,12 +99,12 @@ export default function OrgScheduleScreen() { const uniqueEventIds = useMemo( () => [...new Set(occurrences.map((o) => o.event.id))], - [occurrences], + [occurrences] ); const reviewResults = useQueries({ queries: uniqueEventIds.map((eventId) => - getGetReviewAggregateQueryOptions(eventId), + getGetReviewAggregateQueryOptions(eventId) ), }); @@ -124,7 +126,7 @@ export default function OrgScheduleScreen() { const grouped = useMemo(() => { const sorted = [...filteredOccurrences].sort( (a, b) => - new Date(a.start_time).getTime() - new Date(b.start_time).getTime(), + new Date(a.start_time).getTime() - new Date(b.start_time).getTime() ); const groups = new Map(); @@ -135,6 +137,7 @@ export default function OrgScheduleScreen() { } return Array.from(groups.entries()).map(([, items]) => ({ + month: formatSectionMonth(items[0].start_time), label: formatSectionDate(items[0].start_time), items, })); @@ -163,12 +166,14 @@ export default function OrgScheduleScreen() { /> {translate("org.schedule")} + router.push(`/org/${id}/filters`)} activeOpacity={0.7} @@ -183,7 +188,7 @@ export default function OrgScheduleScreen() { {activeCount > 0 && ( )} + {occurrencesLoading ? ( @@ -222,16 +228,29 @@ export default function OrgScheduleScreen() { > {grouped.map((group) => ( - - {group.label} - + + + {group.month} + + + {group.label} + + {group.items.map((occ) => ( (null); + const isSuccessTransition = useRef(false); const [selectedChildId, setSelectedChildId] = useState(null); - const [step, setStep] = useState<"select" | "done">("select"); const [reservationError, setReservationError] = useState(null); - - useEffect(() => { - if (visible) { - sheetRef.current?.present(); - } else { - sheetRef.current?.dismiss(); - } - }, [visible]); + const [errorDetails, setErrorDetails] = useState([]); + const [errorExpanded, setErrorExpanded] = useState(false); + const [termsAccepted, setTermsAccepted] = useState(false); + const [showTermsModal, setShowTermsModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); const { data: childrenResp, isLoading: childrenLoading } = useGetChildrenByGuardianId(guardianId!, { @@ -74,6 +62,8 @@ export function ReservationModal({ function handleReserve() { if (!selectedChildId || !guardianId) return; setReservationError(null); + setErrorDetails([]); + setErrorExpanded(false); createRegistration( { data: { @@ -86,199 +76,311 @@ export function ReservationModal({ }, { onSuccess: () => { - setStep("done"); + isSuccessTransition.current = true; + setShowSuccessModal(true); + onClose(); }, onError: (error: unknown) => { - const message = - error instanceof Error - ? error.message - : translate("reservation.paymentFailed"); - setReservationError(message); + try { + const typedError = error as createRegistrationResponseError; + const errorMsgs = (typedError.data.errors || []) + .map((e) => e.message) + .filter((m): m is string => !!m); + setErrorDetails(errorMsgs); + setReservationError(translate("reservation.paymentFailed")); + } catch { + setReservationError(translate("reservation.paymentFailed")); + } }, - }, + } ); } - function handleClose() { - setStep("select"); + function resetState() { setSelectedChildId(null); setReservationError(null); - onClose(); + setErrorDetails([]); + setErrorExpanded(false); + setTermsAccepted(false); } - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - // handleClose intentionally omitted — state setters are stable and - // onClose identity changing should not recreate the backdrop component. - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + function handleClose() { + resetState(); + if (!isSuccessTransition.current) { + onClose(); + } + isSuccessTransition.current = false; + } + + function handleSuccessClose() { + setShowSuccessModal(false); + onClose(); + } return ( - - + - setShowTermsModal(false)} + /> + + + {}} + style={{ + width: "90%", + maxHeight: "85%", + backgroundColor: "#fff", + borderRadius: 20, + overflow: "hidden", + }} + > + {/* X button */} + + + - {step === "select" ? ( - <> - {/* Child selection */} - - - {translate("reservation.selectChildLabel")} - - {childrenLoading ? ( - - ) : children.length === 0 ? ( + + + + {/* Child selection */} + - {translate("reservation.noChildren")} + {translate("reservation.selectChildLabel")} - ) : ( - - {children.map((child) => ( - - setSelectedChildId( - selectedChildId === child.id ? null : child.id, - ) - } - /> - ))} - - )} - - - {/* Terms */} - - - {translate("reservation.termsNote")} - - - {translate("reservation.terms")} - - + {childrenLoading ? ( + + ) : children.length === 0 ? ( + + {translate("reservation.noChildren")} + + ) : ( + + {children.map((child) => ( + + setSelectedChildId( + selectedChildId === child.id ? null : child.id + ) + } + style={ + selectedChildId === child.id + ? { + borderRadius: 999, + borderWidth: 2, + borderColor: AppColors.primaryBlue, + } + : undefined + } + > + + + ))} + + )} + - {/* Error message */} - {!!reservationError && ( - + {/* Terms */} + - {reservationError} + {translate("reservation.termsNote")} + setShowTermsModal(true)}> + + {translate("reservation.terms")} + + + setTermsAccepted((prev) => !prev)} + activeOpacity={0.7} + className="flex-row items-center gap-2 mt-3 self-center" + > + + {termsAccepted && ( + + ✓ + + )} + + + {translate("reservation.termsAgree")} + + - )} - {/* Reserve button */} - - {isPending ? ( - - ) : ( - - {translate("reservation.payAndReserve")} - + + {reservationError} + + {errorDetails.length > 0 && ( + <> + setErrorExpanded((prev) => !prev)} + className="mt-2 items-center" + > + + {errorExpanded ? "See less" : "See more"} + + + {errorExpanded && ( + + {errorDetails.map((detail, i) => ( + + {"\u2022"} {detail} + + ))} + + )} + + )} + )} - - - ) : ( - <> - {/* Completed state */} - - {translate("reservation.seeYouSoon")} - - - - {translate("common.close")} - - - - )} - - + {isPending ? ( + + ) : ( + + {translate("reservation.payAndReserve")} + + )} + + + + + + ); } diff --git a/frontend/apps/mobile/components/ReservationSuccessModal.tsx b/frontend/apps/mobile/components/ReservationSuccessModal.tsx new file mode 100644 index 00000000..71317387 --- /dev/null +++ b/frontend/apps/mobile/components/ReservationSuccessModal.tsx @@ -0,0 +1,117 @@ +import { Image } from "expo-image"; +import { Modal, Text, TouchableOpacity, View } from "react-native"; +import { AppColors, FontFamilies } from "@/constants/theme"; +import { formatModalTime } from "@/utils/format"; +import type { EventOccurrence } from "@skillspark/api-client"; +import { useTranslation } from "react-i18next"; + +interface ReservationSuccessModalProps { + visible: boolean; + occurrence: EventOccurrence; + onClose: () => void; +} + +export function ReservationSuccessModal({ + visible, + occurrence, + onClose, +}: ReservationSuccessModalProps) { + const { t: translate } = useTranslation(); + const timeLabel = translate("occurrence.classTime", { + time: formatModalTime(occurrence.start_time), + }); + + return ( + + + + {/* Event image */} + + {occurrence.event.presigned_url ? ( + + ) : null} + + + {/* Completed title */} + + {translate("reservation.completed")} + + + {/* Description */} + {!!occurrence.event.description && ( + + {occurrence.event.description} + + )} + + {/* Time */} + + {timeLabel} + + + {/* See you soon */} + + {translate("reservation.seeYouSoon")} + + + {/* Close button */} + + + {translate("common.close")} + + + + + + ); +} diff --git a/frontend/apps/mobile/components/StaticBackground.tsx b/frontend/apps/mobile/components/StaticBackground.tsx index c25ddc83..4908276d 100644 --- a/frontend/apps/mobile/components/StaticBackground.tsx +++ b/frontend/apps/mobile/components/StaticBackground.tsx @@ -24,11 +24,11 @@ export function StaticBackdrop({ () => animatedIndex.value, (current, previous) => { if (previous === null) return; - if (current > -1 && previous <= -1) { - // Sheet just started opening — appear immediately. + if (current >= 0 && previous < 0) { + // Sheet returned to open position — appear immediately. opacity.value = 1; - } else if (current < previous && current < 0) { - // Sheet just started closing — disappear immediately. + } else if (current < previous && current < -0.3) { + // Sheet has moved past 30% toward dismissal — disappear immediately. opacity.value = 0; } }, diff --git a/frontend/apps/mobile/components/TermsAndConditionsModal.tsx b/frontend/apps/mobile/components/TermsAndConditionsModal.tsx new file mode 100644 index 00000000..ba98160a --- /dev/null +++ b/frontend/apps/mobile/components/TermsAndConditionsModal.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { Modal, ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { Colors } from "@/constants/theme"; +import { useTranslation } from "react-i18next"; + +interface TermsAndConditionsModalProps { + visible: boolean; + onClose: () => void; +} + +export function TermsAndConditionsModal({ + visible, + onClose, +}: TermsAndConditionsModalProps) { + const insets = useSafeAreaInsets(); + const theme = Colors.light; + const { t: translate } = useTranslation(); + + return ( + + + + + + + + {translate("settings.termsAndConditions")} + + + + + + + {translate("termsAndConditions.lastUpdated")} + + + + {translate("termsAndConditions.acknowledgmentTitle")} + + + {translate("termsAndConditions.acknowledgment1")} + + + {translate("termsAndConditions.acknowledgment2")} + + + {translate("termsAndConditions.acknowledgment3")} + + + {translate("termsAndConditions.acknowledgment4")} + + + {translate("termsAndConditions.acknowledgment5")} + + + + {translate("termsAndConditions.userAccountsTitle")} + + + {translate("termsAndConditions.userAccounts1")} + + + {translate("termsAndConditions.userAccounts2")} + + + + {translate("termsAndConditions.intellectualPropertyTitle")} + + + {translate("termsAndConditions.intellectualPropertyDesc")} + + + + {translate("termsAndConditions.limitationTitle")} + + + {translate("termsAndConditions.limitationDesc")} + + + + {translate("termsAndConditions.governingLawTitle")} + + + {translate("termsAndConditions.governingLawDesc")} + + + + {translate("termsAndConditions.changesTitle")} + + + {translate("termsAndConditions.changesDesc")} + + + + {translate("termsAndConditions.contactTitle")} + + + {translate("termsAndConditions.contactDesc")} + + + + + ); +} diff --git a/frontend/apps/mobile/i18n/en.json b/frontend/apps/mobile/i18n/en.json index 546f7c6d..dcbae776 100644 --- a/frontend/apps/mobile/i18n/en.json +++ b/frontend/apps/mobile/i18n/en.json @@ -439,6 +439,7 @@ "termsNote": "By clicking 'Pay Now', you authorize Skill Spark to charge your card for the total amount, plus applicable fees.", "terms": "Terms & Conditions", "payAndReserve": "Pay Now & Reserve", + "termsAgree": "I agree to the Terms & Conditions", "seeYouSoon": "See you soon!" } } diff --git a/frontend/apps/mobile/i18n/th.json b/frontend/apps/mobile/i18n/th.json index 7ad4d31e..102644c8 100644 --- a/frontend/apps/mobile/i18n/th.json +++ b/frontend/apps/mobile/i18n/th.json @@ -438,6 +438,7 @@ "termsNote": "เมื่อคลิก 'ชำระเงินตอนนี้' คุณอนุญาตให้ Skill Spark เรียกเก็บเงินจากบัตรของคุณในจำนวนเงินรวม บวกค่าธรรมเนียมที่เกี่ยวข้อง", "terms": "ข้อกำหนดและเงื่อนไข", "payAndReserve": "ชำระเงินและจอง", + "termsAgree": "ฉันยอมรับข้อกำหนดและเงื่อนไข", "seeYouSoon": "พบกันเร็วๆ นี้!" } } diff --git a/frontend/apps/mobile/utils/format.ts b/frontend/apps/mobile/utils/format.ts index 2a33bf18..daae413b 100644 --- a/frontend/apps/mobile/utils/format.ts +++ b/frontend/apps/mobile/utils/format.ts @@ -53,6 +53,10 @@ export function formatSectionDate(dateStr: string): string { return date.toLocaleDateString("en-US", { weekday: "short", day: "numeric" }); } +export function formatSectionMonth(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { month: "long" }); +} + export function formatTime(dateStr: string): string { return new Date(dateStr).toLocaleTimeString("en-US", { hour: "numeric", From 4d07269329da37fee6b9292f015450b81a09c70f Mon Sep 17 00:00:00 2001 From: adescoteaux1 Date: Fri, 17 Apr 2026 09:41:19 -0400 Subject: [PATCH 2/2] registration fix --- frontend/apps/mobile/app/(app)/event/[id].tsx | 1 + .../app/(app)/org/[id]/OccurrenceCard.tsx | 81 +++++++++++++++---- .../mobile/app/(app)/org/[id]/schedule.tsx | 24 ++++++ .../mobile/components/ReservationModal.tsx | 7 +- frontend/apps/mobile/i18n/en.json | 1 + frontend/apps/mobile/i18n/th.json | 1 + 6 files changed, 98 insertions(+), 17 deletions(-) diff --git a/frontend/apps/mobile/app/(app)/event/[id].tsx b/frontend/apps/mobile/app/(app)/event/[id].tsx index ef6073bb..5522a40b 100644 --- a/frontend/apps/mobile/app/(app)/event/[id].tsx +++ b/frontend/apps/mobile/app/(app)/event/[id].tsx @@ -64,6 +64,7 @@ function EventOccurrenceDetail({ const aggregate = aggregateResp?.status === 200 ? aggregateResp.data : null; const avgRating = aggregate?.average_rating ?? 0; const totalReviews = aggregate?.total_reviews ?? 0; + const ratingOption = getRatingOption(avgRating); const ratingMatch = RATING_OPTIONS.find( (r) => r.rating === Math.round(avgRating) ); diff --git a/frontend/apps/mobile/app/(app)/org/[id]/OccurrenceCard.tsx b/frontend/apps/mobile/app/(app)/org/[id]/OccurrenceCard.tsx index efbc2ea5..f0a91e65 100644 --- a/frontend/apps/mobile/app/(app)/org/[id]/OccurrenceCard.tsx +++ b/frontend/apps/mobile/app/(app)/org/[id]/OccurrenceCard.tsx @@ -8,27 +8,56 @@ import { } from "react-native"; import { useRef, useState } from "react"; import { useRouter } from "expo-router"; -import type { EventOccurrence } from "@skillspark/api-client"; +import { + useCancelRegistration, + getGetRegistrationsByGuardianIdQueryKey, + type EventOccurrence, +} from "@skillspark/api-client"; +import { useQueryClient } from "@tanstack/react-query"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { AppColors, FontFamilies, FontSizes } from "@/constants/theme"; import { ReservationModal } from "@/components/ReservationModal"; import { RatingSmiley } from "@/components/RatingSmiley"; import { useTranslation } from "react-i18next"; import { formatAgeRange, formatTime, formatPrice } from "@/utils/format"; +import { useAuthContext } from "@/hooks/use-auth-context"; const BUTTON_ROW_HEIGHT = 52; export function OccurrenceCard({ occurrence, avgRating, + registrationIds, }: { occurrence: EventOccurrence; avgRating: number | null; + registrationIds?: string[]; }) { const router = useRouter(); const { t: translate } = useTranslation(); + const { guardianId } = useAuthContext(); + const queryClient = useQueryClient(); + const { mutate: cancelRegistration, isPending: cancelPending } = + useCancelRegistration(); const [expanded, setExpanded] = useState(false); const [reservationVisible, setReservationVisible] = useState(false); + + const isRegistered = (registrationIds?.length ?? 0) > 0; + + function handleCancel() { + if (!registrationIds?.length || !guardianId) return; + const queryKey = getGetRegistrationsByGuardianIdQueryKey(guardianId); + registrationIds.forEach((id) => + cancelRegistration( + { id }, + { + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }, + ), + ); + } const progress = useRef(new Animated.Value(0)).current; const toggle = () => { @@ -192,22 +221,42 @@ export function OccurrenceCard({ {translate("occurrence.learnMore")} - setReservationVisible(true)} - activeOpacity={0.7} - className="flex-1 rounded-full py-2.5 items-center" - style={{ backgroundColor: AppColors.checkboxSelected }} - > - - {translate("occurrence.reserve")} - - + + {translate("occurrence.cancel")} + + + ) : ( + setReservationVisible(true)} + activeOpacity={0.7} + className="flex-1 rounded-full py-2.5 items-center" + style={{ backgroundColor: AppColors.checkboxSelected }} + > + + {translate("occurrence.reserve")} + + + )} diff --git a/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx b/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx index e4f71cce..ee24fbaf 100644 --- a/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx +++ b/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx @@ -11,9 +11,12 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { getGetReviewAggregateQueryOptions, useGetEventOccurrencesByOrganizationId, + useGetRegistrationsByGuardianId, type EventOccurrence, + type Registration, } from "@skillspark/api-client"; import { useQueries, type UseQueryOptions } from "@tanstack/react-query"; +import { useAuthContext } from "@/hooks/use-auth-context"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { AppColors, FontFamilies } from "@/constants/theme"; import { useThemeColor } from "@/hooks/use-theme-color"; @@ -28,6 +31,7 @@ export default function OrgScheduleScreen() { filterClass?: string; }>(); const router = useRouter(); + const { guardianId } = useAuthContext(); const { t: translate } = useTranslation(); const backgroundColor = useThemeColor({}, "background"); const borderColor = useThemeColor({}, "borderColor"); @@ -43,6 +47,25 @@ export default function OrgScheduleScreen() { const { data: occurrencesResp, isLoading: occurrencesLoading } = useGetEventOccurrencesByOrganizationId(id); + const { data: registrationsResp } = useGetRegistrationsByGuardianId( + guardianId!, + { query: { enabled: !!guardianId } }, + ); + + const registeredOccurrenceMap = useMemo(() => { + const d = registrationsResp as unknown as + | { data: { registrations: Registration[] } } + | undefined; + const map: Record = {}; + (d?.data?.registrations ?? []) + .filter((r) => r.status === "registered") + .forEach((r) => { + if (!map[r.event_occurrence_id]) map[r.event_occurrence_id] = []; + map[r.event_occurrence_id].push(r.id); + }); + return map; + }, [registrationsResp]); + const occurrences = useMemo(() => { const d = occurrencesResp as unknown as | { data: EventOccurrence[] } @@ -256,6 +279,7 @@ export default function OrgScheduleScreen() { key={occ.id} occurrence={occ} avgRating={ratingsMap.get(occ.event.id) ?? null} + registrationIds={registeredOccurrenceMap[occ.id]} /> ))} diff --git a/frontend/apps/mobile/components/ReservationModal.tsx b/frontend/apps/mobile/components/ReservationModal.tsx index 3fe18a85..6e99bb31 100644 --- a/frontend/apps/mobile/components/ReservationModal.tsx +++ b/frontend/apps/mobile/components/ReservationModal.tsx @@ -13,11 +13,13 @@ import { ReservationSuccessModal } from "./ReservationSuccessModal"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createRegistrationResponseError, + getGetRegistrationsByGuardianIdQueryKey, useCreateRegistration, useGetChildrenByGuardianId, type Child, type EventOccurrence, } from "@skillspark/api-client"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useAuthContext } from "@/hooks/use-auth-context"; import { AppColors, FontFamilies, FontSizes } from "@/constants/theme"; @@ -39,6 +41,7 @@ export function ReservationModal({ const insets = useSafeAreaInsets(); const { t: translate } = useTranslation(); const { guardianId } = useAuthContext(); + const queryClient = useQueryClient(); const isSuccessTransition = useRef(false); const [selectedChildId, setSelectedChildId] = useState(null); @@ -70,12 +73,14 @@ export function ReservationModal({ child_id: selectedChildId, event_occurrence_id: occurrence.id, guardian_id: guardianId, - payment_method_id: "", status: "registered", }, }, { onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getGetRegistrationsByGuardianIdQueryKey(guardianId!), + }); isSuccessTransition.current = true; setShowSuccessModal(true); onClose(); diff --git a/frontend/apps/mobile/i18n/en.json b/frontend/apps/mobile/i18n/en.json index c475c6b2..21b9ccef 100644 --- a/frontend/apps/mobile/i18n/en.json +++ b/frontend/apps/mobile/i18n/en.json @@ -483,6 +483,7 @@ "smiles": "/ 5 Smiles", "learnMore": "Learn more", "reserve": "Reserve", + "cancel": "Cancel Registration", "ages": "Ages {{min}}", "agesOpen": "Ages {{min}}+", "agesRange": "Ages {{min}} - {{max}}", diff --git a/frontend/apps/mobile/i18n/th.json b/frontend/apps/mobile/i18n/th.json index c2f66480..3c17ae7d 100644 --- a/frontend/apps/mobile/i18n/th.json +++ b/frontend/apps/mobile/i18n/th.json @@ -483,6 +483,7 @@ "smiles": "/ 5 รอยยิ้ม", "learnMore": "เรียนรู้เพิ่มเติม", "reserve": "จอง", + "cancel": "ยกเลิกการจอง", "ages": "อายุ {{min}} ปี", "agesOpen": "อายุ {{min}}+ ปี", "agesRange": "อายุ {{min}} - {{max}} ปี",