feat: Implement Phase 2 features including blog, collections, saved listings, manifest, and api cron sync

This commit is contained in:
AyrisAI
2026-07-12 20:49:41 +03:00
parent 862544b5a1
commit f696d359b0
29 changed files with 3066 additions and 194 deletions
@@ -0,0 +1,58 @@
'use client'
import { useState, useEffect } from 'react'
import { Heart } from 'lucide-react'
interface Props {
listingId: string
saveLabel: string
savedLabel: string
}
export default function SaveButton({ listingId, saveLabel, savedLabel }: Props) {
const [isSaved, setIsSaved] = useState(false)
useEffect(() => {
try {
const stored = localStorage.getItem('savedListingIds')
if (stored) {
const ids = JSON.parse(stored) as string[]
setIsSaved(ids.includes(listingId))
}
} catch (e) {
console.error('Error reading localStorage:', e)
}
}, [listingId])
const toggleSave = () => {
try {
const stored = localStorage.getItem('savedListingIds')
let ids: string[] = stored ? JSON.parse(stored) : []
if (ids.includes(listingId)) {
ids = ids.filter(id => id !== listingId)
setIsSaved(false)
} else {
ids.push(listingId)
setIsSaved(true)
}
localStorage.setItem('savedListingIds', JSON.stringify(ids))
window.dispatchEvent(new Event('favorites-updated'))
} catch (err) {
console.error('Error writing localStorage:', err)
}
}
return (
<button
onClick={toggleSave}
className={`flex items-center justify-center gap-2 border font-bold text-xs py-3.5 px-4 rounded-xl transition ${
isSaved
? 'bg-bougainvillea/5 border-bougainvillea/20 text-bougainvillea'
: 'bg-paper border-pine/15 hover:bg-stone text-pine'
}`}
>
<Heart className={`w-4 h-4 ${isSaved ? 'fill-bougainvillea text-bougainvillea' : 'text-pine/70'}`} />
{isSaved ? savedLabel : saveLabel}
</button>
)
}
+146 -23
View File
@@ -5,12 +5,25 @@ import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare } from 'lucide-react'
import { Link } from '@/i18n/routing'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
import SaveButton from './SaveButton'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
}
export async function generateMetadata({ params }: DetailPageProps) {
const { slug } = await params
const listing = await mockDb.getListingBySlug(slug)
if (!listing) return {}
return {
title: `${listing.nameTr} — Marmaris Local`,
description: listing.descriptionTr
}
}
export default async function ListingDetailPage({ params }: DetailPageProps) {
const { locale, category, slug } = await params
setRequestLocale(locale)
@@ -30,6 +43,16 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
.filter(l => l.id !== listing.id)
.slice(0, 3)
// Fetch newest 3 listings (excluding current)
const allListings = await mockDb.getListings()
const latestListings = allListings
.filter(l => l.id !== listing.id)
.slice(0, 3)
// Fetch Instagram Feed Cache
const instagramFeed = await mockDb.getInstagramFeedCacheByListingId(listing.id)
const instagramPosts = instagramFeed?.posts ? (instagramFeed.posts as any[]) : []
// Localized values
const name =
locale === 'ru'
@@ -46,22 +69,28 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
: listing.descriptionTr
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
// Format WhatsApp Link
const getWhatsAppLink = (number: string) => {
// Clean spaces, parenthesis, plus sign
const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}`
}
// WhatsApp Share deep link text
const getShareLink = () => {
const text = encodeURIComponent(`Marmaris Local'da harika bir yer keşfettim: ${name}\nDetayları incele: https://marmarislocal.com/${locale}/${categorySlug}/${listing.slug}`)
return `https://wa.me/?text=${text}`
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
{/* Breadcrumb / Category Link */}
<div className="mb-6 text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
{/* Breadcrumb */}
<div className="text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
<span className="hover:text-turquoise transition-colors">marmaris local</span>
<span>/</span>
<span className="hover:text-turquoise transition-colors">
@@ -72,8 +101,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
{/* Hero Details Block */}
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm mb-10 space-y-8">
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-8">
<div className="flex flex-col lg:flex-row gap-10">
{/* Left: Gallery Panel */}
@@ -107,8 +135,6 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{/* Right: Info Panel */}
<div className="flex-1 flex flex-col justify-between space-y-6">
{/* Badge & Title */}
<div className="space-y-4">
<div className="flex flex-wrap gap-2.5 items-center">
<span className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider bg-bougainvillea/5 border border-bougainvillea/10 px-2.5 py-1 rounded-full">
@@ -141,12 +167,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
{/* Description */}
<div className="text-ink/80 text-sm leading-relaxed font-medium">
<div className="text-ink/80 text-sm sm:text-base leading-relaxed font-medium">
{description}
</div>
{/* Contact Actions */}
<div className="space-y-4">
{/* Contact & Actions Grid */}
<div className="space-y-4 pt-2">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -171,28 +197,52 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{t('whatsapp')}
</a>
)}
{/* Menu Link */}
{listing.menuUrl && (
<a
href={listing.menuUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-paper text-pine font-bold text-xs py-3.5 px-4 rounded-xl border border-pine/15 hover:bg-stone/50 transition sm:col-span-2"
>
<Globe className="w-4 h-4 text-turquoise" />
{t('viewMenu')}
</a>
)}
{/* Save Button (Favorites client action) */}
<SaveButton
listingId={listing.id}
saveLabel={t('save')}
savedLabel={t('saved')}
/>
</div>
{/* External links */}
<div className="flex gap-4 pt-2 text-xs font-semibold text-shutter">
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-shutter">
{listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('website')}
</a>
)}
{listing.instagram && (
<a href={listing.instagram} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
<a href={`https://instagram.com/${listing.instagram}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('instagram')}
</a>
)}
{/* WhatsApp Share button */}
<a href={getShareLink()} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Share2 className="w-4 h-4 text-turquoise" />
{t('shareWhatsapp')}
</a>
</div>
</div>
</div>
</div>
{/* Details footer (Hours & Location map) */}
@@ -245,15 +295,88 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
</div>
)}
</div>
</div>
{/* Instagram Feed Cache (Phase 2) */}
{instagramFeed && instagramPosts.length > 0 && (
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-6">
<div className="flex items-center gap-3 border-b border-dashed border-pine/8 pb-4">
<div className="w-10 h-10 rounded-full border border-pine/10 flex items-center justify-center relative bg-stone shrink-0">
<span className="font-heading font-bold text-pine text-xs tracking-tighter">IG</span>
</div>
<div>
<h3 className="font-heading font-bold text-sm text-pine lowercase">@{instagramFeed.handle}</h3>
<p className="text-[10px] font-mono text-shutter uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{instagramPosts.slice(0, 3).map((post: any, idx: number) => (
<a
key={idx}
href={post.permalink || '#'}
target="_blank"
rel="noopener noreferrer"
className="group relative aspect-square rounded-2xl overflow-hidden bg-stone border border-pine/5 shadow-sm block"
>
<img
src={post.imageUrl}
alt={post.caption || 'Instagram post'}
className="w-full h-full object-cover group-hover:scale-103 transition duration-500"
/>
<div className="absolute inset-0 bg-pine/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 p-4 flex flex-col justify-end">
<p className="text-[11px] text-stone font-medium line-clamp-3 leading-relaxed">
{post.caption}
</p>
</div>
</a>
))}
</div>
</div>
)}
{/* Newly Added Widget (Phase 2) */}
{latestListings.length > 0 && (
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-6">
<div className="border-b border-dashed border-pine/8 pb-4">
<h3 className="text-lg font-heading font-extrabold text-pine lowercase">
{t('newlyAdded')}
</h3>
<p className="text-ink/65 text-xs font-medium mt-0.5">
{t('newlyAddedSubtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{latestListings.map((newest) => {
const catSlug = newest.category?.slug || 'isletmeler'
const newestName = locale === 'en' ? newest.nameEn : locale === 'ru' ? newest.nameRu : newest.nameTr
const newestImg = newest.images && newest.images.length > 0 ? newest.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
return (
<Link
key={newest.id}
href={`/${catSlug}/${newest.slug}`}
className="flex gap-4 items-center group bg-stone/20 p-3.5 rounded-2xl border border-pine/5 hover:border-turquoise/25 transition duration-150"
>
<div className="w-16 h-16 rounded-xl overflow-hidden bg-stone shrink-0 border border-pine/8">
<img src={newestImg} alt={newestName} className="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
</div>
<div>
<h4 className="font-heading font-bold text-xs text-pine lowercase line-clamp-1 group-hover:text-turquoise transition-colors">{newestName}</h4>
<p className="text-[10px] text-shutter font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
</div>
</Link>
)
})}
</div>
</div>
)}
{/* Related Section */}
{relatedListings.length > 0 && (
<div className="space-y-6 pt-10">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase">
<div className="space-y-6 pt-6">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
{t('related')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">