diff --git a/frontend/apps/mobile/app/(app)/event/[id].tsx b/frontend/apps/mobile/app/(app)/event/[id].tsx index f0fc7475..5522a40b 100644 --- a/frontend/apps/mobile/app/(app)/event/[id].tsx +++ b/frontend/apps/mobile/app/(app)/event/[id].tsx @@ -65,6 +65,17 @@ function EventOccurrenceDetail({ 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) + ); + + const cardShadow = { + shadowColor: "#000", + shadowOpacity: 0.08, + shadowRadius: 12, + shadowOffset: { width: 0, height: 2 }, + elevation: 3, + }; return ( @@ -275,8 +286,7 @@ function EventOccurrenceDetail({ router.push({ pathname: "/org/[id]/schedule", 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 801c08fb..ee24fbaf 100644 --- a/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx +++ b/frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx @@ -11,15 +11,18 @@ 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"; 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() { @@ -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[] } @@ -99,13 +122,13 @@ 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), - ) as UseQueryOptions[], + getGetReviewAggregateQueryOptions(eventId) + ), }); const ratingsMap = useMemo(() => { @@ -126,7 +149,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(); @@ -137,6 +160,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, })); @@ -165,12 +189,14 @@ export default function OrgScheduleScreen() { /> {translate("org.schedule")} + router.push(`/org/${id}/filters`)} activeOpacity={0.7} @@ -185,7 +211,7 @@ export default function OrgScheduleScreen() { {activeCount > 0 && ( )} + {occurrencesLoading ? ( @@ -224,21 +251,35 @@ export default function OrgScheduleScreen() { > {grouped.map((group) => ( - - {group.label} - + + + {group.month} + + + {group.label} + + {group.items.map((occ) => ( ))} diff --git a/frontend/apps/mobile/components/ReservationModal.tsx b/frontend/apps/mobile/components/ReservationModal.tsx index a0203b2e..6e99bb31 100644 --- a/frontend/apps/mobile/components/ReservationModal.tsx +++ b/frontend/apps/mobile/components/ReservationModal.tsx @@ -1,37 +1,31 @@ -import { Image } from "expo-image"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { ActivityIndicator, + Modal, Pressable, - StyleSheet, + ScrollView, Text, TouchableOpacity, View, } from "react-native"; -import Animated, { - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, -} from "react-native-reanimated"; -import type { SharedValue } from "react-native-reanimated"; -import { - BottomSheetModal, - BottomSheetScrollView, - type BottomSheetBackdropProps, -} from "@gorhom/bottom-sheet"; +import { TermsAndConditionsModal } from "./TermsAndConditionsModal"; +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"; import { ChildAvatar } from "./ChildAvatar"; import { EventPreviewSection } from "./EventPreviewSection"; -import { StaticBackdrop } from "./StaticBackground"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; interface ReservationModalProps { visible: boolean; @@ -47,19 +41,16 @@ export function ReservationModal({ const insets = useSafeAreaInsets(); const { t: translate } = useTranslation(); const { guardianId } = useAuthContext(); + const queryClient = useQueryClient(); - const sheetRef = useRef(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,211 +65,327 @@ export function ReservationModal({ function handleReserve() { if (!selectedChildId || !guardianId) return; setReservationError(null); + setErrorDetails([]); + setErrorExpanded(false); createRegistration( { data: { child_id: selectedChildId, event_occurrence_id: occurrence.id, guardian_id: guardianId, - payment_method_id: "", status: "registered", }, }, { onSuccess: () => { - setStep("done"); + queryClient.invalidateQueries({ + queryKey: getGetRegistrationsByGuardianIdQueryKey(guardianId!), + }); + 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 e14ecc5b..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}}", @@ -503,6 +504,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!" }, "onboarding": { diff --git a/frontend/apps/mobile/i18n/th.json b/frontend/apps/mobile/i18n/th.json index c5f14aeb..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}} ปี", @@ -503,6 +504,7 @@ "termsNote": "เมื่อคลิก 'ชำระเงินตอนนี้' คุณอนุญาตให้ Skill Spark เรียกเก็บเงินจากบัตรของคุณในจำนวนเงินรวม บวกค่าธรรมเนียมที่เกี่ยวข้อง", "terms": "ข้อกำหนดและเงื่อนไข", "payAndReserve": "ชำระเงินและจอง", + "termsAgree": "ฉันยอมรับข้อกำหนดและเงื่อนไข", "seeYouSoon": "พบกันเร็วๆ นี้!" }, "onboarding": { diff --git a/frontend/apps/mobile/utils/format.ts b/frontend/apps/mobile/utils/format.ts index bd646e8f..bb9a50bf 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",