Autosave: 20260617-143144
This commit is contained in:
parent
2fbb512497
commit
e84665755d
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
80
backend/src/db/models/place_offerings.js
Normal file
80
backend/src/db/models/place_offerings.js
Normal 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;
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
1010
backend/src/db/seeders/20260617120000-public-search-quality-data.js
Normal file
1010
backend/src/db/seeders/20260617120000-public-search-quality-data.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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');
|
||||
|
||||
|
||||
|
||||
1154
backend/src/routes/publicPlaces.js
Normal file
1154
backend/src/routes/publicPlaces.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
@ -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
425
frontend/src/pages/tempat/[placeId].tsx
Normal file
425
frontend/src/pages/tempat/[placeId].tsx
Normal 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>
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user