fix grades

This commit is contained in:
goro 2026-06-16 19:39:12 +03:00
parent 148eaf88c9
commit 2bc0a29a0a
8 changed files with 162 additions and 46 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

@ -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

@ -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,18 +427,26 @@ 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">

View File

@ -3,13 +3,13 @@ import { SlideImage } from "yet-another-react-lightbox"
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,
@ -52,7 +52,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

@ -4,7 +4,9 @@ import {
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)