hilingriviw/src/pages/LocationDetail/index.tsx
2026-04-18 11:52:45 +03:00

919 lines
42 KiB
TypeScript
Executable File

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<LocationDetailResponse>(EmptyLocationDetailResponse)
const [locationImages, setLocationImages] = useState<LocationResponse>(emptyLocationResponse())
const [currentUserReview, setCurrentUserReview] = useState<CurrentUserLocationReviews>()
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false)
const [updatePage, setUpdatePage] = useState<boolean>(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<HTMLInputElement>(null)
function handleImageSelect(e: ChangeEvent<HTMLInputElement>) {
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<boolean>(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<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea);
const { id } = useParams()
async function getLocationDetail(): Promise<void> {
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<void> {
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<void> {
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<HTMLTextAreaElement>): void {
const val = e.target as HTMLTextAreaElement;
setReviewValue({
...reviewValue,
review_textArea: val.value
})
}
function handleScoreInputChange(e: ChangeEvent<HTMLInputElement>): void {
const val = e.target as HTMLInputElement;
setReviewValue({
...reviewValue,
score_input: val.value
})
setPageState({
...pageState,
is_score_rating_panic_msg: ''
})
}
function onChangeCriticsSort(e: TargetedEvent<HTMLAnchorElement>, 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<HTMLAnchorElement>) {
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<HTMLAnchorElement>) {
e.preventDefault();
navigate('/login', { state: { from: `/location/${id}` } })
}
useEffect(() => {
getCurrentUserLocationReview()
}, [])
useEffect(() => {
if (updatePage || id) {
getLocationDetail()
}
}, [updatePage, id])
return (
<div className="content main-content mt-3">
<section name={"HEADER LINK"}>
<div className="text-[0.7em] pb-1.5 border-b border-[#38444d] text-tertiary whitespace-nowrap w-full overflow-x-scroll overflow-y-hidden [-webkit-overflow-scrolling:touch] [-ms-overflow-style:none] max-[380px]:px-2.5 [&::-webkit-scrollbar]:hidden [&>a:hover]:text-white [&>a:hover]:cursor-pointer">
<a className="inline-block">OVERVIEW</a>
<a className="ml-4 inline-block">USER REVIEWS</a>
<a className="ml-4 inline-block">CRITIC REVIEWS</a>
<a className="ml-4 inline-block">COMMENTS</a>
</div>
</section>
<section className="pb-5 border-b border-[#38444d]" name={'LOCATION HEADER'}>
<div>
<div className="font-bold mt-5 text-2xl">
<h1>{locationDetail?.detail.name}</h1>
<p className={'underline flex'}><MapPin /> {locationDetail?.detail.address}</p>
</div>
{/* {isLoading ?
<div className="mt-3 w-[250px] h-[250px] bg-gray float-left" />
:
<div className="inline-block max-w-[320px]">
<a
onClick={() => setLightboxOpen(true)}
className="mt-3 grid relative grid-cols-12 cursor-zoom-in"
>{Number(locationImages?.total_image) > 0 &&
<div class="row-start-1 col-start-2 col-end-[-1] pt-[4%] z-[2]">
<img src={locationDetail.detail.thumbnail ? locationDetail.detail.thumbnail : ""} alt="" className="aspect-square w-full block" />
{locationImages?.images.length > 1 &&
<div className="text-xs p-2 bg-primary absolute bottom-0 right-0">
Total images ({locationImages?.images.length})
</div>
}
</div>
}
{locationImages?.images.length > 1 &&
<div class="ml-2.5 row-start-1 col-start-1 col-end-[-2] pt-[2%] z-[1]">
<img src={locationImages?.images[0].src} alt="" className="aspect-square w-full block" />
</div>
}
<div class="row-start-1 col-start-1 col-end-[-3]" style={Number(locationImages?.total_image) > 1 ? {} : { gridColumn: '13/1' }}>
<img src={Number(locationImages?.total_image) > 1 ? locationImages?.images[1].src.toString() : locationDetail.detail.thumbnail!} alt="" className="aspect-square w-full block" />
</div>
</a>
</div>
} */}
</div>
{!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 }) => (
<div
className={`relative overflow-hidden cursor-zoom-in group/tile ${className}`}
onClick={() => openAt(index)}
>
<img
src={img.src}
alt=""
className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75"
/>
<div className="absolute bottom-0 left-0 right-0 p-2 flex justify-between items-end pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
Photo {index + 1}
</span>
</div>
</div>
);
return (
<div className="mt-4 relative w-full">
{total === 1 && (
<div className="h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="h-full" />
</div>
)}
{total === 2 && (
<div className="grid grid-cols-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="" />
<ImageTile img={imgs[1]} index={1} className="" />
</div>
)}
{total === 3 && (
<div className="grid grid-cols-4 grid-rows-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
<ImageTile img={imgs[0]} index={0} className="col-span-2 row-span-2" />
<ImageTile img={imgs[1]} index={1} className="col-span-2" />
<ImageTile img={imgs[2]} index={2} className="col-span-2" />
</div>
)}
{total >= 4 && (
<div className="grid grid-cols-4 grid-rows-2 gap-1 h-[420px] max-[768px]:h-[240px] rounded-lg overflow-hidden">
{/* Top-left large */}
<ImageTile img={imgs[0]} index={0} className="col-span-2 row-span-1 max-[768px]:col-span-4 max-[768px]:row-span-2" />
{/* Right full-height */}
<div
className="col-span-2 row-span-2 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(1)}
>
<img src={imgs[1].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
<div className="absolute bottom-0 left-0 right-0 p-2 flex justify-between items-end pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 2</span>
{total > 4 && (
<span className="text-white text-xs font-semibold bg-black/50 rounded px-1.5 py-0.5">
🖼 {total.toLocaleString()}
</span>
)}
</div>
</div>
{/* Bottom-left small */}
<div
className="col-span-1 row-span-1 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(2)}
>
<img src={imgs[2].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
<div className="absolute bottom-0 left-0 right-0 p-2 pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 3</span>
</div>
</div>
{/* Bottom-middle small — with "+more" if applicable */}
<div
className="col-span-1 row-span-1 relative overflow-hidden cursor-zoom-in group/tile max-[768px]:hidden"
onClick={() => openAt(3)}
>
<img src={imgs[3].src} alt="" className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75" />
{total > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
<span className="text-white font-bold text-2xl">+{total - 4}</span>
</div>
)}
{total <= 4 && (
<div className="absolute bottom-0 left-0 right-0 p-2 pointer-events-none">
<span className="text-white text-xs font-semibold drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">Photo 4</span>
</div>
)}
</div>
</div>
)}
</div>
);
})()}
<div className="mt-4 flex flex-row items-center gap-5 border border-[#38444d] rounded-md px-5 py-3 text-sm w-fit mx-auto">
<a href="#" className="flex flex-row items-center gap-1.5 underline underline-offset-2 hover:text-white transition-colors">
<Globe className="w-4 h-4" />
<span>Website</span>
</a>
<a href="#" className="flex flex-row items-center gap-1.5 underline underline-offset-2 hover:text-white transition-colors">
<MenuIcon fill={'#ffffff'} className="w-5 h-5" />
<span>Menu</span>
</a>
{/* <a href="#" className="flex flex-row items-center gap-1.5 font-bold underline underline-offset-2 hover:text-white transition-colors">
<Phone className="w-4 h-4" />
<span>-</span>
</a> */}
<div className="w-px h-4 bg-[#38444d]" />
<a href="#" className="hover:text-white transition-colors underline underline-offset-2">Improve this listing</a>
</div>
<div className="flex flex-col md:flex-row gap-4 items-stretch mt-2 bg-black/[0.27] -mx-[25px] px-[25px] pb-4 md:mx-0 md:px-5 md:py-5 md:rounded-xl">
<div className="flex-1 min-w-0">
<RatingsCard
data={locationDetail}
getCriticData={(data) => ({
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
})}
/>
</div>
<div className="w-full md:w-[320px] flex-shrink-0">
<ScheduleCard
schedules={[
{ day: 'Sunday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Monday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Tuesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Wednesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Thursday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Friday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Saturday', open: '7.00 AM', close: '21.00 PM' },
]}
onSuggestEdit={() => console.log('suggest edit')}
/>
</div>
</div>
<FacilitiesCard
seeMoreShow
title="Facilities"
left={[
{
title: 'Main Facilities',
items: [
{ text: '5-floor main prayer hall + 1 basement (capacity 200,000+ worshippers)' },
{ text: 'Advanced high-quality sound system' },
{ text: 'Spacious courtyard for Eid prayers' },
],
},
]}
middle={[
{
title: 'Supporting Facilities',
items: [
{ text: 'Islamic library aoehantdhouantehou asntoehu sote ueaaup,' },
{ text: 'Multipurpose hall' },
{ text: 'Large and comfortable ablution area' },
],
},
]}
right={[
{
title: 'Accessibility',
items: [
{ text: 'Disability-friendly facilities' },
{ text: 'Guiding blocks for the visually impaired' },
{ text: 'Elevators with voice guidance & transparent walls' },
],
},
]}
/>
</section>
<section name={'REVIEWS SECTION'}>
<div className="mt-5 table w-full table-fixed">
<div className="text-left pr-5 max-w-[1096px] min-w-[680px] table-cell relative align-top w-full box-border">
{!user.username ?
<div className="bg-secondary text-center p-3 w-full"><a href={'#'} onClick={handleSignInNavigation} className="border-b border-white">SIGN IN</a> TO REVIEW</div>
:
<div name="REVIEW INPUT TEXTAREA" className="rounded-xl bg-black/[0.27] p-5">
{/* Avatar + Username */}
<div className="flex items-center gap-3 mb-5">
<img
loading="lazy"
src={user.avatar_picture ? user.avatar_picture.toString() : DEFAULT_AVATAR_IMG}
className="w-10 h-10 rounded-full object-cover flex-shrink-0"
/>
<span className="font-semibold">{user.username}</span>
{currentUserReview && (
<>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-3 bg-secondary rounded-lg px-4 py-1.5">
<span className="text-sm tracking-wide">{user.is_critics ? "CRITIC SCORE" : "USER SCORE"}</span>
<span className="text-sm font-bold bg-primary rounded-full w-8 h-8 flex items-center justify-center">{currentUserReview.score}</span>
</div>
</>
)}
</div>
{currentUserReview ? (
<div>
{currentUserReview.created_at?.Valid && (
<span className="text-xs text-tertiary mb-4 block">
Written {new Date(String(currentUserReview.created_at.Time)).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
)}
{currentUserReview.title && (
<p className="font-bold text-2xl mb-3">{currentUserReview.title}</p>
)}
{currentUserReview.images && currentUserReview.images.length > 0 && (
<div className="grid grid-cols-4 gap-1.5 mb-4 rounded-lg overflow-hidden">
{currentUserReview.images.slice(0, 4).map((img, i) => (
<div
key={img.id}
className="relative aspect-[6/3] cursor-zoom-in group/rimg overflow-hidden"
onClick={() => { setReviewLightboxIndex(i); setReviewLightboxOpen(true); }}
>
<img
src={img.src}
alt={`review-${i}`}
className="w-full h-full object-cover transition-all duration-200 group-hover/rimg:brightness-75"
/>
{i === 3 && currentUserReview.images!.length > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
<span className="text-white font-bold text-lg">+{currentUserReview.images!.length - 4} MORE</span>
</div>
)}
</div>
))}
</div>
)}
{/* Review text — clamped with "Show Full Review" */}
<div className="mb-4">
<div className="line-clamp-5">
<CustomInterweave content={currentUserReview.comments} />
</div>
</div>
{/* Footer: likes, comments, show full review */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-tertiary">
<button className="flex items-center gap-1.5 hover:text-white transition-colors text-sm">
<Heart size={18} />
<span>15</span>
</button>
<button className="flex items-center gap-1.5 hover:text-white transition-colors text-sm">
<MessageCircle size={18} />
<span>5</span>
</button>
</div>
<button className="border border-gray-600 rounded-lg px-4 py-2 text-sm font-semibold hover:bg-secondary transition-colors">
Show Full Review
</button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-3 gap-3 mb-5 [&_select]:w-full [&_select]:bg-black/40 [&_select]:border [&_select]:border-gray-600 [&_select]:rounded-lg [&_select]:px-3 [&_select]:py-2 [&_select]:text-sm [&_select]:outline-none [&_select]:appearance-none [&_select]:cursor-pointer [&_select:focus]:border-gray-400">
<div>
<label className="text-xs text-tertiary block mb-1.5">Rating</label>
<select
value={reviewValue.score_input}
onChange={(e) => {
const val = (e.target as HTMLSelectElement).value;
setReviewValue({ ...reviewValue, score_input: val });
setPageState({ ...pageState, is_score_rating_panic_msg: '' });
}}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Rasa</label>
<select
value={reviewValue.rasa}
onChange={(e) => setReviewValue({ ...reviewValue, rasa: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Suasana</label>
<select
value={reviewValue.suasana}
onChange={(e) => setReviewValue({ ...reviewValue, suasana: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div />
<div>
<label className="text-xs text-tertiary block mb-1.5">Pelayanan</label>
<select
value={reviewValue.pelayanan}
onChange={(e) => setReviewValue({ ...reviewValue, pelayanan: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-tertiary block mb-1.5">Kebersihan</label>
<select
value={reviewValue.kebersihan}
onChange={(e) => setReviewValue({ ...reviewValue, kebersihan: (e.target as HTMLSelectElement).value })}
>
<option value="">-</option>
{Array.from({ length: 10 }, (_, i) => (
<option key={i + 1} value={i + 1}>{i + 1}</option>
))}
</select>
</div>
</div>
{pageState.is_score_rating_panic_msg && (
<div className="text-xs text-error mb-3">{pageState.is_score_rating_panic_msg}</div>
)}
{/* Hidden file input */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageSelect}
/>
<button
onClick={() => imageInputRef.current?.click()}
className={`flex items-center gap-2 border border-gray-600 rounded-lg px-4 py-2 text-sm hover:bg-primary transition-colors text-tertiary hover:text-white ${uploadedImages.length === 0 ? 'mb-5' : ''}`}
>
<ImagePlus size={16} />
Add Images {uploadedImages.length > 0 && `(${uploadedImages.length})`}
</button>
{uploadedImages.length > 0 && (
<div className="relative mt-3 mb-5">
<div className="pointer-events-none absolute right-0 top-0 h-full w-12 bg-gradient-to-l from-black/[0.27] to-transparent z-10 rounded-r-lg" />
<div className="flex flex-row gap-2 overflow-x-auto pb-2 [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb:hover]:bg-gray-400">
{uploadedImages.map((img, i) => (
<div key={i} className="relative flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden group/thumb">
{/* Click image to open lightbox */}
<img
src={img.preview}
alt={`upload-${i}`}
className="w-full h-full object-cover cursor-zoom-in"
onClick={() => openUploadLightbox(i)}
/>
{/* X button — top-right corner, always visible */}
<button
onClick={(e) => { e.stopPropagation(); removeImage(i); }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/70 flex items-center justify-center text-white text-xs leading-none hover:bg-black transition-colors"
>
&times;
</button>
</div>
))}
</div>
</div>
)}
<div className="mb-3">
<label className="text-xs text-tertiary block mb-1.5">Title your review</label>
<input
type="text"
placeholder="Give us the gist of your experience"
value={reviewValue.title}
onChange={(e) => 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"
/>
</div>
<div className="mb-4">
<label className="text-xs text-tertiary block mb-1.5">Write your review</label>
<div className="relative">
<ReactTextareaAutosize
minRows={4}
onChange={handleTextAreaChange}
ref={textAreaRef}
placeholder="Share your experience......."
value={reviewValue.review_textArea}
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 resize-none placeholder:text-quartenary pb-8 &::-webkit-scrollbar]:hidden [scrollbar-width:none]"
/>
<button className="absolute bottom-3 right-3 text-tertiary hover:text-white transition-colors text-base leading-none">
<Smile width={24} height={24} />
</button>
</div>
</div>
<div className="flex justify-end">
{pageState.on_submit_loading ? (
<SpinnerLoading />
) : (
pageState.enable_post && (
<a
href="#"
onClick={handleSubmitReview}
className="bg-primary border border-gray-600 rounded-lg px-8 py-2 text-sm font-semibold hover:bg-secondary transition-colors"
>
Post
</a>
)
)}
</div>
</>
)}
</div>
}
<div name={'CRTICITS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
{locationDetail.critics_review.length > 0 ?
<>
<div className="float-right [&_.dropdownLabel]:cursor-pointer">
<div className="inline-block text-sm">Sort by: </div>
<a className="dropdownLabel" onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}>
<p className="ml-2 inline-block capitalize text-sm">{pageState.critic_filter_name}</p>
<svg className="inline-block" fill={"currentColor"} xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
</a>
<div className={`dropdown-content text-sm bg-secondary ${pageState.show_sort ? 'block' : ''}`}>
{SORT_TYPE.map((x, index) => (
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className="block pt-1 capitalize">{x}</a>
))}
</div>
</div>
<div className="clear-both" />
{locationDetail.critics_review.map(x => (
<div className="py-[15px] px-0">
<div className="float-left">
<div className="text-xl mr-5 text-center w-[55px] mb-[3px]">
{x.score}
</div>
<div className="h-1 w-[55px] relative bg-[#d8d8d8]">
<div className="h-1 bg-[#85ce73]" style={{ width: `${x.score}%` }} />
</div>
</div>
<div className="mr-3 inline-block w-10">
<a href="#">
<img
loading={'lazy'}
className="w-full"
src={x.user_avatar ? x.user_avatar : 'https://cdn.discordapp.com/attachments/743422487882104837/1153985664849805392/421-4212617_person-placeholder-image-transparent-hd-png-download.png'}
/>
</a>
</div>
<div className="inline-block align-top">
<div className="font-bold text-base leading-none">
<a>
<span>{x.username}</span>
</a>
</div>
</div>
<div className="text-[15px] leading-6 my-[5px] mx-[75px] mb-[1px]">
<CustomInterweave
content={x.comments}
/>
</div>
<div className="ml-[72px]">
<div className="mr-2 min-w-[55px] inline-block align-middle">
<a className="text-sm" href={'#'}>
<svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className="inline-block">Video</div>
</a>
</div>
<div className="min-w-[55px] inline-block align-middle">
<a className="text-sm" href={'#'}>
<svg className="inline-block mr-1" fill={'currentColor'} xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" /></svg>
<div className="inline-block">Instagram</div>
</a>
</div>
</div>
</div>
))}
</>
:
<span className="text-sm italic">No Critics review to display</span>
}
</div>
<div name={'USERS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"User's review"} pageLink='#' secondLink={locationDetail.users_review.length > 0 ? '#' : ''} />
{locationDetail.users_review.length > 0 ?
<>
{locationDetail.users_review.map(x => (
<div key={x.id} className="py-4 border-b border-[#38444d] last:border-b-0">
{/* Header: avatar + username + score */}
<div className="flex items-center gap-3 mb-3">
<img
loading="lazy"
className="w-10 h-10 rounded-full object-cover flex-shrink-0"
src={x.user_avatar ?? DEFAULT_AVATAR_IMG}
/>
<span className="font-semibold text-sm">{x.username}</span>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-2 bg-secondary rounded-lg px-3 py-1">
<span className="text-xs tracking-wide text-tertiary">USER SCORE</span>
<span className="text-xs font-bold bg-primary rounded-full w-6 h-6 flex items-center justify-center">{x.score}</span>
</div>
</div>
{/* Title */}
{x.title && (
<p className="font-bold text-xl underline mb-2">{x.title}</p>
)}
{/* Images */}
{x.images && x.images.length > 0 && (
<div className="grid grid-cols-4 gap-1.5 mb-3 rounded-lg overflow-hidden">
{x.images.slice(0, 4).map((img, i) => (
<div key={img.id} className="relative aspect-[4/3] overflow-hidden rounded">
<img
src={img.src}
alt={`review-img-${i}`}
className="w-full h-full object-cover"
/>
{i === 3 && x.images!.length > 4 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center">
<span className="text-white font-bold text-sm">+{x.images!.length - 4} MORE</span>
</div>
)}
</div>
))}
</div>
)}
{/* Comment */}
<div className="text-sm leading-6 break-words line-clamp-5">
<CustomInterweave content={x.comments} />
</div>
</div>
))}
<div className="text-center text-sm mt-5 [&>a:hover]:bg-[#38444d] [&>a:hover]:text-white [&>a:hover]:cursor-pointer">
<a className="rounded-[15px] py-2 px-8 border border-[#d8d8d8]">
More
</a>
</div>
</>
:
<>
<span className="text-sm italic">No users review to display</span>
</>
}
</div>
<div className="mb-5">
CONTRUBITION
<DefaultSeparator />
anoeantoeh aoenthaoe aoenth aot
</div>
</div>
</div>
<div className="clear-both" />
</section>
<section>
<div className="text-center text-md pt-5 pb-5">
Added on: 28 May 1988
</div>
</section>
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
slides={locationImages?.images}
/>
<Lightbox
open={uploadLightboxOpen}
close={() => setUploadLightboxOpen(false)}
index={uploadLightboxIndex}
slides={uploadedImages.map(img => ({ src: img.preview }))}
/>
<Lightbox
open={reviewLightboxOpen}
close={() => setReviewLightboxOpen(false)}
index={reviewLightboxIndex}
slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []}
/>
</div>
)
}
export default LocationDetail;