import { useNavigate, useParams } from 'react-router-dom'; import { ChangeEvent, TargetedEvent } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import Lightbox from 'yet-another-react-lightbox'; import useCallbackState from '../../types/state-callback'; import { EmptyLocationDetailResponse, LocationDetailResponse, LocationResponse, emptyLocationResponse, CurrentUserLocationReviews, } from './types'; import { handleApiError, useAutosizeTextArea } from '../../utils'; import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services"; import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components'; import RatingsCard from '../../components/Card/RatingsCard'; import { useSelector } from 'react-redux'; import { UserRootState } from '../../store/type'; import { DEFAULT_AVATAR_IMG } from '../../constants/default'; import { IHttpResponse } from '../../types/common'; import { ImagePlus, Globe, MapPin, Smile, Heart, MessageCircle } from 'lucide-react' import ReactTextareaAutosize from 'react-textarea-autosize'; import { MenuIcon } from '../../../src/components/Icons/MenuIcon'; import ScheduleCard from '../../components/Card/ScheduleCard'; import FacilitiesCard from '../../components/Card/FacilitiesCard'; const SORT_TYPE = [ 'highest rated', 'lowest rated', 'newest', 'oldest' ] function LocationDetail() { const [locationDetail, setLocationDetail] = useCallbackState(EmptyLocationDetailResponse) const [locationImages, setLocationImages] = useState(emptyLocationResponse()) const [currentUserReview, setCurrentUserReview] = useState() const [lightboxOpen, setLightboxOpen] = useState(false) const [updatePage, setUpdatePage] = useState(true) const [pageState, setPageState] = useState({ critic_filter_name: 'highest rated', critic_filter_type: 0, show_sort: false, enable_post: true, on_submit_loading: false, is_score_rating_panic_msg: '', }) const [reviewValue, setReviewValue] = useState({ review_textArea: '', score_input: '', title: '', rasa: '', suasana: '', pelayanan: '', kebersihan: '', }) const [uploadedImages, setUploadedImages] = useState<{ file: File; preview: string }[]>([]) const [uploadLightboxOpen, setUploadLightboxOpen] = useState(false) const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0) const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false) const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0) const imageInputRef = useRef(null) function handleImageSelect(e: ChangeEvent) { const input = e.target as HTMLInputElement if (!input.files) return const newFiles = Array.from(input.files).map(file => ({ file, preview: URL.createObjectURL(file), })) setUploadedImages(prev => [...prev, ...newFiles]) input.value = '' } function removeImage(index: number) { setUploadedImages(prev => { URL.revokeObjectURL(prev[index].preview) return prev.filter((_, i) => i !== index) }) } function openUploadLightbox(index: number) { setUploadLightboxIndex(index) setUploadLightboxOpen(true) } const [isLoading, setIsLoading] = useState(true) const [currentIndex, setCurrentIndex] = useState(0); const currentImage = locationImages?.images[currentIndex]?.src || locationDetail.detail.thumbnail || ""; const navigate = useNavigate(); const user = useSelector((state: UserRootState) => state.auth) const textAreaRef = useRef(null); useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea); const { id } = useParams() async function getLocationDetail(): Promise { try { const res = await getLocationService(Number(id)) setLocationDetail(res.data, (val) => { if (val.detail.thumbnail) { getImage(val.detail.thumbnail) } }) } catch (error) { const err = error as IHttpResponse; if (err.status == 404) { navigate("/") } alert(handleApiError(error)) } } async function getImage(thumbnail?: String): Promise { try { const res = await getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(id) }) res.data.images.push({ src: thumbnail }) setLocationImages(res.data) setUpdatePage(false) } catch (error) { console.log(error) } setIsLoading(false) } async function getCurrentUserLocationReview(): Promise { try { const res = await getCurrentUserLocationReviewService(Number(id)) setCurrentUserReview(res.data) setPageState({ ...pageState, enable_post: false }) } catch (error) { let err = error as IHttpResponse; if (err.status == 404 || err.status == 401) { return } alert(err.error.response.data.message) } } function handleTextAreaChange(e: ChangeEvent): void { const val = e.target as HTMLTextAreaElement; setReviewValue({ ...reviewValue, review_textArea: val.value }) } function handleScoreInputChange(e: ChangeEvent): void { const val = e.target as HTMLInputElement; setReviewValue({ ...reviewValue, score_input: val.value }) setPageState({ ...pageState, is_score_rating_panic_msg: '' }) } function onChangeCriticsSort(e: TargetedEvent, sort_name: string, sort_type: number): void { e.preventDefault() setPageState({ ...pageState, show_sort: false, critic_filter_name: sort_name, critic_filter_type: sort_type }) } async function handleSubmitReview(e: TargetedEvent) { e.preventDefault(); setPageState({ ...pageState, on_submit_loading: true }) if (isNaN(Number(reviewValue.score_input))) { setPageState({ ...pageState, is_score_rating_panic_msg: "SCORE MUST BE A NUMBER" }) return } if (Number(reviewValue.score_input) > 100) { setPageState({ ...pageState, is_score_rating_panic_msg: "SCORE MUST BE LESS OR EQUAL THAN 100" }) return } if (reviewValue.score_input === '') { setPageState({ ...pageState, is_score_rating_panic_msg: "SCORE MUSTN'T BE EMPTY" }) return } try { const { data } = await postReviewLocation({ is_hided: false, location_id: Number(id), score: Number(reviewValue.score_input), submitted_by: Number(user.id), is_from_critic: user.is_critics, comments: reviewValue.review_textArea, title: reviewValue.title, }) if (uploadedImages.length > 0) { try { await postReviewImages(data.id, uploadedImages.map(img => img.file)) } catch (imgErr) { console.log('Image upload failed:', imgErr) alert('Review posted, but images failed to upload. You can try again.') } uploadedImages.forEach(img => URL.revokeObjectURL(img.preview)) setUploadedImages([]) } setPageState({ ...pageState, enable_post: false, on_submit_loading: false }) setReviewValue({ review_textArea: '', score_input: '', title: '', rasa: '', suasana: '', pelayanan: '', kebersihan: '' }) setCurrentUserReview({ id: data.id, comments: data.comments, is_from_critic: data.is_from_critic, is_hided: data.is_hided, location_id: data.location_id, score: data.score, submitted_by: data.submitted_by, created_at: data.created_at, updated_at: data.updated_at }) setUpdatePage(true) } catch (error) { console.log(error) alert(handleApiError(error)) setPageState({ ...pageState, on_submit_loading: false }) } } function handleSignInNavigation(e: TargetedEvent) { e.preventDefault(); navigate('/login', { state: { from: `/location/${id}` } }) } useEffect(() => { getCurrentUserLocationReview() }, []) useEffect(() => { if (updatePage || id) { getLocationDetail() } }, [updatePage, id]) return (

