first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
|
AGENTS.md
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Framework: Next.js 16, App Router, TypeScript strict
|
||||||
|
- Styling: Tailwind CSS v4
|
||||||
|
- UI: shadcn/ui (new-york style, OKLCH)
|
||||||
|
- Animation: Framer Motion
|
||||||
|
- Icons: Lucide React
|
||||||
|
- i18n: next-intl
|
||||||
|
- ORM: Prisma + PostgreSQL
|
||||||
|
- Auth: NextAuth.js v5
|
||||||
|
- Media: Cloudinary
|
||||||
|
- Deploy: Coolify (Docker, standalone output)
|
||||||
|
|
||||||
|
## Sabit Tercihler
|
||||||
|
- Mock data: USE_MOCK=true (demo aşaması)
|
||||||
|
- proxy.ts kullan — middleware.ts deprecated (Next.js 15.3+)
|
||||||
|
- İletişim formu sadece /iletisim sayfasında — ana sayfada olmaz
|
||||||
|
- Footer'da "Created by ayris.tech" linki zorunlu
|
||||||
|
- Dockerfile'da dummy DATABASE_URL (prisma generate için)
|
||||||
|
|
||||||
|
## Altyapı
|
||||||
|
- Gitea: https://git.ayris.tech (kullanıcı: ayrisdev)
|
||||||
|
- Coolify: https://client2.ayris.tech
|
||||||
|
- Cloudflare zone: ayris.tech
|
||||||
|
- Server IP: 188.245.175.169
|
||||||
|
|
||||||
|
## docs/ Klasörü
|
||||||
|
- docs/prd.md → ana içerik kaynağı
|
||||||
|
- docs/*.html → varsa mevcut site içeriği
|
||||||
|
- docs/*.md → ek belgeler
|
||||||
|
|
||||||
|
## Aktif Skill'ler
|
||||||
|
- nextjs-seo → sitemap, metadata, robots.txt
|
||||||
|
- next-best-practices → kod kalitesi
|
||||||
|
- nextjs-app-router-patterns → Server Actions, Suspense
|
||||||
|
- demo-site → komple site üretimi
|
||||||
|
- design-demo → görsel kalite
|
||||||
|
- coolify-deploy → deploy pipeline
|
||||||
|
|
||||||
|
## Proje Özel Notlar
|
||||||
|
<!-- Buraya proje bazlı notlar ekle -->
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Prisma generate için dummy URL — build sırasında gerçek DB gerekmez
|
||||||
|
ARG DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
|
||||||
|
ENV DATABASE_URL=$DATABASE_URL
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
RUN mkdir .next && chown nextjs:nodejs .next
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
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'
|
||||||
|
|
||||||
|
interface DetailPageProps {
|
||||||
|
params: Promise<{ locale: string; category: string; slug: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ListingDetailPage({ params }: DetailPageProps) {
|
||||||
|
const { locale, category, slug } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const t = await getTranslations('detail')
|
||||||
|
|
||||||
|
const listing = await mockDb.getListingBySlug(slug)
|
||||||
|
if (!listing) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch similar listings in same category (limit to 3, excluding current)
|
||||||
|
const allCategoryListings = await mockDb.getListings({
|
||||||
|
categoryId: listing.categoryId
|
||||||
|
})
|
||||||
|
const relatedListings = allCategoryListings
|
||||||
|
.filter(l => l.id !== listing.id)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
// Localized values
|
||||||
|
const name =
|
||||||
|
locale === 'ru'
|
||||||
|
? listing.nameRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.nameEn
|
||||||
|
: listing.nameTr
|
||||||
|
|
||||||
|
const description =
|
||||||
|
locale === 'ru'
|
||||||
|
? listing.descriptionRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.descriptionEn
|
||||||
|
: listing.descriptionTr
|
||||||
|
|
||||||
|
const priceSymbols = '₺'.repeat(listing.priceRange)
|
||||||
|
|
||||||
|
// Format WhatsApp Link
|
||||||
|
const getWhatsAppLink = (number: string) => {
|
||||||
|
// Clean spaces, parenthesis, plus sign
|
||||||
|
const cleanNum = number.replace(/\D/g, '')
|
||||||
|
return `https://wa.me/${cleanNum}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Breadcrumb / Category Link */}
|
||||||
|
<div className="mb-6 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">
|
||||||
|
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
|
||||||
|
</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-pine font-bold">{name}</span>
|
||||||
|
</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="flex flex-col lg:flex-row gap-10">
|
||||||
|
|
||||||
|
{/* Left: Gallery Panel */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm">
|
||||||
|
<Image
|
||||||
|
src={listing.images && listing.images.length > 0 ? listing.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails if multiple images exist */}
|
||||||
|
{listing.images && listing.images.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{listing.images.slice(1, 5).map((img, idx) => (
|
||||||
|
<div key={img.id} className="aspect-square relative rounded-xl overflow-hidden bg-stone-deep border border-pine/5 shadow-sm">
|
||||||
|
<Image
|
||||||
|
src={img.url}
|
||||||
|
alt={`${name} thumbnail ${idx + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover hover:scale-105 transition duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{listing.isLocalApproved && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-turquoise font-bold uppercase tracking-wider bg-turquoise/5 border border-turquoise/10 px-2.5 py-1 rounded-full">
|
||||||
|
★ {t('approved')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-heading font-extrabold text-2xl sm:text-4xl text-pine leading-tight lowercase">
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Stars and Price level */}
|
||||||
|
<div className="flex items-center gap-6 font-mono text-sm border-b border-dashed border-pine/8 pb-4">
|
||||||
|
{listing.rating && (
|
||||||
|
<div className="flex items-center gap-1.5 text-gold font-bold">
|
||||||
|
<Star className="w-4 h-4 fill-gold stroke-gold" />
|
||||||
|
<span>{t('rating')}: {listing.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-pine font-semibold">
|
||||||
|
<span>{t('price')}: {priceSymbols}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="text-ink/80 text-sm leading-relaxed font-medium">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Actions */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">
|
||||||
|
{listing.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${listing.phone}`}
|
||||||
|
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition"
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
{t('call')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listing.whatsapp && (
|
||||||
|
<a
|
||||||
|
href={getWhatsAppLink(listing.whatsapp)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
{t('whatsapp')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* External links */}
|
||||||
|
<div className="flex gap-4 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" />
|
||||||
|
{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" />
|
||||||
|
{t('instagram')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details footer (Hours & Location map) */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-8 border-t border-dashed border-pine/12">
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
|
||||||
|
<MapPin className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>{t('address')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink/75 font-medium leading-relaxed">
|
||||||
|
{listing.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
|
||||||
|
<Clock className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>{t('hours')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink/75 font-medium">
|
||||||
|
{listing.openingHours ? (
|
||||||
|
<p>{(listing.openingHours as any).all || t('noHours')}</p>
|
||||||
|
) : (
|
||||||
|
<p>{t('noHours')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Frame */}
|
||||||
|
{listing.latitude && listing.longitude && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
|
||||||
|
<Globe className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>{t('location')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36">
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
frameBorder="0"
|
||||||
|
scrolling="no"
|
||||||
|
marginHeight={0}
|
||||||
|
marginWidth={0}
|
||||||
|
src={`https://maps.google.com/maps?q=${listing.latitude},${listing.longitude}&t=&z=15&ie=UTF8&iwloc=&output=embed`}
|
||||||
|
className="w-full h-full shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</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">
|
||||||
|
{t('related')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{relatedListings.map((listingItem) => (
|
||||||
|
<ListingCard key={listingItem.id} listing={listingItem} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
|
||||||
|
export default async function AdminCategoriesPage() {
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">kategoriler</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Sistemde listelenen işletmelerin sınıflandırıldığı ana kategoriler.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">ID</th>
|
||||||
|
<th className="px-6 py-4 text-left">Slug</th>
|
||||||
|
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
||||||
|
<th className="px-6 py-4 text-left">Name (EN)</th>
|
||||||
|
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<tr key={cat.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 font-mono text-xs text-shutter">{cat.id}</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{cat.slug}</td>
|
||||||
|
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{cat.nameTr}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{cat.nameEn}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{cat.nameRu}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import { Link, usePathname } from '@/i18n/routing'
|
||||||
|
import { LayoutDashboard, FileText, Inbox, ClipboardList, Map, LogOut, Menu, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
|
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList },
|
||||||
|
{ name: 'Başvurular', href: '/admin/submissions', icon: FileText },
|
||||||
|
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
|
||||||
|
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard },
|
||||||
|
{ name: 'Mahalleler', href: '/admin/neighborhoods', icon: Map },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-stone text-ink flex font-sans">
|
||||||
|
{/* Mobile sidebar backdrop */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-pine/80 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className={`
|
||||||
|
fixed inset-y-0 left-0 z-50 w-64 bg-pine border-r border-white/10
|
||||||
|
transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
`}>
|
||||||
|
<div className="h-full flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
{/* Sidebar Brand Header */}
|
||||||
|
<div className="h-20 flex items-center px-6 border-b border-white/10 gap-3">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-full border border-stone/30 flex items-center justify-center relative bg-paper shrink-0">
|
||||||
|
<div className="absolute inset-[2px] rounded-full border border-dashed border-turquoise/50" />
|
||||||
|
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-heading font-extrabold text-sm text-stone tracking-tight leading-none lowercase">
|
||||||
|
marmaris <span className="text-turquoise">local</span>
|
||||||
|
</h1>
|
||||||
|
<span className="text-[8px] font-mono text-shutter tracking-wider uppercase">backoffice</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="ml-auto lg:hidden text-stone/70 hover:text-stone"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav className="px-3 py-6 space-y-1.5 overflow-y-auto">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href))
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={`
|
||||||
|
flex items-center px-4 py-3 text-xs font-semibold rounded-xl transition-all duration-150
|
||||||
|
${isActive
|
||||||
|
? 'bg-white/5 border-l-4 border-turquoise text-stone pl-3'
|
||||||
|
: 'text-stone/75 hover:bg-white/5 hover:text-stone'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<item.icon className={`mr-3 flex-shrink-0 h-4.5 w-4.5 ${isActive ? 'text-turquoise' : 'text-stone/50'}`} />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Action */}
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: '/' })}
|
||||||
|
className="flex w-full items-center px-4 py-3 text-xs font-semibold text-red-400 hover:text-red-300 rounded-xl hover:bg-red-950/20 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-3 h-4.5 w-4.5 text-red-400/70" />
|
||||||
|
Çıkış Yap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* Mobile Header Bar */}
|
||||||
|
<header className="h-16 flex items-center lg:hidden bg-pine border-b border-white/10 px-4 shrink-0 text-stone">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="text-stone hover:text-turquoise focus:outline-none"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<div className="ml-4 flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full border border-stone/20 flex items-center justify-center relative bg-paper shrink-0">
|
||||||
|
<span className="font-heading font-extrabold text-pine text-[10px] tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-heading font-bold lowercase">marmaris local <span className="text-turquoise">backoffice</span></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page Area */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { createOrUpdateListingAction } from '@/app/actions'
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
listing: any | null
|
||||||
|
categories: Option[]
|
||||||
|
neighborhoods: Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListingForm({ listing, categories, neighborhoods }: FormProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
if (listing) {
|
||||||
|
formData.append('id', listing.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await createOrUpdateListingAction(formData)
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error)
|
||||||
|
} else {
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/admin/listings')
|
||||||
|
router.refresh()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('İşlem sırasında bir hata oluştu.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialImages = listing?.images || []
|
||||||
|
const img1 = initialImages[0]?.url || ''
|
||||||
|
const img2 = initialImages[1]?.url || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-bougainvillea/10 border border-bougainvillea/25 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="bg-turquoise/10 border border-turquoise/25 text-turquoise p-4 rounded-xl text-xs font-bold">
|
||||||
|
Mekan başarıyla kaydedildi! Yönlendiriliyorsunuz...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Language tabs */}
|
||||||
|
<div className="border-b border-pine/8">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['tr', 'en', 'ru'] as const).map((lang) => {
|
||||||
|
const label = lang === 'tr' ? '🇹🇷 Türkçe' : lang === 'en' ? '🇬🇧 English' : '🇷🇺 Русский'
|
||||||
|
const isTabActive = activeTab === lang
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(lang)}
|
||||||
|
className={`py-3 px-4 font-heading font-bold text-xs border-b-2 transition lowercase ${
|
||||||
|
isTabActive
|
||||||
|
? 'border-turquoise text-turquoise'
|
||||||
|
: 'border-transparent text-shutter hover:text-pine'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multilingual input fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* TR Tab */}
|
||||||
|
{activeTab === 'tr' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (TR) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameTr"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.nameTr || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Örn: İskele Balık Ocakbaşı"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (TR) *</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionTr"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
defaultValue={listing?.descriptionTr || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Türkçe tanıtım metni..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EN Tab */}
|
||||||
|
{activeTab === 'en' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (EN) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameEn"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.nameEn || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Örn: Iskele Fish & Grill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (EN) *</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionEn"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
defaultValue={listing?.descriptionEn || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="English description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RU Tab */}
|
||||||
|
{activeTab === 'ru' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (RU) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nameRu"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.nameRu || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Örn: Рыбный Гриль Искеле"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (RU) *</label>
|
||||||
|
<textarea
|
||||||
|
name="descriptionRu"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
defaultValue={listing?.descriptionRu || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Russian description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dashed border-pine/8" />
|
||||||
|
|
||||||
|
{/* Global fields */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Slug */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">URL Slug *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="slug"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.slug || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="iskele-balik-ocakbasi"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kategori *</label>
|
||||||
|
<select
|
||||||
|
name="categoryId"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.categoryId || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Kategori seçin...</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neighborhoods */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mahalle/Bölge *</label>
|
||||||
|
<select
|
||||||
|
name="neighborhoodId"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.neighborhoodId || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Mahalle seçin...</option>
|
||||||
|
{neighborhoods.map((n) => (
|
||||||
|
<option key={n.value} value={n.value}>
|
||||||
|
{n.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açık Adres *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.address || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Yat Limanı No:12, Marmaris"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Telefon</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="phone"
|
||||||
|
defaultValue={listing?.phone || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="+90 252..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whatsapp */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">WhatsApp</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="whatsapp"
|
||||||
|
defaultValue={listing?.whatsapp || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="+90 532..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Web Sitesi</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
defaultValue={listing?.website || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instagram */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Instagram</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="instagram"
|
||||||
|
defaultValue={listing?.instagram || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="https://instagram.com/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||||
|
{/* Price range */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Fiyat Aralığı (1-3) *</label>
|
||||||
|
<select
|
||||||
|
name="priceRange"
|
||||||
|
required
|
||||||
|
defaultValue={listing?.priceRange || 2}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
>
|
||||||
|
<option value="1">₺ (Ekonomik)</option>
|
||||||
|
<option value="2">₺₺ (Orta)</option>
|
||||||
|
<option value="3">₺₺₺ (Lüks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Puan (0.0 - 5.0)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="rating"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="5"
|
||||||
|
defaultValue={listing?.rating || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="4.8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latitude */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Enlem (Lat)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="latitude"
|
||||||
|
step="0.0001"
|
||||||
|
defaultValue={listing?.latitude || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="36.8524"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Longitude */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Boylam (Lng)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="longitude"
|
||||||
|
step="0.0001"
|
||||||
|
defaultValue={listing?.longitude || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
|
||||||
|
placeholder="28.2741"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Opening hours */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Çalışma Saatleri</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="openingHours"
|
||||||
|
defaultValue={listing?.openingHours?.all || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
placeholder="Örn: 12:00 - 00:00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approved toggle */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Yerel Onaylı Mührü</label>
|
||||||
|
<select
|
||||||
|
name="isLocalApproved"
|
||||||
|
defaultValue={listing?.isLocalApproved ? 'true' : 'false'}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
|
||||||
|
>
|
||||||
|
<option value="false">Normal Mekan</option>
|
||||||
|
<option value="true">Yerel Onaylı (Mühürlü)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image Upload fields */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 border-t border-dashed border-pine/8 pt-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Görsel 1 (Dosya Yükle)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="imageFile1"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-3 file:py-1 file:px-2.5 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{img1 && (
|
||||||
|
<div className="mt-2 text-xs flex items-center gap-2">
|
||||||
|
<span className="text-shutter/65">Mevcut Görsel:</span>
|
||||||
|
<a href={img1} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{img1}</a>
|
||||||
|
<input type="hidden" name="imageUrl1" value={img1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Görsel 2 (Dosya Yükle)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="imageFile2"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-3 file:py-1 file:px-2.5 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{img2 && (
|
||||||
|
<div className="mt-2 text-xs flex items-center gap-2">
|
||||||
|
<span className="text-shutter/65">Mevcut Görsel:</span>
|
||||||
|
<a href={img2} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{img2}</a>
|
||||||
|
<input type="hidden" name="imageUrl2" value={img2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4 border-t border-dashed border-pine/8">
|
||||||
|
<Link
|
||||||
|
href="/admin/listings"
|
||||||
|
className="inline-flex items-center gap-1.5 px-5 py-3 border border-pine/20 rounded-xl text-xs font-bold text-pine hover:bg-stone/30 transition duration-150"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
İptal
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-1.5 px-5 py-3 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 text-paper rounded-xl text-xs font-bold shadow-sm transition duration-150"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{loading ? 'Kaydediliyor...' : 'Kaydet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import ListingForm from './ListingForm'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ locale: string; id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminListingEditPage({ params }: Props) {
|
||||||
|
const { locale, id } = await params
|
||||||
|
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
let listing = null
|
||||||
|
|
||||||
|
if (id !== 'new') {
|
||||||
|
listing = await mockDb.getListingById(id)
|
||||||
|
if (!listing) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryOptions = categories.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.nameTr
|
||||||
|
}))
|
||||||
|
|
||||||
|
const neighborhoodOptions = neighborhoods.map(n => ({
|
||||||
|
value: n.id,
|
||||||
|
label: n.nameTr
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{id === 'new' ? 'yeni mekan ekle' : 'mekanı düzenle'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
{id === 'new'
|
||||||
|
? 'Rehbere eklenecek yeni işletme bilgilerini girin.'
|
||||||
|
: 'Mevcut mekan bilgilerini güncelleyin.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
|
||||||
|
<ListingForm
|
||||||
|
listing={listing}
|
||||||
|
categories={categoryOptions}
|
||||||
|
neighborhoods={neighborhoodOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { deleteListingAction } from '@/app/actions'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Edit, Trash, Plus, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminListingsPage() {
|
||||||
|
const listings = await mockDb.getListings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mekanlar</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Marmaris Local rehberinde kayıtlı olan restoran, apart otel ve yerel hizmetlerin listesi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/listings/new"
|
||||||
|
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Yeni Mekan Ekle
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">Görsel</th>
|
||||||
|
<th className="px-6 py-4 text-left">Mekan</th>
|
||||||
|
<th className="px-6 py-4 text-left">Kategori & Konum</th>
|
||||||
|
<th className="px-6 py-4 text-left">Fiyat & Puan</th>
|
||||||
|
<th className="px-6 py-4 text-left">Mühür</th>
|
||||||
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-xs text-shutter font-medium">
|
||||||
|
Henüz kayıtlı mekan bulunmamaktadır.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
listings.map((l) => {
|
||||||
|
const mainImage = l.images && l.images.length > 0 ? l.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
|
||||||
|
return (
|
||||||
|
<tr key={l.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="h-12 w-16 relative rounded-lg overflow-hidden bg-stone border border-pine/8">
|
||||||
|
<img src={mainImage} alt={l.nameTr} className="object-cover w-full h-full" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-heading font-bold text-pine lowercase text-sm">{l.nameTr}</div>
|
||||||
|
<div className="text-xs text-shutter font-mono mt-1">/{l.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-xs font-semibold">
|
||||||
|
<div className="text-pine">Kategori: {l.category?.nameTr}</div>
|
||||||
|
<div className="text-shutter mt-0.5">Bölge: {l.neighborhood?.nameTr}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap font-mono text-xs">
|
||||||
|
<div className="text-pine font-semibold">Fiyat: {'₺'.repeat(l.priceRange)}</div>
|
||||||
|
{l.rating && <div className="text-gold font-bold mt-0.5">★ {l.rating.toFixed(1)}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{l.isLocalApproved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 fill-turquoise/5" />
|
||||||
|
Yerel Onaylı
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-medium text-shutter/60 border border-pine/8 uppercase tracking-wider">
|
||||||
|
Normal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/listings/${l.id}`}
|
||||||
|
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await deleteListingAction(l.id)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="p-1.5 bg-paper text-shutter hover:text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-pine/10 hover:border-bougainvillea/25 transition shadow-sm"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { markMessageReadAction } from '@/app/actions'
|
||||||
|
import { Check, MailOpen, Mail } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminMessagesPage() {
|
||||||
|
const messages = await mockDb.getMessages()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">iletisim mesajları</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Kullanıcılar tarafından `/iletisim` formu üzerinden gönderilen genel mesajlar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">Tarih</th>
|
||||||
|
<th className="px-6 py-4 text-left">Gönderen</th>
|
||||||
|
<th className="px-6 py-4 text-left">Konu</th>
|
||||||
|
<th className="px-6 py-4 text-left">Mesaj</th>
|
||||||
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-xs text-shutter font-medium">
|
||||||
|
Gelen iletişim mesajı bulunmamaktadır.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => {
|
||||||
|
return (
|
||||||
|
<tr key={msg.id} className={`hover:bg-stone/20 transition duration-150 ${!msg.isRead ? 'bg-turquoise/5' : ''}`}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-xs text-shutter font-mono">
|
||||||
|
{new Date(msg.createdAt).toLocaleString('tr-TR')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-pine font-heading font-bold text-xs lowercase">{msg.name}</div>
|
||||||
|
<a href={`mailto:${msg.email}`} className="text-xs text-turquoise hover:underline">
|
||||||
|
{msg.email}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-shutter border border-pine/10 uppercase tracking-wider">
|
||||||
|
{msg.subject}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 max-w-md">
|
||||||
|
<p className="text-xs break-words leading-relaxed whitespace-pre-line font-medium">{msg.message}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
{!msg.isRead ? (
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await markMessageReadAction(msg.id)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-paper hover:bg-turquoise/5 text-turquoise rounded-lg border border-turquoise/20 text-xs font-bold transition shadow-sm"
|
||||||
|
>
|
||||||
|
<MailOpen className="w-3.5 h-3.5" />
|
||||||
|
Okundu İşaretle
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<span className="text-shutter/60 inline-flex items-center gap-1 text-xs font-semibold">
|
||||||
|
<Check className="w-4 h-4 text-turquoise" />
|
||||||
|
Okundu
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
|
||||||
|
export default async function AdminNeighborhoodsPage() {
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mahalleler</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Marmaris Local rehberindeki mekanların filtrelendiği mahalle/bölgeler.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">ID</th>
|
||||||
|
<th className="px-6 py-4 text-left">Slug</th>
|
||||||
|
<th className="px-6 py-4 text-left">Adı (TR)</th>
|
||||||
|
<th className="px-6 py-4 text-left">Name (EN)</th>
|
||||||
|
<th className="px-6 py-4 text-left">Имя (RU)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
|
||||||
|
{neighborhoods.map((neigh) => (
|
||||||
|
<tr key={neigh.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4 font-mono text-xs text-shutter">{neigh.id}</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{neigh.slug}</td>
|
||||||
|
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{neigh.nameTr}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{neigh.nameEn}</td>
|
||||||
|
<td className="px-6 py-4 text-xs">{neigh.nameRu}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { FileText, Inbox, ClipboardList, Map, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminDashboardPage() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
// Load stats
|
||||||
|
const listings = await mockDb.getListings()
|
||||||
|
const submissions = await mockDb.getSubmissions()
|
||||||
|
const messages = await mockDb.getMessages()
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
const pendingSubmissions = submissions.filter(s => s.status === 'PENDING')
|
||||||
|
const unreadMessages = messages.filter(m => !m.isRead)
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ name: 'Toplam Mekan', stat: listings.length.toString(), icon: ClipboardList, color: 'text-pine bg-stone-deep border-pine/8' },
|
||||||
|
{ name: 'Bekleyen Başvuru', stat: pendingSubmissions.length.toString(), icon: FileText, color: 'text-gold bg-paper border-gold/15' },
|
||||||
|
{ name: 'Okunmamış Mesaj', stat: unreadMessages.length.toString(), icon: Inbox, color: 'text-turquoise bg-stone-deep border-turquoise/15' },
|
||||||
|
{ name: 'Kategori / Bölge', stat: `${categories.length} / ${neighborhoods.length}`, icon: Map, color: 'text-shutter bg-paper border-shutter/15' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">dashboard</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Hoş geldiniz, {session?.user?.name || session?.user?.email}. Marmaris Local için genel rehber durumu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="bg-paper rounded-2xl border border-pine/8 p-5 shadow-sm flex items-center gap-4 hover:border-turquoise/30 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className={`p-3.5 rounded-xl border ${item.color}`}>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="truncate text-[10px] font-mono uppercase tracking-wider text-shutter">{item.name}</dt>
|
||||||
|
<dd className="mt-1 text-2xl font-heading font-extrabold text-pine">
|
||||||
|
{item.stat}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
|
||||||
|
{/* Recent Submissions */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm p-6 sm:p-8 space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-dashed border-pine/8 pb-4">
|
||||||
|
<h3 className="text-lg font-heading font-bold text-pine lowercase flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-gold" />
|
||||||
|
onay bekleyen başvurular
|
||||||
|
</h3>
|
||||||
|
<Link href="/admin/submissions" className="text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wide">
|
||||||
|
tümünü gör
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-dashed divide-pine/8">
|
||||||
|
{pendingSubmissions.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-shutter font-medium">
|
||||||
|
Onay bekleyen yeni işletme başvurusu bulunmuyor.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pendingSubmissions.slice(0, 5).map((sub) => (
|
||||||
|
<div key={sub.id} className="py-4 flex items-center justify-between first:pt-0 last:pb-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="font-heading font-bold text-sm text-pine lowercase">{sub.businessName}</h4>
|
||||||
|
<p className="text-xs text-ink/65 font-medium">{sub.contactName} ({sub.contactEmail})</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-paper px-3 py-1 text-[10px] font-mono font-bold text-gold border border-gold/20 uppercase">
|
||||||
|
Bekliyor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Messages */}
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm p-6 sm:p-8 space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-dashed border-pine/8 pb-4">
|
||||||
|
<h3 className="text-lg font-heading font-bold text-pine lowercase flex items-center gap-2">
|
||||||
|
<Inbox className="w-5 h-5 text-turquoise" />
|
||||||
|
okunmamış mesajlar
|
||||||
|
</h3>
|
||||||
|
<Link href="/admin/messages" className="text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wide">
|
||||||
|
tümünü gör
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-dashed divide-pine/8">
|
||||||
|
{unreadMessages.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-shutter font-medium">
|
||||||
|
Okunmamış yeni mesaj bulunmuyor.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
unreadMessages.slice(0, 5).map((msg) => (
|
||||||
|
<div key={msg.id} className="py-4 flex items-center justify-between first:pt-0 last:pb-0">
|
||||||
|
<div className="space-y-1 pr-4 flex-1">
|
||||||
|
<h4 className="font-heading font-bold text-sm text-pine lowercase">{msg.name}</h4>
|
||||||
|
<p className="text-xs text-ink/70 font-medium line-clamp-1">{msg.message}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-shutter font-mono shrink-0">
|
||||||
|
{new Date(msg.createdAt).toLocaleDateString('tr-TR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { approveSubmissionAction, rejectSubmissionAction } from '@/app/actions'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function AdminSubmissionsPage() {
|
||||||
|
const submissions = await mockDb.getSubmissions()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">işletme başvuruları</h2>
|
||||||
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
|
Kullanıcılar tarafından `/isletme-ekle` formu üzerinden gönderilen ve onay bekleyen başvurular.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-pine/8 text-sm">
|
||||||
|
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 text-left">İşletme</th>
|
||||||
|
<th className="px-6 py-4 text-left">Konum & Kategori</th>
|
||||||
|
<th className="px-6 py-4 text-left">Gönderen</th>
|
||||||
|
<th className="px-6 py-4 text-left">Görsel</th>
|
||||||
|
<th className="px-6 py-4 text-left">Durum</th>
|
||||||
|
<th className="px-6 py-4 text-right">İşlemler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
|
||||||
|
{submissions.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-xs text-shutter font-medium">
|
||||||
|
Kayıtlı işletme başvurusu bulunmamaktadır.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
submissions.map((sub) => {
|
||||||
|
return (
|
||||||
|
<tr key={sub.id} className="hover:bg-stone/20 transition duration-150">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-heading font-bold text-pine lowercase text-sm">{sub.businessName}</div>
|
||||||
|
<div className="text-xs text-ink/70 mt-1 max-w-xs line-clamp-2 leading-relaxed">{sub.description}</div>
|
||||||
|
<div className="text-[11px] text-shutter mt-1.5">{sub.address}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-xs">
|
||||||
|
<div className="text-pine font-semibold">Kategori: {sub.categoryId}</div>
|
||||||
|
<div className="text-shutter mt-0.5">Bölge: {sub.neighborhoodId}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-xs font-semibold text-pine">{sub.contactName}</div>
|
||||||
|
<a href={`mailto:${sub.contactEmail}`} className="text-xs text-turquoise hover:underline">
|
||||||
|
{sub.contactEmail}
|
||||||
|
</a>
|
||||||
|
{sub.phone && <div className="text-[11px] text-ink/65 font-mono mt-1">{sub.phone}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{sub.imageUrl ? (
|
||||||
|
<div className="w-16 h-12 relative rounded-lg border border-pine/8 overflow-hidden">
|
||||||
|
<img src={sub.imageUrl} alt={sub.businessName} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-shutter">Görsel Yok</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-mono font-bold border uppercase tracking-wider ${
|
||||||
|
sub.status === 'PENDING' ? 'bg-paper text-gold border-gold/20' :
|
||||||
|
sub.status === 'APPROVED' ? 'bg-paper text-turquoise border-turquoise/20' :
|
||||||
|
'bg-paper text-bougainvillea border-bougainvillea/20'
|
||||||
|
}`}>
|
||||||
|
{sub.status === 'PENDING' ? 'Bekliyor' : sub.status === 'APPROVED' ? 'Onaylandı' : 'Reddedildi'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
{sub.status === 'PENDING' && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await approveSubmissionAction(sub.id)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="p-1.5 bg-paper text-turquoise hover:bg-turquoise/5 rounded-lg border border-turquoise/20 transition shadow-sm"
|
||||||
|
title="Onayla ve Mekanlara Ekle"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await rejectSubmissionAction(sub.id)
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="p-1.5 bg-paper text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-bougainvillea/20 transition shadow-sm"
|
||||||
|
title="Reddet"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import { SlidersHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
searchParams: Promise<{
|
||||||
|
search?: string
|
||||||
|
neighborhood?: string
|
||||||
|
price?: string
|
||||||
|
approved?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ApartsPage({ params, searchParams }: PageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const { search, neighborhood, price, approved } = await searchParams
|
||||||
|
|
||||||
|
const t = await getTranslations('categories')
|
||||||
|
|
||||||
|
// Find Category Apart
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const currentCategory = categories.find(c => c.slug === 'apart')
|
||||||
|
const categoryId = currentCategory?.id
|
||||||
|
|
||||||
|
// Get active neighborhoods for filter
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
// Selected filters
|
||||||
|
const selectedNeighborhoodId = neighborhood || undefined
|
||||||
|
const selectedPriceRange = price ? parseInt(price) : undefined
|
||||||
|
const isApprovedOnly = approved === 'true'
|
||||||
|
|
||||||
|
const listings = await mockDb.getListings({
|
||||||
|
categoryId,
|
||||||
|
neighborhoodId: selectedNeighborhoodId,
|
||||||
|
priceRange: selectedPriceRange,
|
||||||
|
isLocalApproved: isApprovedOnly ? true : undefined,
|
||||||
|
search: search
|
||||||
|
})
|
||||||
|
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{t('apart')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||||
|
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>filtreler</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
defaultValue={search || ''}
|
||||||
|
placeholder="İsim veya adres..."
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neighborhood select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
|
||||||
|
<select
|
||||||
|
name="neighborhood"
|
||||||
|
defaultValue={neighborhood || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allNeighborhoods')}</option>
|
||||||
|
{neighborhoods.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{getLocalizedName(n)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price range select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
|
||||||
|
<select
|
||||||
|
name="price"
|
||||||
|
defaultValue={price || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allPrices')}</option>
|
||||||
|
<option value="1">₺ (Ekonomik)</option>
|
||||||
|
<option value="2">₺₺ (Orta)</option>
|
||||||
|
<option value="3">₺₺₺ (Lüks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit / Checkbox area */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="approved"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={isApprovedOnly}
|
||||||
|
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-pine">{t('filterApproved')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
||||||
|
>
|
||||||
|
Filtrele
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
|
||||||
|
<p className="text-sm font-medium">{t('noResults')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { CheckCircle, ShieldAlert, Award, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AboutPageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const t = await getTranslations('hero')
|
||||||
|
const navT = await getTranslations('nav')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 flex-1 space-y-12">
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-14 h-14 rounded-full border-2 border-turquoise flex items-center justify-center relative bg-paper -rotate-6 shadow-sm">
|
||||||
|
<div className="absolute inset-[3px] rounded-full border border-dashed border-turquoise/60" />
|
||||||
|
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase leading-tight">
|
||||||
|
{locale === 'tr' ? 'hakkımızda' : locale === 'en' ? 'about us' : 'о нас'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs font-mono uppercase tracking-wider text-shutter">
|
||||||
|
marmaris local • yerel bilgi rehberi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="bg-paper p-8 sm:p-12 rounded-3xl border border-pine/8 shadow-sm space-y-8 leading-relaxed font-medium text-sm sm:text-base text-ink/80">
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-heading font-bold text-pine lowercase">
|
||||||
|
{locale === 'tr' ? 'turistin göremediği yerel bilgi' : locale === 'en' ? 'local knowledge hidden from tourists' : 'местные знания, скрытые от туристов'}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{locale === 'tr' && 'Marmaris Local, popüler tatil beldemiz Marmaris\'teki restoranları, apart otelleri ve tekne kiralama ya da dalış merkezleri gibi çeşitli yerel işletmeleri tek bir küratörlü rehberde toplayan bağımsız bir dizin sitesidir. Temel amacımız, yerli ve yabancı turistleri Marmaris\'in gerçek yerel halkının gittiği, kalitesinden ve samimiyetinden emin olduğu işletmelerle buluşturmaktır.'}
|
||||||
|
{locale === 'en' && 'Marmaris Local is an independent directory site that gathers restaurants, apart hotels, and various local businesses such as boat rentals or diving centers in our popular holiday destination Marmaris into a single curated guide. Our main goal is to connect domestic and foreign tourists with establishments that the real local people of Marmaris visit, confident in their quality and friendliness.'}
|
||||||
|
{locale === 'ru' && 'Marmaris Local — это независимый каталог, объединяющий рестораны, апарт-отели и различные местные предприятия, такие как аренда лодок или дайвинг-центры в нашем популярном месте отдыха Мармарис, в единый курируемый гид. Наша главная цель — познакомить местных и иностранных туристов с заведениями, которые посещают настоящие местные жители Мармариса, будучи уверенными в их качестве и дружелюбии.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dashed border-pine/10" />
|
||||||
|
|
||||||
|
{/* How local approved works */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full border-2 border-turquoise bg-stone/30 flex items-center justify-center shrink-0 text-turquoise">
|
||||||
|
<Award className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-heading font-bold text-pine lowercase leading-tight">
|
||||||
|
{locale === 'tr' ? 'yerel onaylı mührü nedir?' : locale === 'en' ? 'what is the local approved seal?' : 'что такое печать качества?'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs sm:text-sm text-ink/70">
|
||||||
|
{t('approvedExplain')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Steps / Criteria */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-4 text-xs font-semibold text-pine font-mono">
|
||||||
|
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
|
||||||
|
<div className="text-turquoise text-lg font-bold">01</div>
|
||||||
|
<div className="uppercase tracking-wider">
|
||||||
|
{locale === 'tr' ? 'editör ziyareti' : locale === 'en' ? 'editor visit' : 'визит редактора'}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
|
||||||
|
{locale === 'tr' ? 'Her mekan editörlerimiz tarafından gizlice ziyaret edilir.' : locale === 'en' ? 'Every place is visited secretly by our editors.' : 'Каждое место тайно посещается нашими редакторами.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
|
||||||
|
<div className="text-turquoise text-lg font-bold">02</div>
|
||||||
|
<div className="uppercase tracking-wider">
|
||||||
|
{locale === 'tr' ? 'fiyat/performans' : locale === 'en' ? 'value for money' : 'цена / качество'}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
|
||||||
|
{locale === 'tr' ? 'Hizmet kalitesi ile fiyat dengesi yerel standartlara göre değerlendirilir.' : locale === 'en' ? 'The balance between service quality and price is evaluated according to local standards.' : 'Баланс качества услуг и цены оценивается по местным стандартам.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
|
||||||
|
<div className="text-turquoise text-lg font-bold">03</div>
|
||||||
|
<div className="uppercase tracking-wider">
|
||||||
|
{locale === 'tr' ? 'yerel onay' : locale === 'en' ? 'local approval' : 'местное одобрение'}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
|
||||||
|
{locale === 'tr' ? 'Marmaris sakinlerinin tavsiye ve memnuniyet oranları kontrol edilir.' : locale === 'en' ? 'Recommendations and satisfaction rates of Marmaris residents are checked.' : 'Проверяются рекомендации и уровень удовлетворенности жителей Мармариса.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-dashed border-pine/10" />
|
||||||
|
|
||||||
|
{/* Action Link */}
|
||||||
|
<div className="text-center pt-4 space-y-4">
|
||||||
|
<h3 className="font-heading font-bold text-pine lowercase text-lg">
|
||||||
|
{locale === 'tr' ? 'kendi işletmenizi önermek ister misiniz?' : locale === 'en' ? 'would you like to suggest your own business?' : 'хотите предложить свой бизнес?'}
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
href="/isletme-ekle"
|
||||||
|
className="inline-flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-6 rounded-xl transition shadow-sm"
|
||||||
|
>
|
||||||
|
{navT('addBusiness')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { submitContactMessageAction } from '@/app/actions'
|
||||||
|
|
||||||
|
interface Translations {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
submit: string
|
||||||
|
sending: string
|
||||||
|
success: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
translations: Translations
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactForm({ translations }: FormProps) {
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
try {
|
||||||
|
const res = await submitContactMessageAction(formData)
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error)
|
||||||
|
} else {
|
||||||
|
setSuccess(true)
|
||||||
|
e.currentTarget.reset()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="bg-turquoise/10 border border-turquoise/20 text-turquoise p-6 rounded-2xl text-center space-y-3">
|
||||||
|
<h3 className="font-heading font-bold text-lg lowercase">teşekkürler!</h3>
|
||||||
|
<p className="text-sm font-medium">{translations.success}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.name} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Adınız Soyadınız"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.email} *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="eposta@adresiniz.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.subject} *</label>
|
||||||
|
<select
|
||||||
|
name="subject"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Konu seçin...</option>
|
||||||
|
<option value="Genel">Genel Sorular</option>
|
||||||
|
<option value="İşbirliği">İşbirliği</option>
|
||||||
|
<option value="Hata Bildirimi">Hata Bildirimi</option>
|
||||||
|
<option value="Diğer">Diğer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.message} *</label>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Mesajınızı buraya yazın..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? translations.sending : translations.submit}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ContactForm from './ContactForm'
|
||||||
|
|
||||||
|
interface ContactPageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const t = await getTranslations('forms')
|
||||||
|
const navT = await getTranslations('nav')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="max-w-xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
|
||||||
|
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
|
||||||
|
|
||||||
|
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{navT('contact')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1.5">
|
||||||
|
marmaris local • bize ulaşın
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactForm
|
||||||
|
translations={{
|
||||||
|
name: t('name'),
|
||||||
|
email: t('email'),
|
||||||
|
subject: t('subject'),
|
||||||
|
message: t('message'),
|
||||||
|
submit: t('submit'),
|
||||||
|
sending: t('sending'),
|
||||||
|
success: t('contactSuccess')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { submitBusinessAction } from '@/app/actions'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Translations {
|
||||||
|
businessName: string
|
||||||
|
category: string
|
||||||
|
neighborhood: string
|
||||||
|
address: string
|
||||||
|
phone: string
|
||||||
|
whatsapp: string
|
||||||
|
description: string
|
||||||
|
image: string
|
||||||
|
submit: string
|
||||||
|
sending: string
|
||||||
|
success: string
|
||||||
|
contactName: string
|
||||||
|
contactEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
translations: Translations
|
||||||
|
categories: Option[]
|
||||||
|
neighborhoods: Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BusinessForm({ translations, categories, neighborhoods }: FormProps) {
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget)
|
||||||
|
try {
|
||||||
|
const res = await submitBusinessAction(formData)
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error)
|
||||||
|
} else {
|
||||||
|
setSuccess(true)
|
||||||
|
e.currentTarget.reset()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="bg-turquoise/10 border border-turquoise/20 text-turquoise p-6 rounded-2xl text-center space-y-3">
|
||||||
|
<h3 className="font-heading font-bold text-lg lowercase">teşekkürler!</h3>
|
||||||
|
<p className="text-sm font-medium">{translations.success}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Business name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.businessName} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="businessName"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Örn: İskele Balık Ocakbaşı"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Category */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.category} *</label>
|
||||||
|
<select
|
||||||
|
name="categoryId"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Kategori seçin...</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neighborhood */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.neighborhood} *</label>
|
||||||
|
<select
|
||||||
|
name="neighborhoodId"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Mahalle seçin...</option>
|
||||||
|
{neighborhoods.map((n) => (
|
||||||
|
<option key={n.value} value={n.value}>
|
||||||
|
{n.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.address} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Örn: Yat Limanı No:12, Marmaris"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.phone}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="phone"
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Örn: +90 252 412 34 56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whatsapp */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.whatsapp}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="whatsapp"
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Örn: +90 532 123 45 67"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.description} *</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="İşletmenizi tanıtan kısa bir açıklama yazın..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image File */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.image}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="imageFile"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Name & Email */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 border-t border-dashed border-pine/10 pt-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.contactName} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="contactName"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="Adınız Soyadınız"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.contactEmail} *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="contactEmail"
|
||||||
|
required
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
|
||||||
|
placeholder="eposta@adresiniz.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? translations.sending : translations.submit}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import BusinessForm from './BusinessForm'
|
||||||
|
|
||||||
|
interface AddBusinessPageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AddBusinessPage({ params }: AddBusinessPageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const t = await getTranslations('forms')
|
||||||
|
const navT = await getTranslations('nav')
|
||||||
|
|
||||||
|
// Fetch categories and neighborhoods to populate form select options
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryOptions = categories.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: getLocalizedName(c)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const neighborhoodOptions = neighborhoods.map(n => ({
|
||||||
|
value: n.id,
|
||||||
|
label: getLocalizedName(n)
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
|
||||||
|
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
|
||||||
|
|
||||||
|
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{navT('addBusiness')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1.5">
|
||||||
|
marmaris local • yeni başvuru
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BusinessForm
|
||||||
|
translations={{
|
||||||
|
businessName: t('businessName'),
|
||||||
|
category: t('category'),
|
||||||
|
neighborhood: t('neighborhood'),
|
||||||
|
address: t('address'),
|
||||||
|
phone: t('phone'),
|
||||||
|
whatsapp: t('whatsapp'),
|
||||||
|
description: t('description'),
|
||||||
|
image: t('image'),
|
||||||
|
submit: t('submit'),
|
||||||
|
sending: t('sending'),
|
||||||
|
success: t('success'),
|
||||||
|
contactName: 'Yetkili Adı Soyadı',
|
||||||
|
contactEmail: 'İletişim E-postası'
|
||||||
|
}}
|
||||||
|
categories={categoryOptions}
|
||||||
|
neighborhoods={neighborhoodOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import { SlidersHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
searchParams: Promise<{
|
||||||
|
search?: string
|
||||||
|
neighborhood?: string
|
||||||
|
price?: string
|
||||||
|
approved?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BusinessesPage({ params, searchParams }: PageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const { search, neighborhood, price, approved } = await searchParams
|
||||||
|
|
||||||
|
const t = await getTranslations('categories')
|
||||||
|
|
||||||
|
// Find Category Isletme
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const currentCategory = categories.find(c => c.slug === 'isletme')
|
||||||
|
const categoryId = currentCategory?.id
|
||||||
|
|
||||||
|
// Get active neighborhoods for filter
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
// Selected filters
|
||||||
|
const selectedNeighborhoodId = neighborhood || undefined
|
||||||
|
const selectedPriceRange = price ? parseInt(price) : undefined
|
||||||
|
const isApprovedOnly = approved === 'true'
|
||||||
|
|
||||||
|
const listings = await mockDb.getListings({
|
||||||
|
categoryId,
|
||||||
|
neighborhoodId: selectedNeighborhoodId,
|
||||||
|
priceRange: selectedPriceRange,
|
||||||
|
isLocalApproved: isApprovedOnly ? true : undefined,
|
||||||
|
search: search
|
||||||
|
})
|
||||||
|
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{t('isletme')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||||
|
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>filtreler</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
defaultValue={search || ''}
|
||||||
|
placeholder="İsim veya adres..."
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neighborhood select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
|
||||||
|
<select
|
||||||
|
name="neighborhood"
|
||||||
|
defaultValue={neighborhood || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allNeighborhoods')}</option>
|
||||||
|
{neighborhoods.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{getLocalizedName(n)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price range select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
|
||||||
|
<select
|
||||||
|
name="price"
|
||||||
|
defaultValue={price || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allPrices')}</option>
|
||||||
|
<option value="1">₺ (Ekonomik)</option>
|
||||||
|
<option value="2">₺₺ (Orta)</option>
|
||||||
|
<option value="3">₺₺₺ (Lüks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit / Checkbox area */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="approved"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={isApprovedOnly}
|
||||||
|
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-pine">{t('filterApproved')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
||||||
|
>
|
||||||
|
Filtrele
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
|
||||||
|
<p className="text-sm font-medium">{t('noResults')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Unbounded, Golos_Text, IBM_Plex_Mono } from "next/font/google";
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const unbounded = Unbounded({
|
||||||
|
variable: "--font-unbounded",
|
||||||
|
subsets: ["latin", "cyrillic"],
|
||||||
|
weight: ["400", "600", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const golosText = Golos_Text({
|
||||||
|
variable: "--font-golos",
|
||||||
|
subsets: ["latin", "cyrillic"],
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ibmPlexMono = IBM_Plex_Mono({
|
||||||
|
variable: "--font-mono",
|
||||||
|
subsets: ["latin", "cyrillic"],
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Marmaris Local — Yerel Rehber",
|
||||||
|
description: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({locale}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}>) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
if (!routing.locales.includes(locale as any)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang={locale}
|
||||||
|
className={`${unbounded.variable} ${golosText.variable} ${ibmPlexMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
redirect: false,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Geçersiz e-posta veya şifre')
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
router.push('/admin')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||||
|
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Admin Girişi</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Yönetim paneline erişmek için giriş yapın</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded-md text-sm mb-6 border border-red-100">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="email">
|
||||||
|
E-posta
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
|
||||||
|
placeholder="admin@ayris.tech"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="password">
|
||||||
|
Şifre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-md transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-xs text-gray-400">
|
||||||
|
Demo credentials: admin@ayris.tech / admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { MapPin } from 'lucide-react'
|
||||||
|
|
||||||
|
interface NeighborhoodPageProps {
|
||||||
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {
|
||||||
|
const { locale, slug } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const neighborhood = await mockDb.getNeighborhoodBySlug(slug)
|
||||||
|
if (!neighborhood) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = await mockDb.getListings({
|
||||||
|
neighborhoodId: neighborhood.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const name =
|
||||||
|
locale === 'ru'
|
||||||
|
? neighborhood.nameRu
|
||||||
|
: locale === 'en'
|
||||||
|
? neighborhood.nameEn
|
||||||
|
: neighborhood.nameTr
|
||||||
|
|
||||||
|
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-12 flex-1">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-10 pb-6 border-b border-pine/8">
|
||||||
|
<div className="p-3 bg-paper rounded-xl border border-pine/8 text-turquoise shadow-sm">
|
||||||
|
<MapPin className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||||
|
{locale === 'tr' ? 'mahalle rehberi' : locale === 'en' ? 'neighborhood directory' : 'гид по району'} • {listings.length} {locale === 'tr' ? 'mekan' : locale === 'en' ? 'places' : 'заведений'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-16 text-center text-shutter">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{locale === 'tr' ? 'Bu mahallede henüz kayıtlı mekan bulunmamaktadır.' : locale === 'en' ? 'No registered places in this neighborhood yet.' : 'В этом районе пока нет зарегистрированных мест.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { Search, MapPin, CheckCircle, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const t = await getTranslations('hero')
|
||||||
|
const homeT = await getTranslations('home')
|
||||||
|
const navT = await getTranslations('nav')
|
||||||
|
|
||||||
|
// Get active listings and filter for featured (Local Approved)
|
||||||
|
const allListings = await mockDb.getListings()
|
||||||
|
const featuredListings = allListings.filter(l => l.isLocalApproved).slice(0, 3)
|
||||||
|
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
// Get localized names
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map category slug to lucide icons or nice visual cues
|
||||||
|
const categoryImages: Record<string, string> = {
|
||||||
|
restoran: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80',
|
||||||
|
apart: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80',
|
||||||
|
isletme: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80'
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryPaths: Record<string, string> = {
|
||||||
|
restoran: '/restoranlar',
|
||||||
|
apart: '/apartlar',
|
||||||
|
isletme: '/isletmeler'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="bg-pine text-stone pt-20 pb-24 relative overflow-hidden">
|
||||||
|
{/* Decorative background shapes */}
|
||||||
|
<div className="absolute inset-0 opacity-5 pointer-events-none">
|
||||||
|
<div className="absolute -top-40 -right-40 w-96 h-96 rounded-full bg-turquoise blur-3xl" />
|
||||||
|
<div className="absolute -bottom-45 -left-40 w-96 h-96 rounded-full bg-bougainvillea blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10 space-y-8">
|
||||||
|
|
||||||
|
{/* Logo stamp effect */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-16 h-16 rounded-full border-2 border-stone/30 flex items-center justify-center relative bg-white/5 backdrop-blur-sm -rotate-6">
|
||||||
|
<div className="absolute inset-[3.5px] rounded-full border border-dashed border-turquoise/50" />
|
||||||
|
<span className="font-heading font-extrabold text-stone text-lg tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="font-heading font-extrabold text-3xl sm:text-5xl lg:text-6xl text-stone leading-tight tracking-tight max-w-3xl mx-auto lowercase">
|
||||||
|
{locale === 'tr' && (
|
||||||
|
<>En iyi yerel adresler,<br /><span className="text-turquoise">turistin göremediği yerde.</span></>
|
||||||
|
)}
|
||||||
|
{locale === 'en' && (
|
||||||
|
<>The best local spots,<br /><span className="text-turquoise">hidden from plain sight.</span></>
|
||||||
|
)}
|
||||||
|
{locale === 'ru' && (
|
||||||
|
<>Лучшие места,<br /><span className="text-turquoise">которые знают местные.</span></>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm sm:text-base text-stone/70 max-w-xl mx-auto font-medium">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Search Form */}
|
||||||
|
<form
|
||||||
|
action={`/${locale}/restoranlar`}
|
||||||
|
method="GET"
|
||||||
|
className="max-w-xl mx-auto bg-paper p-2 rounded-2xl flex items-center shadow-lg border border-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-1 px-3">
|
||||||
|
<Search className="w-5 h-5 text-shutter shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
placeholder={t('searchPlaceholder')}
|
||||||
|
className="w-full bg-transparent border-0 focus:ring-0 text-sm py-2 px-3 text-ink placeholder:text-ink/40 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs px-5 py-3 rounded-xl transition"
|
||||||
|
>
|
||||||
|
{t('cta')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Local Approved Banner */}
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 text-xs">
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full bg-turquoise animate-pulse" />
|
||||||
|
<span className="font-semibold text-turquoise">{t('approvedBadge')}</span>
|
||||||
|
<span className="text-stone/60">mührü ile güvenli rehber</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Category Grid Section */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{homeT('categories')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-ink/65 mt-2 font-medium">
|
||||||
|
{homeT('categoriesSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const imageUrl = categoryImages[cat.slug] || categoryImages.isletme
|
||||||
|
const path = categoryPaths[cat.slug] || '/isletmeler'
|
||||||
|
const catName = getLocalizedName(cat)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
href={path}
|
||||||
|
className="group relative h-64 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition duration-300 border border-pine/5 flex items-end p-6"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={catName}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-pine/90 via-pine/30 to-transparent" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 space-y-1">
|
||||||
|
<h4 className="font-heading font-extrabold text-xl text-stone group-hover:text-turquoise transition-colors lowercase">
|
||||||
|
{catName}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-mono text-turquoise">
|
||||||
|
<span>{homeT('explore')}</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Local Approved Featured Section */}
|
||||||
|
<section className="bg-stone-deep py-20 border-t border-b border-pine/5">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between mb-12 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-turquoise/20 bg-turquoise/5 text-[10px] font-mono text-turquoise font-semibold uppercase tracking-wider mb-3">
|
||||||
|
{t('approvedBadge')}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{homeT('featured')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-ink/65 mt-2 font-medium">
|
||||||
|
{homeT('featuredSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/restoranlar?approved=true"
|
||||||
|
className="flex items-center gap-1 text-xs font-bold text-pine hover:text-turquoise transition-colors border-b border-pine/20 hover:border-turquoise pb-1 w-fit"
|
||||||
|
>
|
||||||
|
<span>{locale === 'tr' ? 'tüm onaylı mekanlar' : locale === 'en' ? 'all approved places' : 'все проверенные места'}</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{featuredListings.map((listing) => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Neighborhoods Section */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{homeT('neighborhoods')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-ink/65 mt-2 font-medium">
|
||||||
|
{homeT('neighborhoodsSubtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{neighborhoods.map((neigh) => {
|
||||||
|
const name = getLocalizedName(neigh)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={neigh.id}
|
||||||
|
href={`/mahalle/${neigh.slug}`}
|
||||||
|
className="bg-paper p-5 rounded-xl border border-pine/8 text-center hover:border-turquoise/40 hover:bg-paper/90 transition shadow-sm group flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<MapPin className="w-5 h-5 text-shutter group-hover:text-turquoise transition-colors" />
|
||||||
|
<span className="font-heading font-bold text-xs text-pine lowercase group-hover:text-turquoise transition-colors">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Trust Badge Explainer */}
|
||||||
|
<section className="bg-pine text-stone py-16 border-t border-white/5">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center space-y-6">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-14 h-14 rounded-full border-2 border-turquoise flex items-center justify-center relative bg-paper -rotate-6">
|
||||||
|
<div className="absolute inset-[2px] rounded-full border border-dashed border-turquoise/60" />
|
||||||
|
<span className="font-mono text-[8px] text-center font-bold text-turquoise tracking-tight leading-none uppercase">
|
||||||
|
YEREL<br />ONAYLI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl sm:text-2xl font-heading font-extrabold lowercase">
|
||||||
|
yerel onay mührü nedir?
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-stone/70 max-w-xl mx-auto font-medium leading-relaxed">
|
||||||
|
{t('approvedExplain')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
|
import Footer from '@/components/Footer'
|
||||||
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
searchParams: Promise<{
|
||||||
|
search?: string
|
||||||
|
neighborhood?: string
|
||||||
|
price?: string
|
||||||
|
approved?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RestaurantsPage({ params, searchParams }: PageProps) {
|
||||||
|
const { locale } = await params
|
||||||
|
setRequestLocale(locale)
|
||||||
|
|
||||||
|
const { search, neighborhood, price, approved } = await searchParams
|
||||||
|
|
||||||
|
const t = await getTranslations('categories')
|
||||||
|
const navT = await getTranslations('nav')
|
||||||
|
|
||||||
|
// Find Category Restoran
|
||||||
|
const categories = await mockDb.getCategories()
|
||||||
|
const currentCategory = categories.find(c => c.slug === 'restoran')
|
||||||
|
const categoryId = currentCategory?.id
|
||||||
|
|
||||||
|
// Get active neighborhoods for filter
|
||||||
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
|
|
||||||
|
// Selected filters
|
||||||
|
const selectedNeighborhoodId = neighborhood || undefined
|
||||||
|
const selectedPriceRange = price ? parseInt(price) : undefined
|
||||||
|
const isApprovedOnly = approved === 'true'
|
||||||
|
|
||||||
|
const listings = await mockDb.getListings({
|
||||||
|
categoryId,
|
||||||
|
neighborhoodId: selectedNeighborhoodId,
|
||||||
|
priceRange: selectedPriceRange,
|
||||||
|
isLocalApproved: isApprovedOnly ? true : undefined,
|
||||||
|
search: search
|
||||||
|
})
|
||||||
|
|
||||||
|
const getLocalizedName = (obj: any) => {
|
||||||
|
if (!obj) return ''
|
||||||
|
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||||
|
{t('restoran')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||||||
|
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
|
||||||
|
<span>filtreler</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
defaultValue={search || ''}
|
||||||
|
placeholder="İsim veya adres..."
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Neighborhood select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
|
||||||
|
<select
|
||||||
|
name="neighborhood"
|
||||||
|
defaultValue={neighborhood || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allNeighborhoods')}</option>
|
||||||
|
{neighborhoods.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>
|
||||||
|
{getLocalizedName(n)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price range select */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
|
||||||
|
<select
|
||||||
|
name="price"
|
||||||
|
defaultValue={price || ''}
|
||||||
|
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||||||
|
>
|
||||||
|
<option value="">{t('allPrices')}</option>
|
||||||
|
<option value="1">₺ (Ekonomik)</option>
|
||||||
|
<option value="2">₺₺ (Orta)</option>
|
||||||
|
<option value="3">₺₺₺ (Lüks)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit / Checkbox area */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="approved"
|
||||||
|
value="true"
|
||||||
|
defaultChecked={isApprovedOnly}
|
||||||
|
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-pine">{t('filterApproved')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
|
||||||
|
>
|
||||||
|
Filtrele
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{listings.length === 0 ? (
|
||||||
|
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
|
||||||
|
<p className="text-sm font-medium">{t('noResults')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { mockDb } from '@/lib/mockDb'
|
||||||
|
import { uploadToOpeninary } from '@/lib/openinary'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export async function submitBusinessAction(formData: FormData) {
|
||||||
|
const businessName = formData.get('businessName') as string
|
||||||
|
const categoryId = formData.get('categoryId') as string
|
||||||
|
const neighborhoodId = formData.get('neighborhoodId') as string
|
||||||
|
const address = formData.get('address') as string
|
||||||
|
const phone = formData.get('phone') as string
|
||||||
|
const whatsapp = formData.get('whatsapp') as string
|
||||||
|
const description = formData.get('description') as string
|
||||||
|
const contactName = formData.get('contactName') as string
|
||||||
|
const contactEmail = formData.get('contactEmail') as string
|
||||||
|
const imageFile = formData.get('imageFile') as File | null
|
||||||
|
|
||||||
|
if (!businessName || !categoryId || !neighborhoodId || !address || !description || !contactName || !contactEmail) {
|
||||||
|
return { error: 'Lütfen tüm zorunlu alanları doldurun.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageUrl = null
|
||||||
|
if (imageFile && imageFile.size > 0) {
|
||||||
|
try {
|
||||||
|
imageUrl = await uploadToOpeninary(imageFile, 'submissions')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Openinary upload error:', e)
|
||||||
|
return { error: `Görsel yüklenemedi: ${e.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mockDb.createSubmission({
|
||||||
|
businessName,
|
||||||
|
categoryId,
|
||||||
|
neighborhoodId,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
|
whatsapp,
|
||||||
|
description,
|
||||||
|
contactName,
|
||||||
|
contactEmail,
|
||||||
|
imageUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/admin/submissions')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContactMessageAction(formData: FormData) {
|
||||||
|
const name = formData.get('name') as string
|
||||||
|
const email = formData.get('email') as string
|
||||||
|
const subject = formData.get('subject') as string
|
||||||
|
const message = formData.get('message') as string
|
||||||
|
|
||||||
|
if (!name || !email || !subject || !message) {
|
||||||
|
return { error: 'Lütfen tüm alanları doldurun.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await mockDb.createMessage({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/admin/messages')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSubmissionAction(id: string) {
|
||||||
|
const submission = await mockDb.getSubmissionById(id)
|
||||||
|
if (!submission) return { error: 'Başvuru bulunamadı' }
|
||||||
|
|
||||||
|
const slug = submission.businessName.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
|
||||||
|
await mockDb.createListing({
|
||||||
|
slug,
|
||||||
|
categoryId: submission.categoryId,
|
||||||
|
neighborhoodId: submission.neighborhoodId,
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: submission.businessName,
|
||||||
|
nameEn: submission.businessName,
|
||||||
|
nameRu: submission.businessName,
|
||||||
|
descriptionTr: submission.description,
|
||||||
|
descriptionEn: submission.description,
|
||||||
|
descriptionRu: submission.description,
|
||||||
|
address: submission.address,
|
||||||
|
phone: submission.phone,
|
||||||
|
whatsapp: submission.whatsapp,
|
||||||
|
priceRange: 2,
|
||||||
|
rating: 5.0,
|
||||||
|
isLocalApproved: false,
|
||||||
|
images: submission.imageUrl ? [submission.imageUrl] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
await mockDb.updateSubmissionStatus(id, 'APPROVED')
|
||||||
|
|
||||||
|
revalidatePath('/admin/submissions')
|
||||||
|
revalidatePath('/restoranlar')
|
||||||
|
revalidatePath('/apartlar')
|
||||||
|
revalidatePath('/isletmeler')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectSubmissionAction(id: string) {
|
||||||
|
await mockDb.updateSubmissionStatus(id, 'REJECTED')
|
||||||
|
revalidatePath('/admin/submissions')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteListingAction(id: string) {
|
||||||
|
await mockDb.deleteListing(id)
|
||||||
|
revalidatePath('/admin/listings')
|
||||||
|
revalidatePath('/restoranlar')
|
||||||
|
revalidatePath('/apartlar')
|
||||||
|
revalidatePath('/isletmeler')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markMessageReadAction(id: string) {
|
||||||
|
await mockDb.markMessageAsRead(id)
|
||||||
|
revalidatePath('/admin/messages')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrUpdateListingAction(formData: FormData) {
|
||||||
|
const id = formData.get('id') as string | null
|
||||||
|
const slug = formData.get('slug') as string
|
||||||
|
const categoryId = formData.get('categoryId') as string
|
||||||
|
const neighborhoodId = formData.get('neighborhoodId') as string
|
||||||
|
|
||||||
|
const nameTr = formData.get('nameTr') as string
|
||||||
|
const nameEn = formData.get('nameEn') as string
|
||||||
|
const nameRu = formData.get('nameRu') as string
|
||||||
|
|
||||||
|
const descriptionTr = formData.get('descriptionTr') as string
|
||||||
|
const descriptionEn = formData.get('descriptionEn') as string
|
||||||
|
const descriptionRu = formData.get('descriptionRu') as string
|
||||||
|
|
||||||
|
const address = formData.get('address') as string
|
||||||
|
const phone = formData.get('phone') as string || null
|
||||||
|
const whatsapp = formData.get('whatsapp') as string || null
|
||||||
|
const website = formData.get('website') as string || null
|
||||||
|
const instagram = formData.get('instagram') as string || null
|
||||||
|
|
||||||
|
const priceRange = parseInt(formData.get('priceRange') as string)
|
||||||
|
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
|
||||||
|
const isLocalApproved = formData.get('isLocalApproved') === 'true'
|
||||||
|
|
||||||
|
const latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
|
||||||
|
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') as string) : null
|
||||||
|
|
||||||
|
const openingHoursStr = formData.get('openingHours') as string
|
||||||
|
const openingHours = openingHoursStr ? { all: openingHoursStr } : null
|
||||||
|
|
||||||
|
// Handle images
|
||||||
|
const images: string[] = []
|
||||||
|
|
||||||
|
const imageFile1 = formData.get('imageFile1') as File | null
|
||||||
|
const imageUrl1 = formData.get('imageUrl1') as string | null
|
||||||
|
const imageFile2 = formData.get('imageFile2') as File | null
|
||||||
|
const imageUrl2 = formData.get('imageUrl2') as string | null
|
||||||
|
|
||||||
|
// Process first image
|
||||||
|
if (imageFile1 && imageFile1.size > 0) {
|
||||||
|
try {
|
||||||
|
const url = await uploadToOpeninary(imageFile1, `listings/${slug}`)
|
||||||
|
images.push(url)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Image 1 upload error:', e)
|
||||||
|
return { success: false, error: `1. Görsel yüklenemedi: ${e.message}` }
|
||||||
|
}
|
||||||
|
} else if (imageUrl1) {
|
||||||
|
images.push(imageUrl1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process second image
|
||||||
|
if (imageFile2 && imageFile2.size > 0) {
|
||||||
|
try {
|
||||||
|
const url = await uploadToOpeninary(imageFile2, `listings/${slug}`)
|
||||||
|
images.push(url)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Image 2 upload error:', e)
|
||||||
|
return { success: false, error: `2. Görsel yüklenemedi: ${e.message}` }
|
||||||
|
}
|
||||||
|
} else if (imageUrl2) {
|
||||||
|
images.push(imageUrl2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
slug,
|
||||||
|
categoryId,
|
||||||
|
neighborhoodId,
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr,
|
||||||
|
nameEn,
|
||||||
|
nameRu,
|
||||||
|
descriptionTr,
|
||||||
|
descriptionEn,
|
||||||
|
descriptionRu,
|
||||||
|
address,
|
||||||
|
phone,
|
||||||
|
whatsapp,
|
||||||
|
website,
|
||||||
|
instagram,
|
||||||
|
priceRange,
|
||||||
|
rating,
|
||||||
|
isLocalApproved,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
openingHours,
|
||||||
|
images
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (id && id !== 'new') {
|
||||||
|
await mockDb.updateListing(id, data)
|
||||||
|
} else {
|
||||||
|
await mockDb.createListing(data)
|
||||||
|
}
|
||||||
|
revalidatePath('/admin/listings')
|
||||||
|
revalidatePath('/restoranlar')
|
||||||
|
revalidatePath('/apartlar')
|
||||||
|
revalidatePath('/isletmeler')
|
||||||
|
return { success: true }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth"
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+153
@@ -0,0 +1,153 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-golos);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--font-heading: var(--font-unbounded);
|
||||||
|
|
||||||
|
--color-pine: var(--pine);
|
||||||
|
--color-turquoise: var(--turquoise);
|
||||||
|
--color-shutter: var(--shutter);
|
||||||
|
--color-gold: var(--gold);
|
||||||
|
--color-bougainvillea: var(--bougainvillea);
|
||||||
|
--color-stone: var(--stone);
|
||||||
|
--color-stone-deep: var(--stone-deep);
|
||||||
|
--color-paper: var(--paper);
|
||||||
|
--color-ink: var(--ink);
|
||||||
|
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--stone: #EDEEE3;
|
||||||
|
--stone-deep: #E2E4D5;
|
||||||
|
--pine: #123238;
|
||||||
|
--turquoise: #2E9C9A;
|
||||||
|
--shutter: #4F7C93;
|
||||||
|
--gold: #E8A23D;
|
||||||
|
--bougainvillea: #E85D6E;
|
||||||
|
--ink: #21231F;
|
||||||
|
--paper: #FBFAF6;
|
||||||
|
|
||||||
|
/* shadcn mappings */
|
||||||
|
--background: var(--stone);
|
||||||
|
--foreground: var(--ink);
|
||||||
|
--card: var(--paper);
|
||||||
|
--card-foreground: var(--ink);
|
||||||
|
--popover: var(--paper);
|
||||||
|
--popover-foreground: var(--ink);
|
||||||
|
--primary: var(--pine);
|
||||||
|
--primary-foreground: var(--stone);
|
||||||
|
--secondary: var(--turquoise);
|
||||||
|
--secondary-foreground: var(--paper);
|
||||||
|
--muted: var(--stone-deep);
|
||||||
|
--muted-foreground: var(--shutter);
|
||||||
|
--accent: var(--turquoise);
|
||||||
|
--accent-foreground: var(--paper);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: rgba(18, 50, 56, 0.08);
|
||||||
|
--input: rgba(18, 50, 56, 0.08);
|
||||||
|
--ring: var(--turquoise);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.75rem;
|
||||||
|
|
||||||
|
--sidebar: var(--paper);
|
||||||
|
--sidebar-foreground: var(--ink);
|
||||||
|
--sidebar-primary: var(--pine);
|
||||||
|
--sidebar-primary-foreground: var(--stone);
|
||||||
|
--sidebar-accent: var(--stone);
|
||||||
|
--sidebar-accent-foreground: var(--pine);
|
||||||
|
--sidebar-border: rgba(18, 50, 56, 0.08);
|
||||||
|
--sidebar-ring: var(--turquoise);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #123238;
|
||||||
|
--foreground: #EDEEE3;
|
||||||
|
--card: #21231F;
|
||||||
|
--card-foreground: #EDEEE3;
|
||||||
|
--popover: #21231F;
|
||||||
|
--popover-foreground: #EDEEE3;
|
||||||
|
--primary: #2E9C9A;
|
||||||
|
--primary-foreground: #123238;
|
||||||
|
--secondary: #4F7C93;
|
||||||
|
--secondary-foreground: #EDEEE3;
|
||||||
|
--muted: #123238;
|
||||||
|
--muted-foreground: #4F7C93;
|
||||||
|
--accent: #2E9C9A;
|
||||||
|
--accent-foreground: #123238;
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: rgba(237, 238, 227, 0.1);
|
||||||
|
--input: rgba(237, 238, 227, 0.1);
|
||||||
|
--ring: #2E9C9A;
|
||||||
|
|
||||||
|
--sidebar: #21231F;
|
||||||
|
--sidebar-foreground: #EDEEE3;
|
||||||
|
--sidebar-primary: #2E9C9A;
|
||||||
|
--sidebar-primary-foreground: #123238;
|
||||||
|
--sidebar-accent: #123238;
|
||||||
|
--sidebar-accent-foreground: #EDEEE3;
|
||||||
|
--sidebar-border: rgba(237, 238, 227, 0.1);
|
||||||
|
--sidebar-ring: #2E9C9A;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-heading;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const t = useTranslations('footer')
|
||||||
|
const nav = useTranslations('nav')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-pine text-stone/70 border-t border-white/10 pt-16 pb-8 font-sans">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
|
||||||
|
|
||||||
|
{/* Logo & Info */}
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
<Link href="/" className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full border-2 border-stone flex items-center justify-center relative bg-paper shrink-0">
|
||||||
|
<div className="absolute inset-[2.5px] rounded-full border border-dashed border-turquoise" />
|
||||||
|
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
|
||||||
|
marmaris <span className="text-turquoise">local</span>
|
||||||
|
</h2>
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-stone/50 max-w-sm font-medium leading-relaxed">
|
||||||
|
{t('about')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-heading font-semibold text-xs text-stone uppercase tracking-wider">
|
||||||
|
{nav('home')}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
<li>
|
||||||
|
<Link href="/restoranlar" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('restaurants')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/apartlar" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('aparts')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/isletmeler" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('businesses')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guidelines */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-heading font-semibold text-xs text-stone uppercase tracking-wider">
|
||||||
|
Marmaris Local
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-xs">
|
||||||
|
<li>
|
||||||
|
<Link href="/hakkinda" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('about')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/iletisim" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('contact')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/isletme-ekle" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('addBusiness')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/login" className="hover:text-turquoise transition-colors">
|
||||||
|
{nav('admin')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Copyright & Branding */}
|
||||||
|
<div className="border-t border-white/5 pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-stone/40 gap-4">
|
||||||
|
<p>{t('rights')}</p>
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<a
|
||||||
|
href="https://ayris.tech"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-stone hover:text-turquoise transition-colors border-b border-stone/20 hover:border-turquoise pb-0.5"
|
||||||
|
>
|
||||||
|
ayris.tech
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { useLocale } from 'next-intl'
|
||||||
|
import { Star } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface Gallery {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
slug: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neighborhood {
|
||||||
|
slug: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Listing {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
categoryId: string
|
||||||
|
neighborhoodId: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
descriptionTr: string
|
||||||
|
descriptionEn: string
|
||||||
|
descriptionRu: string
|
||||||
|
address: string
|
||||||
|
priceRange: number
|
||||||
|
rating?: number | null
|
||||||
|
isLocalApproved: boolean
|
||||||
|
images: Gallery[]
|
||||||
|
category?: Category
|
||||||
|
neighborhood?: Neighborhood
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListingCard({ listing }: { listing: Listing }) {
|
||||||
|
const locale = useLocale()
|
||||||
|
|
||||||
|
const name =
|
||||||
|
locale === 'ru'
|
||||||
|
? listing.nameRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.nameEn
|
||||||
|
: listing.nameTr
|
||||||
|
|
||||||
|
const description =
|
||||||
|
locale === 'ru'
|
||||||
|
? listing.descriptionRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.descriptionEn
|
||||||
|
: listing.descriptionTr
|
||||||
|
|
||||||
|
const categoryName = listing.category
|
||||||
|
? locale === 'ru'
|
||||||
|
? listing.category.nameRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.category.nameEn
|
||||||
|
: listing.category.nameTr
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const neighborhoodName = listing.neighborhood
|
||||||
|
? locale === 'ru'
|
||||||
|
? listing.neighborhood.nameRu
|
||||||
|
: locale === 'en'
|
||||||
|
? listing.neighborhood.nameEn
|
||||||
|
: listing.neighborhood.nameTr
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const priceSymbols = '₺'.repeat(listing.priceRange)
|
||||||
|
const categorySlug = listing.category?.slug || 'isletme'
|
||||||
|
|
||||||
|
const mainImageUrl =
|
||||||
|
listing.images && listing.images.length > 0
|
||||||
|
? listing.images[0].url
|
||||||
|
: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${categorySlug}/${listing.slug}`}
|
||||||
|
className="group bg-paper rounded-2xl border border-pine/8 overflow-hidden flex flex-col relative shadow-sm hover:shadow-md hover:border-turquoise/35 transition-all duration-300 transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
{/* Local Approved Seal */}
|
||||||
|
{listing.isLocalApproved && (
|
||||||
|
<div className="absolute top-4 right-4 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0">
|
||||||
|
<div className="absolute inset-[2.5px] rounded-full border border-dashed border-turquoise/60" />
|
||||||
|
<span className="font-mono text-[7px] text-center font-bold text-turquoise tracking-tight leading-[1.1] uppercase">
|
||||||
|
YEREL<br />ONAYLI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image Preview */}
|
||||||
|
<div className="aspect-[4/3] w-full relative bg-stone-deep overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={mainImageUrl}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-pine/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Details */}
|
||||||
|
<div className="p-5 flex-1 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
{/* Category */}
|
||||||
|
<div className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider mb-2">
|
||||||
|
{categoryName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-heading font-bold text-lg text-pine leading-tight mb-2 group-hover:text-turquoise transition-colors line-clamp-1">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Neighborhood */}
|
||||||
|
<div className="text-xs text-ink/60 font-medium mb-4">
|
||||||
|
{neighborhoodName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer info (price range, rating) */}
|
||||||
|
<div className="border-t border-dashed border-pine/12 pt-3.5 mt-auto flex items-center justify-between text-xs font-mono">
|
||||||
|
<span className="text-pine font-semibold">{priceSymbols}</span>
|
||||||
|
|
||||||
|
{listing.rating && (
|
||||||
|
<div className="flex items-center gap-1 text-gold font-bold">
|
||||||
|
<Star className="w-3.5 h-3.5 fill-gold stroke-gold" />
|
||||||
|
<span>★ {listing.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, usePathname, useRouter } from '@/i18n/routing'
|
||||||
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
|
import { Menu, X, Globe, PlusCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const t = useTranslations('nav')
|
||||||
|
const activeLocale = useLocale()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [langMenuOpen, setLangMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: 'tr', label: 'Türkçe' },
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'ru', label: 'Русский' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: t('home'), href: '/' },
|
||||||
|
{ name: t('restaurants'), href: '/restoranlar' },
|
||||||
|
{ name: t('aparts'), href: '/apartlar' },
|
||||||
|
{ name: t('businesses'), href: '/isletmeler' },
|
||||||
|
{ name: t('about'), href: '/hakkinda' },
|
||||||
|
{ name: t('contact'), href: '/iletisim' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleLanguageChange = (localeCode: string) => {
|
||||||
|
setLangMenuOpen(false)
|
||||||
|
router.replace(pathname, { locale: localeCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-pine text-stone border-b border-white/10 sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-20">
|
||||||
|
|
||||||
|
{/* Logo & Wordmark */}
|
||||||
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
|
<div className="w-11 h-11 rounded-full border-2 border-stone flex items-center justify-center relative bg-paper shrink-0 shadow-sm transition-transform group-hover:scale-105">
|
||||||
|
<div className="absolute inset-[3px] rounded-full border border-dashed border-turquoise" />
|
||||||
|
<span className="font-heading font-extrabold text-pine text-sm tracking-tighter">ML</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<h1 className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
|
||||||
|
marmaris <span className="text-turquoise">local</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[9px] font-mono text-shutter tracking-wider mt-0.5 uppercase">
|
||||||
|
local knowledge
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden lg:flex items-center gap-6">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={`text-sm font-medium transition-colors hover:text-turquoise ${
|
||||||
|
isActive ? 'text-turquoise font-semibold' : 'text-stone/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Action Items */}
|
||||||
|
<div className="hidden lg:flex items-center gap-4">
|
||||||
|
{/* Language Selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setLangMenuOpen(!langMenuOpen)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-stone/20 hover:border-turquoise transition text-xs font-mono"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 text-turquoise" />
|
||||||
|
{activeLocale.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{langMenuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setLangMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 mt-2 w-36 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1.5 overflow-hidden">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => handleLanguageChange(lang.code)}
|
||||||
|
className={`w-full text-left px-4 py-2 text-xs hover:bg-stone transition flex items-center justify-between ${
|
||||||
|
activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang.label}
|
||||||
|
<span className="font-mono text-[10px] text-shutter">{lang.code.toUpperCase()}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Business Button */}
|
||||||
|
<Link
|
||||||
|
href="/isletme-ekle"
|
||||||
|
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2 px-4 rounded-full transition-transform active:scale-95 shadow-sm"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-3.5 h-3.5" />
|
||||||
|
{t('addBusiness')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<div className="flex items-center gap-3 lg:hidden">
|
||||||
|
{/* Lang menu for mobile */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setLangMenuOpen(!langMenuOpen)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-stone/20 text-xs font-mono"
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 text-turquoise" />
|
||||||
|
{activeLocale.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{langMenuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setLangMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 mt-2 w-32 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => handleLanguageChange(lang.code)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-stone"
|
||||||
|
>
|
||||||
|
{lang.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="text-stone hover:text-turquoise focus:outline-none"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="lg:hidden border-t border-white/10 bg-pine py-4 px-4 space-y-3">
|
||||||
|
<nav className="flex flex-col gap-3">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={`text-sm font-medium py-2 px-3 rounded-lg transition-colors ${
|
||||||
|
isActive ? 'bg-white/10 text-turquoise' : 'text-stone/80 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="pt-4 border-t border-white/10">
|
||||||
|
<Link
|
||||||
|
href="/isletme-ekle"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-medium py-3 rounded-lg text-sm transition"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" />
|
||||||
|
{t('addBusiness')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Marmaris Local — Marka Yönü</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;800&family=Golos+Text:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--stone: #EDEEE3;
|
||||||
|
--stone-deep: #E2E4D5;
|
||||||
|
--pine: #123238;
|
||||||
|
--turquoise: #2E9C9A;
|
||||||
|
--shutter: #4F7C93;
|
||||||
|
--gold: #E8A23D;
|
||||||
|
--bougainvillea: #E85D6E;
|
||||||
|
--ink: #21231F;
|
||||||
|
--paper: #FBFAF6;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--stone);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Golos Text', sans-serif;
|
||||||
|
padding: 64px 24px 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header / wordmark ---- */
|
||||||
|
.brandhead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 28px;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2.5px solid var(--pine);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px dashed var(--turquoise);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal-mark {
|
||||||
|
font-family: 'Unbounded', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--pine);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark h1 {
|
||||||
|
font-family: 'Unbounded', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 40px;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
color: var(--pine);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark p {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--shutter);
|
||||||
|
margin-top: 6px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--shutter);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Palette ---- */
|
||||||
|
.palette {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 148px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(18, 50, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch .fill {
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch .meta {
|
||||||
|
background: var(--paper);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch .meta .name {
|
||||||
|
font-family: 'Golos Text', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Type specimen ---- */
|
||||||
|
.type-block {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-block .display {
|
||||||
|
font-family: 'Unbounded', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
color: var(--pine);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-block .display.ru {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 36px;
|
||||||
|
color: var(--turquoise);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-block .body-sample {
|
||||||
|
font-family: 'Golos Text', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ink);
|
||||||
|
max-width: 560px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 28px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--shutter);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cards ---- */
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid rgba(18, 50, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--turquoise);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .stamp span {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 7.5px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--turquoise);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .cat {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--bougainvillea);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
font-family: 'Unbounded', sans-serif;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--pine);
|
||||||
|
max-width: 75%;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .loc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 0.65;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px dashed rgba(18, 50, 56, 0.15);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .price {
|
||||||
|
color: var(--pine);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .gold {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-note {
|
||||||
|
background: var(--pine);
|
||||||
|
color: var(--stone);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 36px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-note b {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Golos Text';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<div class="brandhead">
|
||||||
|
<div class="seal">
|
||||||
|
<div class="seal-mark">ML</div>
|
||||||
|
</div>
|
||||||
|
<div class="wordmark">
|
||||||
|
<h1>marmaris local</h1>
|
||||||
|
<p>YEREL BİLGİ · МЕСТНЫЕ ЗНАНИЯ · LOCAL KNOWLEDGE</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span class="label">01 — Renk Paleti</span>
|
||||||
|
<div class="palette">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#123238"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Pine Night</div>#123238
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#2E9C9A"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Bay Turquoise</div>#2E9C9A
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#4F7C93"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Shutter Blue</div>#4F7C93
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#E8A23D"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Golden Hour</div>#E8A23D
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#E85D6E"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Bougainvillea</div>#E85D6E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="fill" style="background:#EDEEE3"></div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="name">Limestone</div>#EDEEE3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span class="label">02 — Tipografi</span>
|
||||||
|
<div class="type-block">
|
||||||
|
<div class="display">En iyi yerel adresler,<br>turistin göremediği yerde.</div>
|
||||||
|
<div class="display ru">Лучшие места, которые знают местные.</div>
|
||||||
|
<div class="body-sample">Golos Text — gövde metni. Hem Latin hem Kiril alfabesinde eşit ağırlıkta
|
||||||
|
okunur, çeviri hissi vermez. Restoran açıklamaları, işletme saatleri ve kullanıcı yorumları bu yazı
|
||||||
|
tipiyle yazılır.</div>
|
||||||
|
<div class="type-note">
|
||||||
|
<span>DISPLAY — Unbounded 800/600</span>
|
||||||
|
<span>BODY — Golos Text 400/500</span>
|
||||||
|
<span>UTILITY — IBM Plex Mono (fiyat, saat, telefon)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span class="label">03 — Kart Sistemi (imza öğe: "Yerel Onaylı" mührü)</span>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
|
||||||
|
<div class="cat">Restoran</div>
|
||||||
|
<h3>İskele Balık Ocakbaşı</h3>
|
||||||
|
<div class="loc">Yat Limanı, Marmaris</div>
|
||||||
|
<div class="foot"><span class="price">₺₺₺</span><span class="gold">★ 4.8</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
|
||||||
|
<div class="cat">Apart</div>
|
||||||
|
<h3>Zeytin Bahçesi Apart</h3>
|
||||||
|
<div class="loc">Sedir Mah., Marmaris</div>
|
||||||
|
<div class="foot"><span class="price">₺₺</span><span class="gold">★ 4.6</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
|
||||||
|
<div class="cat">Hizmet</div>
|
||||||
|
<h3>Marina Dalış Merkezi</h3>
|
||||||
|
<div class="loc">İçmeler, Marmaris</div>
|
||||||
|
<div class="foot"><span class="price">₺₺₺</span><span class="gold">★ 4.9</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<span class="label">04 — Neden bu yön</span>
|
||||||
|
<div class="signature-note">
|
||||||
|
Palet klasik "krem + turuncu" AI şablonundan kaçınıp doğrudan Marmaris'in kendi görsel dünyasından
|
||||||
|
geliyor: çam yeşiline kaçan koyu <b>Pine Night</b> zemin, körfezin <b>Bay Turquoise</b>'u imza rengi,
|
||||||
|
badem çiçeği pembesi <b>Bougainvillea</b> nadir vurgu olarak. <b>Unbounded</b> hem Latin hem Kiril'de
|
||||||
|
doğal duran, karaktersiz Inter/Manrope klişesine düşmeyen bir başlık fontu — Rus kullanıcıya "sonradan
|
||||||
|
çevrilmiş" değil, kendisi için tasarlanmış hissi verir. İmza öğe: her mekanın yanındaki daire mühür —
|
||||||
|
markanın vaadini ("yereldan onaylı") harf yerine görsel bir jestle taşıyor.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
# PRD — Marmaris Local
|
||||||
|
|
||||||
|
**Domain:** marmarislocal.com
|
||||||
|
**Tarih:** 2026-07-12
|
||||||
|
**Durum:** MVP tanımı — geliştirme başka ortamda yapılacak
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Proje Özeti
|
||||||
|
|
||||||
|
Marmaris Local, Marmaris'teki restoran, apart otel ve yerel işletmeleri (dalış merkezi, tekne kiralama, tur operatörü, transfer vb.) tek bir küratörlü rehberde toplayan, uluslararası turiste (öncelikle Rus ve İngiliz, ayrıca Türk) hitap eden çok dilli bir dizin/rehber sitesi.
|
||||||
|
|
||||||
|
Konumlandırma: "Turistin göremediği yerel bilgi." Genel bir Yelp/TripAdvisor klonu değil — her listelemenin bir kürasyon/onay katmanı var ("Yerel Onaylı" mührü), bu da markanın ana güven vaadi.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Marka Kimliği (özet — tam mockup ekte: `marmaris-local-brand.html`)
|
||||||
|
|
||||||
|
- **Palet:** Pine Night `#123238` (zemin/koyu), Bay Turquoise `#2E9C9A` (imza rengi), Shutter Blue `#4F7C93`, Golden Hour `#E8A23D` (puan/rozet), Bougainvillea `#E85D6E` (nadir vurgu, kategori etiketleri), Limestone `#EDEEE3` (açık zemin)
|
||||||
|
- **Tipografi:** Unbounded (başlık, 800/600 — Latin+Kiril), Golos Text (gövde — Latin+Kiril), IBM Plex Mono (fiyat/saat/telefon gibi pratik veri)
|
||||||
|
- **İmza öğe:** Dairesel "Yerel Onaylı" mührü — her kürasyona giren listelemenin yanında görünür, logo motifiyle aynı dil
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sayfa Envanteri / Site Haritası
|
||||||
|
|
||||||
|
```
|
||||||
|
/ → Anasayfa (öne çıkan listelemeler, kategori girişleri, arama)
|
||||||
|
/restoranlar → Restoran listesi (filtre: mahalle, mutfak, fiyat)
|
||||||
|
/apartlar → Apart/konaklama listesi
|
||||||
|
/isletmeler → Genel işletme listesi (dalış, tekne, tur, transfer, kiralama)
|
||||||
|
/[kategori]/[slug] → Tekil listeleme detay sayfası
|
||||||
|
/mahalle/[slug] → Mahalle bazlı liste (Yat Limanı, İçmeler, Armutalan vb.)
|
||||||
|
/isletme-ekle → İşletme sahibi başvuru formu
|
||||||
|
/hakkinda → Marka hikayesi, "Yerel Onaylı" nasıl çalışır
|
||||||
|
/iletisim → İletişim formu
|
||||||
|
/admin/* → Yönetim paneli (bkz. §8)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tekil listeleme sayfası içeriği: galeri, açıklama (TR/EN/RU), adres + harita, çalışma saatleri, fiyat aralığı, telefon/WhatsApp, "Yerel Onaylı" rozeti (varsa), kategori etiketleri, benzer listelemeler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dil Seçenekleri
|
||||||
|
|
||||||
|
`next-intl` locale listesi: **tr, en, ru**
|
||||||
|
|
||||||
|
- Varsayılan: `tr` (yerel SEO ve Türk kullanıcı için)
|
||||||
|
- Öncelik sırası: TR → EN → RU (RU içerik girişi başta makine çevirisi + manuel düzeltme olabilir, MVP'de tüm içerik 3 dilde eksiksiz olmalı — Rus turist kitlesi ana hedef olduğu için RU içerik EN kadar özenli olmalı, "sonradan eklenmiş" hissi vermemeli)
|
||||||
|
- Fiyat/saat gibi sayısal veriler dil bağımsız (IBM Plex Mono ile gösterilir, çeviri gerektirmez)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. İçerik Modelleri (Prisma)
|
||||||
|
|
||||||
|
| Model | Açıklama |
|
||||||
|
|---|---|
|
||||||
|
| `Listing` | Ana varlık — restoran/apart/işletme hepsi bu modelde, `category` alanıyla ayrılır |
|
||||||
|
| `Category` | Restoran, Apart, Dalış, Tekne Kiralama, Tur Operatörü, Transfer, Araç Kiralama vb. |
|
||||||
|
| `Neighborhood` | Yat Limanı, İçmeler, Armutalan, Siteler, Turunç vb. |
|
||||||
|
| `BusinessSubmission` | `/isletme-ekle` formundan gelen, admin onayı bekleyen başvurular |
|
||||||
|
| `ContactMessage` | `/iletisim` formundan gelen genel mesajlar |
|
||||||
|
| `Gallery` | Listeleme başına çoklu görsel (Cloudinary) |
|
||||||
|
|
||||||
|
### Listing alanları
|
||||||
|
```prisma
|
||||||
|
model Listing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
categoryId String
|
||||||
|
neighborhoodId String
|
||||||
|
|
||||||
|
nameTr String
|
||||||
|
nameEn String
|
||||||
|
nameRu String
|
||||||
|
descriptionTr String
|
||||||
|
descriptionEn String
|
||||||
|
descriptionRu String
|
||||||
|
|
||||||
|
address String
|
||||||
|
phone String?
|
||||||
|
whatsapp String?
|
||||||
|
website String?
|
||||||
|
instagram String?
|
||||||
|
|
||||||
|
priceRange Int // 1-3 (₺ / ₺₺ / ₺₺₺)
|
||||||
|
rating Float? // 0-5, admin girişli (MVP'de kullanıcı yorumu yok)
|
||||||
|
isLocalApproved Boolean @default(false) // "Yerel Onaylı" mührü
|
||||||
|
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
openingHours Json? // gün bazlı saat aralıkları
|
||||||
|
|
||||||
|
images Gallery[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
neighborhood Neighborhood @relation(fields: [neighborhoodId], references: [id])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not:** MVP'de kullanıcı yorumu/puanlama yok — `rating` admin tarafından manuel giriliyor (küratörlü rehber mantığına uygun). Kullanıcı yorumları P2'de değerlendirilebilir (bkz. §11).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. "İşletme Ekle" Başvuru Formu Alanları
|
||||||
|
|
||||||
|
`/isletme-ekle` — işletme sahiplerinin kendi mekanlarını öneri olarak gönderdiği form:
|
||||||
|
|
||||||
|
- İşletme adı
|
||||||
|
- Kategori (dropdown → `Category`)
|
||||||
|
- Mahalle (dropdown → `Neighborhood`)
|
||||||
|
- Adres
|
||||||
|
- Telefon / WhatsApp
|
||||||
|
- Kısa açıklama (tek dilde yeterli, admin çevirip yayınlıyor)
|
||||||
|
- İletişim eden kişinin adı + e-postası
|
||||||
|
- Görsel yükleme (opsiyonel, Cloudinary)
|
||||||
|
|
||||||
|
→ `BusinessSubmission` tablosuna düşer, admin panelden onaylanınca `Listing`'e dönüştürülür (manuel "Onayla ve Listeye Ekle" aksiyonu — otomatik değil, kürasyon burada gerçekleşiyor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. İletişim Formu Alanları (`/iletisim`)
|
||||||
|
|
||||||
|
- Ad Soyad
|
||||||
|
- E-posta
|
||||||
|
- Konu (dropdown: Genel, İşbirliği, Hata Bildirimi, Diğer)
|
||||||
|
- Mesaj
|
||||||
|
|
||||||
|
→ `ContactMessage` tablosu, admin panelden okunur/işaretlenir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Admin / Yönetim İhtiyaçları
|
||||||
|
|
||||||
|
```
|
||||||
|
/admin → Dashboard (toplam listeleme, kategori bazlı sayı, bekleyen başvuru sayısı)
|
||||||
|
/admin/listings → Liste + CRUD (kategori, mahalle filtreli)
|
||||||
|
/admin/listings/[id] → Düzenleme formu (3 dilde içerik alanları, görsel yönetimi, "Yerel Onaylı" toggle)
|
||||||
|
/admin/submissions → Bekleyen işletme başvuruları → Onayla (Listing'e dönüştür) / Reddet
|
||||||
|
/admin/messages → İletişim mesajları
|
||||||
|
/admin/categories → Kategori yönetimi
|
||||||
|
/admin/neighborhoods → Mahalle yönetimi
|
||||||
|
/admin/users → Kullanıcı yönetimi (sabit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth: NextAuth (credentials, default) — tek admin rolü yeterli, MVP'de çoklu yetki seviyesi gerekmiyor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Arama & Filtreleme
|
||||||
|
|
||||||
|
- Anasayfa arama kutusu: isim + kategori + mahalle üzerinden basit metin araması
|
||||||
|
- Kategori sayfalarında filtre: mahalle, fiyat aralığı, sadece "Yerel Onaylı" olanlar
|
||||||
|
- Harita görünümü: MVP'de opsiyonel (P1) — Leaflet + OpenStreetMap (Google Maps API maliyeti yerine)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sosyal Medya / 3rd Party — Env Değişkenleri
|
||||||
|
|
||||||
|
```env
|
||||||
|
CLOUDINARY_CLOUD_NAME=""
|
||||||
|
CLOUDINARY_API_KEY=""
|
||||||
|
CLOUDINARY_API_SECRET=""
|
||||||
|
NEXT_PUBLIC_INSTAGRAM_URL=""
|
||||||
|
NEXT_PUBLIC_WHATSAPP_NUMBER="" # site geneli iletişim WhatsApp
|
||||||
|
RESEND_API_KEY="" # iletişim formu bildirimleri için
|
||||||
|
```
|
||||||
|
|
||||||
|
Harita için P1'de `NEXT_PUBLIC_MAPBOX_TOKEN` veya Leaflet (ücretsiz, tercih edilir) değerlendirilecek.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. SEO & Uluslararası Notlar
|
||||||
|
|
||||||
|
- `hreflang` etiketleri tr/en/ru için eksiksiz olmalı — Google'ın doğru dil versiyonunu doğru kullanıcıya göstermesi kritik
|
||||||
|
- RU içerik gerçek çeviri kalitesinde olmalı, otomatik çeviri kokan metin marka güvenini zedeler (bkz. marka notunda "sonradan çevrilmiş hissi vermemeli")
|
||||||
|
- Her `Listing` için yapılandırılmış veri (schema.org `LocalBusiness`/`Restaurant`) eklenmeli — Google'da zengin sonuç (rating, adres, fiyat) için
|
||||||
|
- Sayfa başlıkları ve meta açıklamalar 3 dilde ayrı üretilmeli, tek dilden otomatik türetilmemeli
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Teknik Notlar
|
||||||
|
|
||||||
|
- Çok-şehirli genişleme ihtimaline karşı: `Listing` modelinde şimdiden bir `city` alanı (MVP'de sabit `"marmaris"`) bulunmalı — ileride Fethiye/Bodrum/Ören/Datça eklenirse migration basit olsun
|
||||||
|
- Görseller Cloudinary, klasörleme: `marmarislocal/{category}/{listingSlug}/`
|
||||||
|
- Mock data: MVP gösterimi için en az 3 restoran, 2 apart, 3 işletme — gerçekçi TR/EN/RU içerikle (Unsplash görselli, mevcut demo-site kuralına uygun)
|
||||||
|
- Standart: soft delete (`deletedAt`), `USE_MOCK` env flag, `requireAdmin()` tüm admin route'larında — mevcut altyapı kurallarıyla birebir uyumlu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. MVP Kapsamı
|
||||||
|
|
||||||
|
**P0 (MVP'siz olmaz):**
|
||||||
|
- Listing/Category/Neighborhood modelleri + 3 dilli içerik
|
||||||
|
- Kategori ve mahalle bazlı listeleme sayfaları + tekil detay sayfası
|
||||||
|
- "Yerel Onaylı" rozet sistemi
|
||||||
|
- İşletme ekle formu + admin onay akışı
|
||||||
|
- İletişim formu
|
||||||
|
- Admin panel (CRUD + başvuru onayı)
|
||||||
|
- TR/EN/RU dil desteği
|
||||||
|
|
||||||
|
**P1 (hızlı takip):**
|
||||||
|
- Harita görünümü (Leaflet)
|
||||||
|
- Gelişmiş filtreleme (fiyat aralığı, açık/kapalı durumu)
|
||||||
|
- Instagram feed entegrasyonu (anasayfa)
|
||||||
|
|
||||||
|
**P2 (gelecek, mimariyi şimdiden zorlamasın):**
|
||||||
|
- Kullanıcı yorumu/puanlama sistemi
|
||||||
|
- Çok-şehir desteği (Fethiye Local, Bodrum Local vb. — aynı codebase, `city` filtresiyle)
|
||||||
|
- İşletme sahibi kendi paneline giriş yapıp kendi listesini güncelleyebilsin (şu an sadece admin düzenliyor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Açık Sorular
|
||||||
|
|
||||||
|
- [ ] Harita için Leaflet/OSM mi yoksa Google Maps mi tercih edilecek? (maliyet vs. tanıdıklık — Rus/İngiliz turist Google Maps'e daha alışkın olabilir)
|
||||||
|
- [ ] RU içerik çevirisi kim yapacak — profesyonel çeviri mi, DeepSeek/Gemini ile üretim + manuel kontrol mü?
|
||||||
|
- [ ] "Yerel Onaylı" kriterleri net bir liste halinde tanımlanacak mı (editoryal tutarlılık için), yoksa şimdilik sübjektif admin kararı mı?
|
||||||
|
- [ ] İlk yayın için hedef listeleme sayısı ve hangi kategoriden başlanacağı (öneri: restoranla başlamak, en yüksek arama hacmi orada)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {getRequestConfig} from 'next-intl/server';
|
||||||
|
import {routing} from './routing';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({requestLocale}) => {
|
||||||
|
let locale = await requestLocale;
|
||||||
|
|
||||||
|
if (!locale || !routing.locales.includes(locale as any)) {
|
||||||
|
locale = routing.defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {defineRouting} from 'next-intl/routing';
|
||||||
|
import {createNavigation} from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['en', 'tr', 'ru'],
|
||||||
|
defaultLocale: 'tr'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {Link, redirect, usePathname, useRouter, getPathname} =
|
||||||
|
createNavigation(routing);
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
// Boilerplate mock logic
|
||||||
|
// TODO: In production, lookup user in Prisma and verify password using bcrypt
|
||||||
|
// const user = await db.user.findUnique({ where: { email: credentials.email } })
|
||||||
|
|
||||||
|
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
|
||||||
|
return {
|
||||||
|
id: "1",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@ayris.tech",
|
||||||
|
role: "ADMIN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = (user as any).role
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user && token.role) {
|
||||||
|
(session.user as any).role = token.role
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/login'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { v2 as cloudinary } from 'cloudinary'
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
|
||||||
|
api_key: process.env.CLOUDINARY_API_KEY!,
|
||||||
|
api_secret: process.env.CLOUDINARY_API_SECRET!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function uploadImage(file: string, folder: string) {
|
||||||
|
const result = await cloudinary.uploader.upload(file, {
|
||||||
|
folder, transformation: [{ quality: 'auto', fetch_format: 'auto' }],
|
||||||
|
})
|
||||||
|
return { url: result.secure_url, publicId: result.public_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImage(publicId: string) {
|
||||||
|
await cloudinary.uploader.destroy(publicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cloudinary }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||||
+675
@@ -0,0 +1,675 @@
|
|||||||
|
import { db } from './db'
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neighborhood {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Gallery {
|
||||||
|
id: string
|
||||||
|
listingId: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Listing {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
categoryId: string
|
||||||
|
neighborhoodId: string
|
||||||
|
city: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
descriptionTr: string
|
||||||
|
descriptionEn: string
|
||||||
|
descriptionRu: string
|
||||||
|
address: string
|
||||||
|
phone?: string | null
|
||||||
|
whatsapp?: string | null
|
||||||
|
website?: string | null
|
||||||
|
instagram?: string | null
|
||||||
|
priceRange: number
|
||||||
|
rating?: number | null
|
||||||
|
isLocalApproved: boolean
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
openingHours?: any | null
|
||||||
|
images: Gallery[]
|
||||||
|
category?: Category
|
||||||
|
neighborhood?: Neighborhood
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
deletedAt?: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessSubmission {
|
||||||
|
id: string
|
||||||
|
businessName: string
|
||||||
|
categoryId: string
|
||||||
|
neighborhoodId: string
|
||||||
|
address: string
|
||||||
|
phone?: string | null
|
||||||
|
whatsapp?: string | null
|
||||||
|
description: string
|
||||||
|
contactName: string
|
||||||
|
contactEmail: string
|
||||||
|
imageUrl?: string | null
|
||||||
|
status: string
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactMessage {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
subject: string
|
||||||
|
message: string
|
||||||
|
isRead: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForMockDb = globalThis as unknown as {
|
||||||
|
categories: Category[]
|
||||||
|
neighborhoods: Neighborhood[]
|
||||||
|
listings: Listing[]
|
||||||
|
submissions: BusinessSubmission[]
|
||||||
|
messages: ContactMessage[]
|
||||||
|
initialized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalForMockDb.initialized) {
|
||||||
|
globalForMockDb.categories = [
|
||||||
|
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан' },
|
||||||
|
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель' },
|
||||||
|
{ id: 'cat-3', slug: 'isletme', nameTr: 'İşletme & Hizmet', nameEn: 'Business & Service', nameRu: 'Бизнес и Услуги' }
|
||||||
|
]
|
||||||
|
|
||||||
|
globalForMockDb.neighborhoods = [
|
||||||
|
{ id: 'neigh-1', slug: 'yat-limani', nameTr: 'Yat Limanı', nameEn: 'Marina', nameRu: 'Марина' },
|
||||||
|
{ id: 'neigh-2', slug: 'icmeler', nameTr: 'İçmeler', nameEn: 'Icmeler', nameRu: 'Ичмелер' },
|
||||||
|
{ id: 'neigh-3', slug: 'armutalan', nameTr: 'Armutalan', nameEn: 'Armutalan', nameRu: 'Армуталан' },
|
||||||
|
{ id: 'neigh-4', slug: 'siteler', nameTr: 'Siteler', nameEn: 'Siteler', nameRu: 'Сителер' },
|
||||||
|
{ id: 'neigh-5', slug: 'turunc', nameTr: 'Turunç', nameEn: 'Turunc', nameRu: 'Турунч' }
|
||||||
|
]
|
||||||
|
|
||||||
|
globalForMockDb.submissions = []
|
||||||
|
globalForMockDb.messages = []
|
||||||
|
|
||||||
|
// Pre-populate with realistic Marmaris data
|
||||||
|
globalForMockDb.listings = [
|
||||||
|
{
|
||||||
|
id: 'list-1',
|
||||||
|
slug: 'iskele-balik-ocakbasi',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
neighborhoodId: 'neigh-1',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'İskele Balık Ocakbaşı',
|
||||||
|
nameEn: 'Iskele Fish & Grill',
|
||||||
|
nameRu: 'Рыбный Гриль Искеле',
|
||||||
|
descriptionTr: 'Yat Limanı\'nda taze Ege balıkları ve geleneksel meze çeşitleriyle yerel lezzet durağınız. Mükemmel körfez manzarası eşliğinde akşam yemeği.',
|
||||||
|
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
|
||||||
|
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
|
||||||
|
address: 'Yat Limanı No:12, Marmaris',
|
||||||
|
phone: '+90 252 412 34 56',
|
||||||
|
whatsapp: '+90 532 123 45 67',
|
||||||
|
website: 'https://iskelemarmaris.com',
|
||||||
|
instagram: 'https://instagram.com/iskele_marmaris',
|
||||||
|
priceRange: 3,
|
||||||
|
rating: 4.8,
|
||||||
|
isLocalApproved: true,
|
||||||
|
latitude: 36.8524,
|
||||||
|
longitude: 28.2741,
|
||||||
|
openingHours: { all: '12:00 - 00:00' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-1-1', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=800&auto=format&fit=crop&q=80' },
|
||||||
|
{ id: 'img-1-2', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-2',
|
||||||
|
slug: 'mavi-beyaz-restoran',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
neighborhoodId: 'neigh-1',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Mavi Beyaz Restoran',
|
||||||
|
nameEn: 'Blue White Restaurant',
|
||||||
|
nameRu: 'Ресторан Сине-Белый',
|
||||||
|
descriptionTr: 'Ege kıyılarının esintisini taşıyan, deniz ürünleri ağırlıklı menüsü ve gün batımı eşliğindeki eşsiz mezeleriyle ünlüdür.',
|
||||||
|
descriptionEn: 'Famous for its seafood-oriented menu carrying the breeze of the Aegean coasts and unique appetizers accompanied by sunset.',
|
||||||
|
descriptionRu: 'Славится своим меню из морепродуктов, несущим бриз Эгейского побережья, и уникальными закусками на фоне заката.',
|
||||||
|
address: 'Kordon Caddesi No:45, Yat Limanı, Marmaris',
|
||||||
|
phone: '+90 252 412 78 90',
|
||||||
|
whatsapp: '+90 533 987 65 43',
|
||||||
|
website: 'https://mavibeyazmarmaris.com',
|
||||||
|
instagram: 'https://instagram.com/mavibeyaz_marmaris',
|
||||||
|
priceRange: 2,
|
||||||
|
rating: 4.5,
|
||||||
|
isLocalApproved: true,
|
||||||
|
latitude: 36.8530,
|
||||||
|
longitude: 28.2725,
|
||||||
|
openingHours: { all: '11:00 - 23:30' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-2-1', listingId: 'list-2', url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-3',
|
||||||
|
slug: 'dostlar-kebap',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
neighborhoodId: 'neigh-3',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Dostlar Kebap',
|
||||||
|
nameEn: 'Dostlar Kebab House',
|
||||||
|
nameRu: 'Кебаб Хаус Достлар',
|
||||||
|
descriptionTr: 'Marmaris Armutalan\'da yıllardır değişmeyen lezzetiyle gerçek ocakbaşı ve kebap deneyimi sunan samimi bir mahalle restoranı.',
|
||||||
|
descriptionEn: 'A cozy neighborhood restaurant offering real grill and kebab experience with its unchanged taste for years in Armutalan, Marmaris.',
|
||||||
|
descriptionRu: 'Уютный районный ресторан, предлагающий настоящий гриль и кебаб с неизменным вкусом на протяжении многих лет в Армуталане, Мармарис.',
|
||||||
|
address: 'Vatan Caddesi No:18, Armutalan, Marmaris',
|
||||||
|
phone: '+90 252 413 11 22',
|
||||||
|
whatsapp: null,
|
||||||
|
website: null,
|
||||||
|
instagram: 'https://instagram.com/dostlarkebap_marmaris',
|
||||||
|
priceRange: 1,
|
||||||
|
rating: 4.7,
|
||||||
|
isLocalApproved: false,
|
||||||
|
latitude: 36.8488,
|
||||||
|
longitude: 28.2450,
|
||||||
|
openingHours: { all: '11:00 - 22:00' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-3-1', listingId: 'list-3', url: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-4',
|
||||||
|
slug: 'zeytin-bahcesi-apart',
|
||||||
|
categoryId: 'cat-2',
|
||||||
|
neighborhoodId: 'neigh-3',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Zeytin Bahçesi Apart',
|
||||||
|
nameEn: 'Olive Garden Apart',
|
||||||
|
nameRu: 'Апарт Оливковый Сад',
|
||||||
|
descriptionTr: 'Zeytin ağaçları arasında, sakin ve huzurlu bir ortamda ailece tatil yapmak isteyenler için tasarlanmış geniş mutfaklı apart daireler.',
|
||||||
|
descriptionEn: 'Spacious apart apartments with kitchens designed for families who want to have a holiday in a quiet and peaceful environment among olive trees.',
|
||||||
|
descriptionRu: 'Просторные апартаменты с кухнями, предназначенные для семей, желающих отдохнуть в тихой и спокойной обстановке среди оливковых деревьев.',
|
||||||
|
address: 'Zeytinlik Sokak No:5, Armutalan, Marmaris',
|
||||||
|
phone: '+90 252 413 55 66',
|
||||||
|
whatsapp: '+90 535 555 66 77',
|
||||||
|
website: 'https://zeytinbahcesiapart.com',
|
||||||
|
instagram: null,
|
||||||
|
priceRange: 2,
|
||||||
|
rating: 4.6,
|
||||||
|
isLocalApproved: true,
|
||||||
|
latitude: 36.8510,
|
||||||
|
longitude: 28.2415,
|
||||||
|
openingHours: null,
|
||||||
|
images: [
|
||||||
|
{ id: 'img-4-1', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80' },
|
||||||
|
{ id: 'img-4-2', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-5',
|
||||||
|
slug: 'deniz-apart',
|
||||||
|
categoryId: 'cat-2',
|
||||||
|
neighborhoodId: 'neigh-4',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Deniz Apart',
|
||||||
|
nameEn: 'Sea Apart Hotel',
|
||||||
|
nameRu: 'Апарт-отель Дениз',
|
||||||
|
descriptionTr: 'Denize sadece 100 metre mesafede, bütçe dostu fiyatları ve güler yüzlü yerel işletme sahibiyle Marmaris\'te sıcak konaklama.',
|
||||||
|
descriptionEn: 'Cozy accommodation in Marmaris, just 100 meters from the sea, with budget-friendly prices and a friendly local owner.',
|
||||||
|
descriptionRu: 'Уютное жилье в Мармарисе, всего в 100 метрах от моря, с доступными ценами и дружелюбным местным владельцем.',
|
||||||
|
address: 'Sahil Yolu Caddesi No:88, Siteler, Marmaris',
|
||||||
|
phone: '+90 252 417 88 99',
|
||||||
|
whatsapp: null,
|
||||||
|
website: null,
|
||||||
|
instagram: null,
|
||||||
|
priceRange: 1,
|
||||||
|
rating: 4.2,
|
||||||
|
isLocalApproved: false,
|
||||||
|
latitude: 36.8375,
|
||||||
|
longitude: 28.2580,
|
||||||
|
openingHours: null,
|
||||||
|
images: [
|
||||||
|
{ id: 'img-5-1', listingId: 'list-5', url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-6',
|
||||||
|
slug: 'marina-dalis-merkezi',
|
||||||
|
categoryId: 'cat-3',
|
||||||
|
neighborhoodId: 'neigh-2',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Marina Dalış Merkezi',
|
||||||
|
nameEn: 'Marina Diving Center',
|
||||||
|
nameRu: 'Дайвинг-центр Марина',
|
||||||
|
descriptionTr: 'Marmaris\'in eşsiz koylarında profesyonel eğitmenler eşliğinde tüplü dalış deneyimi. Başlangıç seviyesinden PADI sertifikasyonuna kadar hizmet.',
|
||||||
|
descriptionEn: 'Scuba diving experience in unique bays of Marmaris accompanied by professional instructors. Service from discovery dive to PADI certification.',
|
||||||
|
descriptionRu: 'Опыт подводного плавания в уникальных бухтах Мармариса в сопровождении профессиональных инструкторов. Услуги от ознакомительного погружения до сертификации PADI.',
|
||||||
|
address: 'Liman Yolu No:32, İçmeler, Marmaris',
|
||||||
|
phone: '+90 252 455 22 33',
|
||||||
|
whatsapp: '+90 536 777 88 99',
|
||||||
|
website: 'https://marinadivingmarmaris.com',
|
||||||
|
instagram: 'https://instagram.com/marinadiving_marmaris',
|
||||||
|
priceRange: 3,
|
||||||
|
rating: 4.9,
|
||||||
|
isLocalApproved: true,
|
||||||
|
latitude: 36.8020,
|
||||||
|
longitude: 28.2320,
|
||||||
|
openingHours: { all: '08:30 - 19:30' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-6-1', listingId: 'list-6', url: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-7',
|
||||||
|
slug: 'ege-ruzgari-tekne-kiralama',
|
||||||
|
categoryId: 'cat-3',
|
||||||
|
neighborhoodId: 'neigh-1',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Ege Rüzgarı Tekne Kiralama',
|
||||||
|
nameEn: 'Aegean Wind Boat Rental',
|
||||||
|
nameRu: 'Аренда Лодок Эгейский Ветер',
|
||||||
|
descriptionTr: 'Kaptanlı veya kaptansız günlük ve haftalık özel tekne kiralama hizmeti. Marmaris koylarını kendi rotanızda özgürce keşfedin.',
|
||||||
|
descriptionEn: 'Daily and weekly private boat rental service with or without skipper. Discover Marmaris bays freely on your own route.',
|
||||||
|
descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.',
|
||||||
|
address: 'Yat Limanı G İskelesi, Marmaris',
|
||||||
|
phone: '+90 532 999 88 77',
|
||||||
|
whatsapp: '+90 532 999 88 77',
|
||||||
|
website: 'https://egeruzgariboat.com',
|
||||||
|
instagram: 'https://instagram.com/egeruzgariboat',
|
||||||
|
priceRange: 3,
|
||||||
|
rating: 4.8,
|
||||||
|
isLocalApproved: true,
|
||||||
|
latitude: 36.8528,
|
||||||
|
longitude: 28.2755,
|
||||||
|
openingHours: { all: '08:00 - 21:00' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-7-1', listingId: 'list-7', url: 'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'list-8',
|
||||||
|
slug: 'marmaris-transfer-tours',
|
||||||
|
categoryId: 'cat-3',
|
||||||
|
neighborhoodId: 'neigh-3',
|
||||||
|
city: 'marmaris',
|
||||||
|
nameTr: 'Marmaris VIP Transfer & Tur',
|
||||||
|
nameEn: 'Marmaris VIP Transfer & Tours',
|
||||||
|
nameRu: 'Marmaris VIP Трансфер и Туры',
|
||||||
|
descriptionTr: 'Dalaman Havalimanı transferleri ve Marmaris çevresindeki tarihi/doğal alanlara özel konforlu turlar. Güvenilir ve lüks taşımacılık.',
|
||||||
|
descriptionEn: 'Dalaman Airport transfers and comfortable private tours to historical/natural areas around Marmaris. Reliable and luxurious transportation.',
|
||||||
|
descriptionRu: 'Трансфер из аэропорта Даламан и комфортабельные частные туры по историческим и природным местам вокруг Мармариса. Надежный и роскошный транспорт.',
|
||||||
|
address: 'Atatürk Caddesi No:102, Armutalan, Marmaris',
|
||||||
|
phone: '+90 252 413 77 88',
|
||||||
|
whatsapp: '+90 541 333 44 55',
|
||||||
|
website: 'https://marmarisviptransfer.com',
|
||||||
|
instagram: null,
|
||||||
|
priceRange: 2,
|
||||||
|
rating: 4.6,
|
||||||
|
isLocalApproved: false,
|
||||||
|
latitude: 36.8495,
|
||||||
|
longitude: 28.2430,
|
||||||
|
openingHours: { all: '24 Hours Open' },
|
||||||
|
images: [
|
||||||
|
{ id: 'img-8-1', listingId: 'list-8', url: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=800&auto=format&fit=crop&q=80' }
|
||||||
|
],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
globalForMockDb.initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockDb = {
|
||||||
|
// Config helpers
|
||||||
|
isMock: () => process.env.USE_MOCK === 'true',
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
async getCategories() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.categories
|
||||||
|
}
|
||||||
|
return db.category.findMany({ orderBy: { nameTr: 'asc' } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCategoryBySlug(slug: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.categories.find(c => c.slug === slug) || null
|
||||||
|
}
|
||||||
|
return db.category.findUnique({ where: { slug } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCategory(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newCat = { id: `cat-${Date.now()}`, ...data }
|
||||||
|
globalForMockDb.categories.push(newCat)
|
||||||
|
return newCat
|
||||||
|
}
|
||||||
|
return db.category.create({ data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCategory(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data }
|
||||||
|
return globalForMockDb.categories[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.category.update({ where: { id }, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Neighborhoods
|
||||||
|
async getNeighborhoods() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.neighborhoods
|
||||||
|
}
|
||||||
|
return db.neighborhood.findMany({ orderBy: { nameTr: 'asc' } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async getNeighborhoodBySlug(slug: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.neighborhoods.find(n => n.slug === slug) || null
|
||||||
|
}
|
||||||
|
return db.neighborhood.findUnique({ where: { slug } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNeighborhood(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newNeigh = { id: `neigh-${Date.now()}`, ...data }
|
||||||
|
globalForMockDb.neighborhoods.push(newNeigh)
|
||||||
|
return newNeigh
|
||||||
|
}
|
||||||
|
return db.neighborhood.create({ data })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateNeighborhood(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.neighborhoods.findIndex(n => n.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.neighborhoods[idx] = { ...globalForMockDb.neighborhoods[idx], ...data }
|
||||||
|
return globalForMockDb.neighborhoods[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.neighborhood.update({ where: { id }, data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Listings
|
||||||
|
async getListings(filters?: {
|
||||||
|
categoryId?: string
|
||||||
|
neighborhoodId?: string
|
||||||
|
priceRange?: number
|
||||||
|
isLocalApproved?: boolean
|
||||||
|
search?: string
|
||||||
|
}) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
let result = globalForMockDb.listings.filter(l => !l.deletedAt)
|
||||||
|
if (filters) {
|
||||||
|
if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId)
|
||||||
|
if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId)
|
||||||
|
if (filters.priceRange) result = result.filter(l => l.priceRange === filters.priceRange)
|
||||||
|
if (filters.isLocalApproved !== undefined) result = result.filter(l => l.isLocalApproved === filters.isLocalApproved)
|
||||||
|
if (filters.search) {
|
||||||
|
const s = filters.search.toLowerCase()
|
||||||
|
result = result.filter(l =>
|
||||||
|
l.nameTr.toLowerCase().includes(s) ||
|
||||||
|
l.nameEn.toLowerCase().includes(s) ||
|
||||||
|
l.nameRu.toLowerCase().includes(s) ||
|
||||||
|
l.address.toLowerCase().includes(s)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.map(l => ({
|
||||||
|
...l,
|
||||||
|
category: globalForMockDb.categories.find(c => c.id === l.categoryId),
|
||||||
|
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: any = { deletedAt: null }
|
||||||
|
if (filters) {
|
||||||
|
if (filters.categoryId) where.categoryId = filters.categoryId
|
||||||
|
if (filters.neighborhoodId) where.neighborhoodId = filters.neighborhoodId
|
||||||
|
if (filters.priceRange) where.priceRange = filters.priceRange
|
||||||
|
if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved
|
||||||
|
if (filters.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ nameTr: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ nameEn: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ nameRu: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
{ address: { contains: filters.search, mode: 'insensitive' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.listing.findMany({
|
||||||
|
where,
|
||||||
|
include: { category: true, neighborhood: true, images: true },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getListingBySlug(slug: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const listing = globalForMockDb.listings.find(l => l.slug === slug && !l.deletedAt)
|
||||||
|
if (!listing) return null
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
|
||||||
|
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.listing.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: { category: true, neighborhood: true, images: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getListingById(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const listing = globalForMockDb.listings.find(l => l.id === id && !l.deletedAt)
|
||||||
|
if (!listing) return null
|
||||||
|
return {
|
||||||
|
...listing,
|
||||||
|
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
|
||||||
|
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.listing.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { category: true, neighborhood: true, images: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async createListing(data: Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'> & { images?: string[] }) {
|
||||||
|
const { images, category, neighborhood, ...rest } = data
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newId = `list-${Date.now()}`
|
||||||
|
const gallery = (images || []).map((url, i) => ({ id: `img-${newId}-${i}`, listingId: newId, url }))
|
||||||
|
const newListing: Listing = {
|
||||||
|
id: newId,
|
||||||
|
...rest,
|
||||||
|
images: gallery,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.listings.unshift(newListing)
|
||||||
|
return newListing
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.listing.create({
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
images: images ? { create: images.map(url => ({ url })) } : undefined
|
||||||
|
},
|
||||||
|
include: { images: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateListing(id: string, data: Partial<Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'>> & { images?: string[] }) {
|
||||||
|
const { images, category, neighborhood, ...rest } = data
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const oldListing = globalForMockDb.listings[idx]
|
||||||
|
const updatedGallery = images
|
||||||
|
? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url }))
|
||||||
|
: oldListing.images
|
||||||
|
const updated = {
|
||||||
|
...oldListing,
|
||||||
|
...rest,
|
||||||
|
images: updatedGallery,
|
||||||
|
updatedAt: new Date()
|
||||||
|
} as Listing
|
||||||
|
globalForMockDb.listings[idx] = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images) {
|
||||||
|
await db.gallery.deleteMany({ where: { listingId: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.listing.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
images: images ? { create: images.map(url => ({ url })) } : undefined
|
||||||
|
},
|
||||||
|
include: { images: true }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteListing(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.listings[idx].deletedAt = new Date()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await db.listing.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date() }
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Business Submissions
|
||||||
|
async getSubmissions() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.submissions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
}
|
||||||
|
return db.businessSubmission.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSubmission(data: Omit<BusinessSubmission, 'id' | 'status' | 'createdAt' | 'updatedAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newSub: BusinessSubmission = {
|
||||||
|
id: `sub-${Date.now()}`,
|
||||||
|
...data,
|
||||||
|
status: 'PENDING',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.submissions.push(newSub)
|
||||||
|
return newSub
|
||||||
|
}
|
||||||
|
return db.businessSubmission.create({ data: { ...data, status: 'PENDING' } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSubmissionStatus(id: string, status: 'APPROVED' | 'REJECTED') {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.submissions.findIndex(s => s.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.submissions[idx].status = status
|
||||||
|
globalForMockDb.submissions[idx].updatedAt = new Date()
|
||||||
|
return globalForMockDb.submissions[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.businessSubmission.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSubmissionById(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.submissions.find(s => s.id === id) || null
|
||||||
|
}
|
||||||
|
return db.businessSubmission.findUnique({ where: { id } })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Contact Messages
|
||||||
|
async getMessages() {
|
||||||
|
if (this.isMock()) {
|
||||||
|
return globalForMockDb.messages.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
}
|
||||||
|
return db.contactMessage.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async createMessage(data: Omit<ContactMessage, 'id' | 'isRead' | 'createdAt' | 'updatedAt'>) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const newMsg: ContactMessage = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
...data,
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
globalForMockDb.messages.push(newMsg)
|
||||||
|
return newMsg
|
||||||
|
}
|
||||||
|
return db.contactMessage.create({ data: { ...data, isRead: false } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async markMessageAsRead(id: string) {
|
||||||
|
if (this.isMock()) {
|
||||||
|
const idx = globalForMockDb.messages.findIndex(m => m.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
globalForMockDb.messages[idx].isRead = true
|
||||||
|
globalForMockDb.messages[idx].updatedAt = new Date()
|
||||||
|
return globalForMockDb.messages[idx]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return db.contactMessage.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isRead: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export async function uploadToOpeninary(file: File, folder: string = "marmarislocal"): Promise<string> {
|
||||||
|
const url = `${process.env.OPENINARY_API_URL}/upload`;
|
||||||
|
const key = process.env.OPENINARY_API_KEY;
|
||||||
|
|
||||||
|
if (!url || !key) {
|
||||||
|
throw new Error("Openinary configuration (OPENINARY_API_URL or OPENINARY_API_KEY) is missing in environment variables.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("files", file);
|
||||||
|
formData.append("folder", folder);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": key,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Openinary upload failed: ${response.statusText} - ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Openinary standard response is usually an array: [{ url: "...", name: "..." }]
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
return data[0].url || data[0].secure_url;
|
||||||
|
} else if (data && typeof data === "object") {
|
||||||
|
return data.url || data.secure_url || (data.files && data.files[0]?.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid response format from Openinary server.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"restaurants": "Restaurants",
|
||||||
|
"aparts": "Aparts",
|
||||||
|
"businesses": "Businesses",
|
||||||
|
"about": "About Us",
|
||||||
|
"contact": "Contact",
|
||||||
|
"addBusiness": "Add Business",
|
||||||
|
"admin": "Admin Panel",
|
||||||
|
"login": "Login"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": "The Best Local Places in Marmaris",
|
||||||
|
"subtitle": "Local knowledge tourists can't see. Handpicked restaurants, apart hotels and hidden gems approved by locals.",
|
||||||
|
"searchPlaceholder": "Search place, category or neighborhood...",
|
||||||
|
"cta": "Start Exploring",
|
||||||
|
"approvedBadge": "Local Approved",
|
||||||
|
"approvedExplain": "The Local Approved Seal shows businesses that have been personally experienced and verified by Marmaris Local editors."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"categories": "Categories",
|
||||||
|
"categoriesSubtitle": "Everything you need to live Marmaris like a local",
|
||||||
|
"featured": "Featured Local Approved Places",
|
||||||
|
"featuredSubtitle": "Establishments selected by our editors with quality and taste guarantees",
|
||||||
|
"neighborhoods": "Neighborhoods",
|
||||||
|
"neighborhoodsSubtitle": "Explore Marmaris by region",
|
||||||
|
"explore": "Explore"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"restoran": "Restaurants",
|
||||||
|
"apart": "Aparts",
|
||||||
|
"isletme": "General Businesses",
|
||||||
|
"filterNeighborhood": "Neighborhood Filter",
|
||||||
|
"filterPrice": "Price Range",
|
||||||
|
"filterApproved": "Local Approved Only",
|
||||||
|
"noResults": "No results found matching your criteria.",
|
||||||
|
"allNeighborhoods": "All Neighborhoods",
|
||||||
|
"allPrices": "All Prices",
|
||||||
|
"rating": "Rating",
|
||||||
|
"address": "Address",
|
||||||
|
"phone": "Phone",
|
||||||
|
"price": "Price",
|
||||||
|
"viewDetails": "View Details"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"approved": "Local Approved Sealed Business",
|
||||||
|
"rating": "Editor Rating",
|
||||||
|
"price": "Price Level",
|
||||||
|
"address": "Address",
|
||||||
|
"hours": "Opening Hours",
|
||||||
|
"contact": "Contact Info",
|
||||||
|
"call": "Call Now",
|
||||||
|
"whatsapp": "WhatsApp Message",
|
||||||
|
"website": "Web Site",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"location": "Location / Map",
|
||||||
|
"related": "Similar Places",
|
||||||
|
"noHours": "Opening hours not specified."
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"name": "Full Name",
|
||||||
|
"email": "Email Address",
|
||||||
|
"message": "Your Message",
|
||||||
|
"subject": "Subject",
|
||||||
|
"businessName": "Business Name",
|
||||||
|
"category": "Category",
|
||||||
|
"neighborhood": "Neighborhood",
|
||||||
|
"address": "Business Address",
|
||||||
|
"phone": "Phone Number",
|
||||||
|
"whatsapp": "WhatsApp Number (Optional)",
|
||||||
|
"description": "Short Description",
|
||||||
|
"image": "Image File or URL (Optional)",
|
||||||
|
"submit": "Submit",
|
||||||
|
"sending": "Submitting...",
|
||||||
|
"success": "Your submission has been received successfully! It will be published after editor review.",
|
||||||
|
"contactSuccess": "Your message has been sent successfully. We will get back to you soon."
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "© 2026 Marmaris Local. All rights reserved.",
|
||||||
|
"about": "Marmaris Local is a curated directory bringing together the best restaurants, accommodation, and services in Marmaris."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Главная",
|
||||||
|
"restaurants": "Рестораны",
|
||||||
|
"aparts": "Апартаменты",
|
||||||
|
"businesses": "Заведения",
|
||||||
|
"about": "О нас",
|
||||||
|
"contact": "Контакты",
|
||||||
|
"addBusiness": "Добавить бизнес",
|
||||||
|
"admin": "Панель администратора",
|
||||||
|
"login": "Войти"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": "Лучшие места Мармариса от местных жителей",
|
||||||
|
"subtitle": "Информация, которую не увидит обычный турист. Рестораны, апарт-отели и скрытые жемчужины, проверенные местными жителями.",
|
||||||
|
"searchPlaceholder": "Поиск места, категории или района...",
|
||||||
|
"cta": "Начать исследование",
|
||||||
|
"approvedBadge": "Проверено местными",
|
||||||
|
"approvedExplain": "Печать «Проверено местными» отмечает заведения, которые были лично опробованы и подтверждены редакторами Marmaris Local."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"categories": "Категории",
|
||||||
|
"categoriesSubtitle": "Все, что нужно, чтобы жить в Мармарисе как местный житель",
|
||||||
|
"featured": "Рекомендуемые места с печатью качества",
|
||||||
|
"featuredSubtitle": "Заведения, выбранные нашими редакторами с гарантией качества и вкуса",
|
||||||
|
"neighborhoods": "Районы",
|
||||||
|
"neighborhoodsSubtitle": "Исследуйте Мармарис по регионам",
|
||||||
|
"explore": "Исследовать"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"restoran": "Рестораны",
|
||||||
|
"apart": "Апартаменты",
|
||||||
|
"isletme": "Бизнес и Услуги",
|
||||||
|
"filterNeighborhood": "Фильтр по районам",
|
||||||
|
"filterPrice": "Ценовой диапазон",
|
||||||
|
"filterApproved": "Только проверенные местными",
|
||||||
|
"noResults": "Результатов по вашему запросу не найдено.",
|
||||||
|
"allNeighborhoods": "Все районы",
|
||||||
|
"allPrices": "Все цены",
|
||||||
|
"rating": "Рейтинг",
|
||||||
|
"address": "Адрес",
|
||||||
|
"phone": "Телефон",
|
||||||
|
"price": "Цена",
|
||||||
|
"viewDetails": "Подробнее"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"approved": "Заведение с печатью «Проверено местными»",
|
||||||
|
"rating": "Рейтинг редакции",
|
||||||
|
"price": "Уровень цен",
|
||||||
|
"address": "Точный адрес",
|
||||||
|
"hours": "Часы работы",
|
||||||
|
"contact": "Контактная информация",
|
||||||
|
"call": "Позвонить",
|
||||||
|
"whatsapp": "Написать в WhatsApp",
|
||||||
|
"website": "Веб-сайт",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"location": "Местоположение / Карта",
|
||||||
|
"related": "Похожие места",
|
||||||
|
"noHours": "Часы работы не указаны."
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"name": "Имя и фамилия",
|
||||||
|
"email": "Электронная почта",
|
||||||
|
"message": "Ваше сообщение",
|
||||||
|
"subject": "Тема",
|
||||||
|
"businessName": "Название компании",
|
||||||
|
"category": "Категория",
|
||||||
|
"neighborhood": "Район",
|
||||||
|
"address": "Адрес компании",
|
||||||
|
"phone": "Номер телефона",
|
||||||
|
"whatsapp": "Номер WhatsApp (необязательно)",
|
||||||
|
"description": "Краткое описание",
|
||||||
|
"image": "Файл изображения или URL-адрес (необязательно)",
|
||||||
|
"submit": "Отправить",
|
||||||
|
"sending": "Отправка...",
|
||||||
|
"success": "Ваша заявка успешно принята! Она будет опубликована после проверки редактором.",
|
||||||
|
"contactSuccess": "Ваше сообщение успешно отправлено. Мы свяжемся с вами в ближайшее время."
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "© 2026 Marmaris Local. Все права защищены.",
|
||||||
|
"about": "Marmaris Local — это курируемый гид, объединяющий лучшие рестораны, жилье и местные услуги в Мармарисе."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Ana Sayfa",
|
||||||
|
"restaurants": "Restoranlar",
|
||||||
|
"aparts": "Apartlar",
|
||||||
|
"businesses": "İşletmeler",
|
||||||
|
"about": "Hakkımızda",
|
||||||
|
"contact": "İletişim",
|
||||||
|
"addBusiness": "İşletme Ekle",
|
||||||
|
"admin": "Yönetim",
|
||||||
|
"login": "Giriş Yap"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": "Marmaris'in En İyi Yerel Adresleri",
|
||||||
|
"subtitle": "Turistin göremediği yerel bilgi. Yerel sakinlerin onayladığı restoranlar, apart oteller ve gizli yerler.",
|
||||||
|
"searchPlaceholder": "Mekan, kategori veya mahalle ara...",
|
||||||
|
"cta": "Keşfetmeye Başla",
|
||||||
|
"approvedBadge": "Yerel Onaylı",
|
||||||
|
"approvedExplain": "Yerel Onaylı Mührü, Marmaris Local editörleri tarafından bizzat deneyimlenip onaylanmış mekanları gösterir."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"categories": "Kategoriler",
|
||||||
|
"categoriesSubtitle": "Marmaris'i bir yerel gibi yaşamak için ihtiyacınız olan her şey",
|
||||||
|
"featured": "Öne Çıkan Yerel Onaylı Mekanlar",
|
||||||
|
"featuredSubtitle": "Editörlerimizin seçtiği, kalite ve lezzet garantili işletmeler",
|
||||||
|
"neighborhoods": "Mahalleler",
|
||||||
|
"neighborhoodsSubtitle": "Bölgelere göre Marmaris'i keşfedin",
|
||||||
|
"explore": "Keşfet"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"restoran": "Restoranlar",
|
||||||
|
"apart": "Apartlar",
|
||||||
|
"isletme": "Genel İşletmeler",
|
||||||
|
"filterNeighborhood": "Mahalle Filtresi",
|
||||||
|
"filterPrice": "Fiyat Aralığı",
|
||||||
|
"filterApproved": "Sadece Yerel Onaylılar",
|
||||||
|
"noResults": "Kriterlerinize uygun sonuç bulunamadı.",
|
||||||
|
"allNeighborhoods": "Tüm Mahalleler",
|
||||||
|
"allPrices": "Tüm Fiyatlar",
|
||||||
|
"rating": "Puan",
|
||||||
|
"address": "Adres",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"price": "Fiyat",
|
||||||
|
"viewDetails": "Detayları Gör"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"approved": "Yerel Onaylı Mühürlü İşletme",
|
||||||
|
"rating": "Editör Puanı",
|
||||||
|
"price": "Fiyat Seviyesi",
|
||||||
|
"address": "Açık Adres",
|
||||||
|
"hours": "Çalışma Saatleri",
|
||||||
|
"contact": "İletişim Bilgileri",
|
||||||
|
"call": "Hemen Ara",
|
||||||
|
"whatsapp": "WhatsApp Mesajı",
|
||||||
|
"website": "Web Sitesi",
|
||||||
|
"instagram": "Instagram",
|
||||||
|
"location": "Konum / Harita",
|
||||||
|
"related": "Benzer Mekanlar",
|
||||||
|
"noHours": "Çalışma saatleri belirtilmemiş."
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"name": "Ad Soyad",
|
||||||
|
"email": "E-posta Adresi",
|
||||||
|
"message": "Mesajınız",
|
||||||
|
"subject": "Konu",
|
||||||
|
"businessName": "İşletme Adı",
|
||||||
|
"category": "Kategori",
|
||||||
|
"neighborhood": "Mahalle",
|
||||||
|
"address": "İşletme Adresi",
|
||||||
|
"phone": "Telefon Numarası",
|
||||||
|
"whatsapp": "WhatsApp Numarası (Opsiyonel)",
|
||||||
|
"description": "Kısa Açıklama",
|
||||||
|
"image": "Görsel Dosyası veya URL (Opsiyonel)",
|
||||||
|
"submit": "Gönder",
|
||||||
|
"sending": "Gönderiliyor...",
|
||||||
|
"success": "Başvurunuz başarıyla alınmıştır! Editör incelemesinden sonra yayınlanacaktır.",
|
||||||
|
"contactSuccess": "Mesajınız başarıyla iletildi. En kısa sürede dönüş yapacağız."
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "© 2026 Marmaris Local. Tüm hakları saklıdır.",
|
||||||
|
"about": "Marmaris Local, Marmaris'teki en iyi restoran, konaklama ve yerel hizmetleri bir araya getiren küratörlü bir rehberdir."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: 'https', hostname: 'res.cloudinary.com' },
|
||||||
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig)
|
||||||
Generated
+10695
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "marmarislocal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@prisma/client": "^6.3.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"developer-icons": "^7.0.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
|
"lucide-react": "^1.18.0",
|
||||||
|
"next": "16.2.9",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.11.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.9",
|
||||||
|
"prisma": "^6.3.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String @unique
|
||||||
|
password String?
|
||||||
|
role Role @default(USER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
nameTr String
|
||||||
|
nameEn String
|
||||||
|
nameRu String
|
||||||
|
listings Listing[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Neighborhood {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
nameTr String
|
||||||
|
nameEn String
|
||||||
|
nameRu String
|
||||||
|
listings Listing[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Listing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
categoryId String
|
||||||
|
neighborhoodId String
|
||||||
|
city String @default("marmaris")
|
||||||
|
|
||||||
|
nameTr String
|
||||||
|
nameEn String
|
||||||
|
nameRu String
|
||||||
|
descriptionTr String
|
||||||
|
descriptionEn String
|
||||||
|
descriptionRu String
|
||||||
|
|
||||||
|
address String
|
||||||
|
phone String?
|
||||||
|
whatsapp String?
|
||||||
|
website String?
|
||||||
|
instagram String?
|
||||||
|
|
||||||
|
priceRange Int // 1-3
|
||||||
|
rating Float? // 0-5
|
||||||
|
isLocalApproved Boolean @default(false)
|
||||||
|
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
openingHours Json?
|
||||||
|
|
||||||
|
images Gallery[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
neighborhood Neighborhood @relation(fields: [neighborhoodId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Gallery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
url String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessSubmission {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
businessName String
|
||||||
|
categoryId String
|
||||||
|
neighborhoodId String
|
||||||
|
address String
|
||||||
|
phone String?
|
||||||
|
whatsapp String?
|
||||||
|
description String
|
||||||
|
contactName String
|
||||||
|
contactEmail String
|
||||||
|
imageUrl String?
|
||||||
|
status String @default("PENDING") // PENDING, APPROVED, REJECTED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model ContactMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
email String
|
||||||
|
subject String // Genel, İşbirliği, Hata Bildirimi, Diğer
|
||||||
|
message String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import createMiddleware from 'next-intl/middleware'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { routing } from '@/i18n/routing'
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing)
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
if (request.nextUrl.pathname.includes('/admin')) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intlMiddleware(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user