886 lines
40 KiB
TypeScript
Executable File
886 lines
40 KiB
TypeScript
Executable File
import { Link, 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 { recordLocationVisitService } from "../../services/locations";
|
||
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } 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 [listReviewLightboxOpen, setListReviewLightboxOpen] = useState(false)
|
||
const [listReviewLightboxIndex, setListReviewLightboxIndex] = useState(0)
|
||
const [listReviewLightboxImages, setListReviewLightboxImages] = useState<{ src: string }[]>([])
|
||
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(
|
||
{
|
||
id: Number(id)
|
||
}
|
||
)
|
||
setLocationDetail(res.data, (val) => {
|
||
if (val.detail.thumbnail) {
|
||
getImage(val.detail.thumbnail)
|
||
}
|
||
})
|
||
// Fire-and-forget visit hit. Only fires once per (browser tab × location);
|
||
// the backend additionally dedupes by client IP for 30 minutes via Redis,
|
||
// so refreshes / multiple tabs / bots cannot inflate the trending counts.
|
||
// Anything that fails here is silently swallowed inside the service.
|
||
recordLocationVisitService(Number(id))
|
||
} 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 text-white ">OVERVIEW</a>
|
||
<Link to={`/location/user_review/${id}`} className="ml-4 inline-block" >USER REVIEWS</Link>
|
||
<Link to={`/location/critic_review/${id}`} className="ml-4 inline-block" >CRITIC REVIEWS</Link>
|
||
<a className="ml-4 inline-block">COMMENTS</a>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="pb-5 border-b border-[#38444d]" name={'LOCATION HEADER'}>
|
||
<div className="font-bold mt-5 text-2xl">
|
||
<h1>{locationDetail?.detail.name}</h1>
|
||
<p className={'underline flex'}><MapPin /> {locationDetail?.detail.address}</p>
|
||
</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
|
||
onClick={() => navigate(`/location/${id}/review/${currentUserReview.id}`)}
|
||
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"
|
||
>
|
||
×
|
||
</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 ?
|
||
<>
|
||
{locationDetail.critics_review.map(x => {
|
||
return (
|
||
<ReviewCard
|
||
avatar={x.user_avatar}
|
||
username={x.username}
|
||
score={x.score}
|
||
// isCritic={x.is_from_critic}
|
||
title={x.title}
|
||
comment={x.comments}
|
||
images={x.images}
|
||
likes={0}
|
||
commentsCount={0}
|
||
onShowFullReview={() => navigate(`/location/${id}/review/${x.id}`)}
|
||
/>
|
||
)
|
||
})}
|
||
</>
|
||
:
|
||
<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 => (
|
||
<ReviewCard
|
||
avatar={x.user_avatar}
|
||
username={x.username}
|
||
score={x.score}
|
||
// isCritic={x.is_from_critic}
|
||
title={x.title}
|
||
comment={x.comments}
|
||
images={x.images}
|
||
likes={0}
|
||
commentsCount={0}
|
||
onShowFullReview={() => navigate(`/location/${id}/review/${x.id}`)}
|
||
/>
|
||
// <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>
|
||
|
||
// {x.title && (
|
||
// <p className="font-bold text-xl underline mb-2">{x.title}</p>
|
||
// )}
|
||
|
||
// {x.images && x.images.length > 0 && (
|
||
// <div className="flex flex-row gap-1.5 mb-3 overflow-x-auto [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-thumb]:rounded-full">
|
||
// {x.images.slice(0, 20).map((img, i) => (
|
||
// <div
|
||
// key={img.id}
|
||
// className="relative w-16 h-16 flex-shrink-0 overflow-hidden rounded cursor-zoom-in group/limg"
|
||
// onClick={() => {
|
||
// setListReviewLightboxImages(x.images!.map(im => ({ src: im.src })))
|
||
// setListReviewLightboxIndex(i)
|
||
// setListReviewLightboxOpen(true)
|
||
// }}
|
||
// >
|
||
// <img
|
||
// src={img.src}
|
||
// alt={`review-img-${i}`}
|
||
// className="w-full h-full object-cover transition-all duration-200 group-hover/limg:brightness-75"
|
||
// />
|
||
// {i === 19 && x.images!.length > 20 && (
|
||
// <div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
|
||
// <span className="text-white font-bold text-sm">+{x.images!.length - 20} MORE</span>
|
||
// </div>
|
||
// )}
|
||
// </div>
|
||
// ))}
|
||
// </div>
|
||
// )}
|
||
|
||
// {/* Comment */}
|
||
// <div className="text-md 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 })) ?? []}
|
||
/>
|
||
<Lightbox
|
||
open={listReviewLightboxOpen}
|
||
close={() => setListReviewLightboxOpen(false)}
|
||
index={listReviewLightboxIndex}
|
||
slides={listReviewLightboxImages}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default LocationDetail; |