diff --git a/app/[locale]/[category]/[slug]/SaveButton.tsx b/app/[locale]/[category]/[slug]/SaveButton.tsx new file mode 100644 index 0000000..9ea3bea --- /dev/null +++ b/app/[locale]/[category]/[slug]/SaveButton.tsx @@ -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 ( + + ) +} diff --git a/app/[locale]/[category]/[slug]/page.tsx b/app/[locale]/[category]/[slug]/page.tsx index f9661b7..f751921 100644 --- a/app/[locale]/[category]/[slug]/page.tsx +++ b/app/[locale]/[category]/[slug]/page.tsx @@ -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 (
-
+
- {/* Breadcrumb / Category Link */} -
+ {/* Breadcrumb */} +
marmaris local / @@ -72,8 +101,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{/* Hero Details Block */} -
- +
{/* Left: Gallery Panel */} @@ -107,8 +135,6 @@ export default async function ListingDetailPage({ params }: DetailPageProps) { {/* Right: Info Panel */}
- - {/* Badge & Title */}
@@ -141,12 +167,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{/* Description */} -
+
{description}
- {/* Contact Actions */} -
+ {/* Contact & Actions Grid */} +

{t('contact')}

@@ -171,28 +197,52 @@ export default async function ListingDetailPage({ params }: DetailPageProps) { {t('whatsapp')} )} + + {/* Menu Link */} + {listing.menuUrl && ( + + + {t('viewMenu')} + + )} + + {/* Save Button (Favorites client action) */} +
{/* External links */} -
+
{listing.website && ( - - + + {t('website')} )} {listing.instagram && ( - - + + {t('instagram')} )} + + {/* WhatsApp Share button */} + + + {t('shareWhatsapp')} +
-
-
{/* Details footer (Hours & Location map) */} @@ -245,15 +295,88 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
)} -
-
+ {/* Instagram Feed Cache (Phase 2) */} + {instagramFeed && instagramPosts.length > 0 && ( +
+
+
+ IG +
+
+

@{instagramFeed.handle}

+

{t('instagramFeed')}

+
+
+ +
+ {instagramPosts.slice(0, 3).map((post: any, idx: number) => ( + + {post.caption +
+

+ {post.caption} +

+
+
+ ))} +
+
+ )} + + {/* Newly Added Widget (Phase 2) */} + {latestListings.length > 0 && ( +
+
+

+ {t('newlyAdded')} +

+

+ {t('newlyAddedSubtitle')} +

+
+ +
+ {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 ( + +
+ {newestName} +
+
+

{newestName}

+

{newest.neighborhood?.nameTr}

+
+ + ) + })} +
+
+ )} + {/* Related Section */} {relatedListings.length > 0 && ( -
-

+
+

{t('related')}

diff --git a/app/[locale]/admin/blog/[id]/BlogForm.tsx b/app/[locale]/admin/blog/[id]/BlogForm.tsx new file mode 100644 index 0000000..4b17025 --- /dev/null +++ b/app/[locale]/admin/blog/[id]/BlogForm.tsx @@ -0,0 +1,399 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { createOrUpdateBlogPostAction } from '@/app/actions' +import { ArrowLeft, Save, Eye, Edit3 } from 'lucide-react' +import { Link } from '@/i18n/routing' + +interface Option { + value: string + label: string +} + +interface FormProps { + post: any | null + listingOptions: Option[] +} + +// Simple local markdown preview renderer helper +function previewMarkdown(md: string): string { + if (!md) return '' + let html = md + .replace(/&/g, '&') + .replace(//g, '>') + + html = html.replace(/\*\*(.*?)\*\*/g, '$1') + html = html.replace(/\*(.*?)\*/g, '$1') + html = html.replace(/^### (.*?)$/gm, '

$1

') + html = html.replace(/^## (.*?)$/gm, '

$1

') + html = html.replace(/^# (.*?)$/gm, '

$1

') + html = html.replace(/^\* (.*?)$/gm, '
  • $1
  • ') + html = html.replace(/^- (.*?)$/gm, '
  • $1
  • ') + html = html.replace(/\[(.*?)\]\((.*?)\)/g, '$1') + + const paragraphs = html.split(/\n\n+/) + return paragraphs.map(p => { + const t = p.trim() + if (!t) return '' + if (t.startsWith('${t.replace(/\n/g, '
    ')}

    ` + }).join('\n') +} + +export default function BlogForm({ post, listingOptions }: FormProps) { + const router = useRouter() + const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr') + const [editorMode, setEditorMode] = useState<'edit' | 'preview'>('edit') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + + // Markdown content states for live preview + const [contentTr, setContentTr] = useState(post?.contentTr || '') + const [contentEn, setContentEn] = useState(post?.contentEn || '') + const [contentRu, setContentRu] = useState(post?.contentRu || '') + + // Selected Listings + const [selectedListings, setSelectedListings] = useState(post?.relatedListingIds || []) + const [listingSearch, setListingSearch] = useState('') + + const handleListingToggle = (id: string) => { + setSelectedListings(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + setSuccess(false) + + const formData = new FormData(e.currentTarget) + if (post) { + formData.append('id', post.id) + } + formData.append('relatedListingIds', selectedListings.join(',')) + + try { + const res = await createOrUpdateBlogPostAction(formData) + if (res.error) { + setError(res.error) + } else { + setSuccess(true) + setTimeout(() => { + router.push('/admin/blog') + router.refresh() + }, 1500) + } + } catch (err) { + setError('İşlem sırasında bir hata oluştu.') + } finally { + setLoading(false) + } + } + + const filteredListings = listingOptions.filter(opt => + opt.label.toLowerCase().includes(listingSearch.toLowerCase()) + ) + + const activeContent = activeTab === 'tr' ? contentTr : activeTab === 'en' ? contentEn : contentRu + + return ( +
    + {error && ( +
    + {error} +
    + )} + + {success && ( +
    + Yazı başarıyla kaydedildi! Yönlendiriliyorsunuz... +
    + )} + + {/* Language tabs */} +
    +
    + {(['tr', 'en', 'ru'] as const).map((lang) => { + const label = lang === 'tr' ? '🇹🇷 Türkçe' : lang === 'en' ? '🇬🇧 English' : '🇷🇺 Русский' + const isTabActive = activeTab === lang + return ( + + ) + })} +
    + + {/* Edit / Preview controls */} +
    + + +
    +
    + + {/* Localized inputs */} +
    + {activeTab === 'tr' && ( +
    +
    + + +
    + +
    + + {editorMode === 'edit' ? ( +