Autosave: 20260617-143144

This commit is contained in:
Flatlogic Bot 2026-06-17 14:31:41 +00:00
parent 2fbb512497
commit e84665755d
10 changed files with 3942 additions and 172 deletions

View File

@ -0,0 +1,133 @@
'use strict';
const isMissingTableError = (err) => {
const message = String(err && err.message);
return message.includes('No description found')
|| message.includes('does not exist')
|| message.includes('Cannot read properties of undefined');
};
module.exports = {
up: async (queryInterface, Sequelize) => {
const transaction = await queryInterface.sequelize.transaction();
try {
try {
await queryInterface.describeTable('place_offerings');
await transaction.commit();
return;
} catch (err) {
if (!isMissingTableError(err)) {
throw err;
}
}
await queryInterface.createTable('place_offerings', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
placeId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
references: {
key: 'id',
model: 'places',
},
},
name: {
type: Sequelize.DataTypes.TEXT,
allowNull: false,
},
description: {
type: Sequelize.DataTypes.TEXT,
},
offering_type: {
type: Sequelize.DataTypes.ENUM('product', 'service'),
allowNull: false,
defaultValue: 'product',
},
price: {
type: Sequelize.DataTypes.DECIMAL,
},
stock_status: {
type: Sequelize.DataTypes.ENUM('in_stock', 'limited', 'out_of_stock', 'by_request'),
allowNull: false,
defaultValue: 'by_request',
},
stock_quantity: {
type: Sequelize.DataTypes.INTEGER,
},
is_verified: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: Sequelize.DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
last_stock_update: {
type: Sequelize.DataTypes.DATE,
},
createdAt: {
type: Sequelize.DataTypes.DATE,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
},
deletedAt: {
type: Sequelize.DataTypes.DATE,
},
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
}, { transaction });
await queryInterface.addIndex('place_offerings', ['placeId'], {
transaction,
});
await queryInterface.addIndex('place_offerings', ['offering_type', 'stock_status'], {
transaction,
});
await queryInterface.addIndex('place_offerings', ['is_active'], {
transaction,
});
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
down: async (queryInterface) => {
const transaction = await queryInterface.sequelize.transaction();
try {
let tableExists = true;
try {
await queryInterface.describeTable('place_offerings');
} catch (err) {
if (!isMissingTableError(err)) {
throw err;
}
tableExists = false;
}
if (tableExists) {
await queryInterface.dropTable('place_offerings', { transaction });
}
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_offering_type";', { transaction });
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_place_offerings_stock_status";', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,80 @@
module.exports = function(sequelize, DataTypes) {
const place_offerings = sequelize.define(
'place_offerings',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.TEXT,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
},
offering_type: {
type: DataTypes.ENUM,
values: ['product', 'service'],
allowNull: false,
defaultValue: 'product',
},
price: {
type: DataTypes.DECIMAL,
},
stock_status: {
type: DataTypes.ENUM,
values: ['in_stock', 'limited', 'out_of_stock', 'by_request'],
allowNull: false,
defaultValue: 'by_request',
},
stock_quantity: {
type: DataTypes.INTEGER,
},
is_verified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
last_stock_update: {
type: DataTypes.DATE,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
place_offerings.associate = (db) => {
db.place_offerings.belongsTo(db.places, {
as: 'place',
foreignKey: {
name: 'placeId',
},
constraints: false,
});
db.place_offerings.belongsTo(db.users, {
as: 'createdBy',
});
db.place_offerings.belongsTo(db.users, {
as: 'updatedBy',
});
};
return place_offerings;
};

View File

@ -1,8 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const places = sequelize.define(
@ -227,6 +222,15 @@ is_verified: {
});
db.places.hasMany(db.place_offerings, {
as: 'offerings',
foreignKey: {
name: 'placeId',
},
constraints: false,
});
db.places.hasMany(db.reviews, {
as: 'reviews_place',
foreignKey: {

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -16,6 +15,7 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const publicPlacesRoutes = require('./routes/publicPlaces');
const openaiRoutes = require('./routes/openai');
@ -100,6 +100,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/public', publicPlacesRoutes);
app.enable('trust proxy');

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,425 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import axios from 'axios'
import {
mdiArrowLeft,
mdiCash,
mdiChartTimelineVariant,
mdiClockOutline,
mdiCrosshairsGps,
mdiMapMarkerRadiusOutline,
mdiNavigationVariantOutline,
mdiOpenInNew,
mdiPackageVariantClosed,
mdiPhoneOutline,
mdiRobotHappyOutline,
mdiShieldCheckOutline,
mdiStar,
mdiWeb,
mdiWhatsapp,
} from '@mdi/js'
import BaseButton from '../../components/BaseButton'
import BaseIcon from '../../components/BaseIcon'
import LayoutGuest from '../../layouts/Guest'
import { getPageTitle } from '../../config'
type Category = {
id: string
name: string
slug: string
description?: string
color_hex?: string
}
type Offering = {
id: string
name: string
description?: string
offering_type: 'product' | 'service'
price?: number | null
stock_status: 'in_stock' | 'limited' | 'out_of_stock' | 'by_request'
stock_label: string
stock_quantity?: number | null
is_verified?: boolean
}
type OfferingSummary = {
total: number
available: number
products: number
services: number
verified: number
top_available: Offering[]
}
type GeoScoreBreakdown = {
relevance: number
distance: number
reputation: number
activity: number
interaction: number
}
type RadiusZone = {
value: number
label: string
range: string
description?: string
}
type LiveStatus = {
status: 'open' | 'closed'
label: string
crowd: string
updated_label: string
source?: string
}
type AiRecommendation = {
label: string
reason: string
source?: string
}
type PublicPlace = {
id: string
name: string
short_description?: string
full_description?: string
address?: string
city?: string
province?: string
latitude?: number | null
longitude?: number | null
phone_number?: string
whatsapp_number?: string
website_url?: string
google_maps_url?: string
price_level?: string
average_price?: number | null
rating_average?: number | null
rating_count?: number
is_verified?: boolean
distance_km?: number | null
search_score?: number
geo_score?: number
geo_score_breakdown?: GeoScoreBreakdown
geo_score_formula?: Record<string, number>
radius_zone?: RadiusZone
live_status?: LiveStatus
ai_recommendation?: AiRecommendation
offerings?: Offering[]
offerings_summary?: OfferingSummary
category?: Category | null
}
const priceLabels: Record<string, string> = {
budget: 'Ramah kantong',
midrange: 'Menengah',
premium: 'Premium',
unknown: 'Tanya harga',
}
const scoreLabels: Record<keyof GeoScoreBreakdown, string> = {
relevance: 'Relevansi kata kunci',
distance: 'Jarak',
reputation: 'Rating/reputasi',
activity: 'Aktivitas terbaru',
interaction: 'Interaksi pengguna',
}
const formatRupiah = (value?: number | null) => {
if (!value) return 'Tanya tempat'
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(value)
}
const stockColor = (status: string) => {
if (status === 'in_stock') return 'bg-[#E8F6F1] text-[#087F6D]'
if (status === 'limited') return 'bg-[#FFF3D7] text-[#9A6500]'
if (status === 'out_of_stock') return 'bg-[#FFE7E0] text-[#A23A24]'
return 'bg-[#F7F2E8] text-[#5D6B62]'
}
export default function PlaceDetailPage() {
const router = useRouter()
const { placeId, lat, lng, radiusKm, q } = router.query
const [place, setPlace] = useState<PublicPlace | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const mapsUrl = useMemo(() => {
if (place?.google_maps_url) return place.google_maps_url
if (place?.latitude && place?.longitude) return `https://maps.google.com/?q=${place.latitude},${place.longitude}`
return `https://maps.google.com/?q=${encodeURIComponent(place?.address || place?.name || '')}`
}, [place])
const queryText = Array.isArray(q) ? q[0] : q
const topOfferings = place?.offerings_summary?.top_available || []
const formulaEntries = Object.entries(place?.geo_score_formula || { relevance: 40, distance: 25, reputation: 15, activity: 10, interaction: 10 })
useEffect(() => {
if (!placeId || Array.isArray(placeId)) return
const fetchPlace = async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/public/places/${placeId}`, {
params: {
lat: Array.isArray(lat) ? lat[0] : lat,
lng: Array.isArray(lng) ? lng[0] : lng,
radiusKm: Array.isArray(radiusKm) ? radiusKm[0] : radiusKm,
q: queryText,
},
})
setPlace(response.data)
} catch (err) {
console.error('Gagal memuat detail tempat publik GeoSeek', err)
setError('Tempat tidak ditemukan atau belum bisa dimuat.')
} finally {
setLoading(false)
}
}
fetchPlace()
}, [placeId, lat, lng, radiusKm, queryText])
return (
<>
<Head>
<title>{getPageTitle(place?.name || 'Detail GeoSeek')}</title>
</Head>
<main className='min-h-screen bg-[#F7F2E8] text-[#17231B]'>
<section className='bg-[#073B3A] px-6 py-6 text-white lg:px-8'>
<div className='mx-auto flex max-w-7xl items-center justify-between rounded-full border border-white/15 bg-white/10 px-5 py-3 backdrop-blur'>
<Link href='/' className='flex items-center gap-3 font-black'>
<span className='grid h-10 w-10 place-items-center rounded-full bg-[#F2A541] text-[#073B3A]'>GS</span>
GeoSeek 2.0
</Link>
<div className='flex items-center gap-3'>
<BaseButton href='/' label='Cari lagi' color='white' roundedFull className='font-bold' />
<BaseButton href='/login' label='Admin / Login' color='warning' roundedFull className='border-0 font-bold text-[#073B3A]' />
</div>
</div>
</section>
<section className='mx-auto max-w-7xl px-6 py-10 lg:px-8'>
<Link href='/' className='mb-6 inline-flex items-center gap-2 text-sm font-bold text-[#087F6D] hover:text-[#073B3A]'>
<BaseIcon path={mdiArrowLeft} size={18} /> Kembali ke GeoSeek
</Link>
{loading ? (
<div className='grid gap-6 lg:grid-cols-[1fr_390px]'>
<div className='h-[620px] animate-pulse rounded-[2rem] bg-white' />
<div className='h-[620px] animate-pulse rounded-[2rem] bg-white' />
</div>
) : error || !place ? (
<div className='rounded-[2rem] bg-white p-10 text-center shadow-sm'>
<h1 className='text-3xl font-black'>Detail belum tersedia</h1>
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>{error || 'Tempat tidak ditemukan.'}</p>
<div className='mt-6'>
<BaseButton href='/' label='Cari tempat lain' color='info' roundedFull />
</div>
</div>
) : (
<div className='grid gap-6 lg:grid-cols-[1fr_390px]'>
<article className='overflow-hidden rounded-[2.3rem] bg-white shadow-xl shadow-[#073B3A]/8'>
<div className='relative min-h-[330px] bg-[#D9E7D8] p-8'>
<div className='absolute inset-0 opacity-40 [background-image:linear-gradient(#ffffff_1px,transparent_1px),linear-gradient(90deg,#ffffff_1px,transparent_1px)] [background-size:42px_42px]' />
<div className='relative z-10 flex flex-col gap-5 md:flex-row md:items-start md:justify-between'>
<div>
<span className='rounded-full px-3 py-1 text-xs font-black text-white' style={{ backgroundColor: place.category?.color_hex || '#087F6D' }}>
{place.category?.name || 'Tempat'}
</span>
<h1 className='mt-5 max-w-3xl text-5xl font-black leading-tight tracking-tight'>{place.name}</h1>
<p className='mt-4 max-w-2xl text-lg leading-8 text-[#385143]'>{place.short_description || 'Tempat lokal yang bisa ditemukan berdasarkan lokasi dan kategori.'}</p>
</div>
<div className='flex flex-col items-start gap-3 md:items-end'>
{place.is_verified ? (
<span className='inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-black text-[#087F6D] shadow-sm'>
<BaseIcon path={mdiShieldCheckOutline} size={18} /> Verified
</span>
) : null}
<div className='rounded-[1.4rem] bg-white p-4 text-right shadow-sm'>
<p className='text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>GeoScore</p>
<p className='text-4xl font-black text-[#F26A4B]'>{place.geo_score || place.search_score || '-'}</p>
<p className='text-xs font-bold text-[#087F6D]'>{place.radius_zone?.range} · {place.radius_zone?.label}</p>
</div>
</div>
</div>
</div>
<div className='p-8'>
<div className='grid gap-4 md:grid-cols-4'>
<InfoCard label='Jarak' value={place.distance_km !== null && place.distance_km !== undefined ? `${place.distance_km} km` : 'Aktifkan lokasi'} icon={mdiCrosshairsGps} />
<InfoCard label='Rating' value={`${place.rating_average || '-'} (${place.rating_count || 0})`} icon={mdiStar} />
<InfoCard label='Live' value={`${place.live_status?.label || '-'} · ${place.live_status?.crowd || '-'}`} icon={mdiClockOutline} />
<InfoCard label='Estimasi' value={formatRupiah(place.average_price)} icon={mdiCash} />
</div>
<div className='mt-8 grid gap-8 lg:grid-cols-[1fr_0.9fr]'>
<div>
<h2 className='text-2xl font-black'>Tentang tempat ini</h2>
<p className='mt-4 leading-8 text-[#5D6B62]'>
{place.full_description || place.short_description || 'Admin belum menambahkan deskripsi lengkap untuk tempat ini.'}
</p>
</div>
<div className='rounded-[1.8rem] bg-[#F7F2E8] p-5'>
<h2 className='text-xl font-black'>Alamat & arah</h2>
<p className='mt-3 leading-7 text-[#5D6B62]'>{place.address || [place.city, place.province].filter(Boolean).join(', ') || 'Alamat belum tersedia'}</p>
<a href={mapsUrl} target='_blank' rel='noreferrer' className='mt-5 inline-flex items-center gap-2 rounded-2xl bg-[#073B3A] px-4 py-3 font-black text-white transition hover:bg-[#087F6D]'>
Buka arah di Maps <BaseIcon path={mdiOpenInNew} size={18} />
</a>
</div>
</div>
<div className='mt-8 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]'>
<div className='rounded-[1.8rem] border border-[#E8DEC9] p-5'>
<div className='mb-4 flex items-center gap-2 text-xl font-black'>
<BaseIcon path={mdiChartTimelineVariant} size={24} className='text-[#087F6D]' /> Breakdown GeoScore
</div>
{place.geo_score_breakdown ? (
(Object.entries(place.geo_score_breakdown) as [keyof GeoScoreBreakdown, number][]).map(([key, value]) => (
<ScoreBar key={key} label={`${scoreLabels[key]} · ${formulaEntries.find(([formulaKey]) => formulaKey === key)?.[1] || 0}%`} value={value} />
))
) : (
<p className='text-sm text-[#5D6B62]'>Breakdown belum tersedia.</p>
)}
</div>
<div className='rounded-[1.8rem] border border-[#E8DEC9] p-5'>
<div className='mb-4 flex items-center gap-2 text-xl font-black'>
<BaseIcon path={mdiRobotHappyOutline} size={24} className='text-[#087F6D]' /> AI Recommendation
</div>
<p className='font-black text-[#087F6D]'>{place.ai_recommendation?.label || 'Rekomendasi lokal'}</p>
<p className='mt-3 leading-7 text-[#5D6B62]'>{place.ai_recommendation?.reason || 'Rekomendasi dibuat dari sinyal lokal dan GeoScore.'}</p>
<p className='mt-3 text-xs font-bold text-[#6A7A70]'>{place.ai_recommendation?.source || 'GeoScore rules-based MVP'}</p>
</div>
</div>
<div className='mt-8 rounded-[1.8rem] bg-[#F7F2E8] p-5'>
<div className='flex flex-col gap-3 md:flex-row md:items-center md:justify-between'>
<div>
<h2 className='text-2xl font-black'>Produk, stok & jasa</h2>
<p className='mt-1 text-sm text-[#5D6B62]'>Produk {place.offerings_summary?.products || 0} · Jasa {place.offerings_summary?.services || 0} · Tersedia {place.offerings_summary?.available || 0}</p>
</div>
<span className='inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-black text-[#087F6D]'>
<BaseIcon path={mdiPackageVariantClosed} size={18} /> {place.live_status?.updated_label || 'Update berkala'}
</span>
</div>
<div className='mt-5 grid gap-3 md:grid-cols-2'>
{(place.offerings || []).length ? (place.offerings || []).map((offering) => (
<div key={offering.id} className='rounded-2xl bg-white p-4'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-black'>{offering.name}</p>
<p className='mt-1 text-sm leading-6 text-[#5D6B62]'>{offering.description || 'Detail belum tersedia.'}</p>
</div>
{offering.is_verified ? <BaseIcon path={mdiShieldCheckOutline} size={20} className='text-[#087F6D]' /> : null}
</div>
<div className='mt-3 flex flex-wrap items-center gap-2'>
<span className={`rounded-full px-3 py-1 text-xs font-black ${stockColor(offering.stock_status)}`}>{offering.stock_label}</span>
<span className='rounded-full bg-[#F7F2E8] px-3 py-1 text-xs font-bold text-[#5D6B62]'>{offering.offering_type === 'service' ? 'Jasa' : 'Produk'}</span>
<span className='rounded-full bg-[#F7F2E8] px-3 py-1 text-xs font-bold text-[#5D6B62]'>{formatRupiah(offering.price)}</span>
</div>
</div>
)) : (
<p className='rounded-2xl bg-white p-4 text-sm text-[#5D6B62]'>Belum ada produk atau jasa. Pemilik listing dapat menambahkannya dari panel admin.</p>
)}
</div>
</div>
</div>
</article>
<aside className='space-y-5'>
<div className='rounded-[2rem] bg-white p-6 shadow-sm'>
<h2 className='text-2xl font-black'>Kontak cepat</h2>
<div className='mt-5 space-y-3'>
{place.whatsapp_number ? (
<a href={`https://wa.me/${place.whatsapp_number.replace(/\D/g, '')}`} target='_blank' rel='noreferrer' className='flex items-center gap-3 rounded-2xl bg-[#E8F6F1] p-4 font-bold text-[#087F6D] hover:bg-[#D8EFE7]'>
<BaseIcon path={mdiWhatsapp} size={22} /> WhatsApp
</a>
) : null}
{place.phone_number ? (
<a href={`tel:${place.phone_number}`} className='flex items-center gap-3 rounded-2xl bg-[#F7F2E8] p-4 font-bold text-[#17231B] hover:bg-[#EFE4CF]'>
<BaseIcon path={mdiPhoneOutline} size={22} /> {place.phone_number}
</a>
) : null}
{place.website_url ? (
<a href={place.website_url} target='_blank' rel='noreferrer' className='flex items-center gap-3 rounded-2xl bg-[#F7F2E8] p-4 font-bold text-[#17231B] hover:bg-[#EFE4CF]'>
<BaseIcon path={mdiWeb} size={22} /> Website
</a>
) : null}
{!place.whatsapp_number && !place.phone_number && !place.website_url ? (
<p className='rounded-2xl bg-[#F7F2E8] p-4 text-sm text-[#5D6B62]'>Kontak belum tersedia. Admin bisa menambahkannya dari panel admin.</p>
) : null}
</div>
</div>
<div className='rounded-[2rem] bg-white p-6 shadow-sm'>
<h2 className='text-2xl font-black'>Ringkasan sinyal lokal</h2>
<div className='mt-5 space-y-3'>
<SignalRow icon={mdiNavigationVariantOutline} label='Radius zone' value={`${place.radius_zone?.range || '-'} · ${place.radius_zone?.label || '-'}`} />
<SignalRow icon={mdiClockOutline} label='Live status' value={`${place.live_status?.label || '-'} · ${place.live_status?.crowd || '-'}`} />
<SignalRow icon={mdiPackageVariantClosed} label='Top stok' value={topOfferings[0]?.name || 'Belum ada'} />
<SignalRow icon={mdiStar} label='Reputasi' value={`${place.rating_average || '-'} dari ${place.rating_count || 0} ulasan`} />
</div>
</div>
<div className='rounded-[2rem] bg-[#073B3A] p-6 text-white shadow-xl shadow-[#073B3A]/20'>
<BaseIcon path={mdiMapMarkerRadiusOutline} size={30} className='text-[#F2A541]' />
<h2 className='mt-4 text-2xl font-black'>Untuk pemilik data</h2>
<p className='mt-3 text-sm leading-6 text-white/75'>Masuk ke panel admin untuk memperbarui detail, kontak, kategori, rating, status tempat, produk, jasa, dan stok.</p>
<div className='mt-5'>
<BaseButton href='/login' label='Masuk admin' color='warning' roundedFull className='border-0 font-black text-[#073B3A]' />
</div>
</div>
</aside>
</div>
)}
</section>
</main>
</>
)
}
const InfoCard = ({ label, value, icon }: { label: string; value: string; icon?: string }) => (
<div className='rounded-2xl bg-[#F7F2E8] p-4'>
<div className='flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>
{icon ? <BaseIcon path={icon} size={15} /> : null}
{label}
</div>
<p className='mt-2 font-black'>{value}</p>
</div>
)
const ScoreBar = ({ label, value }: { label: string; value: number }) => (
<div className='mb-3 last:mb-0'>
<div className='mb-1 flex items-center justify-between text-xs text-[#5D6B62]'>
<span>{label}</span>
<span className='font-black'>{Math.round(value)}</span>
</div>
<div className='h-2 overflow-hidden rounded-full bg-[#F7F2E8]'>
<div className='h-full rounded-full bg-[#2CA58D]' style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</div>
</div>
)
const SignalRow = ({ icon, label, value }: { icon: string; label: string; value: string }) => (
<div className='flex items-start gap-3 rounded-2xl bg-[#F7F2E8] p-4'>
<BaseIcon path={icon} size={20} className='mt-0.5 text-[#087F6D]' />
<div>
<p className='text-xs font-bold uppercase tracking-wide text-[#6A7A70]'>{label}</p>
<p className='mt-1 font-black'>{value}</p>
</div>
</div>
)
PlaceDetailPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>
}