diff --git a/src/components/Card/ReviewCard/index.tsx b/src/components/Card/ReviewCard/index.tsx
new file mode 100644
index 0000000..624bc1b
--- /dev/null
+++ b/src/components/Card/ReviewCard/index.tsx
@@ -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 (
+
+
+ {/* Header: avatar + username + date + score */}
+
+

+
+ {username}
+ {date && (
+ Written {date}
+ )}
+
+
|
+
+
+ {isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
+
+
+ {score}
+
+
+
+
+ {/* Title */}
+ {title && (
+
{title}
+ )}
+
+ {/* Images — horizontal scrollable row */}
+ {visibleImages.length > 0 && (
+
+ {visibleImages.map((img, i) => (
+
openLightbox(i)}
+ >
+

+ {i === MAX_IMAGES - 1 && remaining > 0 && (
+
+ +{remaining}
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Comment */}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+
+ {onShowFullReview && (
+
+ )}
+
+
+ {/* Lightbox */}
+
setLightboxOpen(false)}
+ index={lightboxIndex}
+ slides={images.map(img => ({ src: img.src }))}
+ />
+
+ )
+}
diff --git a/src/components/Card/ReviewCardFull/index.tsx b/src/components/Card/ReviewCardFull/index.tsx
new file mode 100644
index 0000000..6983ed7
--- /dev/null
+++ b/src/components/Card/ReviewCardFull/index.tsx
@@ -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 (
+
+
+ {/* Header */}
+
+
+

+
+
{username}
+ {date &&
Written {date}
}
+
+
+
+
+
+ {isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
+
+
+ {score}
+
+
+
+
+ {/* Image Grid */}
+ {visibleImages.length > 0 && (
+
+ {visibleImages.map((img, i) => (
+
openLightbox(i)}
+ >
+

+ {i === VISIBLE_IMAGES - 1 && remaining > 0 && (
+
+ +{remaining} MORE
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Review Body */}
+
+ {paragraphs.map((p, i) => (
+
{p}
+ ))}
+
+
+ {/* Footer */}
+
+
+
+
+
+ {onShowFullReview && (
+
+ )}
+
+
+
setLightboxOpen(false)}
+ index={lightboxIndex}
+ slides={images.map((img) => ({ src: img.src }))}
+ />
+
+ )
+}
diff --git a/src/components/Card/UserLocationDetailRatingsCard/index.tsx b/src/components/Card/UserLocationDetailRatingsCard/index.tsx
new file mode 100644
index 0000000..fd9b074
--- /dev/null
+++ b/src/components/Card/UserLocationDetailRatingsCard/index.tsx
@@ -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 {
+ data: T;
+ getCriticData: (data: T) => RatingData;
+ getUserData: (data: T) => RatingData;
+ getCriticDetails?: (data: T) => DetailRatings;
+ getUserDetails?: (data: T) => DetailRatings;
+}
+
+const UserLocationDetailRatingsCard = ({
+ data,
+ getUserData,
+ getUserDetails
+}: UserLocationDetailRatingsCardProps) => {
+ 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: },
+ { label: 'Pelayanan', value: userDetails.price, icon: },
+ { label: 'Kebersihan', value: userDetails.cleanliness, icon: },
+ { label: 'Fasilitas', value: userDetails.facility, icon: },
+ ] : [];
+
+ return (
+
+
+ {/* USER SCORE card */}
+
+
+
USER SCORE
+ {userData.count !== 0 && (
+
Based on {formatCount(userData.count)} reviews
+ )}
+
+
+ {calculateScore(userData.score, userData.count)}
+
+
+
+ {/* Detail rating cards — all in one row */}
+ {detailItems.map((item) => (
+
+
{item.icon}
+
+
{item.label}
+
{item.value}
+
Based on {formatCount(userData.count)} reviews
+
+
+ ))}
+
+
+ );
+};
+
+export default UserLocationDetailRatingsCard;
diff --git a/src/components/Icons/AccessIcon/index.tsx b/src/components/Icons/AccessIcon/index.tsx
new file mode 100644
index 0000000..76f79d6
--- /dev/null
+++ b/src/components/Icons/AccessIcon/index.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/Img/FallbackImage.tsx b/src/components/Img/FallbackImage.tsx
new file mode 100644
index 0000000..268f8ff
--- /dev/null
+++ b/src/components/Img/FallbackImage.tsx
@@ -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) => (
+
{
+ if (locationType === 'restaurant') {
+ e.currentTarget.src = restaurantThumbnailSrc;
+ } else {
+ e.currentTarget.src = fallbackThumbnailSrc;
+ }
+ e.currentTarget.onerror = null;
+ }}
+ />
+)
+
+export default FallbackImage
\ No newline at end of file
diff --git a/src/components/index.ts b/src/components/index.ts
index 15d4fa5..3979ea1 100755
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -18,6 +18,9 @@ import SpinnerLoading from "./Loading/Spinner";
import FilterButton from "./Button/FilterButton";
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";
@@ -41,6 +44,9 @@ export {
SpinnerLoading,
LocationCard,
+ ReviewCard,
+ UserLocationDetailRatingsCard,
+ ReviewCardFull,
FilterButton,
}
\ No newline at end of file
diff --git a/src/constants/api.ts b/src/constants/api.ts
index dbf0e11..e713591 100755
--- a/src/constants/api.ts
+++ b/src/constants/api.ts
@@ -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_LOCATION_URI = `${BASE_URL}/location`;
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`;
diff --git a/src/index.css b/src/index.css
index 9663783..f6d3e7c 100755
--- a/src/index.css
+++ b/src/index.css
@@ -9,7 +9,7 @@
/* color-scheme: light dark; */
color: rgba(255, 255, 255, 0.87);
- background-color: #202225;
+ background-color: #1D1F21;
font-synthesis: none;
text-rendering: optimizeLegibility;
diff --git a/src/pages/AddLocation/AmenitiesModal.tsx b/src/pages/AddLocation/AmenitiesModal.tsx
new file mode 100644
index 0000000..588e412
--- /dev/null
+++ b/src/pages/AddLocation/AmenitiesModal.tsx
@@ -0,0 +1,188 @@
+import { useState } from "preact/compat";
+import { LocationType } from "../../types/common";
+
+const AMENITIES_CONFIG: Record> = {
+ [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>;
+ onSave: (amenities: Array>) => void;
+ onClose: () => void;
+}
+
+export default function AmenitiesModal({ locationType, selected, onSave, onClose }: AmenitiesModalProps) {
+ const config = AMENITIES_CONFIG[locationType] ?? {};
+
+ const [sections, setSections] = useState>(() => {
+ const init: Record = {};
+ 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 (
+
+
e.stopPropagation()}>
+
+ Configure Amenities
+
+
+
+
+ {Object.entries(config).map(([category, items]) => {
+ const sec = sections[category];
+ return (
+
+ );
+ })}
+
+
+
+
{totalSelected} selected
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/AddLocation/index.tsx b/src/pages/AddLocation/index.tsx
index 3196363..c7b126c 100755
--- a/src/pages/AddLocation/index.tsx
+++ b/src/pages/AddLocation/index.tsx
@@ -11,6 +11,8 @@ import { createLocationService } from "../../services/locations";
import { useSelector } from "react-redux";
import { UserRootState } from "../../store/type";
import DefaultLoadingAnimation from "../../components/LoadingAnimation/Default";
+import FallbackImage from "../../../src/components/Img/FallbackImage";
+import AmenitiesModal from "./AmenitiesModal";
function AddLocation() {
const [recentLocations, setRecentLocations] = useState>()
@@ -24,7 +26,9 @@ function AddLocation() {
location_type: LocationType.Recreation,
regency: emptyRegency(),
thumbnails: [],
+ amenities: [],
})
+ const [showAmenitiesModal, setShowAmenitiesModal] = useState(false)
const [submitLoading, setSubmitLoading ] = useState(false)
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) {
e.preventDefault();
- setSubmitLoading(true)
-
if(form.regency.regency_name === '') {
setPageState({ regency_form_error: true })
return
}
+ setSubmitLoading(true)
+
let tempThumbnailArr: Array = [];
let formData = new FormData();
@@ -83,6 +92,9 @@ function AddLocation() {
formData.append("regency_id", form.regency.id.toString());
formData.append("submitted_by", user.id.toString());
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++) {
formData.append("thumbnail", tempThumbnailArr[i])
@@ -169,6 +181,7 @@ function AddLocation() {
}, [])
return (
+ <>
@@ -180,12 +193,30 @@ function AddLocation() {
Address *
-
+ {showAmenitiesModal && (
+ { setForm({ ...form, amenities }); setShowAmenitiesModal(false); }}
+ onClose={() => setShowAmenitiesModal(false)}
+ />
+ )}
+ >
)
}
diff --git a/src/pages/AddLocation/style.css b/src/pages/AddLocation/style.css
index a0fed08..60941d9 100755
--- a/src/pages/AddLocation/style.css
+++ b/src/pages/AddLocation/style.css
@@ -30,4 +30,192 @@
.inputfile + label {
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;
}
\ No newline at end of file
diff --git a/src/pages/AddLocation/types.ts b/src/pages/AddLocation/types.ts
index 91f787c..ca4e797 100755
--- a/src/pages/AddLocation/types.ts
+++ b/src/pages/AddLocation/types.ts
@@ -12,5 +12,6 @@ export interface Form {
regency: Regency,
location_type: LocationType,
google_maps_link: string,
- thumbnails: Array
+ thumbnails: Array,
+ amenities: Array>
}
diff --git a/src/pages/BestLocations/index.tsx b/src/pages/BestLocations/index.tsx
index c150d5e..17bfcae 100755
--- a/src/pages/BestLocations/index.tsx
+++ b/src/pages/BestLocations/index.tsx
@@ -214,7 +214,7 @@ const [page, _setPage] = useState(1);
))}
- {/*
+
{REVIEWERS_TYPE.map((x, idx) => (
(1);
))}
-
*/}
+
diff --git a/src/pages/CriticLocationDetailReview/index.tsx b/src/pages/CriticLocationDetailReview/index.tsx
new file mode 100644
index 0000000..d8162e9
--- /dev/null
+++ b/src/pages/CriticLocationDetailReview/index.tsx
@@ -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(EmptyLocationDetailResponse)
+ const [locationImages, setLocationImages] = useState(emptyLocationResponse())
+ const [currentUserReview, setCurrentUserReview] = useState()
+ const [lightboxOpen, setLightboxOpen] = useState(false)
+ const [updatePage, setUpdatePage] = useState(true)
+ const [pageState, setPageState] = useState({
+ critic_filter_name: 'highest rated',
+ critic_filter_type: 0,
+ show_sort: false,
+ enable_post: true,
+ on_submit_loading: false,
+ is_score_rating_panic_msg: '',
+ })
+ const [reviewValue, setReviewValue] = useState({
+ review_textArea: '',
+ score_input: '',
+ title: '',
+ rasa: '',
+ suasana: '',
+ pelayanan: '',
+ kebersihan: '',
+ })
+ const [uploadedImages, setUploadedImages] = useState<{ file: File; preview: string }[]>([])
+ const [uploadLightboxOpen, setUploadLightboxOpen] = useState(false)
+ const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0)
+ const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false)
+ const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxOpen, setListReviewLightboxOpen] = useState(false)
+ const [listReviewLightboxIndex, setListReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxImages, setListReviewLightboxImages] = useState<{ src: string }[]>([])
+
+
+
+ const [isLoading, setIsLoading] = useState(true)
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const navigate = useNavigate();
+
+
+ const textAreaRef = useRef(null);
+ useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea);
+
+ const { location_id } = useParams()
+
+ async function getLocationDetail(): Promise {
+ 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 {
+ 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 {
+ 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 (
+
+
+
+
+
+
+
{locationDetail?.detail.name}
+
{locationDetail?.detail.address}
+
+ {/* {isLoading ?
+
+ :
+
+ } */}
+
+
+ {!isLoading && (() => {
+ const imgs = locationImages?.images ?? [];
+ const total = imgs.length;
+ if (total === 0) return null;
+ const openAt = (i: number) => { setCurrentIndex(i); setLightboxOpen(true); };
+
+ const ImageTile = ({ img, index, className }: { img: typeof imgs[0], index: number, className: string }) => (
+ openAt(index)}
+ >
+

+
+
+ Photo {index + 1}
+
+
+
+ );
+
+ return (
+
+ {total === 1 && (
+
+
+
+ )}
+
+ {total === 2 && (
+
+
+
+
+ )}
+
+ {total === 3 && (
+
+
+
+
+
+ )}
+
+ {total >= 4 && (
+
+ {/* Top-left large */}
+
+ {/* Right full-height */}
+
openAt(1)}
+ >
+

+
+ Photo 2
+ {total > 4 && (
+
+ 🖼 {total.toLocaleString()}
+
+ )}
+
+
+ {/* Bottom-left small */}
+
openAt(2)}
+ >
+

+
+ Photo 3
+
+
+ {/* Bottom-middle small — with "+more" if applicable */}
+
openAt(3)}
+ >
+

+ {total > 4 && (
+
+ +{total - 4}
+
+ )}
+ {total <= 4 && (
+
+ Photo 4
+
+ )}
+
+
+ )}
+
+ );
+ })()}
+
+
+
+ ({
+ 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
+ })}
+ />
+
+
+
+
+
+
+
+
+
+
0 ? '#' : ''} />
+ {locationDetail.critics_review.length > 0 ?
+ <>
+ {locationDetail.critics_review.map(x => (
+ {}}
+ />
+ //
+ // {/* Header: avatar + username + score */}
+ //
+ //

+ //
{x.username}
+ //
|
+ //
+ // USER SCORE
+ // {x.score}
+ //
+ //
+
+ // {x.title && (
+ //
{x.title}
+ // )}
+
+ // {x.images && x.images.length > 0 && (
+ //
+ // {x.images.slice(0, 20).map((img, i) => (
+ //
{
+ // setListReviewLightboxImages(x.images!.map(im => ({ src: im.src })))
+ // setListReviewLightboxIndex(i)
+ // setListReviewLightboxOpen(true)
+ // }}
+ // >
+ //

+ // {i === 19 && x.images!.length > 20 && (
+ //
+ // +{x.images!.length - 20} MORE
+ //
+ // )}
+ //
+ // ))}
+ //
+ // )}
+
+ // {/* Comment */}
+ //
+ //
+ //
+ //
+ ))}
+
+
+
+ {/* */}
+ >
+ :
+ <>
+ No users review to display
+ >
+ }
+
+
+
+ CONTRUBITION
+
+ anoeantoeh aoenthaoe aoenth aot
+
+
+
+
+
+
+
+
+ Added on: 28 May 1988
+
+
+
setLightboxOpen(false)}
+ slides={locationImages?.images}
+ />
+ setUploadLightboxOpen(false)}
+ index={uploadLightboxIndex}
+ slides={uploadedImages.map(img => ({ src: img.preview }))}
+ />
+ setReviewLightboxOpen(false)}
+ index={reviewLightboxIndex}
+ slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []}
+ />
+ setListReviewLightboxOpen(false)}
+ index={listReviewLightboxIndex}
+ slides={listReviewLightboxImages}
+ />
+
+ )
+}
+
+export default CriticLocationDeailReview;
\ No newline at end of file
diff --git a/src/pages/CriticLocationDetailReview/types.ts b/src/pages/CriticLocationDetailReview/types.ts
new file mode 100644
index 0000000..af4dffc
--- /dev/null
+++ b/src/pages/CriticLocationDetailReview/types.ts
@@ -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
+ users_review: Array,
+ critics_review: Array
+}
+
+
+export function EmptyLocationDetailResponse(): LocationDetailResponse {
+ return {
+ detail: emptyLocationDetail(),
+ tags: [],
+ critics_review: Array(),
+ users_review: Array()
+ }
+}
+
+export interface LocationImage extends SlideImage {
+ id: number,
+ src: string,
+ created_at: String,
+ uploaded_by: String
+}
+
+export interface LocationResponse {
+ total_image: number,
+ images: Array
+}
+
+export function emptyLocationResponse(): LocationResponse {
+ return {
+ total_image: 0,
+ images: Array()
+ }
+}
+
+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 }>,
+}
\ No newline at end of file
diff --git a/src/pages/LocationDetail/index.tsx b/src/pages/LocationDetail/index.tsx
index 3bec69d..cdd076b 100755
--- a/src/pages/LocationDetail/index.tsx
+++ b/src/pages/LocationDetail/index.tsx
@@ -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 { useEffect, useRef, useState } from 'preact/hooks';
import Lightbox from 'yet-another-react-lightbox';
@@ -12,7 +12,7 @@ import {
} from './types';
import { handleApiError, useAutosizeTextArea } from '../../utils';
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 { useSelector } from 'react-redux';
import { UserRootState } from '../../store/type';
@@ -60,6 +60,9 @@ function LocationDetail() {
const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0)
const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false)
const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxOpen, setListReviewLightboxOpen] = useState(false)
+ const [listReviewLightboxIndex, setListReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxImages, setListReviewLightboxImages] = useState<{ src: string }[]>([])
const imageInputRef = useRef(null)
function handleImageSelect(e: ChangeEvent) {
@@ -99,7 +102,11 @@ function LocationDetail() {
async function getLocationDetail(): Promise {
try {
- const res = await getLocationService(Number(id))
+ const res = await getLocationService(
+ {
+ id: Number(id)
+ }
+ )
setLocationDetail(res.data, (val) => {
if (val.detail.thumbnail) {
getImage(val.detail.thumbnail)
@@ -252,47 +259,17 @@ function LocationDetail() {
-
-
-
{locationDetail?.detail.name}
-
{locationDetail?.detail.address}
-
- {/* {isLoading ?
-
- :
-
- } */}
+
+
{locationDetail?.detail.name}
+
{locationDetail?.detail.address}
{!isLoading && (() => {
@@ -571,7 +548,10 @@ function LocationDetail() {
5
-
@@ -742,73 +722,28 @@ function LocationDetail() {
)}
}
+
{locationDetail.critics_review.length > 0 ?
<>
-
-
-
- {locationDetail.critics_review.map(x => (
-
- ))}
+ {locationDetail.critics_review.map(x => {
+ return (
+
navigate(`/location/${id}/review/${x.id}`)}
+ />
+ )
+ })}
>
-
:
No Critics review to display
}
@@ -819,54 +754,74 @@ function LocationDetail() {
{locationDetail.users_review.length > 0 ?
<>
{locationDetail.users_review.map(x => (
-
- {/* Header: avatar + username + score */}
-
-

-
{x.username}
-
|
-
- USER SCORE
- {x.score}
-
-
+
navigate(`/location/${id}/review/${x.id}`)}
+ />
+ //
+ // {/* Header: avatar + username + score */}
+ //
+ //

+ //
{x.username}
+ //
|
+ //
+ // USER SCORE
+ // {x.score}
+ //
+ //
- {/* Title */}
- {x.title && (
-
{x.title}
- )}
+ // {x.title && (
+ //
{x.title}
+ // )}
- {/* Images */}
- {x.images && x.images.length > 0 && (
-
- {x.images.slice(0, 4).map((img, i) => (
-
-

- {i === 3 && x.images!.length > 4 && (
-
- +{x.images!.length - 4} MORE
-
- )}
-
- ))}
-
- )}
+ // {x.images && x.images.length > 0 && (
+ //
+ // {x.images.slice(0, 20).map((img, i) => (
+ //
{
+ // setListReviewLightboxImages(x.images!.map(im => ({ src: im.src })))
+ // setListReviewLightboxIndex(i)
+ // setListReviewLightboxOpen(true)
+ // }}
+ // >
+ //

+ // {i === 19 && x.images!.length > 20 && (
+ //
+ // +{x.images!.length - 20} MORE
+ //
+ // )}
+ //
+ // ))}
+ //
+ // )}
- {/* Comment */}
-
-
-
-
+ // {/* Comment */}
+ //
+ //
+ //
+ //
))}
+
+
)
}
diff --git a/src/pages/ReviewDetail/index.tsx b/src/pages/ReviewDetail/index.tsx
new file mode 100644
index 0000000..6de24d1
--- /dev/null
+++ b/src/pages/ReviewDetail/index.tsx
@@ -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 }[] = [
+ { 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 Loading...
;
+ }
+
+ return (
+
+
+
+ {/* Location Header */}
+ {!isLoading && (locationName || locationAddress) && (
+
+
+
+
+

+
+
{reviewer.username}
+
Review on {reviewer.date}
+
+
+
+
+
+
+
+ {reviewer.isCritic ? 'CRITIC SCORE' : 'USER SCORE'}
+
+
+ {reviewer.criticScore}
+
+
+
+
+
+
+
{locationName}
+
+
{locationAddress}
+
+
+ )}
+
+
+
+ {reviewTitle && (
+
{reviewTitle}
+ )}
+
+
+ {SCORE_CATEGORIES.map(({ key, label, icon: Icon }, index) => (
+
+
+
+ {label}
+
+
{scoreBreakdown[key]}
+ {index < SCORE_CATEGORIES.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Image Gallery */}
+ {images.length > 0 && (
+
+
+ {images.slice(0, VISIBLE_IMAGES).map((img, i) => {
+ const remaining = images.length - VISIBLE_IMAGES;
+ return (
+
openLightbox(i)}
+ >
+

+ {i === VISIBLE_IMAGES - 1 && remaining > 0 && (
+
+ +{remaining} MORE
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Review Content */}
+
+
+ {reviewBody.length > 0
+ ? reviewBody.map((paragraph: string, i: number) =>
{paragraph}
)
+ : review?.comments &&
{review.comments}
+ }
+
+
+
+
+
+
+
+ 0
+
+
+
+ {review?.likes ?? 0}
+
+
+
+
+
setLocationLightboxOpen(false)}
+ index={currentIndex}
+ slides={locationImgs.map((img) => ({ src: img.src }))}
+ />
+ setLightboxOpen(false)}
+ index={lightboxIndex}
+ slides={images.map((img) => ({ src: img.src }))}
+ />
+
+ );
+}
+
+export default ReviewDetail;
diff --git a/src/pages/UserLocationDetailReview/index.tsx b/src/pages/UserLocationDetailReview/index.tsx
new file mode 100644
index 0000000..3bb9475
--- /dev/null
+++ b/src/pages/UserLocationDetailReview/index.tsx
@@ -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(EmptyLocationDetailResponse)
+ const [locationImages, setLocationImages] = useState(emptyLocationResponse())
+ const [currentUserReview, setCurrentUserReview] = useState()
+ const [lightboxOpen, setLightboxOpen] = useState(false)
+ const [updatePage, setUpdatePage] = useState(true)
+ const [pageState, setPageState] = useState({
+ critic_filter_name: 'highest rated',
+ critic_filter_type: 0,
+ show_sort: false,
+ enable_post: true,
+ on_submit_loading: false,
+ is_score_rating_panic_msg: '',
+ })
+ const [reviewValue, setReviewValue] = useState({
+ review_textArea: '',
+ score_input: '',
+ title: '',
+ rasa: '',
+ suasana: '',
+ pelayanan: '',
+ kebersihan: '',
+ })
+ const [uploadedImages, setUploadedImages] = useState<{ file: File; preview: string }[]>([])
+ const [uploadLightboxOpen, setUploadLightboxOpen] = useState(false)
+ const [uploadLightboxIndex, setUploadLightboxIndex] = useState(0)
+ const [reviewLightboxOpen, setReviewLightboxOpen] = useState(false)
+ const [reviewLightboxIndex, setReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxOpen, setListReviewLightboxOpen] = useState(false)
+ const [listReviewLightboxIndex, setListReviewLightboxIndex] = useState(0)
+ const [listReviewLightboxImages, setListReviewLightboxImages] = useState<{ src: string }[]>([])
+
+
+
+ const [isLoading, setIsLoading] = useState(true)
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const navigate = useNavigate();
+
+
+ const textAreaRef = useRef(null);
+ useAutosizeTextArea(textAreaRef.current, reviewValue.review_textArea);
+
+ const { location_id } = useParams()
+
+ async function getLocationDetail(): Promise {
+ 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 {
+ 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 {
+ 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 (
+
+
+
+
+
+
+
{locationDetail?.detail.name}
+
{locationDetail?.detail.address}
+
+ {/* {isLoading ?
+
+ :
+
+ } */}
+
+
+ {!isLoading && (() => {
+ const imgs = locationImages?.images ?? [];
+ const total = imgs.length;
+ if (total === 0) return null;
+ const openAt = (i: number) => { setCurrentIndex(i); setLightboxOpen(true); };
+
+ const ImageTile = ({ img, index, className }: { img: typeof imgs[0], index: number, className: string }) => (
+ openAt(index)}
+ >
+

+
+
+ Photo {index + 1}
+
+
+
+ );
+
+ return (
+
+ {total === 1 && (
+
+
+
+ )}
+
+ {total === 2 && (
+
+
+
+
+ )}
+
+ {total === 3 && (
+
+
+
+
+
+ )}
+
+ {total >= 4 && (
+
+ {/* Top-left large */}
+
+ {/* Right full-height */}
+
openAt(1)}
+ >
+

+
+ Photo 2
+ {total > 4 && (
+
+ 🖼 {total.toLocaleString()}
+
+ )}
+
+
+ {/* Bottom-left small */}
+
openAt(2)}
+ >
+

+
+ Photo 3
+
+
+ {/* Bottom-middle small — with "+more" if applicable */}
+
openAt(3)}
+ >
+

+ {total > 4 && (
+
+ +{total - 4}
+
+ )}
+ {total <= 4 && (
+
+ Photo 4
+
+ )}
+
+
+ )}
+
+ );
+ })()}
+
+
+
+ ({
+ 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
+ })}
+ />
+
+
+
+
+
+
+
+
+
+
0 ? '#' : ''} />
+ {locationDetail.users_review.length > 0 ?
+ <>
+ {locationDetail.users_review.map(x => (
+ {}}
+ />
+ //
+ // {/* Header: avatar + username + score */}
+ //
+ //

+ //
{x.username}
+ //
|
+ //
+ // USER SCORE
+ // {x.score}
+ //
+ //
+
+ // {x.title && (
+ //
{x.title}
+ // )}
+
+ // {x.images && x.images.length > 0 && (
+ //
+ // {x.images.slice(0, 20).map((img, i) => (
+ //
{
+ // setListReviewLightboxImages(x.images!.map(im => ({ src: im.src })))
+ // setListReviewLightboxIndex(i)
+ // setListReviewLightboxOpen(true)
+ // }}
+ // >
+ //

+ // {i === 19 && x.images!.length > 20 && (
+ //
+ // +{x.images!.length - 20} MORE
+ //
+ // )}
+ //
+ // ))}
+ //
+ // )}
+
+ // {/* Comment */}
+ //
+ //
+ //
+ //
+ ))}
+
+
+
+ {/* */}
+ >
+ :
+ <>
+ No users review to display
+ >
+ }
+
+
+
+ CONTRUBITION
+
+ anoeantoeh aoenthaoe aoenth aot
+
+
+
+
+
+
+
+
+ Added on: 28 May 1988
+
+
+
setLightboxOpen(false)}
+ slides={locationImages?.images}
+ />
+ setUploadLightboxOpen(false)}
+ index={uploadLightboxIndex}
+ slides={uploadedImages.map(img => ({ src: img.preview }))}
+ />
+ setReviewLightboxOpen(false)}
+ index={reviewLightboxIndex}
+ slides={currentUserReview?.images?.map(img => ({ src: img.src })) ?? []}
+ />
+ setListReviewLightboxOpen(false)}
+ index={listReviewLightboxIndex}
+ slides={listReviewLightboxImages}
+ />
+
+ )
+}
+
+export default UserLocationDetailReview;
\ No newline at end of file
diff --git a/src/pages/UserLocationDetailReview/types.ts b/src/pages/UserLocationDetailReview/types.ts
new file mode 100644
index 0000000..af4dffc
--- /dev/null
+++ b/src/pages/UserLocationDetailReview/types.ts
@@ -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
+ users_review: Array,
+ critics_review: Array
+}
+
+
+export function EmptyLocationDetailResponse(): LocationDetailResponse {
+ return {
+ detail: emptyLocationDetail(),
+ tags: [],
+ critics_review: Array(),
+ users_review: Array()
+ }
+}
+
+export interface LocationImage extends SlideImage {
+ id: number,
+ src: string,
+ created_at: String,
+ uploaded_by: String
+}
+
+export interface LocationResponse {
+ total_image: number,
+ images: Array
+}
+
+export function emptyLocationResponse(): LocationResponse {
+ return {
+ total_image: 0,
+ images: Array()
+ }
+}
+
+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 }>,
+}
\ No newline at end of file
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 4b4ed1f..b101c51 100755
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -4,6 +4,9 @@ import Discovery from "./Discovery";
import Story from "./Stories";
import NewsEvent from "./NewsEvent";
import LocationDetail from "./LocationDetail";
+import UserLocationDetailReview from "./UserLocationDetailReview";
+import CriticLocationDetailReview from "./CriticLocationDetailReview";
+import ReviewDetail from "./ReviewDetail";
import Login from './Login';
import NotFound from "./NotFound";
import AddLocation from "./AddLocation";
@@ -25,6 +28,9 @@ export {
BestLocation,
AddLocation,
LocationDetail,
+ UserLocationDetailReview,
+ CriticLocationDetailReview,
+ ReviewDetail,
Submissions,
Discovery,
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 10904ed..59bfb98 100755
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -9,7 +9,10 @@ import {
UserProfile,
UserFeed,
UserSettings,
- Submissions
+ Submissions,
+ UserLocationDetailReview,
+ CriticLocationDetailReview,
+ ReviewDetail,
} from '../pages';
interface BaseRoutes {
@@ -55,6 +58,21 @@ export const getRoutes = (): IRoutes => {
name: "LocationDetail",
element:
},
+ {
+ path: "/location/user_review/:location_id",
+ name: "UserLocationDetailReview",
+ element:
+ },
+ {
+ path: "/location/critic_review/:location_id",
+ name: "CriticLocationDetailReview",
+ element:
+ },
+ {
+ path: "/location/:location_id/review/:review_id",
+ name: "ReviewDetail",
+ element:
+ },
// PROTECTED USER ROUTES
{
path: "/add-location",
diff --git a/src/services/locations.ts b/src/services/locations.ts
index b898996..2ad3cbf 100755
--- a/src/services/locations.ts
+++ b/src/services/locations.ts
@@ -40,8 +40,20 @@ const fetchTopLocations = async ({ page, page_size, order_by, region_type }: Get
return response.data
}
-const fetchLocation = async (id: number) => {
- const url = `${GET_LOCATION_URI}/${id}`
+interface GetLocationArg {
+ 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 })
return response.data
}
@@ -93,11 +105,11 @@ export const useTopLocations = (params: GetListLocationsArg, options?: Omit, 'queryKey' | 'queryFn'>) => {
+export const useLocation = (params: GetLocationArg, options?: Omit, 'queryKey' | 'queryFn'>) => {
return useQuery({
- queryKey: ['location', id],
- queryFn: () => fetchLocation(id),
- enabled: !!id,
+ queryKey: ['location', params],
+ queryFn: () => fetchLocation(params),
+ enabled: !!params.id,
...options
})
}
@@ -155,9 +167,9 @@ async function getListTopLocationsService(params: GetListLocationsArg) {
}
}
-async function getLocationService(id: number) {
+async function getLocationService(params: GetLocationArg) {
try {
- const data = await fetchLocation(id)
+ const data = await fetchLocation(params)
return { data, error: null }
} catch (error) {
throw error
diff --git a/src/services/review.ts b/src/services/review.ts
index 40e61d6..407d2ac 100755
--- a/src/services/review.ts
+++ b/src/services/review.ts
@@ -1,6 +1,6 @@
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from "@tanstack/react-query";
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";
interface postReviewLocationReq {
@@ -28,6 +28,15 @@ const fetchCurrentUserLocationReview = async (location_id: number) => {
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
export const usePostReview = (options?: UseMutationOptions) => {
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, '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
async function postReviewLocation(req: postReviewLocationReq) {
try {
@@ -76,4 +94,5 @@ export {
postReviewLocation,
postReviewImages,
getCurrentUserLocationReviewService,
+ getLocationReviewById
}
\ No newline at end of file