added bunch of cards and adjust new 7tv emote

This commit is contained in:
goro 2026-04-12 10:22:47 +03:00
parent a6d3fd5098
commit 0f2628928b
5 changed files with 388 additions and 105 deletions

View File

@ -0,0 +1,155 @@
import { useState } from 'preact/hooks';
import { Dialog, DialogPanel, DialogTitle, Tab, TabGroup, TabList, TabPanel, TabPanels, Transition, TransitionChild } from '@headlessui/react';
export interface FacilityItem {
text: string;
}
export interface FacilityCategory {
title: string;
items: FacilityItem[];
}
interface FacilitiesCardProps {
title?: string;
left: FacilityCategory[];
middle: FacilityCategory[];
right: FacilityCategory[];
seeMoreShow?: boolean;
}
function CategorySection({ category }: { category: FacilityCategory }) {
return (
<div className="mb-6">
<h4 className="text-[#C74F28] underline font-semibold mb-3">{category.title}</h4>
<ol className="list-decimal list-outside pl-5 flex flex-col gap-2.5">
{category.items.map((item, i) => (
<li key={i} className="text-md">
{item.text}
</li>
))}
</ol>
</div>
);
}
export function FacilitiesCard({ title = 'Facilities & Amenities', left, middle, right, seeMoreShow }: FacilitiesCardProps) {
const [dialogOpen, setDialogOpen] = useState(false);
// Flatten all categories from the 3 columns into a single ordered list for tabs
const allCategories = [...left, ...middle, ...right];
function handleSeeMore() {
setDialogOpen(true);
}
return (
<>
<div className="w-full mt-4 bg-black/[0.27] p-6 rounded-xl">
<h3 className="text-center font-bold text-2xl mb-6">{title}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={'border-r-2 border-x-gray-700'}>
{left.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
<div className={'border-r-2 border-x-gray-700'}>
{middle.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
<div className={''}>
{right.map((cat, i) => <CategorySection key={i} category={cat} />)}
</div>
</div>
{seeMoreShow !== undefined && (
<div className="flex justify-end mt-4">
<button
onClick={handleSeeMore}
className="text-sm text-tertiary underline underline-offset-2 hover:text-white transition-colors"
>
See more
</button>
</div>
)}
</div>
{/* Dialog */}
<Transition show={dialogOpen}>
<Dialog onClose={() => setDialogOpen(false)} className="relative z-50">
{/* Backdrop */}
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/70" />
</TransitionChild>
{/* Panel container */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="bg-[#1a1a1a] text-white rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden relative">
{/* Close button — top right */}
<button
onClick={() => setDialogOpen(false)}
className="absolute top-5 right-5 text-tertiary hover:text-white transition-colors text-2xl leading-none w-9 h-9 flex items-center justify-center rounded-full hover:bg-white/10 z-10"
>
&times;
</button>
{/* Header */}
<div className="px-8 pt-8 pb-0 flex-shrink-0">
<DialogTitle className="text-4xl font-bold mb-5">{title}</DialogTitle>
{/* Pill tabs */}
<TabGroup>
<TabList className="flex flex-row gap-1 bg-[#2a2a2a] rounded-xl p-1 w-fit overflow-x-auto [-webkit-overflow-scrolling:touch] [&::-webkit-scrollbar]:hidden">
{allCategories.map((cat, i) => (
<Tab
key={i}
className="whitespace-nowrap px-5 py-2 text-sm font-medium text-tertiary rounded-lg transition-colors outline-none
data-[selected]:bg-black data-[selected]:text-white
hover:text-white"
>
{cat.title}
</Tab>
))}
</TabList>
{/* Tab panels */}
<TabPanels className="overflow-y-auto py-6 flex-1">
{allCategories.map((cat, i) => (
<TabPanel key={i}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-12 gap-y-4">
{cat.items.map((item, j) => (
<div key={j} className="flex items-start gap-3">
<span className="text-sm leading-relaxed">{item.text}</span>
</div>
))}
</div>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</>
);
}
export default FacilitiesCard;

View File

@ -1,3 +1,8 @@
import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { RestaurantIcon } from '../../Icons/RestaurantIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
interface RatingData { interface RatingData {
score: number; score: number;
count: number; count: number;
@ -41,117 +46,77 @@ const RatingsCard = <T,>({
}; };
return ( return (
<div className="flex flex-col gap-1 mt-2 items-center"> <div className="flex flex-col gap-1 items-center">
{/* Critics Score Section */} <div className="w-full mt-2 flex flex-col md:flex-row gap-4">
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full">
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full"> {/* Critics column */}
<div className="font-bold text-xs mb-2 text-center">CRITICS SCORE</div> <div className="flex-1 flex flex-col gap-3">
<div className="text-4xl text-center my-2"> <div className="bg-secondary rounded-xl px-4 py-3 flex items-center justify-between">
{calculateScore(criticData.score, criticData.count)} <div>
<div className="text-base font-bold tracking-wide">CRITICS SCORE</div>
{criticData.count !== 0 && (
<div className="text-tertiary mt-0.5 text-xs">Based on {formatCount(criticData.count)} reviews</div>
)}
</div>
<div className="w-11 h-11 rounded-full bg-black flex items-center justify-center text-lg font-bold flex-shrink-0">
{calculateScore(criticData.score, criticData.count)}
</div>
</div> </div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2"> {criticDetails && (
<div <div className="grid grid-cols-2 gap-2">
className="h-1 bg-brand-green" {([
style={{ width: `${criticData.count !== 0 ? criticData.score : 0}%` }} { 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} /> },
</div> { label: 'Service', value: criticDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
{criticData.count !== 0 && ( { label: 'Facilities', value: criticDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
<div className="text-sm text-center"> ] as const).map((item) => (
Based on {formatCount(criticData.count)} reviews <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-tertiary text-xs">Based on {formatCount(criticData.count)} reviews</div>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
{/* Critics Detail Cards */} {/* Users column */}
{criticDetails && ( <div className="flex-1 flex flex-col gap-3">
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between"> <div className="bg-secondary rounded-xl px-4 py-3 flex items-center justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1"> <div>
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div> <div className="text-base font-bold tracking-wide">USERS SCORE</div>
<div className="flex-1 flex flex-col items-center justify-center"> {userData.count !== 0 && (
<div className="text-2xl text-center my-1">{criticDetails.environment}</div> <div className="text-tertiary mt-0.5 text-xs">Based on {formatCount(userData.count)} reviews</div>
<div className="h-1 w-16 bg-[#72767d]"> )}
<div className="h-1 bg-green" style={{ width: `${criticDetails.environment}%` }} />
</div>
</div>
</div> </div>
<div className="w-11 h-11 rounded-full bg-black flex items-center justify-center text-lg font-bold flex-shrink-0">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1"> {calculateScore(userData.score, userData.count)}
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{criticDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${criticDetails.facility}%` }} />
</div>
</div>
</div> </div>
</div> </div>
)} {userDetails && (
</div> <div className="grid grid-cols-2 gap-2">
{([
{/* Users Score Section */} { label: 'Taste', value: userDetails.environment, icon: <RestaurantIcon className="w-7 h-7" strokeWidth={1.1} /> },
<div className="flex gap-2 max-[768px]:flex-col max-[768px]:w-full"> { label: 'Cleanliness', value: userDetails.cleanliness, icon: <CleanlinessIcon className="w-7 h-7" strokeWidth={1.1} /> },
<div className="p-4 bg-secondary rounded-lg w-[180px] max-[768px]:w-full"> { label: 'Service', value: userDetails.price, icon: <ServiceIcon className="w-7 h-7" strokeWidth={1.1} /> },
<div className="font-bold text-xs mb-2 text-center">USERS SCORE</div> { label: 'Facilities', value: userDetails.facility, icon: <FacilityIcon className="w-7 h-7" strokeWidth={1.1} /> },
<div className="text-4xl text-center my-2"> ] as const).map((item) => (
{calculateScore(userData.score, userData.count)} <div key={item.label} className="border border-gray-600 rounded-xl px-3 py-2 flex items-center gap-3">
</div> <div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div className="h-1 w-20 bg-[#72767d] mx-auto mb-2"> <div>
<div <div className="text-tertiary text-xs">{item.label}</div>
className="h-1 bg-brand-green" <div className="text-lg font-bold leading-none my-0.5">{item.value}</div>
style={{ width: `${userData.count !== 0 ? userData.score / userData.count : 0}%` }} <div className="text-tertiary text-xs">Based on {formatCount(userData.count)} reviews</div>
/> </div>
</div> </div>
{userData.count !== 0 && ( ))}
<div className="text-sm text-center">
Based on {formatCount(userData.count)} reviews
</div> </div>
)} )}
</div> </div>
{/* Users Detail Cards */}
{userDetails && (
<div className="flex gap-2 max-[768px]:w-full max-[768px]:justify-between">
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">ENVIRONMENT</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.environment}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.environment}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">PRICE</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.price}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.price}%` }} />
</div>
</div>
</div>
<div className="p-3 bg-secondary rounded-lg w-[120px] flex flex-col max-[768px]:flex-1">
<div className="font-bold text-xs mb-1 text-center">FACILITY</div>
<div className="flex-1 flex flex-col items-center justify-center">
<div className="text-2xl text-center my-1">{userDetails.facility}</div>
<div className="h-1 w-16 bg-[#72767d]">
<div className="h-1 bg-green" style={{ width: `${userDetails.facility}%` }} />
</div>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,79 @@
import { CleanlinessIcon } from '../../Icons/CleanlinessIcon';
import { FacilityIcon } from '../../Icons/FacilityIcon';
import { RestaurantIcon } from '../../Icons/RestaurantIcon';
import { ServiceIcon } from '../../Icons/ServiceIcon';
interface RatingData {
score: number;
count: number;
}
interface DetailRatings {
environment: number;
cleanliness: number;
price: number;
facility: number;
}
interface RatingsCardRowProps<T> {
data: T;
title: string;
getRatingData: (data: T) => RatingData;
getDetails?: (data: T) => DetailRatings;
}
const RatingsCardRow = <T,>({
data,
title,
getRatingData,
getDetails,
}: RatingsCardRowProps<T>) => {
const ratingData = getRatingData(data);
const details = getDetails?.(data);
const formatCount = (count: number): string | number =>
count >= 1000 ? `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k` : count;
const calculateScore = (score: number, count: number): string | number =>
count !== 0 ? Math.floor(score / count) : 'NR';
const score = calculateScore(ratingData.score, ratingData.count);
return (
<div className="flex flex-col md:flex-row gap-3 items-stretch">
<div className="bg-secondary rounded-xl px-5 py-5 flex-shrink-0 md:w-[320px] flex flex-col gap-2 self-center">
<div className="flex flex-row items-center justify-between gap-3">
<div className="text-2xl font-bold tracking-wide leading-tight">{title}</div>
<div className="w-16 h-16 rounded-full bg-black flex items-center justify-center text-3xl font-bold flex-shrink-0">
{score}
</div>
</div>
{ratingData.count !== 0 && (
<div className="text-tertiary text-sm">Based on {formatCount(ratingData.count)} reviews</div>
)}
</div>
{details && (
<div className="grid grid-cols-2 gap-3 flex-1">
{([
{ label: 'Rasa', value: details.environment, icon: <RestaurantIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Kebersihan', value: details.cleanliness, icon: <CleanlinessIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Pelayanan', value: details.price, icon: <ServiceIcon className="w-10 h-10" strokeWidth={1.1} /> },
{ label: 'Fasilitas', value: details.facility, icon: <FacilityIcon className="w-10 h-10" strokeWidth={1.1} /> },
] as const).map((item) => (
<div key={item.label} className="border border-gray-700 rounded-xl px-4 py-3 flex items-center gap-4">
<div className="text-tertiary flex-shrink-0">{item.icon}</div>
<div>
<div className="text-tertiary text-sm">{item.label}</div>
<div className="text-4xl font-bold leading-none my-0.5">{item.value}</div>
<div className="text-tertiary text-xs">Based on {formatCount(ratingData.count)} reviews</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default RatingsCardRow;

View File

@ -0,0 +1,82 @@
interface DaySchedule {
day: 'Sunday' | 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday';
open: string; // e.g. "7.00 AM"
close: string; // e.g. "21.00 PM"
closed?: boolean;
}
interface ScheduleCardProps {
schedules: DaySchedule[];
onSuggestEdit?: () => void;
}
const DAYS: DaySchedule['day'][] = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
];
function getCurrentDay(): DaySchedule['day'] {
return DAYS[new Date().getDay()];
}
function isOpenNow(schedules: DaySchedule[]): boolean {
const today = getCurrentDay();
const todaySchedule = schedules.find((s) => s.day === today);
if (!todaySchedule || todaySchedule.closed) return false;
const now = new Date();
const [openHour, openMin] = todaySchedule.open.replace(/[^0-9.]/g, '').split('.').map(Number);
const [closeHour, closeMin] = todaySchedule.close.replace(/[^0-9.]/g, '').split('.').map(Number);
const openIsPm = todaySchedule.open.toUpperCase().includes('PM');
const closeIsPm = todaySchedule.close.toUpperCase().includes('PM');
const openTotal = (openIsPm && openHour !== 12 ? openHour + 12 : openHour) * 60 + (openMin || 0);
const closeTotal = (closeIsPm && closeHour !== 12 ? closeHour + 12 : closeHour) * 60 + (closeMin || 0);
const nowTotal = now.getHours() * 60 + now.getMinutes();
return nowTotal >= openTotal && nowTotal < closeTotal;
}
export function ScheduleCard({ schedules, onSuggestEdit }: ScheduleCardProps) {
const today = getCurrentDay();
return (
<div className="bg-black/[0.27] rounded-xl mt-2 px-4 py-4 w-full">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold">Hours</h3>
</div>
<button
onClick={onSuggestEdit}
className="text-sm underline underline-offset-2 hover:text-white transition-colors text-tertiary mt-1"
>
Suggest an edit
</button>
</div>
{/* <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;
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>
);
}
export default ScheduleCard;

View File

@ -6,11 +6,13 @@ import { EmojiMatcher, PathConfig } from 'interweave-emoji';
class SevenTVMatcher extends Matcher { class SevenTVMatcher extends Matcher {
private emotes: Record<string, string> = { private emotes: Record<string, string> = {
'booba': '01F6N31ETR0004P7N4A9PKS5X9', 'booba': '01F6N31ETR0004P7N4A9PKS5X9',
'pepeJAM': '5f1f0ea5cf6d2144653d7501', 'pepeJAM': '01EZY967K0000CYST6006V20T8',
'OMEGALUL': '5f4b3bc28fb088567e5cbb3b', 'OMEGALUL': '01F00Z3A9G0007E4VV006YKSK9',
'monkaS': '01F78CHJ2G0005TDSTZFBDGMK4', 'monkaS': '01F78CHJ2G0005TDSTZFBDGMK4',
'Sadge': '5f1f0f61b5e9d35e9a2f8a0e', 'Sadge': '01EZPG1FN80001SNAW00ADK2DY',
'PogU': '5f1f0c1235c7c40e6a3f9c1b', 'PogU': '01F6M3N17G000B5V5G2M2RYJN7',
'skateParkGe': '01GMKPJQJR0008GS0R6JQ1670A',
'crashout': '01KM204X52XJ5GAA1TAP6AZWGC'
}; };
replaceWith(match: string): Node { replaceWith(match: string): Node {
@ -20,12 +22,12 @@ class SevenTVMatcher extends Matcher {
if (emoteId) { if (emoteId) {
return ( return (
<img <img
src={`https://cdn.7tv.app/emote/${emoteId}/3x.avif`} src={`https://cdn.7tv.app/emote/${emoteId}/4x.avif`}
alt={emoteName} alt={emoteName}
title={emoteName} title={emoteName}
style={{ style={{
display: 'inline-block', display: 'inline-block',
height: '28px', height: '32px',
verticalAlign: 'middle', verticalAlign: 'middle',
margin: '0 2px' margin: '0 2px'
}} }}