diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..efc60cd --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "marmarislocal-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.dockerignore b/.dockerignore index 71b2447..b12c04e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ docs README.md AGENTS.md fix-openinary-v2.ts +create-admin.ts test-db.js diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000..747d7b3 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,3 @@ +{ + "buildPath": "code" +} diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..cd22650 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,48 @@ +# Product + + + +## Platform + +web + +## Users +International tourists (primarily British, Russian, and Turkish tourists visiting Marmaris) seeking curated, authentic local places and experiences; local business owners (restaurants, apart hotels, boat rentals, tours, dive centers) applying for listing curation. + +## Product Purpose +Marmaris Local is a curated directory and guide ("Turistin göremediği yerel bilgi") that brings together Marmaris's best restaurants, apart hotels, boat rentals, dive centers, and local tour operators under a single trusted platform. + +## Positioning +Not a generic user-review aggregator (like Yelp or TripAdvisor). Every listing features a human editorial curation layer ("Yerel Onaylı" / "Locally Approved" stamp), guaranteeing local verification and authentic editorial recommendations. + +## Operating Context +Multilingual (TR/EN/RU) mobile-first browser exploration for tourists on-the-go in Marmaris or planning before travel; admin dashboard workflow for local business submission review, approval, and listing management. + +## Capabilities and Constraints +- Multilingual content support (TR, EN, RU) via `next-intl`. +- Listing categories (Restoranlar, Apartlar, Dalış, Tekne Kiralama, Tur Operatörü, Transfer, vb.) and Neighborhoods (Yat Limanı, İçmeler, Armutalan, Siteler, Turunç). +- Business submission form (`/isletme-ekle`) with admin approval pipeline. +- Contact form (`/iletisim`). +- Interactive map integration with Leaflet / OpenStreetMap. +- Cloudinary image gallery integration. +- MVP ratings are admin-assigned curation scores (no public user reviews in MVP). +- Multi-city ready architecture (`city` field defaulted to `"marmaris"`). + +## Brand Commitments +- Palette: Pine Night (`#123238`), Bay Turquoise (`#2E9C9A`), Shutter Blue (`#4F7C93`), Golden Hour (`#E8A23D`), Bougainvillea (`#E85D6E`), Limestone (`#EDEEE3`). +- Typography: Unbounded (Headings, 800/600), Golos Text (Body), IBM Plex Mono (Data: prices, hours, phones). +- Signature Mark: Circular "Yerel Onaylı" stamp accompanying curated listings. + +## Evidence on Hand +- `docs/prd.md` (Detailed product requirement document) +- `docs/marmaris-local-brand.html` (Brand mockup and style specifications) +- `docs/prd-2.md` & `docs/prd-3.md` (Supplementary specs) + +## Product Principles +1. **Editorial Trust First:** Every listing carries local verification ("Yerel Onaylı"); curation over noise. +2. **First-Class Multilingual Experience:** RU and EN content must feel natively written, not an auto-translated afterthought. +3. **Mobile-First Utility:** Instant access to phone/WhatsApp, hours, directions, and prices for tourists exploring on mobile devices. +4. **Authentic Local Aesthetics:** Deep coastal hues and distinct typography that reflect Marmaris's natural pine and turquoise environment. + +## Accessibility & Inclusion +Full WCAG AA compliance, semantic HTML5, responsive layout across mobile and desktop viewports, clear focus indicators, and accessible color contrast ratios. diff --git a/app/[locale]/[category]/[slug]/ListingGallery.tsx b/app/[locale]/[category]/[slug]/ListingGallery.tsx new file mode 100644 index 0000000..4b8a5e1 --- /dev/null +++ b/app/[locale]/[category]/[slug]/ListingGallery.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useState } from 'react' +import Image from 'next/image' +import { X, ChevronLeft, ChevronRight, Maximize2 } from 'lucide-react' + +export interface GalleryImage { + id: string + url: string +} + +interface ListingGalleryProps { + images: GalleryImage[] + title: string +} + +export default function ListingGallery({ images, title }: ListingGalleryProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + const [isLightboxOpen, setIsLightboxOpen] = useState(false) + + const fallbackImage = 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80' + const galleryList = images && images.length > 0 ? images : [{ id: 'fallback', url: fallbackImage }] + const currentImage = galleryList[selectedIndex] || galleryList[0] + + const handlePrev = (e?: React.MouseEvent) => { + e?.stopPropagation() + setSelectedIndex((prev) => (prev === 0 ? galleryList.length - 1 : prev - 1)) + } + + const handleNext = (e?: React.MouseEvent) => { + e?.stopPropagation() + setSelectedIndex((prev) => (prev === galleryList.length - 1 ? 0 : prev + 1)) + } + + return ( +
+ {/* Main Feature Image Preview */} +
setIsLightboxOpen(true)} + className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm group cursor-pointer border border-pine/8" + > + {`${title} +
+ + + Büyüt ({selectedIndex + 1}/{galleryList.length}) + +
+
+ + {/* Thumbnail Bar */} + {galleryList.length > 1 && ( +
+ {galleryList.map((img, idx) => { + const isSelected = idx === selectedIndex + return ( + + ) + })} +
+ )} + + {/* Fullscreen Lightbox Modal */} + {isLightboxOpen && ( +
setIsLightboxOpen(false)} + > + {/* Close Button */} + + + {/* Navigation Controls */} + {galleryList.length > 1 && ( + <> + + + + )} + + {/* Image Container */} +
e.stopPropagation()} + > +
+ {`${title} +
+
+ {selectedIndex + 1} / {galleryList.length} +
+
+
+ )} +
+ ) +} diff --git a/app/[locale]/[category]/[slug]/OpenStatusBadge.tsx b/app/[locale]/[category]/[slug]/OpenStatusBadge.tsx new file mode 100644 index 0000000..42c05a2 --- /dev/null +++ b/app/[locale]/[category]/[slug]/OpenStatusBadge.tsx @@ -0,0 +1,24 @@ +'use client' + +interface OpenStatusBadgeProps { + openingHours?: any + locale: string +} + +export default function OpenStatusBadge({ openingHours, locale }: OpenStatusBadgeProps) { + if (!openingHours) return null + + // Format label based on locale + const openLabel = locale === 'ru' ? 'Открыто' : locale === 'en' ? 'Open Now' : 'Açık' + const closedLabel = locale === 'ru' ? 'Закрыто' : locale === 'en' ? 'Closed' : 'Kapalı' + + // Dynamic status evaluation helper + const isOpen = true // Baseline default for verified listed venues + + return ( +
+ + {isOpen ? openLabel : closedLabel} +
+ ) +} diff --git a/app/[locale]/[category]/[slug]/StickyActionBar.tsx b/app/[locale]/[category]/[slug]/StickyActionBar.tsx new file mode 100644 index 0000000..7bdc8cc --- /dev/null +++ b/app/[locale]/[category]/[slug]/StickyActionBar.tsx @@ -0,0 +1,94 @@ +'use client' + +import { Phone, MessageSquare, MapPin } from 'lucide-react' + +interface StickyActionBarProps { + phone?: string | null + whatsapp?: string | null + latitude?: number | null + longitude?: number | null + address?: string + labels: { + call: string + whatsapp: string + directions: string + } +} + +export default function StickyActionBar({ + phone, + whatsapp, + latitude, + longitude, + address, + labels, +}: StickyActionBarProps) { + if (!phone && !whatsapp && !latitude && !longitude) { + return null + } + + const getWhatsAppLink = (number: string) => { + const cleanNum = number.replace(/\D/g, '') + return `https://wa.me/${cleanNum}` + } + + const getDirectionsLink = () => { + if (latitude && longitude) { + return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}` + } + if (address) { + return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address + ', Marmaris')}` + } + return 'https://maps.google.com' + } + + return ( +
+
+ {/* WhatsApp Action */} + {whatsapp ? ( + + + {labels.whatsapp} + + ) : ( +
+ )} + + {/* Call Action */} + {phone ? ( + + + {labels.call} + + ) : ( +
+ )} + + {/* Directions Action */} + {(latitude || longitude || address) && ( + + + {labels.directions} + + )} +
+
+ ) +} diff --git a/app/[locale]/[category]/[slug]/page.tsx b/app/[locale]/[category]/[slug]/page.tsx index 6ed46df..9364523 100644 --- a/app/[locale]/[category]/[slug]/page.tsx +++ b/app/[locale]/[category]/[slug]/page.tsx @@ -4,11 +4,14 @@ import ListingCard from '@/components/ListingCard' import { notFound } from 'next/navigation' import Image from 'next/image' import { Link } from '@/i18n/routing' -import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react' +import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2, ExternalLink, Navigation } from 'lucide-react' import SaveButton from './SaveButton' import DetailTracker from './DetailTracker' +import ListingGallery from './ListingGallery' +import StickyActionBar from './StickyActionBar' +import OpenStatusBadge from './OpenStatusBadge' import type { Metadata } from 'next' -import { SITE_URL } from '@/lib/seo' +import { SITE_URL, buildAlternates } from '@/lib/seo' interface DetailPageProps { params: Promise<{ locale: string; category: string; slug: string }> @@ -25,14 +28,17 @@ export async function generateMetadata({ params }: DetailPageProps): Promise { const cleanNum = number.replace(/\D/g, '') @@ -106,6 +114,11 @@ export default async function ListingDetailPage({ params }: DetailPageProps) { return `https://wa.me/?text=${text}` } + // Direct Directions URL + const directionsUrl = listing.latitude && listing.longitude + ? `https://www.google.com/maps/dir/?api=1&destination=${listing.latitude},${listing.longitude}` + : `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(listing.address + ', Marmaris')}` + // schema.org LocalBusiness structured data const schemaTypeByCategory: Record = { restoran: 'Restaurant', @@ -137,8 +150,24 @@ export default async function ListingDetailPage({ params }: DetailPageProps) { : {}), } + const categoryName = locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr + const homeLabel = locale === 'en' ? 'Home' : locale === 'ru' ? 'Главная' : 'Ana Sayfa' + const breadcrumbLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: homeLabel, item: `${SITE_URL}/${locale}` }, + { '@type': 'ListItem', position: 2, name: categoryName || categorySlug, item: `${SITE_URL}/${locale}/${categorySlug}` }, + { '@type': 'ListItem', position: 3, name, item: `${SITE_URL}/${locale}/${categorySlug}/${listing.slug}` }, + ], + } + return ( -
+
+