Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions frontend/apps/mobile/app/(app)/event/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SafeAreaView className="flex-1 bg-white" edges={["top", "bottom"]}>
Expand Down Expand Up @@ -275,8 +286,7 @@ function EventOccurrenceDetail({
<View className="px-4 pb-2 pt-1">
<TouchableOpacity
activeOpacity={0.85}
className="w-full items-center rounded-full py-4"
style={{ backgroundColor: AppColors.checkboxSelected }}
className="w-full items-center rounded-full py-4 bg-black"
onPress={() =>
router.push({
pathname: "/org/[id]/schedule",
Expand Down
81 changes: 65 additions & 16 deletions frontend/apps/mobile/app/(app)/org/[id]/OccurrenceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -192,22 +221,42 @@ export function OccurrenceCard({
{translate("occurrence.learnMore")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setReservationVisible(true)}
activeOpacity={0.7}
className="flex-1 rounded-full py-2.5 items-center"
style={{ backgroundColor: AppColors.checkboxSelected }}
>
<Text
style={{
fontFamily: FontFamilies.semiBold,
fontSize: FontSizes.base,
color: "#fff",
}}
{isRegistered ? (
<TouchableOpacity
onPress={handleCancel}
activeOpacity={0.7}
disabled={cancelPending}
className="flex-1 rounded-full py-2.5 items-center"
style={{ backgroundColor: "#EF4444" }}
>
{translate("occurrence.reserve")}
</Text>
</TouchableOpacity>
<Text
style={{
fontFamily: FontFamilies.semiBold,
fontSize: FontSizes.base,
color: "#fff",
}}
>
{translate("occurrence.cancel")}
</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => setReservationVisible(true)}
activeOpacity={0.7}
className="flex-1 rounded-full py-2.5 items-center"
style={{ backgroundColor: AppColors.checkboxSelected }}
>
<Text
style={{
fontFamily: FontFamilies.semiBold,
fontSize: FontSizes.base,
color: "#fff",
}}
>
{translate("occurrence.reserve")}
</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>

Expand Down
75 changes: 58 additions & 17 deletions frontend/apps/mobile/app/(app)/org/[id]/schedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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");
Expand All @@ -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<string, string[]> = {};
(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[] }
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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<string, EventOccurrence[]>();
Expand All @@ -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,
}));
Expand Down Expand Up @@ -165,12 +189,14 @@ export default function OrgScheduleScreen() {
/>
</TouchableOpacity>
<Text
className="flex-1 text-center text-[16px] font-nunito-bold"
className="absolute inset-x-0 text-center text-[16px] font-nunito-bold"
style={{ color: AppColors.primaryText }}
numberOfLines={1}
pointerEvents="none"
>
{translate("org.schedule")}
</Text>
<View className="flex-1 items-end">
<TouchableOpacity
onPress={() => router.push(`/org/${id}/filters`)}
activeOpacity={0.7}
Expand All @@ -185,7 +211,7 @@ export default function OrgScheduleScreen() {
</Text>
{activeCount > 0 && (
<View
className="h-4 w-4 items-center justify-center rounded-full"
className="h-[18px] w-[18px] items-center justify-center rounded-full"
style={{ backgroundColor: AppColors.white }}
>
<Text
Expand All @@ -200,6 +226,7 @@ export default function OrgScheduleScreen() {
</View>
)}
</TouchableOpacity>
</View>
</View>

{occurrencesLoading ? (
Expand All @@ -224,21 +251,35 @@ export default function OrgScheduleScreen() {
>
{grouped.map((group) => (
<View key={group.label} className="mb-4">
<Text
className="px-4 pb-3"
style={{
fontFamily: FontFamilies.bold,
fontSize: 22,
color: AppColors.primaryText,
}}
>
{group.label}
</Text>
<View className="px-4 pb-3">
<Text
style={{
fontFamily: FontFamilies.regular,
fontSize: 11,
color: AppColors.primaryText,
opacity: 0.6,
textTransform: "uppercase",
letterSpacing: 0.5,
}}
>
{group.month}
</Text>
<Text
style={{
fontFamily: FontFamilies.bold,
fontSize: 22,
color: AppColors.primaryText,
}}
>
{group.label}
</Text>
</View>
{group.items.map((occ) => (
<OccurrenceCard
key={occ.id}
occurrence={occ}
avgRating={ratingsMap.get(occ.event.id) ?? null}
registrationIds={registeredOccurrenceMap[occ.id]}
/>
))}
</View>
Expand Down
Loading
Loading