{locationDetail?.detail.name}

{locationDetail?.detail.address}

{/* {isLoading ? {!isLoading && (() => { const imgs = locationImages?.images ?? []; const total = imgs.length; if (total === 0) return null; const openAt = (i: number) => { setCurrentIndex(i); setLightboxOpen(true); }; const ImageTile = ({ img, index, className }: { img: typeof imgs[0], index: number, className: string }) => (
openAt(index)} >
Photo {index + 1}
); return (
{total === 1 && (
)} {total === 2 && (
)} {total === 3 && (
)} {total >= 4 && (
{/* Top-left large */} {/* Right full-height */}
openAt(1)} >
Photo 2 {total > 4 && ( 🖼 {total.toLocaleString()} )}
{/* Bottom-left small */}
openAt(2)} >
Photo 3
{/* Bottom-middle small — with "+more" if applicable */}
openAt(3)} > {total > 4 && (
+{total - 4}
)} {total <= 4 && (
Photo 4
)}
)}
); })()}
Website Menu {/* - */}
({ score: Number(data.detail.critic_score), count: Number(data.detail.critic_count) })} getUserData={(data) => ({ score: Number(data.detail.user_score), count: Number(data.detail.user_count) })} getCriticDetails={() => ({ environment: 85, cleanliness: 90, price: 75, facility: 80 })} getUserDetails={() => ({ environment: 82, cleanliness: 88, price: 70, facility: 78 })} />
console.log('suggest edit')} />
{!user.username ?
SIGN IN TO REVIEW
:
{/* Avatar + Username */}
{user.username} {currentUserReview && ( <> |
{user.is_critics ? "CRITIC SCORE" : "USER SCORE"} {currentUserReview.score}
)}
{currentUserReview ? (
{currentUserReview.created_at?.Valid && ( Written {new Date(String(currentUserReview.created_at.Time)).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} )} {currentUserReview.title && (

{currentUserReview.title}

)} {currentUserReview.images && currentUserReview.images.length > 0 && (
{currentUserReview.images.slice(0, 4).map((img, i) => (
{ setReviewLightboxIndex(i); setReviewLightboxOpen(true); }} > {`review-${i}`} {i === 3 && currentUserReview.images!.length > 4 && (
+{currentUserReview.images!.length - 4} MORE
)}
))}
)} {/* Review text — clamped with "Show Full Review" */}
{/* Footer: likes, comments, show full review */}
) : ( <>
{pageState.is_score_rating_panic_msg && (
{pageState.is_score_rating_panic_msg}
)} {/* Hidden file input */} {uploadedImages.length > 0 && (
{uploadedImages.map((img, i) => (
{/* Click image to open lightbox */} {`upload-${i}`} openUploadLightbox(i)} /> {/* X button — top-right corner, always visible */}
))}
)}
setReviewValue({ ...reviewValue, title: (e.target as HTMLInputElement).value })} className="w-full bg-transparent border border-gray-600 rounded-lg px-3 py-2 text-sm outline-none focus:border-gray-400 transition-colors placeholder:text-quartenary" />
{pageState.on_submit_loading ? ( ) : ( pageState.enable_post && ( Post ) )}
)}
}
{locationDetail.critics_review.length > 0 ? <>
{locationDetail.critics_review.map(x => (
))} : No Critics review to display }
0 ? '#' : ''} /> {locationDetail.users_review.length > 0 ? <> {locationDetail.users_review.map(x => (
{/* Header: avatar + username + score */}
{x.username} |
USER SCORE {x.score}
{/* Title */} {x.title && (

{x.title}

)} {/* Images */} {x.images && x.images.length > 0 && (
{x.images.slice(0, 4).map((img, i) => (
{`review-img-${i}`} {i === 3 && x.images!.length > 4 && (
+{x.images!.length - 4} MORE
)}
))}
)} {/* Comment */}
))} : <> No users review to display }
CONTRUBITION anoeantoeh aoenthaoe aoenth aot
Added on: 28 May 1988
setLightboxOpen(false)} slides={locationImages?.images} /> setUploadLightboxOpen(false)} index={uploadLightboxIndex} slides={uploadedImages.map(img => ({ src: img.preview }))} /> setReviewLightboxOpen(false)} index={reviewLightboxIndex} slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []} />
) } export default LocationDetail;