diff --git a/frontend/apps/mobile/app/(app)/event/[id].tsx b/frontend/apps/mobile/app/(app)/event/[id].tsx index f0fc7475..7e376075 100644 --- a/frontend/apps/mobile/app/(app)/event/[id].tsx +++ b/frontend/apps/mobile/app/(app)/event/[id].tsx @@ -14,21 +14,23 @@ import { useGetEventOccurrencesByEventId, useGetOrganization, useGetReviewAggregate, + useGetReviewByEventId, } from "@skillspark/api-client"; -import type { EventOccurrence, Organization } from "@skillspark/api-client"; +import type { EventOccurrence, Organization, Review } from "@skillspark/api-client"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { RATING_OPTIONS } from "@/constants/ratings"; import { AppColors, Shadows } from "@/constants/theme"; import { useOrgLinks } from "@/hooks/useOrgLinks"; import { BookmarkButton } from "@/components/BookmarkButton"; +import { ReviewCard } from "@/components/ReviewCard"; import { useTranslation } from "react-i18next"; import { AboutPage } from "@/components/AboutPage"; import { ShareModal } from "@/components/ShareModal"; import { formatLocation } from "@/utils/format"; import { getRatingOption } from "@/utils/ratings"; import { EventImage } from "@/components/EventImage"; -import { ExpandableText } from "@/components/ExpandableText"; import { ErrorScreen } from "@/components/ErrorScreen"; +import LogoBgWrapper from "@/components/LogoBgWrapper"; import { useState } from "react"; function EventOccurrenceDetail({ @@ -66,6 +68,24 @@ function EventOccurrenceDetail({ const totalReviews = aggregate?.total_reviews ?? 0; const ratingOption = getRatingOption(avgRating); + const { data: reviewsResp } = useGetReviewByEventId( + occurrence.event.id, + { page: 1, page_size: 5, sort_by: "highest" }, + { query: { enabled: !!occurrence.event.id && totalReviews > 0 } }, + ); + const rawReviews = + reviewsResp?.status === 200 ? (reviewsResp.data as Review[]) : []; + const previewReview = + rawReviews.length > 0 + ? rawReviews.reduce( + (best, r) => + Math.abs(r.rating - avgRating) < Math.abs(best.rating - avgRating) + ? r + : best, + rawReviews[0], + ) + : null; + return ( {/* Header */} @@ -89,7 +109,7 @@ function EventOccurrenceDetail({ style={{ color: AppColors.primaryText }} numberOfLines={1} > - {translate("org.class")} + {occurrence.event.title} @@ -107,36 +127,42 @@ function EventOccurrenceDetail({ {/* White content card overlapping image */} - + {/* Drag handle */} {/* Title row with bookmark + share */} - + setShareVisible(true)} activeOpacity={0.7} + className="h-9 w-9 items-center justify-center rounded-full border-2" + style={{ borderColor: AppColors.borderLight }} > - + {occurrence.event.title} - + {/* Location */} @@ -165,34 +191,41 @@ function EventOccurrenceDetail({ )} - {!!orgId && ( + {/* Bookings this week */} + + + + {occurrence.curr_enrolled}+ {translate("event.bookingsThisWeek")} + + + + {/* Org badge */} + {!!orgName && !!orgId && ( router.push(`../org/${orgId}`)} - className="flex-row items-center gap-1 mb-2" + activeOpacity={0.7} + className="self-start px-4 py-1.5 rounded-full mt-1" + style={{ backgroundColor: AppColors.savedBackground }} > - {orgName} )} - - {/* Bookings this week */} - - {occurrence.curr_enrolled}+ {translate("event.bookingsThisWeek")} - + + {/* About card */} {translate("event.reviews")} - {/* Aggregate rating */} {totalReviews > 0 ? ( - - - - {translate(ratingOption.labelKey!)} - - - ({totalReviews}) - + + {/* Left: aggregate number + smiley + count */} + + + {avgRating % 1 === 0 + ? avgRating.toFixed(0) + : avgRating.toFixed(1)} + + + + ({totalReviews}) + + + + {/* Right: preview review (no avatar — aggregate smiley already on left) */} + {previewReview && ( + + + + )} ) : ( {translate("review.noReviews")} )} - + + + + + 0 } }, + ); + const previewEvent = + previewResp?.status === 200 && + Array.isArray(previewResp.data) && + previewResp.data.length > 0 + ? (previewResp.data[0] as SimpleReviewAggregate) + : null; + const cardStyle = { shadowColor: "#000", shadowOpacity: 0.08, @@ -144,59 +159,13 @@ function OrgDetail({ className="mx-4 mb-4 rounded-2xl bg-white p-5" style={cardStyle} > - - {translate("org.about")} - - { - if (!aboutExpanded) - setAboutTruncated(e.nativeEvent.lines.length >= 4); - }} - className={`text-sm leading-[22px] ${ - aboutTruncated ? "mb-1" : "mb-4" - }`} - style={{ color: AppColors.secondaryText }} - > - {org.about ?? ""} - - {aboutTruncated && ( - setAboutExpanded((prev) => !prev)} - className="mb-4" - > - - {aboutExpanded - ? translate("event.seeLess") - : translate("event.seeMore")} - - - )} - {hasLinks && ( - - {(org.links ?? []).map((link, index) => ( - openLink(link.href)} - className="rounded-full px-5 py-2.5 items-center" - style={{ backgroundColor: AppColors.borderLight }} - > - - {link.label} - - - ))} - - )} + - {/* Rating card */} + {/* Reviews card */} router.push(`/org/${org.id}/reviews`)} @@ -205,38 +174,73 @@ function OrgDetail({ className="mx-4 mb-4 rounded-2xl bg-white p-5" style={cardStyle} > - + {translate("org.reviews")} - {org.review_summary && org.review_summary.total_reviews > 0 ? ( - - + + {totalOrgReviews > 0 ? ( + + {/* Left: aggregate number + smiley + count */} + + + {org.review_summary!.average_rating % 1 === 0 + ? org.review_summary!.average_rating.toFixed(0) + : org.review_summary!.average_rating.toFixed(1)} + - - - - {org.review_summary.average_rating.toFixed(1)} - - ({org.review_summary.total_reviews}) + ({totalOrgReviews}) + + {/* Right: preview event rating card */} + {previewEvent && ( + + router.push(`/org/${org.id}/reviews`)} + /> + + )} ) : ( - - - - {translate("review.noReviews")} - - + + {translate("review.noReviews")} + )} + + + + + {translate("event.seeMoreReviews")} + + diff --git a/frontend/apps/mobile/components/AboutPage.tsx b/frontend/apps/mobile/components/AboutPage.tsx index f0ad5b76..f6e5e215 100644 --- a/frontend/apps/mobile/components/AboutPage.tsx +++ b/frontend/apps/mobile/components/AboutPage.tsx @@ -15,6 +15,7 @@ export function AboutPage({ description, links }: AboutPageProps) { const { t: translate } = useTranslation(); const [expanded, setExpanded] = useState(false); const [truncated, setTruncated] = useState(false); + const [measured, setMeasured] = useState(false); return ( @@ -25,10 +26,14 @@ export function AboutPage({ description, links }: AboutPageProps) { {translate("event.about")} { - if (!expanded) setTruncated(e.nativeEvent.lines.length >= 4); + if (!measured) { + setMeasured(true); + setTruncated(e.nativeEvent.lines.length > 4); + } }} + style={{ opacity: measured ? 1 : 0 }} className="text-sm text-gray-500 leading-relaxed mb-1" > {description} diff --git a/frontend/apps/mobile/components/BookmarkButton.tsx b/frontend/apps/mobile/components/BookmarkButton.tsx index 9ce6ebba..40774caa 100644 --- a/frontend/apps/mobile/components/BookmarkButton.tsx +++ b/frontend/apps/mobile/components/BookmarkButton.tsx @@ -1,4 +1,4 @@ -import { TouchableOpacity } from "react-native"; +import { StyleProp, TouchableOpacity, ViewStyle } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useCreateSaved, @@ -20,12 +20,18 @@ interface BookmarkButtonProps { // Controlled mode: pass these to skip the internal fetch isBookmarked?: boolean; savedEntryId?: string; + iconSize?: number; + className?: string; + style?: StyleProp; } export function BookmarkButton({ eventId, isBookmarked: isBookmarkedProp, savedEntryId, + iconSize = 40, + className, + style, }: BookmarkButtonProps) { const queryClient = useQueryClient(); const { guardianId } = useAuthContext(); @@ -115,10 +121,12 @@ export function BookmarkButton({ onPress={handlePress} activeOpacity={0.7} disabled={isPending} + className={className} + style={style} > diff --git a/frontend/apps/mobile/components/ReviewCard.tsx b/frontend/apps/mobile/components/ReviewCard.tsx index 29ea77ae..75a8fb22 100644 --- a/frontend/apps/mobile/components/ReviewCard.tsx +++ b/frontend/apps/mobile/components/ReviewCard.tsx @@ -19,7 +19,7 @@ function timeAgo(dateStr: string, translate: (key: string) => string) { : `${years} ${translate("time.yearsAgo")}`; } -export function ReviewCard({ review }: { review: Review }) { +export function ReviewCard({ review, hideAvatar }: { review: Review; hideAvatar?: boolean }) { const { t: translate } = useTranslation(); const match = RATING_OPTIONS.find((r) => r.rating === review.rating); @@ -36,9 +36,11 @@ export function ReviewCard({ review }: { review: Review }) { return ( - - {match && } - + {!hideAvatar && ( + + {match && } + + )}