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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const places = sequelize.define(
|
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, {
|
db.places.hasMany(db.reviews, {
|
||||||
as: 'reviews_place',
|
as: 'reviews_place',
|
||||||
foreignKey: {
|
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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -16,6 +15,7 @@ const fileRoutes = require('./routes/file');
|
|||||||
const searchRoutes = require('./routes/search');
|
const searchRoutes = require('./routes/search');
|
||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
|
const publicPlacesRoutes = require('./routes/publicPlaces');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
@ -100,6 +100,7 @@ app.use(bodyParser.json());
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
|
app.use('/api/public', publicPlacesRoutes);
|
||||||
app.enable('trust proxy');
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
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