This commit is contained in:
goro 2026-06-03 13:48:23 +03:00
parent b59c7cef9b
commit 38e44d7056
23 changed files with 2358 additions and 167 deletions

View File

@ -0,0 +1,172 @@
import { useState } from 'preact/hooks'
import Lightbox from 'yet-another-react-lightbox'
import { Heart, MessageCircle } from 'lucide-react'
import { DEFAULT_AVATAR_IMG } from '../../../constants/default'
import CustomInterweave from '../../CustomInterweave'
export interface ReviewCardImage {
id: number
src: string
}
export interface ReviewCardProps {
avatar?: string | null
username: string
date?: string
isCritic?: boolean
score: number
title?: string
comment: string
images?: ReviewCardImage[]
likes?: number
commentsCount?: number
onShowFullReview?: () => void
}
const MAX_IMAGES = 10
const DUMMY_PROPS: ReviewCardProps = {
avatar: DEFAULT_AVATAR_IMG,
username: 'adhi kara',
date: 'March 15, 2025',
isCritic: true,
score: 10,
title: 'An unforgettable experience',
comment:
'Lorem ipsum dolor sit amet consectetur. Eget nunc nec dolor condimentum. Nunc ac ipsum augue porta commodo. Lectus pellentesque cursus placerat mauris sed pellentesque. Eget dui aliquam vivamus vitae ligula feugiat purus quis ut. Et congue fames viverra velit neque.\n\nMolestie et vehicula feugiat quis enim dignissim sed tristique dictumst. Vulputate morbi diam massa eget mauris condimentum egestas. Felis vestibulum scelerisque varius ut turpis molestie urna quis.',
images: [
{ id: 1, src: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=400' },
{ id: 2, src: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=400' },
{ id: 3, src: 'https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=400' },
{ id: 4, src: 'https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?w=400' },
{ id: 5, src: 'https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?w=400' },
{ id: 6, src: 'https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?w=400' },
{ id: 7, src: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=400' },
{ id: 8, src: 'https://images.unsplash.com/photo-1512058564366-18510be2db19?w=400' },
],
likes: 15,
commentsCount: 5,
onShowFullReview: () => {},
}
export default function ReviewCard(props: ReviewCardProps = DUMMY_PROPS) {
const {
avatar,
username,
date,
isCritic,
score,
title,
comment,
images = [],
likes = 0,
commentsCount = 0,
onShowFullReview,
} = props
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const visibleImages = images.slice(0, MAX_IMAGES)
const remaining = images.length - MAX_IMAGES
function openLightbox(i: number) {
setLightboxIndex(i)
setLightboxOpen(true)
}
return (
<div className="rounded-xl bg-[#242628] p-5 mt-3">
{/* Header: avatar + username + date + score */}
<div className="flex items-center gap-3 mb-4">
<img
src={avatar ?? DEFAULT_AVATAR_IMG}
alt={username}
className="w-10 h-10 rounded-full object-cover flex-shrink-0"
loading="lazy"
/>
<div className="flex flex-col">
<span className="font-semibold text-sm leading-tight">{username}</span>
{date && (
<span className="text-xs text-tertiary">Written {date}</span>
)}
</div>
<span className="text-gray-600">|</span>
<div className="flex items-center gap-3 bg-secondary rounded-lg px-4 py-1.5 shadow-[0px_4px_4px_0px_rgba(0,0,0,0.25)]">
<span className="text-sm tracking-wide">
{isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
</span>
<span className="text-sm font-bold bg-primary rounded-full w-8 h-8 flex items-center justify-center">
{score}
</span>
</div>
</div>
{/* Title */}
{title && (
<p className="font-bold text-xl mb-3">{title}</p>
)}
{/* Images — horizontal scrollable row */}
{visibleImages.length > 0 && (
<div className="flex flex-row gap-1.5 mb-4 overflow-x-auto [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-thumb]:rounded-full">
{visibleImages.map((img, i) => (
<div
key={img.id}
className="relative w-32 h-16 flex-shrink-0 overflow-hidden rounded cursor-zoom-in group/img"
onClick={() => openLightbox(i)}
>
<img
src={img.src}
alt={`review-img-${i}`}
className="w-full h-full object-cover transition-all duration-200 group-hover/img:brightness-75"
/>
{i === MAX_IMAGES - 1 && remaining > 0 && (
<div className="absolute inset-0 bg-black/55 flex items-center justify-center pointer-events-none">
<span className="text-white font-bold text-xs">+{remaining}</span>
</div>
)}
</div>
))}
</div>
)}
{/* Comment */}
<div className="mb-4 text-md leading-6 line-clamp-5 break-words">
<CustomInterweave content={comment} />
</div>
{/* Footer */}
<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>{likes}</span>
</button>
<button className="flex items-center gap-1.5 hover:text-white transition-colors text-sm">
<MessageCircle size={18} />
<span>{commentsCount}</span>
</button>
</div>
{onShowFullReview && (
<button
onClick={onShowFullReview}
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>
{/* Lightbox */}
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
index={lightboxIndex}
slides={images.map(img => ({ src: img.src }))}
/>
</div>
)
}

View File

@ -0,0 +1,160 @@
import { useState } from 'preact/hooks'
import Lightbox from 'yet-another-react-lightbox'
import { Heart, MessageCircle } from 'lucide-react'
import { DEFAULT_AVATAR_IMG } from '../../../constants/default'
export interface ReviewCardFullProps {
avatar?: string | null
username?: string
date?: string
isCritic?: boolean
comment?: string
score?: number
images?: Array<{ id: number; src: string }>
likes?: number
commentsCount?: number
onShowFullReview?: () => void
}
const VISIBLE_IMAGES = 3
const DUMMY_PROPS: ReviewCardFullProps = {
avatar: null,
username: 'adhi kara',
date: 'March 15, 2025',
isCritic: true,
score: 100,
comment:
'Lorem ipsum dolor sit amet consectetur. Eget nunc nec dolor condimentum. Nunc ac ipsum augue porta commodo. Lectus pellentesque cursus placerat mauris sed pellentesque. Eget dui aliquam vivamus vitae ligula feugiat purus quis ut. Et congue fames viverra velit neque. Sed id in ullamcorper velit urna neque. Ut varius volutpat urna nulla tortor habitasse. Aliquam volutpat nibh integer non. Quis blandit sit in tellus duis. Vitae euismod tincidunt id vitae tincidunt fringilla.\n\nMolestie et vehicula feugiat quis enim dignissim sed tristique dictumst. Vulputate morbi diam massa eget mauris condimentum egestas. Felis vestibulum scelerisque varius ut turpis molestie urna quis. Aliquam vestibulum aliquam scelerisque consectetur id in viverra. Maecenas interdum tempus feugiat consequat semper tortor. Odio neque ac nulla enim at scelerisque. Sed cras et nunc vel ut nec donec hac facilisi. Dignissim ligula etiam risus quam placerat interdum. Ullamcorper accumsan erat fringilla augue nulla.',
images: [
{ id: 1, src: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=800' },
{ id: 2, src: 'https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=800' },
{ id: 3, src: 'https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?w=800' },
{ id: 4, src: 'https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?w=800' },
{ id: 5, src: 'https://images.unsplash.com/photo-1567620905732-2d1ec7ab7445?w=800' },
{ id: 6, src: 'https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?w=800' },
{ id: 7, src: 'https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=800' },
{ id: 8, src: 'https://images.unsplash.com/photo-1512058564366-18510be2db19?w=800' },
],
likes: 15,
commentsCount: 5,
onShowFullReview: () => {},
}
export default function ReviewCardFull(props: ReviewCardFullProps = DUMMY_PROPS) {
const {
avatar,
username,
date,
isCritic,
score,
comment,
images = [],
likes = 0,
commentsCount = 0,
onShowFullReview,
} = props
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const visibleImages = images.slice(0, VISIBLE_IMAGES)
const remaining = images.length - VISIBLE_IMAGES
const paragraphs = comment?.split('\n').filter((p) => p.trim() !== '') || []
function openLightbox(i: number) {
setLightboxIndex(i)
setLightboxOpen(true)
}
return (
<div className="bg-[#242628] rounded-xl p-6">
{/* Header */}
<div className="flex items-center gap-4 mb-5 flex-wrap">
<div className="flex items-center gap-3">
<img
src={avatar ?? DEFAULT_AVATAR_IMG}
alt={username}
className="w-16 h-16 rounded-full object-cover flex-shrink-0"
loading="lazy"
/>
<div>
<p className="font-bold text-2xl leading-tight text-white">{username}</p>
{date && <p className="text-[15px] text-[#d9d9d9]">Written {date}</p>}
</div>
</div>
<div className="flex items-center gap-3 bg-[#313336] rounded-[9.35px] px-4 py-2.5 shadow">
<span className="text-[19px] tracking-wide text-white">
{isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
</span>
<div className="w-10 h-10 rounded-full border border-white/30 flex items-center justify-center flex-shrink-0">
<span className="font-medium text-base text-white">{score}</span>
</div>
</div>
</div>
{/* Image Grid */}
{visibleImages.length > 0 && (
<div className="grid grid-cols-3 gap-2 h-[200px] rounded-[10px] overflow-hidden mb-5">
{visibleImages.map((img, i) => (
<div
key={img.id}
className="relative overflow-hidden cursor-zoom-in group/img"
onClick={() => openLightbox(i)}
>
<img
src={img.src}
alt={`review-img-${i + 1}`}
className="w-full h-full object-cover transition-all duration-300 group-hover/img:brightness-75"
loading="lazy"
/>
{i === VISIBLE_IMAGES - 1 && remaining > 0 && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center pointer-events-none">
<span className="text-white font-medium text-xl tracking-wider">+{remaining} MORE</span>
</div>
)}
</div>
))}
</div>
)}
{/* Review Body */}
<div className="text-[20px] leading-relaxed text-white space-y-4 mb-6 break-words">
{paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<button className="flex items-center gap-1.5 text-[#bcbdbe] hover:text-white transition-colors text-xl">
<Heart size={22} />
<span>{likes}</span>
</button>
<button className="flex items-center gap-1.5 text-[#bcbdbe] hover:text-white transition-colors text-xl">
<MessageCircle size={22} />
<span>{commentsCount}</span>
</button>
</div>
{onShowFullReview && (
<button
onClick={onShowFullReview}
className="bg-[#4c4c4c] rounded-lg px-4 py-2 text-2xl font-medium text-white hover:bg-[#5a5a5a] transition-colors"
>
Show Full Review
</button>
)}
</div>
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
index={lightboxIndex}
slides={images.map((img) => ({ src: img.src }))}
/>
</div>
)
}

View File

@ -0,0 +1,83 @@
import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { AccessIcon } from '../../Icons/AccessIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
interface RatingData {
score: number;
count: number;
}
interface DetailRatings {
environment: number;
cleanliness: number;
price: number;
facility: number;
}
interface UserLocationDetailRatingsCardProps<T> {
data: T;
getCriticData: (data: T) => RatingData;
getUserData: (data: T) => RatingData;
getCriticDetails?: (data: T) => DetailRatings;
getUserDetails?: (data: T) => DetailRatings;
}
const UserLocationDetailRatingsCard = <T,>({
data,
getUserData,
getUserDetails
}: UserLocationDetailRatingsCardProps<T>) => {
const userData = getUserData(data);
const userDetails = getUserDetails?.(data);
const formatCount = (count: number): string | number => {
return count >= 1000
? `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`
: count;
};
const calculateScore = (score: number, count: number): string | number => {
return count !== 0 ? Math.floor(score / count) : "NR";
};
const detailItems = userDetails ? [
{ label: 'Akses', value: userDetails.environment, icon: <AccessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Pelayanan', value: userDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Kebersihan', value: userDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Fasilitas', value: userDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
] : [];
return (
<div className="w-full justify-center mt-2 flex flex-row gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{/* USER SCORE card */}
<div className="bg-secondary rounded-xl px-4 py-3 flex items-center gap-4 flex-shrink-0 min-w-[240px]">
<div>
<div className="text-base font-bold tracking-wide">USER SCORE</div>
{userData.count !== 0 && (
<div className="text-tertiary mt-0.5 text-sm">Based on {formatCount(userData.count)} reviews</div>
)}
</div>
<div className="w-11 h-11 rounded-full ml-7 bg-primary flex items-center justify-center text-lg font-bold flex-shrink-0">
{calculateScore(userData.score, userData.count)}
</div>
</div>
{/* Detail rating cards — all in one row */}
{detailItems.map((item) => (
<div key={item.label} className="border border-gray-600 rounded-xl px-4 py-3 flex items-center gap-3 flex-shrink-0 min-w-[240px]">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-sm">{item.label}</div>
<div className="text-2xl font-bold leading-none my-0.5">{item.value}</div>
<div className="text-tertiary text-sm">Based on {formatCount(userData.count)} reviews</div>
</div>
</div>
))}
</div>
);
};
export default UserLocationDetailRatingsCard;

View File

@ -0,0 +1,29 @@
interface AccessIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function AccessIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: AccessIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg
width={width}
height={height}
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
stroke={stroke}
strokeWidth={sw}
>
<path d="M8.15234 6.52617C8.7977 6.52606 9.42852 6.33458 9.96506 5.97596C10.5016 5.61734 10.9197 5.10767 11.1666 4.51141C11.4135 3.91515 11.478 3.25907 11.3521 2.62613C11.2261 1.99319 10.9153 1.41182 10.4589 0.95553C10.0025 0.499238 9.4211 0.188517 8.78814 0.0626579C8.15518 -0.0632015 7.49911 0.00145267 6.90289 0.248445C6.30668 0.495438 5.79709 0.913676 5.43856 1.45027C5.08003 1.98687 4.88867 2.61773 4.88867 3.26309C4.88976 4.12828 5.23397 4.95772 5.84581 5.56945C6.45765 6.18117 7.28715 6.52524 8.15234 6.52617ZM8.15234 0.959767C8.60778 0.959767 9.05299 1.09482 9.43167 1.34785C9.81036 1.60088 10.1055 1.96051 10.2798 2.38128C10.4541 2.80205 10.4997 3.26506 10.4108 3.71174C10.322 4.15843 10.1027 4.56874 9.78062 4.89078C9.45858 5.21282 9.04827 5.43214 8.60158 5.52099C8.1549 5.60984 7.6919 5.56424 7.27113 5.38995C6.85036 5.21566 6.49072 4.92052 6.23769 4.54183C5.98466 4.16315 5.84961 3.71794 5.84961 3.2625C5.85039 2.65207 6.09326 2.06687 6.52496 1.63529C6.95665 1.2037 7.54191 0.960973 8.15234 0.960353V0.959767Z" fill={fill}/>
<path d="M21.4563 17.5945C21.1203 17.2591 20.6649 17.0707 20.1901 17.0707C19.7154 17.0707 19.26 17.2591 18.9239 17.5945L18.6655 17.8506L15.1739 14.3584C15.0082 14.1922 14.8112 14.0604 14.5942 13.9707C14.3773 13.881 14.1448 13.8351 13.9101 13.8357H9.61924V8.6994C9.62354 8.46149 9.5804 8.2251 9.49233 8.00405C9.40426 7.783 9.27303 7.5817 9.1063 7.41193C8.93957 7.24216 8.74069 7.10731 8.52126 7.01526C8.30184 6.9232 8.06627 6.87579 7.82832 6.87579C7.59037 6.87579 7.3548 6.9232 7.13538 7.01526C6.91595 7.10731 6.71707 7.24216 6.55034 7.41193C6.38361 7.5817 6.25238 7.783 6.16431 8.00405C6.07624 8.2251 6.0331 8.46149 6.0374 8.6994V15.6246C6.03771 16.0991 6.22613 16.554 6.56134 16.8898C6.89656 17.2256 7.35123 17.4147 7.82568 17.4158H13.1653L17.3993 21.6492C17.5655 21.8156 17.7629 21.9477 17.9802 22.0377C18.1974 22.1278 18.4303 22.1742 18.6655 22.1742C18.9007 22.1742 19.1336 22.1278 19.3509 22.0377C19.5682 21.9477 19.7655 21.8156 19.9317 21.6492L21.4552 20.1258C21.7904 19.7899 21.9787 19.3349 21.979 18.8604C21.9792 18.3859 21.7912 17.9307 21.4563 17.5945ZM20.7749 19.4478L19.2515 20.9713C19.0935 21.1217 18.8837 21.2056 18.6655 21.2056C18.4474 21.2056 18.2376 21.1217 18.0796 20.9713L13.7056 16.5973C13.6611 16.5524 13.6081 16.5169 13.5498 16.4926C13.4914 16.4684 13.4289 16.456 13.3657 16.456H7.82568C7.60558 16.4557 7.39457 16.3682 7.23893 16.2125C7.0833 16.0569 6.99572 15.8459 6.99541 15.6258V8.6994C6.99541 8.4792 7.08289 8.26801 7.23859 8.11231C7.3943 7.9566 7.60548 7.86913 7.82568 7.86913C8.04589 7.86913 8.25707 7.9566 8.41278 8.11231C8.56848 8.26801 8.65596 8.4792 8.65596 8.6994V14.3144C8.65611 14.4418 8.7068 14.5638 8.79689 14.6538C8.88698 14.7438 9.0091 14.7943 9.13643 14.7943H13.9077C14.0166 14.7943 14.1244 14.8158 14.2249 14.8575C14.3255 14.8992 14.4168 14.9604 14.4937 15.0375L18.3251 18.8689C18.3696 18.9136 18.4225 18.9491 18.4808 18.9733C18.5391 18.9975 18.6016 19.01 18.6646 19.01C18.7277 19.01 18.7902 18.9975 18.8485 18.9733C18.9068 18.9491 18.9597 18.9136 19.0042 18.8689L19.603 18.2736C19.761 18.1232 19.9708 18.0392 20.189 18.0392C20.4071 18.0392 20.6169 18.1232 20.7749 18.2736C20.9299 18.4292 21.0169 18.6399 21.0169 18.8596C21.0169 19.0792 20.9299 19.2899 20.7749 19.4455V19.4478Z" fill={fill}/>
<path d="M13.2703 21.4002C12.6087 22.1168 11.8053 22.6881 10.9113 23.0778C10.0172 23.4675 9.05191 23.6673 8.07659 23.6643C6.52767 23.6651 5.02085 23.16 3.78528 22.226C2.54971 21.2919 1.65298 19.9798 1.23146 18.4894C0.809932 16.9989 0.886672 15.4116 1.45001 13.9687C2.01334 12.5259 3.03246 11.3065 4.35237 10.4959C4.40623 10.4628 4.45304 10.4194 4.49013 10.3682C4.52722 10.317 4.55385 10.2589 4.56851 10.1974C4.58318 10.1359 4.58558 10.0721 4.57559 10.0097C4.5656 9.94724 4.54341 9.88738 4.51028 9.83352C4.47716 9.77965 4.43375 9.73284 4.38253 9.69576C4.33131 9.65867 4.27329 9.63204 4.21178 9.61737C4.15027 9.60271 4.08648 9.60031 4.02404 9.6103C3.9616 9.62029 3.90174 9.64248 3.84788 9.67561C2.66976 10.3937 1.6967 11.4034 1.02267 12.6072C0.348647 13.8111 -0.00357421 15.1684 2.7345e-05 16.5481C2.7345e-05 21.0012 3.62288 24.6246 8.07659 24.6246C9.18379 24.6283 10.2797 24.4018 11.2947 23.9595C12.3097 23.5172 13.2218 22.8688 13.9729 22.0553C14.0159 22.0091 14.0494 21.9549 14.0715 21.8958C14.0935 21.8367 14.1037 21.7738 14.1015 21.7108C14.0993 21.6477 14.0847 21.5857 14.0585 21.5283C14.0323 21.4709 13.995 21.4192 13.9489 21.3762C13.9027 21.3332 13.8485 21.2997 13.7894 21.2776C13.7303 21.2555 13.6674 21.2453 13.6043 21.2476C13.5413 21.2498 13.4793 21.2644 13.4219 21.2906C13.3645 21.3168 13.3128 21.354 13.2698 21.4002H13.2703Z" fill={fill}/>
</svg>
);
}

View File

@ -0,0 +1,28 @@
interface FallbackImageProps {
thumbnail: string
locationType?: string
style: React.CSSProperties
alt?: string
}
const fallbackThumbnailSrc = 'https://otherstuff.nochill.in/public/upload/misty-forest-black-white.webp';
const restaurantThumbnailSrc = 'https://otherstuff.nochill.in/restaorunta.webp';
const FallbackImage = ({ thumbnail, locationType, style, alt }: FallbackImageProps) => (
<img
src={thumbnail}
loading={"lazy"}
style={style}
alt={alt}
onError={(e) => {
if (locationType === 'restaurant') {
e.currentTarget.src = restaurantThumbnailSrc;
} else {
e.currentTarget.src = fallbackThumbnailSrc;
}
e.currentTarget.onerror = null;
}}
/>
)
export default FallbackImage

View File

@ -18,6 +18,9 @@ import SpinnerLoading from "./Loading/Spinner";
import FilterButton from "./Button/FilterButton"; import FilterButton from "./Button/FilterButton";
import LocationCard from "./Card/LocationCard"; import LocationCard from "./Card/LocationCard";
import ReviewCard from "./Card/ReviewCard";
import ReviewCardFull from "./Card/ReviewCardFull";
import UserLocationDetailRatingsCard from "./Card/UserLocationDetailRatingsCard";
import CheckboxInput from "./Input/CheckboxInput"; import CheckboxInput from "./Input/CheckboxInput";
@ -41,6 +44,9 @@ export {
SpinnerLoading, SpinnerLoading,
LocationCard, LocationCard,
ReviewCard,
UserLocationDetailRatingsCard,
ReviewCardFull,
FilterButton, FilterButton,
} }

View File

@ -22,7 +22,7 @@ const GET_LIST_TOP_LOCATIONS = `${BASE_URL}/locations/top-ratings
const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`; const GET_LIST_RECENT_LOCATIONS_RATING_URI = `${BASE_URL}/locations/recent`;
const GET_LOCATION_URI = `${BASE_URL}/location`; const GET_LOCATION_URI = `${BASE_URL}/location`;
const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`; const GET_LOCATION_TAGS_URI = `${BASE_URL}/location/tags`;
const POST_CREATE_LOCATION = GET_LIST_LOCATIONS_URI; const POST_CREATE_LOCATION = `${BASE_URL}/location`;
const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`; const GET_IMAGES_BY_LOCATION_URI = `${BASE_URL}/images/location`;

View File

@ -9,7 +9,7 @@
/* color-scheme: light dark; */ /* color-scheme: light dark; */
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: #202225; background-color: #1D1F21;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;

View File

@ -0,0 +1,188 @@
import { useState } from "preact/compat";
import { LocationType } from "../../types/common";
const AMENITIES_CONFIG: Record<string, Record<string, string[]>> = {
[LocationType.Mall]: {
"Food & Beverages": [
"KFC", "McDonald's", "A&W", "Burger King", "Pizza Hut",
"Starbucks", "J.CO Donuts", "Chatime", "Hokben", "Solaria",
"Bakmi GM", "CFC", "Dunkin'", "Yoshinoya", "Domino's",
],
"Fashion": [
"Zara", "H&M", "Uniqlo", "Mango", "Cotton On",
"Pull&Bear", "Bershka", "Nevada", "Lea Jeans", "Levi's",
"Cardinal", "Gavinci", "Polo Ralph Lauren",
],
"Sports": [
"Nike", "Adidas", "Specs", "Puma", "Reebok",
"New Balance", "Under Armour", "Erigo Sport", "Mills",
],
"Health & Beauty": [
"Guardian", "Watson", "The Body Shop", "Sephora",
"Innisfree", "Miniso", "Face Shop", "Caring Colours",
],
},
[LocationType.Accommodation]: {
"Property": [
"Free Parking", "Swimming Pool", "Gym", "Free Breakfast",
"Restaurant", "Bar", "Spa", "WiFi", "Laundry",
"24-Hour Front Desk", "Airport Shuttle", "Concierge", "Pet Friendly",
],
"Room Features": [
"Single Room", "Double Room", "Twin Room", "King Bed",
"Family Room", "Suite", "Air Conditioning", "Hot Water",
"TV", "Mini Bar", "Balcony", "Kitchen", "Bathtub",
],
},
[LocationType.Culinary]: {
"Facilities": [
"Outdoor Seating", "Indoor Seating", "Private Room",
"Live Music", "Karaoke", "Smoking Area", "Kids Friendly", "Drive-Thru",
],
"Services": [
"WiFi", "Parking", "Delivery", "Takeout",
"Reservations", "Credit Card Accepted", "Self-Service",
],
"Food Options": [
"Halal", "Vegetarian", "Vegan", "Seafood",
"Western", "Local Cuisine", "Desserts & Drinks Only",
],
},
};
type SectionState = {
checked: string[]; // predefined items checked
customs: string[]; // user-typed items for this section
input: string; // current text in the input for this section
};
interface AmenitiesModalProps {
locationType: LocationType;
selected: Array<Record<string, string[]>>;
onSave: (amenities: Array<Record<string, string[]>>) => void;
onClose: () => void;
}
export default function AmenitiesModal({ locationType, selected, onSave, onClose }: AmenitiesModalProps) {
const config = AMENITIES_CONFIG[locationType] ?? {};
const [sections, setSections] = useState<Record<string, SectionState>>(() => {
const init: Record<string, SectionState> = {};
for (const [cat, items] of Object.entries(config)) {
const existing = selected.find(s => s[cat])?.[cat] ?? [];
init[cat] = {
checked: existing.filter(s => items.includes(s)),
customs: existing.filter(s => !items.includes(s)),
input: "",
};
}
return init;
});
function togglePredefined(category: string, item: string) {
setSections(prev => {
const sec = prev[category];
const checked = sec.checked.includes(item)
? sec.checked.filter(c => c !== item)
: [...sec.checked, item];
return { ...prev, [category]: { ...sec, checked } };
});
}
function removeCustom(category: string, item: string) {
setSections(prev => {
const sec = prev[category];
return { ...prev, [category]: { ...sec, customs: sec.customs.filter(c => c !== item) } };
});
}
function addCustom(category: string) {
const trimmed = sections[category].input.trim();
if (!trimmed) return;
const alreadyExists = sections[category].customs.includes(trimmed) ||
(config[category] ?? []).includes(trimmed);
if (alreadyExists) return;
setSections(prev => {
const sec = prev[category];
return { ...prev, [category]: { ...sec, customs: [...sec.customs, trimmed], input: "" } };
});
}
function setInput(category: string, value: string) {
setSections(prev => ({ ...prev, [category]: { ...prev[category], input: value } }));
}
function handleSave() {
const structured = Object.entries(sections)
.filter(([, sec]) => sec.checked.length + sec.customs.length > 0)
.map(([category, sec]) => ({ [category]: [...sec.checked, ...sec.customs] }));
onSave(structured);
}
const totalSelected = Object.values(sections).reduce(
(sum, sec) => sum + sec.checked.length + sec.customs.length, 0
);
return (
<div className="amenities-overlay" onClick={onClose}>
<div className="amenities-modal" onClick={e => e.stopPropagation()}>
<div className="amenities-modal-header">
<span>Configure Amenities</span>
<button className="amenities-close-btn" onClick={onClose}></button>
</div>
<div className="amenities-modal-body">
{Object.entries(config).map(([category, items]) => {
const sec = sections[category];
return (
<div key={category} className="amenities-section">
<p className="amenities-section-title">{category}</p>
<div className="amenities-grid">
{items.map(item => (
<label key={item} className="amenities-checkbox-label">
<input
type="checkbox"
checked={sec.checked.includes(item)}
onChange={() => togglePredefined(category, item)}
/>
<span>{item}</span>
</label>
))}
{sec.customs.map(item => (
<label key={item} className="amenities-checkbox-label amenities-custom-item">
<input
type="checkbox"
checked
onChange={() => removeCustom(category, item)}
/>
<span>{item}</span>
</label>
))}
</div>
<div className="amenities-custom-row">
<input
type="text"
className="bg-primary text-sm amenities-custom-input"
placeholder={`Add to ${category.toLowerCase()}...`}
value={sec.input}
onInput={e => setInput(category, (e.target as HTMLInputElement).value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addCustom(category); } }}
/>
<button className="amenities-add-btn" onClick={() => addCustom(category)}>Add</button>
</div>
</div>
);
})}
</div>
<div className="amenities-modal-footer">
<span className="text-sm" style={{ opacity: 0.6 }}>{totalSelected} selected</span>
<div style={{ display: 'flex', gap: 8 }}>
<button className="amenities-cancel-btn" onClick={onClose}>Cancel</button>
<button className="amenities-save-btn" onClick={handleSave}>Save</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -11,6 +11,8 @@ import { createLocationService } from "../../services/locations";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { UserRootState } from "../../store/type"; import { UserRootState } from "../../store/type";
import DefaultLoadingAnimation from "../../components/LoadingAnimation/Default"; import DefaultLoadingAnimation from "../../components/LoadingAnimation/Default";
import FallbackImage from "../../../src/components/Img/FallbackImage";
import AmenitiesModal from "./AmenitiesModal";
function AddLocation() { function AddLocation() {
const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>() const [recentLocations, setRecentLocations] = useState<Array<LocationInfo>>()
@ -24,7 +26,9 @@ function AddLocation() {
location_type: LocationType.Recreation, location_type: LocationType.Recreation,
regency: emptyRegency(), regency: emptyRegency(),
thumbnails: [], thumbnails: [],
amenities: [],
}) })
const [showAmenitiesModal, setShowAmenitiesModal] = useState(false)
const [submitLoading, setSubmitLoading ] = useState<boolean>(false) const [submitLoading, setSubmitLoading ] = useState<boolean>(false)
const user = useSelector((state: UserRootState) => state.auth) const user = useSelector((state: UserRootState) => state.auth)
@ -60,16 +64,21 @@ function AddLocation() {
} }
function onChangeLocationType(e: ChangeEvent) {
const value = (e.target as HTMLSelectElement).value as LocationType
setForm({ ...form, location_type: value, amenities: [] })
}
async function onSubmitForm(e: TargetedEvent) { async function onSubmitForm(e: TargetedEvent) {
e.preventDefault(); e.preventDefault();
setSubmitLoading(true)
if(form.regency.regency_name === '') { if(form.regency.regency_name === '') {
setPageState({ regency_form_error: true }) setPageState({ regency_form_error: true })
return return
} }
setSubmitLoading(true)
let tempThumbnailArr: Array<File> = []; let tempThumbnailArr: Array<File> = [];
let formData = new FormData(); let formData = new FormData();
@ -83,6 +92,9 @@ function AddLocation() {
formData.append("regency_id", form.regency.id.toString()); formData.append("regency_id", form.regency.id.toString());
formData.append("submitted_by", user.id.toString()); formData.append("submitted_by", user.id.toString());
formData.append("google_maps_link", form.google_maps_link); formData.append("google_maps_link", form.google_maps_link);
if (form.amenities.length > 0) {
formData.append("amenities", JSON.stringify(form.amenities))
}
for(let i = 0; i < tempThumbnailArr.length; i++) { for(let i = 0; i < tempThumbnailArr.length; i++) {
formData.append("thumbnail", tempThumbnailArr[i]) formData.append("thumbnail", tempThumbnailArr[i])
@ -169,6 +181,7 @@ function AddLocation() {
}, []) }, [])
return ( return (
<>
<div className={'content main-content'}> <div className={'content main-content'}>
<div style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}> <div style={{ tableLayout: 'fixed', display: 'table', width: '100%' }}>
<div style={{ textAlign: 'left', padding: '20px 30px', maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%' }}> <div style={{ textAlign: 'left', padding: '20px 30px', maxWidth: 1096, minWidth: 680, display: 'table-cell', position: 'relative', verticalAlign: 'top', width: '100%' }}>
@ -180,12 +193,30 @@ function AddLocation() {
<span className={'block mt-2 text-sm mb-2'}>Address <span className={'text-error'}>*</span></span> <span className={'block mt-2 text-sm mb-2'}>Address <span className={'text-error'}>*</span></span>
<input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'address'} value={form.address} required /> <input className={'bg-primary text-sm input-text'} type={'text'} onChange={onChangeFormInput} name={'address'} value={form.address} required />
<label className={'block text-sm mt-2 mb-2'} for="location_type">Location Category</label> <label className={'block text-sm mt-2 mb-2'} for="location_type">Location Category</label>
<select className={'bg-primary p-1'} name="location_type" id="location_type"> <select className={'bg-primary p-1'} name="location_type" id="location_type" value={form.location_type} onChange={onChangeLocationType}>
{ enumKeys(LocationType).map(x => ( { enumKeys(LocationType).map(x => (
<option value={LocationType[x]}>{LocationType[x]}</option> <option value={LocationType[x]}>{LocationType[x]}</option>
)) ))
} }
</select> </select>
{(form.location_type === LocationType.Accommodation || form.location_type === LocationType.Culinary || form.location_type === LocationType.Mall) && (
<div>
<button type="button" className="amenities-trigger-btn" onClick={() => setShowAmenitiesModal(true)}>
{form.amenities.length > 0
? `Amenities (${form.amenities.reduce((n, s) => n + Object.values(s).flat().length, 0)} selected)`
: '+ Configure Amenities'}
</button>
{form.amenities.length > 0 && (
<div className="amenities-tags">
{form.amenities.map(section =>
Object.entries(section).map(([cat, items]) => (
<span key={cat} className="amenity-tag">{cat}: {items.join(', ')}</span>
))
)}
</div>
)}
</div>
)}
<span className={'block mt-2 text-sm mb-2'}>Kota / Kabupaten <span className={'text-error'}>*</span> <span className={`text-xs text-error ${!pageState.regency_form_error && 'hidden'}`}> (regency mustn't be empty)</span></span> <span className={'block mt-2 text-sm mb-2'}>Kota / Kabupaten <span className={'text-error'}>*</span> <span className={`text-xs text-error ${!pageState.regency_form_error && 'hidden'}`}> (regency mustn't be empty)</span></span>
<DropdownInput <DropdownInput
isSearchable={true} isSearchable={true}
@ -232,11 +263,11 @@ function AddLocation() {
{recentLocations?.map(x => ( {recentLocations?.map(x => (
<div style={{ width: '32%', display: 'inline-block', padding: '1px 1%' }}> <div style={{ width: '32%', display: 'inline-block', padding: '1px 1%' }}>
<a href={'#'} title={x.name}> <a href={'#'} title={x.name}>
<img <FallbackImage
loading={'lazy'} thumbnail={x.thumbnail}
src={x.thumbnail ? x.thumbnail : ""} locationType={x.location_type}
alt={x.name} alt={x.name}
style={{ aspectRatio: '1/1' }} style={{ aspectRatio: '1/1'}}
/> />
</a> </a>
</div> </div>
@ -244,6 +275,15 @@ function AddLocation() {
</div> </div>
</div> </div>
</div> </div>
{showAmenitiesModal && (
<AmenitiesModal
locationType={form.location_type}
selected={form.amenities}
onSave={(amenities) => { setForm({ ...form, amenities }); setShowAmenitiesModal(false); }}
onClose={() => setShowAmenitiesModal(false)}
/>
)}
</>
) )
} }

View File

@ -31,3 +31,191 @@
.inputfile + label { .inputfile + label {
cursor: pointer; /* "hand" cursor */ cursor: pointer; /* "hand" cursor */
} }
/* Amenities Modal */
.amenities-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.amenities-modal {
background: #2f3136;
border-radius: 10px;
width: 90%;
max-width: 580px;
max-height: 82vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.amenities-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #202225;
font-weight: bold;
font-size: 15px;
}
.amenities-close-btn {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 17px;
opacity: 0.6;
padding: 0 2px;
}
.amenities-close-btn:hover {
opacity: 1;
}
.amenities-modal-body {
overflow-y: auto;
padding: 16px 20px;
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.amenities-section {}
.amenities-section-title {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1.2px;
opacity: 0.55;
margin-bottom: 10px;
}
.amenities-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
gap: 7px;
margin-bottom: 10px;
}
.amenities-checkbox-label {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
cursor: pointer;
padding: 3px 0;
}
.amenities-checkbox-label input[type="checkbox"] {
accent-color: #a8adb3;
cursor: pointer;
flex-shrink: 0;
}
.amenities-custom-item span {
opacity: 0.85;
font-style: italic;
}
.amenities-custom-row {
display: flex;
gap: 7px;
margin-top: 4px;
}
.amenities-custom-input {
flex: 1;
padding: 4px 9px;
border-radius: 5px;
min-width: 0;
}
.amenities-add-btn {
background: #555;
border: none;
color: white;
padding: 4px 13px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.amenities-add-btn:hover {
background: #666;
}
.amenities-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-top: 1px solid #202225;
}
.amenities-cancel-btn {
background: #444;
border: none;
color: white;
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.amenities-cancel-btn:hover {
background: #555;
}
.amenities-save-btn {
background: #a8adb3;
border: none;
color: #111;
padding: 6px 18px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
}
.amenities-save-btn:hover {
background: #c0c5cc;
}
.amenities-trigger-btn {
margin-top: 6px;
background: #444;
border: none;
color: white;
padding: 5px 13px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.amenities-trigger-btn:hover {
background: #555;
}
.amenities-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 7px;
}
.amenity-tag {
background: #202225;
padding: 3px 9px;
border-radius: 20px;
font-size: 12px;
opacity: 0.8;
}

View File

@ -12,5 +12,6 @@ export interface Form {
regency: Regency, regency: Regency,
location_type: LocationType, location_type: LocationType,
google_maps_link: string, google_maps_link: string,
thumbnails: Array<Thumbnail> thumbnails: Array<Thumbnail>,
amenities: Array<Record<string, string[]>>
} }

View File

@ -214,7 +214,7 @@ const [page, _setPage] = useState<number>(1);
</div> </div>
))} ))}
</div> </div>
{/* <div className={'p-4 bg-secondary'} style={{ minWidth: 300 }}> <div className={'p-4 bg-secondary'} style={{ minWidth: 300 }}>
<div className={'h-30 bg-primary p-4 right-filter'}> <div className={'h-30 bg-primary p-4 right-filter'}>
{REVIEWERS_TYPE.map((x, idx) => ( {REVIEWERS_TYPE.map((x, idx) => (
<a <a
@ -226,7 +226,7 @@ const [page, _setPage] = useState<number>(1);
</a> </a>
))} ))}
</div> </div>
</div> */} </div>
</div> </div>
</section> </section>
</div> </div>

View File

@ -0,0 +1,430 @@
import { Link, useNavigate, useParams } from 'react-router-dom';
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 } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, ReviewCard, UserLocationDetailRatingsCard } from '../../components';
import { IHttpResponse } from '../../types/common';
import { MapPin } from 'lucide-react'
function CriticLocationDeailReview() {
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 [isLoading, setIsLoading] = useState<boolean>(true)
const [currentIndex, setCurrentIndex] = useState(0);
const navigate = useNavigate();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea);
const { location_id } = useParams()
async function getLocationDetail(): Promise<void> {
try {
const res = await getLocationService(
{
id: Number(location_id),
review: 'critics'
}
)
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(location_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(location_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)
}
}
useEffect(() => {
getCurrentUserLocationReview()
}, [])
useEffect(() => {
if (updatePage || location_id) {
getLocationDetail()
}
}, [updatePage, location_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">
<Link to={`/location/${location_id}`} className="inline-block">OVERVIEW</Link>
<Link to={`/location/user_review/${location_id}`} className="ml-4 inline-block" >USER REVIEWS</Link>
<a className="ml-4 inline-block text-white font-bold">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="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">
<UserLocationDetailRatingsCard
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>
</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">
<div name={'CRITICS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"Critics review"} pageLink='#' secondLink={locationDetail.critics_review.length > 0 ? '#' : ''} />
{locationDetail.critics_review.length > 0 ?
<>
{locationDetail.critics_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={() => {}}
/>
// <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 CriticLocationDeailReview;

View File

@ -0,0 +1,99 @@
import { NullValueRes } from "../../types/common"
import { SlideImage } from "yet-another-react-lightbox"
export interface ILocationDetail {
id: number,
name: String,
address: String,
regency_name: String,
province_name: String,
region_name: String,
google_maps_link: String,
thumbnail: string | null,
submitted_by: number,
critic_score: number,
critic_count: number,
user_score: number,
user_count: number
}
export function emptyLocationDetail(): ILocationDetail {
return {
id: 0,
address: '',
google_maps_link: '',
thumbnail: "",
name: '',
province_name: '',
regency_name: '',
region_name: '',
submitted_by: 0,
critic_score: 0,
critic_count: 0,
user_score: 0,
user_count: 0,
}
}
export interface LocationReviewsResponse {
id: number,
title?: string,
score: number,
comments: string,
user_id: number,
username: string,
user_avatar: string | null,
created_at: string,
updated_at: string,
images?: Array<{ id: number, src: string }>
}
export interface LocationDetailResponse {
detail: ILocationDetail,
tags: Array<String>
users_review: Array<LocationReviewsResponse>,
critics_review: Array<LocationReviewsResponse>
}
export function EmptyLocationDetailResponse(): LocationDetailResponse {
return {
detail: emptyLocationDetail(),
tags: [],
critics_review: Array<LocationReviewsResponse>(),
users_review: Array<LocationReviewsResponse>()
}
}
export interface LocationImage extends SlideImage {
id: number,
src: string,
created_at: String,
uploaded_by: String
}
export interface LocationResponse {
total_image: number,
images: Array<LocationImage>
}
export function emptyLocationResponse(): LocationResponse {
return {
total_image: 0,
images: Array<LocationImage>()
}
}
export type CurrentUserLocationReviews = {
id: number,
title?: string,
comments: string,
is_from_critic: boolean,
is_hided: boolean,
location_id: number,
score: number,
submitted_by: number,
created_at: NullValueRes<"Time", string>,
updated_at: NullValueRes<"Time", string>,
images?: Array<{ id: number, src: string }>,
}

View File

@ -1,4 +1,4 @@
import { useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { ChangeEvent, TargetedEvent } from 'preact/compat'; import { ChangeEvent, TargetedEvent } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import Lightbox from 'yet-another-react-lightbox'; import Lightbox from 'yet-another-react-lightbox';
@ -12,7 +12,7 @@ import {
} from './types'; } from './types';
import { handleApiError, useAutosizeTextArea } from '../../utils'; import { handleApiError, useAutosizeTextArea } from '../../utils';
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services"; import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading } from '../../components'; import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } from '../../components';
import RatingsCard from '../../components/Card/RatingsCard'; import RatingsCard from '../../components/Card/RatingsCard';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { UserRootState } from '../../store/type'; import { UserRootState } from '../../store/type';
@ -60,6 +60,9 @@ function LocationDetail() {
const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0) const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0)
const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false) const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false)
const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0) 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) const imageInputRef = useRef<HTMLInputElement>(null)
function handleImageSelect(e: ChangeEvent<HTMLInputElement>) { function handleImageSelect(e: ChangeEvent<HTMLInputElement>) {
@ -99,7 +102,11 @@ function LocationDetail() {
async function getLocationDetail(): Promise<void> { async function getLocationDetail(): Promise<void> {
try { try {
const res = await getLocationService(Number(id)) const res = await getLocationService(
{
id: Number(id)
}
)
setLocationDetail(res.data, (val) => { setLocationDetail(res.data, (val) => {
if (val.detail.thumbnail) { if (val.detail.thumbnail) {
getImage(val.detail.thumbnail) getImage(val.detail.thumbnail)
@ -252,47 +259,17 @@ function LocationDetail() {
<div className="content main-content mt-3"> <div className="content main-content mt-3">
<section name={"HEADER LINK"}> <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"> <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="inline-block text-white ">OVERVIEW</a>
<a className="ml-4 inline-block">USER REVIEWS</a> <Link to={`/location/user_review/${id}`} className="ml-4 inline-block" >USER REVIEWS</Link>
<a className="ml-4 inline-block">CRITIC REVIEWS</a> <Link to={`/location/critic_review/${id}`} className="ml-4 inline-block" >CRITIC REVIEWS</Link>
<a className="ml-4 inline-block">COMMENTS</a> <a className="ml-4 inline-block">COMMENTS</a>
</div> </div>
</section> </section>
<section className="pb-5 border-b border-[#38444d]" name={'LOCATION HEADER'}> <section className="pb-5 border-b border-[#38444d]" name={'LOCATION HEADER'}>
<div> <div className="font-bold mt-5 text-2xl">
<div className="font-bold mt-5 text-2xl"> <h1>{locationDetail?.detail.name}</h1>
<h1>{locationDetail?.detail.name}</h1> <p className={'underline flex'}><MapPin /> {locationDetail?.detail.address}</p>
<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> </div>
{!isLoading && (() => { {!isLoading && (() => {
@ -571,7 +548,10 @@ function LocationDetail() {
<span>5</span> <span>5</span>
</button> </button>
</div> </div>
<button className="border border-gray-600 rounded-lg px-4 py-2 text-sm font-semibold hover:bg-secondary transition-colors"> <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 Show Full Review
</button> </button>
</div> </div>
@ -742,73 +722,28 @@ function LocationDetail() {
)} )}
</div> </div>
} }
<div name={'CRTICITS REVIEW'} className="my-[50px] mx-0 text-left"> <div name={'CRTICITS REVIEW'} className="my-[50px] mx-0 text-left">
<SeparatorWithAnchor pageName={"critic's review"} pageLink='#' /> <SeparatorWithAnchor pageName={"critic's review"} pageLink='#' />
{locationDetail.critics_review.length > 0 ? {locationDetail.critics_review.length > 0 ?
<> <>
<div className="float-right [&_.dropdownLabel]:cursor-pointer"> {locationDetail.critics_review.map(x => {
<div className="inline-block text-sm">Sort by: </div> return (
<a className="dropdownLabel" onClick={() => setPageState({ ...pageState, show_sort: !pageState.show_sort })}> <ReviewCard
<p className="ml-2 inline-block capitalize text-sm">{pageState.critic_filter_name}</p> avatar={x.user_avatar}
<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> username={x.username}
</a> score={x.score}
<div className={`dropdown-content text-sm bg-secondary ${pageState.show_sort ? 'block' : ''}`}> // isCritic={x.is_from_critic}
{SORT_TYPE.map((x, index) => ( title={x.title}
<a onClick={(e) => onChangeCriticsSort(e, x, index)} className="block pt-1 capitalize">{x}</a> comment={x.comments}
))} images={x.images}
</div> likes={0}
</div> commentsCount={0}
<div className="clear-both" /> onShowFullReview={() => navigate(`/location/${id}/review/${x.id}`)}
/>
{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> <span className="text-sm italic">No Critics review to display</span>
} }
@ -819,54 +754,74 @@ function LocationDetail() {
{locationDetail.users_review.length > 0 ? {locationDetail.users_review.length > 0 ?
<> <>
{locationDetail.users_review.map(x => ( {locationDetail.users_review.map(x => (
<div key={x.id} className="py-4 border-b border-[#38444d] last:border-b-0"> <ReviewCard
{/* Header: avatar + username + score */} avatar={x.user_avatar}
<div className="flex items-center gap-3 mb-3"> username={x.username}
<img score={x.score}
loading="lazy" // isCritic={x.is_from_critic}
className="w-10 h-10 rounded-full object-cover flex-shrink-0" title={x.title}
src={x.user_avatar ?? DEFAULT_AVATAR_IMG} comment={x.comments}
/> images={x.images}
<span className="font-semibold text-sm">{x.username}</span> likes={0}
<span className="text-gray-600">|</span> commentsCount={0}
<div className="flex items-center gap-2 bg-secondary rounded-lg px-3 py-1"> onShowFullReview={() => navigate(`/location/${id}/review/${x.id}`)}
<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 key={x.id} className="py-4 border-b border-[#38444d] last:border-b-0">
</div> // {/* Header: avatar + username + score */}
</div> // <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 && (
{x.title && ( // <p className="font-bold text-xl underline mb-2">{x.title}</p>
<p className="font-bold text-xl underline mb-2">{x.title}</p> // )}
)}
{/* Images */} // {x.images && x.images.length > 0 && (
{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">
<div className="grid grid-cols-4 gap-1.5 mb-3 rounded-lg overflow-hidden"> // {x.images.slice(0, 20).map((img, i) => (
{x.images.slice(0, 4).map((img, i) => ( // <div
<div key={img.id} className="relative aspect-[4/3] overflow-hidden rounded"> // key={img.id}
<img // className="relative w-16 h-16 flex-shrink-0 overflow-hidden rounded cursor-zoom-in group/limg"
src={img.src} // onClick={() => {
alt={`review-img-${i}`} // setListReviewLightboxImages(x.images!.map(im => ({ src: im.src })))
className="w-full h-full object-cover" // setListReviewLightboxIndex(i)
/> // setListReviewLightboxOpen(true)
{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> // <img
</div> // src={img.src}
)} // alt={`review-img-${i}`}
</div> // className="w-full h-full object-cover transition-all duration-200 group-hover/limg:brightness-75"
))} // />
</div> // {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 */} // {/* Comment */}
<div className="text-sm leading-6 break-words line-clamp-5"> // <div className="text-md leading-6 break-words line-clamp-5">
<CustomInterweave content={x.comments} /> // <CustomInterweave content={x.comments} />
</div> // </div>
</div> // </div>
))} ))}
<div className="text-center text-sm mt-5 [&>a:hover]:bg-[#38444d] [&>a:hover]:text-white [&>a:hover]:cursor-pointer"> <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]"> <a className="rounded-[15px] py-2 px-8 border border-[#d8d8d8]">
More More
@ -912,6 +867,12 @@ function LocationDetail() {
index={reviewLightboxIndex} index={reviewLightboxIndex}
slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []} slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []}
/> />
<Lightbox
open={listReviewLightboxOpen}
close={() => setListReviewLightboxOpen(false)}
index={listReviewLightboxIndex}
slides={listReviewLightboxImages}
/>
</div> </div>
) )
} }

View File

@ -0,0 +1,222 @@
import { Link, useParams } from 'react-router-dom';
import { useEffect, useState } from 'preact/hooks';
import { getImagesByLocationService, getLocationService } from '../../services';
import { useLocationReviewById } from '../../services/review';
import Lightbox from 'yet-another-react-lightbox';
import { Heart, MapPin, MessageCircle } from 'lucide-react';
import { DEFAULT_AVATAR_IMG } from '../../constants/default';
import { ServiceIcon } from '../../components/Icons/ServiceIcon';
import { CleanlinessIcon } from '../../components/Icons/CleanlinessIcon';
import { FacilityIcon } from '../../components/Icons/FacilityIcon';
import { RestaurantIcon } from '../../components/Icons/RestaurantIcon';
interface ScoreBreakdown {
pelayanan: number;
kebersihan: number;
fasilitas: number;
rasa: number;
}
//@ts-ignore
const SCORE_CATEGORIES: { key: keyof ScoreBreakdown; label: string; icon: React.ComponentType<any> }[] = [
{ key: 'pelayanan', label: 'PELAYANAN', icon: ServiceIcon },
{ key: 'kebersihan', label: 'KEBERSIHAN', icon: CleanlinessIcon },
{ key: 'fasilitas', label: 'FASILITAS', icon: FacilityIcon },
{ key: 'rasa', label: 'RASA', icon: RestaurantIcon },
];
const VISIBLE_IMAGES = 3;
function ReviewDetail() {
const { location_id, review_id } = useParams();
const [isLoading, setIsLoading] = useState(true);
const [locationImgs, setLocationImgs] = useState<{ id: number; src: string }[]>([]);
const [locationName, setLocationName] = useState('');
const [locationAddress, setLocationAddress] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [locationLightboxOpen, setLocationLightboxOpen] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const locationId = location_id ?? '1';
const reviewId = review_id ?? '0';
const { data: reviewData, isLoading: reviewLoading } = useLocationReviewById(
Number(locationId),
Number(reviewId)
);
useEffect(() => {
async function fetchData() {
const [imagesRes, locationRes] = await Promise.all([
getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(locationId) }),
getLocationService({ id: Number(locationId) }),
]);
if (imagesRes.data?.images) setLocationImgs(imagesRes.data.images);
if (locationRes.data?.detail) {
setLocationName(String(locationRes.data.detail.name));
setLocationAddress(String(locationRes.data.detail.address));
}
setIsLoading(false);
}
fetchData();
}, [locationId]);
const review = reviewData?.detail ?? reviewData;
const reviewer = {
avatar: review?.user_avatar ?? null,
username: review?.username ?? '',
date: review?.created_at ? new Date(review.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) : '',
isCritic: review?.is_from_critic ?? false,
criticScore: review?.score ?? 0,
};
const reviewTitle = review?.title ?? '';
const reviewBody = (review?.comments ?? '').split('\n').filter((p: string) => p.trim() !== '');
const images: { id: number; src: string }[] = review?.images ?? [];
const scoreBreakdown: ScoreBreakdown = { pelayanan: 10, kebersihan: 10, fasilitas: 10, rasa: 10 };
function openLightbox(i: number) {
setLightboxIndex(i);
setLightboxOpen(true);
}
if (reviewLoading) {
return <div className="content main-content mt-3 flex items-center justify-center py-20"><span className="text-tertiary">Loading...</span></div>;
}
return (
<div className="content main-content mt-3">
{/* Location Header */}
{!isLoading && (locationName || locationAddress) && (
<div className="font-bold mt-5 text-2xl flex flex-row items-start justify-between gap-4 flex-wrap ">
<section className="border-primary ">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-3">
<img
src={reviewer.avatar ?? DEFAULT_AVATAR_IMG}
alt={reviewer.username}
className="w-14 h-14 rounded-full object-cover flex-shrink-0"
loading="lazy"
/>
<div>
<p className="font-bold text-xl leading-tight">{reviewer.username}</p>
<p className="text-sm text-[#d9d9d9]">Review on {reviewer.date}</p>
</div>
</div>
<div className="hidden sm:block w-px h-12 bg-white/20" />
<div className="flex items-center gap-2 bg-secondary rounded-[9.35px] px-4 py-2.5 shadow">
<span className="text-sm tracking-wider text-white">
{reviewer.isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
</span>
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center shadow-[0px_4px_4px_0px_rgba(0,0,0,0.25)]">
<span className="font-medium text-sm text-white">{reviewer.criticScore}</span>
</div>
</div>
</div>
</section>
<div className="text-right">
<Link to={`/location/${locationId}`} className="text-white hover:underline">
<h1>{locationName}</h1>
</Link>
<p className="flex items-center gap-1"><MapPin /> {locationAddress}</p>
</div>
</div>
)}
<div className="flex flex-row items-end gap-6 mt-4 mb-5 flex-wrap">
{reviewTitle && (
<h2 className="font-bold text-2xl shrink-0">{reviewTitle}</h2>
)}
<div className="border border-white/60 rounded-[9.35px] bg-primary/60 overflow-hidden w-fit ml-auto">
<div className="grid grid-cols-4">
{SCORE_CATEGORIES.map(({ key, label, icon: Icon }, index) => (
<div key={key} className="flex items-center px-5 py-2.5 relative">
<div className="flex gap-1 flex-shrink-0 items-center">
<Icon width={20} height={20} />
<span className="ml-1.5 text-[12px] text-[#d9d9d9] leading-tight tracking-wide">{label}</span>
</div>
<span className="font-bold text-xl text-white ml-auto pl-3">{scoreBreakdown[key]}</span>
{index < SCORE_CATEGORIES.length - 1 && (
<span className="absolute right-0 top-[20%] bottom-[20%] w-px bg-white/60" />
)}
</div>
))}
</div>
</div>
</div>
{/* Image Gallery */}
{images.length > 0 && (
<section className="mb-4">
<div className="grid grid-cols-3 gap-1.5 h-[200px] rounded-lg overflow-hidden">
{images.slice(0, VISIBLE_IMAGES).map((img, i) => {
const remaining = images.length - VISIBLE_IMAGES;
return (
<div
key={img.id}
className="relative overflow-hidden cursor-zoom-in group/tile"
onClick={() => openLightbox(i)}
>
<img
src={img.src}
alt={`review-image-${i + 1}`}
className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75"
loading="lazy"
/>
{i === VISIBLE_IMAGES - 1 && remaining > 0 && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center pointer-events-none">
<span className="text-white font-medium text-base tracking-wider">+{remaining} MORE</span>
</div>
)}
</div>
);
})}
</div>
</section>
)}
{/* Review Content */}
<section className="mb-4">
<div className="text-base leading-6 break-words text-white/90 space-y-4">
{reviewBody.length > 0
? reviewBody.map((paragraph: string, i: number) => <p key={i}>{paragraph}</p>)
: review?.comments && <p>{review.comments}</p>
}
</div>
</section>
<section className="mb-8">
<div className="flex items-center gap-4 pb-3 border-b border-primary mb-1">
<button className="flex items-center gap-1.5 text-tertiary hover:text-white transition-colors">
<MessageCircle size={20} />
<span className="text-[#bcbdbe] text-lg">0</span>
</button>
<button className="flex items-center gap-1.5 text-tertiary hover:text-white transition-colors">
<Heart size={20} />
<span className="text-[#bcbdbe] text-lg">{review?.likes ?? 0}</span>
</button>
</div>
</section>
<Lightbox
open={locationLightboxOpen}
close={() => setLocationLightboxOpen(false)}
index={currentIndex}
slides={locationImgs.map((img) => ({ src: img.src }))}
/>
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
index={lightboxIndex}
slides={images.map((img) => ({ src: img.src }))}
/>
</div>
);
}
export default ReviewDetail;

View File

@ -0,0 +1,430 @@
import { Link, useNavigate, useParams } from 'react-router-dom';
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 } from "../../services";
import { DefaultSeparator, SeparatorWithAnchor, ReviewCard, UserLocationDetailRatingsCard } from '../../components';
import { IHttpResponse } from '../../types/common';
import { MapPin } from 'lucide-react'
function UserLocationDetailReview() {
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 [isLoading, setIsLoading] = useState<boolean>(true)
const [currentIndex, setCurrentIndex] = useState(0);
const navigate = useNavigate();
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea);
const { location_id } = useParams()
async function getLocationDetail(): Promise<void> {
try {
const res = await getLocationService(
{
id: Number(location_id),
review: 'user'
}
)
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(location_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(location_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)
}
}
useEffect(() => {
getCurrentUserLocationReview()
}, [])
useEffect(() => {
if (updatePage || location_id) {
getLocationDetail()
}
}, [updatePage, location_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">
<Link to={`/location/${location_id}`} className="inline-block">OVERVIEW</Link>
<a className="ml-4 inline-block text-white font-bold">USER REVIEWS</a>
<Link to={`/location/critic_review/${location_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>
<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="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">
<UserLocationDetailRatingsCard
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>
</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">
<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={() => {}}
/>
// <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 UserLocationDetailReview;

View File

@ -0,0 +1,99 @@
import { NullValueRes } from "../../types/common"
import { SlideImage } from "yet-another-react-lightbox"
export interface ILocationDetail {
id: number,
name: String,
address: String,
regency_name: String,
province_name: String,
region_name: String,
google_maps_link: String,
thumbnail: string | null,
submitted_by: number,
critic_score: number,
critic_count: number,
user_score: number,
user_count: number
}
export function emptyLocationDetail(): ILocationDetail {
return {
id: 0,
address: '',
google_maps_link: '',
thumbnail: "",
name: '',
province_name: '',
regency_name: '',
region_name: '',
submitted_by: 0,
critic_score: 0,
critic_count: 0,
user_score: 0,
user_count: 0,
}
}
export interface LocationReviewsResponse {
id: number,
title?: string,
score: number,
comments: string,
user_id: number,
username: string,
user_avatar: string | null,
created_at: string,
updated_at: string,
images?: Array<{ id: number, src: string }>
}
export interface LocationDetailResponse {
detail: ILocationDetail,
tags: Array<String>
users_review: Array<LocationReviewsResponse>,
critics_review: Array<LocationReviewsResponse>
}
export function EmptyLocationDetailResponse(): LocationDetailResponse {
return {
detail: emptyLocationDetail(),
tags: [],
critics_review: Array<LocationReviewsResponse>(),
users_review: Array<LocationReviewsResponse>()
}
}
export interface LocationImage extends SlideImage {
id: number,
src: string,
created_at: String,
uploaded_by: String
}
export interface LocationResponse {
total_image: number,
images: Array<LocationImage>
}
export function emptyLocationResponse(): LocationResponse {
return {
total_image: 0,
images: Array<LocationImage>()
}
}
export type CurrentUserLocationReviews = {
id: number,
title?: string,
comments: string,
is_from_critic: boolean,
is_hided: boolean,
location_id: number,
score: number,
submitted_by: number,
created_at: NullValueRes<"Time", string>,
updated_at: NullValueRes<"Time", string>,
images?: Array<{ id: number, src: string }>,
}

View File

@ -4,6 +4,9 @@ import Discovery from "./Discovery";
import Story from "./Stories"; import Story from "./Stories";
import NewsEvent from "./NewsEvent"; import NewsEvent from "./NewsEvent";
import LocationDetail from "./LocationDetail"; import LocationDetail from "./LocationDetail";
import UserLocationDetailReview from "./UserLocationDetailReview";
import CriticLocationDetailReview from "./CriticLocationDetailReview";
import ReviewDetail from "./ReviewDetail";
import Login from './Login'; import Login from './Login';
import NotFound from "./NotFound"; import NotFound from "./NotFound";
import AddLocation from "./AddLocation"; import AddLocation from "./AddLocation";
@ -25,6 +28,9 @@ export {
BestLocation, BestLocation,
AddLocation, AddLocation,
LocationDetail, LocationDetail,
UserLocationDetailReview,
CriticLocationDetailReview,
ReviewDetail,
Submissions, Submissions,
Discovery, Discovery,

View File

@ -9,7 +9,10 @@ import {
UserProfile, UserProfile,
UserFeed, UserFeed,
UserSettings, UserSettings,
Submissions Submissions,
UserLocationDetailReview,
CriticLocationDetailReview,
ReviewDetail,
} from '../pages'; } from '../pages';
interface BaseRoutes { interface BaseRoutes {
@ -55,6 +58,21 @@ export const getRoutes = (): IRoutes => {
name: "LocationDetail", name: "LocationDetail",
element: <LocationDetail /> element: <LocationDetail />
}, },
{
path: "/location/user_review/:location_id",
name: "UserLocationDetailReview",
element: <UserLocationDetailReview />
},
{
path: "/location/critic_review/:location_id",
name: "CriticLocationDetailReview",
element: <CriticLocationDetailReview />
},
{
path: "/location/:location_id/review/:review_id",
name: "ReviewDetail",
element: <ReviewDetail />
},
// PROTECTED USER ROUTES // PROTECTED USER ROUTES
{ {
path: "/add-location", path: "/add-location",

View File

@ -40,8 +40,20 @@ const fetchTopLocations = async ({ page, page_size, order_by, region_type }: Get
return response.data return response.data
} }
const fetchLocation = async (id: number) => { interface GetLocationArg {
const url = `${GET_LOCATION_URI}/${id}` id: number
review?: 'user' | 'critics'
page?: number
page_size?: number
}
const fetchLocation = async ({ id, review, page, page_size }: GetLocationArg) => {
const params = new URLSearchParams()
if (review) params.set('review', review)
if (page) params.set('page', String(page))
if (page_size) params.set('page_size', String(page_size))
const query = params.toString()
const url = `${GET_LOCATION_URI}/${id}${query ? `?${query}` : ''}`
const response = await client({ method: 'GET', url }) const response = await client({ method: 'GET', url })
return response.data return response.data
} }
@ -93,11 +105,11 @@ export const useTopLocations = (params: GetListLocationsArg, options?: Omit<UseQ
}) })
} }
export const useLocation = (id: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => { export const useLocation = (params: GetLocationArg, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({ return useQuery({
queryKey: ['location', id], queryKey: ['location', params],
queryFn: () => fetchLocation(id), queryFn: () => fetchLocation(params),
enabled: !!id, enabled: !!params.id,
...options ...options
}) })
} }
@ -155,9 +167,9 @@ async function getListTopLocationsService(params: GetListLocationsArg) {
} }
} }
async function getLocationService(id: number) { async function getLocationService(params: GetLocationArg) {
try { try {
const data = await fetchLocation(id) const data = await fetchLocation(params)
return { data, error: null } return { data, error: null }
} catch (error) { } catch (error) {
throw error throw error

View File

@ -1,6 +1,6 @@
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query"; import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
import { client } from "./config"; import { client } from "./config";
import { GET_CURRENT_USER_REVIEW_LOCATION_URI, POST_REVIEW_IMAGES_URI, POST_REVIEW_LOCATION_URI } from "../constants/api"; import { GET_CURRENT_USER_REVIEW_LOCATION_URI, GET_LOCATION_URI, POST_REVIEW_IMAGES_URI, POST_REVIEW_LOCATION_URI } from "../constants/api";
import { IHttpResponse } from "src/types/common"; import { IHttpResponse } from "src/types/common";
interface postReviewLocationReq { interface postReviewLocationReq {
@ -28,6 +28,15 @@ const fetchCurrentUserLocationReview = async (location_id: number) => {
return response.data return response.data
} }
const getLocationReviewById = async (location_id: number, review_id: number) => {
const response = await client({
method: 'GET',
url: `${GET_LOCATION_URI}/${location_id}/review/${review_id}`,
withCredentials: true
})
return response.data
}
// React Query Hooks // React Query Hooks
export const usePostReview = (options?: UseMutationOptions<any, Error, postReviewLocationReq>) => { export const usePostReview = (options?: UseMutationOptions<any, Error, postReviewLocationReq>) => {
return useMutation({ return useMutation({
@ -45,6 +54,15 @@ export const useCurrentUserLocationReview = (location_id: number, options?: Omit
}) })
} }
export const useLocationReviewById = (location_id: number, review_id: number, options?: Omit<UseQueryOptions<any, Error>, 'queryKey' | 'queryFn'>) => {
return useQuery({
queryKey: ['location', location_id, 'review', review_id],
queryFn: () => getLocationReviewById(location_id, review_id),
enabled: !!location_id && !!review_id,
...options
})
}
// Legacy service functions for backward compatibility // Legacy service functions for backward compatibility
async function postReviewLocation(req: postReviewLocationReq) { async function postReviewLocation(req: postReviewLocationReq) {
try { try {
@ -76,4 +94,5 @@ export {
postReviewLocation, postReviewLocation,
postReviewImages, postReviewImages,
getCurrentUserLocationReviewService, getCurrentUserLocationReviewService,
getLocationReviewById
} }