Compare commits

..

2 Commits

Author SHA1 Message Date
goro
f1bf0ca0b5 fix business hours 2026-06-27 18:42:17 +03:00
goro
2bc0a29a0a fix grades 2026-06-16 19:39:12 +03:00
10 changed files with 205 additions and 78 deletions

View File

@ -2,18 +2,20 @@ import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { RestaurantIcon } from '../../Icons/RestaurantIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
import { ComfortIcon } from '../../Icons/ComfortIcon';
import { LocationType } from '../../../types/common';
interface RatingData {
score: number;
count: number;
}
interface DetailRatings {
environment: number;
cleanliness: number;
price: number;
facility: number;
}
type DetailRatings = [
{ label: string; value: number | null },
{ label: string; value: number | null },
{ label: string; value: number | null },
{ label: string; value: number | null },
];
interface RatingsCardProps<T> {
data: T;
@ -65,16 +67,17 @@ const RatingsCard = <T,>({
{criticDetails && (
<div className="grid grid-cols-2 gap-2">
{([
{ label: 'Taste', value: criticDetails.environment, icon: <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Cleanliness', value: criticDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Service', value: criticDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Facilities', value: criticDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
] as const).map((item) => (
//@ts-ignore
{ ...criticDetails[0], icon: data.detail.location_type === LocationType.Culinary ? <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> : <ComfortIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...criticDetails[1], icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...criticDetails[2], icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...criticDetails[3], icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
]).map((item) => (
<div key={item.label} className="border border-gray-600 rounded-xl px-3 py-2 flex items-center gap-3">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-xs">{item.label}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value ?? '-'}</div>
<div className="text-tertiary text-xs">Based on {formatCount(criticData.count)} reviews</div>
</div>
</div>
@ -99,16 +102,17 @@ const RatingsCard = <T,>({
{userDetails && (
<div className="grid grid-cols-2 gap-2">
{([
{ label: 'Taste', value: userDetails.environment, icon: <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Cleanliness', value: userDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Service', value: userDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ label: 'Facilities', value: userDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
] as const).map((item) => (
//@ts-ignore
{ ...userDetails[0], icon: data.detail.location_type === LocationType.Culinary ? <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> : <ComfortIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...userDetails[1], icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...userDetails[2], icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{ ...userDetails[3], icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
]).map((item) => (
<div key={item.label} className="border border-gray-600 rounded-xl px-3 py-2 flex items-center gap-3">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-xs">{item.label}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value}</div>
<div className="text-lg font-bold leading-none my-0.5">{item.value ?? '-'}</div>
<div className="text-tertiary text-xs">Based on {formatCount(userData.count)} reviews</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
interface DaySchedule {
day: 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday';
open: string; // e.g. "7.00 AM"
close: string; // e.g. "21.00 PM"
open?: string;
close?: string;
closed?: boolean;
}
@ -21,7 +21,7 @@ function getCurrentDay(): DaySchedule['day'] {
function isOpenNow(schedules: DaySchedule[]): boolean {
const today = getCurrentDay();
const todaySchedule = schedules.find((s) => s.day === today);
if (!todaySchedule || todaySchedule.closed) return false;
if (!todaySchedule || todaySchedule.closed || !todaySchedule.open || !todaySchedule.close) return false;
const now = new Date();
const [openHour, openMin] = todaySchedule.open.replace(/[^0-9.]/g, '').split('.').map(Number);
@ -55,26 +55,30 @@ export function ScheduleCard({ schedules, onSuggestEdit }: ScheduleCardProps) {
{/* <hr className="border-[#38444d] mb-4 mt-2" /> */}
<div className="flex flex-col gap-1">
{DAYS.map((day) => {
const schedule = schedules.find((s) => s.day === day);
const isToday = day === today;
{schedules.length === 0 ? (
<p className="text-sm text-tertiary italic">No data yet</p>
) : (
<div className="flex flex-col gap-1">
{DAYS.map((day) => {
const schedule = schedules.find((s) => s.day === day);
const isToday = day === today;
return (
<div
key={day}
className={`flex items-center justify-between text-sm ${isToday ? 'font-bold text-white' : 'text-tertiary'}`}
>
<span>{day}</span>
<span>
{!schedule || schedule.closed
? 'Closed'
: `${schedule.open} - ${schedule.close}`}
</span>
</div>
);
})}
</div>
return (
<div
key={day}
className={`flex items-center justify-between text-sm ${isToday ? 'font-bold text-white' : 'text-tertiary'}`}
>
<span>{day}</span>
<span>
{!schedule || schedule.closed
? 'Closed'
: `${schedule.open} - ${schedule.close}`}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,43 @@
interface ComfortIconProps {
width?: number;
height?: number;
fill?: string;
className?: string;
bold?: boolean;
strokeWidth?: number;
}
export function ComfortIcon({ width = 30, height = 30, fill = 'white', className, bold = false, strokeWidth = 0.6 }: ComfortIconProps) {
const stroke = bold ? fill : 'none';
const sw = bold ? strokeWidth : 0;
return (
<svg width={width} height={height} viewBox="0 0 300 213" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4332_1873)">
<mask id="mask0_4332_1873" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="-46" width="300" height="300">
<path d="M300 -46H0V254H300V-46Z" fill="white" />
</mask>
<g mask="url(#mask0_4332_1873)">
<mask id="mask1_4332_1873" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="-46" width="300" height="300">
<path d="M299.707 253.707V-45.707H0.292969V253.707H299.707Z" fill="white" stroke="white" stroke-width="0.585937" />
</mask>
<g mask="url(#mask1_4332_1873)">
<path d="M40.1281 145.015L27.7941 186.711C26.7992 190.423 22.9484 192.647 19.2353 191.653C15.5228 190.657 13.2992 186.806 14.2941 183.093L23.4365 145.015H40.1281Z" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M23.4369 109.859H276.564C286.232 109.859 294.141 117.769 294.141 127.436C294.141 137.104 286.232 145.015 276.564 145.015H23.4369C13.7689 145.015 5.85938 137.105 5.85938 127.437C5.85938 117.77 13.7689 109.859 23.4369 109.859Z" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M259.873 145.015L272.207 186.711C273.202 190.423 277.053 192.647 280.766 191.653C284.478 190.657 286.702 186.806 285.707 183.093L276.565 145.015H259.873Z" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M34.9277 162.594H265.072" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M42.1875 57.1238C31.875 57.1238 23.4375 65.5613 23.4375 75.8738V109.859M276.564 109.859V34.8582C276.564 24.5451 268.127 16.1082 257.815 16.1082H168.751C158.439 16.1082 150.001 24.5451 150.001 34.8582V38.3738C150.001 48.6863 141.564 57.1238 131.251 57.1238H82.5967" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M189.818 109.859C190.697 92.9557 203.611 64.7094 220.456 64.8922C228.369 64.9772 239.719 69.8334 252.219 82.3971C263.233 93.4672 257.678 103.811 250.463 109.859" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M199.235 80.8274C196.772 78.0307 193.463 75.4655 190.096 74.5596C182.432 72.4965 170.268 74.1102 155.09 82.8571C141.716 90.5639 144.526 102.061 150.001 109.859" stroke="white" stroke-width="11.7187" stroke-miterlimit="22.926" stroke-linecap="round" stroke-linejoin="round" />
<path d="M59.0215 57.1238H59.1791" stroke="white" stroke-width="11.7187" stroke-miterlimit="2.613" stroke-linecap="round" stroke-linejoin="round" />
<path d="M276.425 127.436H276.563M235.547 127.435H259.591" stroke="white" stroke-width="11.7187" stroke-miterlimit="2.613" stroke-linecap="round" stroke-linejoin="round" />
</g>
</g>
</g>
<defs>
<clipPath id="clip0_4332_1873">
<rect width="300" height="213" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -1,19 +1,21 @@
interface FallbackImageProps {
thumbnail: string
locationType?: string
style: React.CSSProperties
style?: React.CSSProperties
alt?: string
className?: 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) => (
const FallbackImage = ({ thumbnail, locationType, style, alt, className }: FallbackImageProps) => (
<img
src={thumbnail}
loading={"lazy"}
style={style}
alt={alt}
className={className}
onError={(e) => {
if (locationType === 'restaurant' || locationType === 'culinary') {
e.currentTarget.src = restaurantThumbnailSrc;

View File

@ -23,6 +23,7 @@ const GET_LIST_TRENDING_LOCATIONS_URI = `${BASE_URL}/locations/trending`;
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 GET_LOCATION_REVIEW_GRADES_URI = `${BASE_URL}/location/grades`;
const POST_CREATE_LOCATION = `${BASE_URL}/location`;
const POST_LOCATION_VISIT_URI = `${BASE_URL}/location`;
@ -51,6 +52,7 @@ export {
GET_LOCATION_URI,
GET_SEARCH_LOCATIONS_URI,
GET_LOCATION_TAGS_URI,
GET_LOCATION_REVIEW_GRADES_URI,
GET_IMAGES_BY_LOCATION_URI,
GET_CURRENT_USER_REVIEW_LOCATION_URI,
PATCH_USER_AVATAR,

View File

@ -4,6 +4,7 @@
"id": 1,
"name": "Warung Sate Kambing",
"thumbnail": "https://fatahilaharis.files.wordpress.com/2017/07/wp-1499292117215.jpeg",
"location_type": "culinary",
"location": "Bandung",
"critic_rating": 78,
"visited": 48,
@ -15,6 +16,7 @@
"id": 2,
"name": "Pantai Balekambang",
"thumbnail": "https://www.nativeindonesia.com/foto/pantai-balekambang/Lokasi-Pura-Yang-Berada-Di-Atas-Pulau-Karang.jpg",
"location_type": "culinary",
"location": "Malang",
"critic_rating": 82,
"visited": 48,
@ -26,6 +28,7 @@
"id": 3,
"name": "Bebek Sinjay",
"thumbnail": "https://i0.wp.com/harga.web.id/wp-content/uploads/bebek-sinjay-surabaya-1.jpg?resize=680%2C300&ssl=1",
"location_type": "culinary",
"location": "Surabaya",
"critic_rating": 70,
"visited": 48,
@ -37,6 +40,7 @@
"id": 4,
"name": "Taman Pelangi",
"thumbnail": "https://tempat.org/wp-content/uploads/2016/11/57488321_255018015317182_1189210435487115990_n.jpg",
"location_type": "culinary",
"location": "Surabaya",
"critic_rating": 75,
"visited": 48,
@ -47,6 +51,7 @@
{
"id": 5,
"thumbnail": "https://ak-d.tripcdn.com/images/1i61t22348mz2uz9cB9FF.jpg?proc=source/trip",
"location_type": "culinary",
"name": "Ragunan Zoo",
"location": "Jakarta",
"critic_rating": 88,
@ -59,6 +64,7 @@
"id": 6,
"name": "Sate Lilit Bali",
"thumbnail": "https://lh3.googleusercontent.com/p/AF1QipNX3DCZxF8MOuyptvxPGfTT4-KNw0BTEqYqeled=s680-w680-h510",
"location_type": "culinary",
"location": "Denpasar",
"critic_rating": 83,
"visited": 48,

View File

@ -11,8 +11,9 @@ import {
CurrentUserLocationReviews,
} from './types';
import { handleApiError, useAutosizeTextArea } from '../../utils';
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages } from "../../services";
import { recordLocationVisitService } from "../../services/locations";
import { getCurrentUserLocationReviewService, getImagesByLocationService, getLocationService, postReviewLocation, postReviewImages, LocationReviewGrades } from "../../services";
import { recordLocationVisitService, getLocationReviewGradesService } from "../../services/locations";
import { LocationType } from '../../types/common';
import { DefaultSeparator, SeparatorWithAnchor, CustomInterweave, SpinnerLoading, ReviewCard, ReviewCardFull } from '../../components';
import RatingsCard from '../../components/Card/RatingsCard';
import { useSelector } from 'react-redux';
@ -24,6 +25,7 @@ import ReactTextareaAutosize from 'react-textarea-autosize';
import { MenuIcon } from '../../../src/components/Icons/MenuIcon';
import ScheduleCard from '../../components/Card/ScheduleCard';
import FacilitiesCard from '../../components/Card/FacilitiesCard';
import FallbackImage from '../../components/Img/FallbackImage';
const SORT_TYPE = [
@ -37,6 +39,7 @@ function LocationDetail() {
const [locationDetail, setLocationDetail] = useCallbackState<LocationDetailResponse>(EmptyLocationDetailResponse)
const [locationImages, setLocationImages] = useState<LocationResponse>(emptyLocationResponse())
const [currentUserReview, setCurrentUserReview] = useState<CurrentUserLocationReviews>()
const [reviewGrades, setReviewGrades] = useState<LocationReviewGrades | null>(null)
const [lightboxOpen, setLightboxOpen] = useState<boolean>(false)
const [updatePage, setUpdatePage] = useState<boolean>(true)
const [pageState, setPageState] = useState({
@ -127,6 +130,12 @@ function LocationDetail() {
}
}
async function getLocationReviewGrades(): Promise<void> {
const { data, error } = await getLocationReviewGradesService(Number(id))
if (error) return
setReviewGrades(data)
}
async function getImage(thumbnail?: String): Promise<void> {
try {
const res = await getImagesByLocationService({ page: 1, page_size: 15, location_id: Number(id) })
@ -261,6 +270,12 @@ function LocationDetail() {
}
}, [updatePage, id])
useEffect(() => {
if (id) {
getLocationReviewGrades()
}
}, [id])
return (
<div className="content main-content mt-3">
<section name={"HEADER LINK"}>
@ -289,11 +304,17 @@ function LocationDetail() {
className={`relative overflow-hidden cursor-zoom-in group/tile ${className}`}
onClick={() => openAt(index)}
>
<img
<FallbackImage
alt=""
thumbnail={img.src}
locationType={locationDetail.detail.location_type}
className="w-full h-full object-cover transition-all duration-300 group-hover/tile:brightness-75"
/>
{/* <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}
@ -406,31 +427,31 @@ function LocationDetail() {
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
})}
getCriticDetails={reviewGrades ? () => {
const isCulinary = locationDetail.detail.location_type === LocationType.Culinary
const g = reviewGrades.critics_grades
return [
{ label: isCulinary ? 'Taste' : 'Comfort', value: isCulinary ? g.avg_taste_grade : g.avg_comfort_grade },
{ label: 'Cleanliness', value: g.avg_cleanliness_grade },
{ label: 'Service', value: g.avg_service_grade },
{ label: 'Facilities', value: g.avg_facilities_grade },
]
} : undefined}
getUserDetails={reviewGrades ? () => {
const isCulinary = locationDetail.detail.location_type === LocationType.Culinary
const g = reviewGrades.users_grades
return [
{ label: isCulinary ? 'Taste' : 'Comfort', value: isCulinary ? g.avg_taste_grade : g.avg_comfort_grade },
{ label: 'Cleanliness', value: g.avg_cleanliness_grade },
{ label: 'Service', value: g.avg_service_grade },
{ label: 'Facilities', value: g.avg_facilities_grade },
]
} : undefined}
/>
</div>
<div className="w-full md:w-[320px] flex-shrink-0">
<ScheduleCard
schedules={[
{ day: 'Sunday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Monday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Tuesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Wednesday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Thursday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Friday', open: '7.00 AM', close: '21.00 PM' },
{ day: 'Saturday', open: '7.00 AM', close: '21.00 PM' },
]}
schedules={locationDetail.detail.business_hours ?? []}
onSuggestEdit={() => console.log('suggest edit')}
/>
</div>

View File

@ -1,21 +1,29 @@
import { NullValueRes } from "../../types/common"
import { SlideImage } from "yet-another-react-lightbox"
export interface BusinessHourEntry {
day: 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday';
open?: string;
close?: string;
closed?: boolean;
}
export interface ILocationDetail {
id: number,
name: String,
address: String,
regency_name: String,
province_name: String,
location_type: String,
region_name: String,
google_maps_link: String,
name: string,
address: string,
regency_name: string,
province_name: string,
location_type: 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
user_count: number,
business_hours: BusinessHourEntry[] | null,
}
export function emptyLocationDetail(): ILocationDetail {
@ -34,6 +42,7 @@ export function emptyLocationDetail(): ILocationDetail {
critic_count: 0,
user_score: 0,
user_count: 0,
business_hours: null,
}
}
@ -52,7 +61,7 @@ export interface LocationReviewsResponse {
export interface LocationDetailResponse {
detail: ILocationDetail,
tags: Array<String>
tags: Array<string>
users_review: Array<LocationReviewsResponse>,
critics_review: Array<LocationReviewsResponse>
}

View File

@ -1,10 +1,12 @@
import {
import {
getListLocationsService,
getListRecentLocationsRatingsService,
getListTopLocationsService,
getLocationService,
getLocationTagsService,
getLocationReviewGradesService,
} from "./locations";
export type { LocationReviewGrades, LocationReviewGradesGroup } from "./locations";
import { getImagesByLocationService } from "./images"
import { createAccountService, loginService, logoutService } from "./auth";
import { postReviewLocation, postReviewImages, getCurrentUserLocationReviewService } from "./review";
@ -28,6 +30,7 @@ export {
getListTopLocationsService,
getLocationService,
getLocationTagsService,
getLocationReviewGradesService,
getImagesByLocationService,
postReviewLocation,

View File

@ -7,6 +7,7 @@ import {
GET_LIST_TRENDING_LOCATIONS_URI,
GET_LOCATION_TAGS_URI,
GET_LOCATION_URI,
GET_LOCATION_REVIEW_GRADES_URI,
GET_SEARCH_LOCATIONS_URI,
POST_CREATE_LOCATION,
POST_LOCATION_VISIT_URI,
@ -173,7 +174,6 @@ async function getListTopLocationsService(params: GetListLocationsArg) {
async function getLocationService(params: GetLocationArg) {
try {
const data = await fetchLocation(params)
console.log(data)
return { data, error: null }
} catch (error) {
throw error
@ -277,6 +277,39 @@ export const recordLocationVisitService = async (locationId: number): Promise<vo
}
}
export interface LocationReviewGradesGroup {
avg_taste_grade: number | null
taste_grade_count: number
avg_comfort_grade: number | null
comfort_grade_count: number
avg_cleanliness_grade: number | null
cleanliness_grade_count: number
avg_service_grade: number | null
service_grade_count: number
avg_facilities_grade: number | null
facilities_grade_count: number
}
export interface LocationReviewGrades {
critics_grades: LocationReviewGradesGroup
users_grades: LocationReviewGradesGroup
}
const fetchLocationReviewGrades = async (locationId: number): Promise<LocationReviewGrades> => {
const url = `${GET_LOCATION_REVIEW_GRADES_URI}/${locationId}`
const response = await client({ method: 'GET', url })
return response.data
}
export const getLocationReviewGradesService = async (locationId: number) => {
try {
const data = await fetchLocationReviewGrades(locationId)
return { data, error: null }
} catch (error: any) {
return { data: null, error, status: error?.status }
}
}
export const getMenuItemsService = async (locationId: number) => {
try {
const data = await fetchMenuItems(locationId)