fix: add force-dynamic to auth-protected pages, sunum gallery+detail modal, form step 3 layout, academy tour updates
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { GraduationCap } from '@/lib/icons';
|
||||
import { AcademyClient } from "@/components/academy/academy-client";
|
||||
|
||||
@@ -5,7 +7,7 @@ export const metadata = { title: "Akademi | KovakEmlak" };
|
||||
|
||||
export default function AcademyPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl">
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary p-2 rounded-lg">
|
||||
<GraduationCap className="size-6" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CheckCircle, XCircle } from '@/lib/icons';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
import { Buildings, MapPin, House } from '@/lib/icons';
|
||||
import { Buildings, House } from '@/lib/icons';
|
||||
|
||||
import { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { incrementPresentationViewCount } from "@/lib/appwrite/presentation-actions";
|
||||
import {
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
} from "@/lib/appwrite/schema";
|
||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { SunumClient } from "./sunum-client";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>;
|
||||
@@ -102,15 +97,14 @@ export default async function SunumPage({ params }: Props) {
|
||||
</header>
|
||||
|
||||
{/* Grid */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{properties.map((p) => (
|
||||
<PropertyCard key={p.$id} property={p} />
|
||||
))}
|
||||
{properties.length === 0 && (
|
||||
<div className="col-span-full text-center py-20">
|
||||
<main className="max-w-5xl mx-auto px-4 py-10">
|
||||
{properties.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<House className="size-10 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p>
|
||||
</div>
|
||||
) : (
|
||||
<SunumClient properties={properties} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -131,113 +125,3 @@ export default async function SunumPage({ params }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ property: p }: { property: Property }) {
|
||||
const isSold = p.status === "satildi" || p.status === "kiralandit";
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const coverImageId = imageIds[0];
|
||||
|
||||
const statusStyle: Record<string, string> = {
|
||||
aktif: "bg-green-500/90 text-white",
|
||||
pasif: "bg-gray-500/80 text-white",
|
||||
satildi: "bg-orange-500/90 text-white",
|
||||
kiralandit: "bg-blue-500/90 text-white",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100 flex flex-col ${isSold ? "opacity-70" : ""}`}
|
||||
>
|
||||
{/* Photo */}
|
||||
<div className="relative h-52 bg-slate-100 overflow-hidden shrink-0">
|
||||
{coverImageId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(coverImageId, 640, 420)}
|
||||
alt={p.title}
|
||||
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<House className="size-12 text-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge — top left */}
|
||||
<span
|
||||
className={`absolute top-3 left-3 text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm ${statusStyle[p.status] ?? "bg-gray-500/80 text-white"}`}
|
||||
>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
|
||||
{/* Listing type — top right */}
|
||||
<span className="absolute top-3 right-3 text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-900/70 text-white backdrop-blur-sm">
|
||||
{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||
</span>
|
||||
|
||||
{/* Photo count pill */}
|
||||
{imageIds.length > 1 && (
|
||||
<span className="absolute bottom-3 right-3 text-xs bg-black/50 text-white px-2 py-0.5 rounded-full backdrop-blur-sm">
|
||||
{imageIds.length} fotoğraf
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
||||
<h2 className="font-semibold text-gray-900 leading-snug line-clamp-2">{p.title}</h2>
|
||||
|
||||
{/* Location */}
|
||||
{(p.city || p.district || p.neighborhood) && (
|
||||
<div className="flex items-start gap-1 text-xs text-gray-500">
|
||||
<MapPin className="size-3 shrink-0 mt-0.5 text-gray-400" />
|
||||
<span className="line-clamp-1">
|
||||
{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spec pills */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
</span>
|
||||
{p.roomCount && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">
|
||||
{p.roomCount}
|
||||
</span>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">
|
||||
{p.netM2} m²
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price — pushed to bottom */}
|
||||
<div className="mt-auto pt-2">
|
||||
<p className="text-2xl font-bold text-slate-900 tracking-tight">
|
||||
{p.price.toLocaleString("tr-TR")}
|
||||
<span className="text-sm font-normal text-gray-400 ml-1.5">{p.currency ?? "TRY"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{p.description && (
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed">{p.description}</p>
|
||||
)}
|
||||
|
||||
{p.mapLat != null && p.mapLng != null && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${p.mapLat},${p.mapLng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<MapPin className="size-3" />
|
||||
Haritada gör
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, CaretLeft, CaretRight, MapPin, House, ImageSquare } from "@/lib/icons";
|
||||
import {
|
||||
getPropertyImageUrl,
|
||||
getPropertyImagePreviewUrl,
|
||||
parseImageIds,
|
||||
} from "@/lib/appwrite/storage-utils";
|
||||
import {
|
||||
PROPERTY_TYPE_LABELS,
|
||||
LISTING_TYPE_LABELS,
|
||||
PROPERTY_STATUS_LABELS,
|
||||
type Property,
|
||||
} from "@/lib/appwrite/schema";
|
||||
|
||||
/* ── Status style map ── */
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
aktif: "bg-green-500/90 text-white",
|
||||
pasif: "bg-gray-500/80 text-white",
|
||||
satildi: "bg-orange-500/90 text-white",
|
||||
kiralandit: "bg-blue-500/90 text-white",
|
||||
};
|
||||
|
||||
/* ── Mini gallery (card içi) ── */
|
||||
function CardGallery({ imageIds, title, onOpenLightbox, onOpenDetail }: {
|
||||
imageIds: string[];
|
||||
title: string;
|
||||
onOpenLightbox: (idx: number) => void;
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const safeIdx = Math.min(idx, imageIds.length - 1);
|
||||
|
||||
if (imageIds.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
className="flex h-full w-full items-center justify-center flex-col gap-2"
|
||||
>
|
||||
<ImageSquare className="size-12 text-slate-200" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ana görsel */}
|
||||
<div className="relative h-full group/img">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(imageIds[safeIdx], 640, 420)}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover cursor-zoom-in transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
onClick={() => onOpenLightbox(safeIdx)}
|
||||
/>
|
||||
{/* Prev / Next */}
|
||||
{imageIds.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i - 1 + imageIds.length) % imageIds.length); }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIdx((i) => (i + 1) % imageIds.length); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-7 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretRight className="size-3.5" />
|
||||
</button>
|
||||
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-1.5 py-0.5 rounded-full tabular-nums opacity-0 group-hover/img:opacity-100 transition-opacity">
|
||||
{safeIdx + 1}/{imageIds.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail şeridi */}
|
||||
{imageIds.length > 1 && (
|
||||
<div className="flex gap-1 px-2 pb-2 overflow-x-auto scrollbar-hide shrink-0">
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className={`shrink-0 w-12 h-9 rounded overflow-hidden border-2 transition-all ${
|
||||
i === safeIdx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(id, 100, 75)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lightbox ── */
|
||||
function Lightbox({ imageIds, title, initialIndex, onClose }: {
|
||||
imageIds: string[];
|
||||
title: string;
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(initialIndex);
|
||||
const prev = useCallback(() => setIdx((i) => (i - 1 + imageIds.length) % imageIds.length), [imageIds.length]);
|
||||
const next = useCallback(() => setIdx((i) => (i + 1) % imageIds.length), [imageIds.length]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums select-none">
|
||||
{idx + 1} / {imageIds.length}
|
||||
</div>
|
||||
{imageIds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); prev(); }}
|
||||
className="absolute left-3 sm:left-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<CaretLeft className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImageUrl(imageIds[idx])}
|
||||
alt={`${title} ${idx + 1}`}
|
||||
className="max-h-[90dvh] max-w-[90vw] object-contain rounded-lg select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageIds.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); next(); }}
|
||||
className="absolute right-3 sm:right-6 size-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white"
|
||||
>
|
||||
<CaretRight className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
{imageIds.length > 1 && (
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-1.5 overflow-x-auto max-w-[90vw] px-2 scrollbar-hide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
|
||||
i === idx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={getPropertyImagePreviewUrl(id, 160, 120)} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Detay Modal ── */
|
||||
function DetailModal({ p, onClose }: { p: Property; onClose: () => void }) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-end sm:items-center justify-center bg-black/60 p-0 sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white w-full max-w-2xl max-h-[95dvh] rounded-t-2xl sm:rounded-2xl overflow-hidden flex flex-col shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Galeri */}
|
||||
<div className="relative shrink-0 bg-black">
|
||||
{imageIds.length > 0 ? (
|
||||
<>
|
||||
<div className="relative aspect-[16/9] group">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPropertyImagePreviewUrl(imageIds[activeIdx], 900, 506)}
|
||||
alt={p.title}
|
||||
className="h-full w-full object-cover cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
{imageIds.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIdx((i) => (i - 1 + imageIds.length) % imageIds.length)}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIdx((i) => (i + 1) % imageIds.length)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 size-9 rounded-full bg-black/40 hover:bg-black/65 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<CaretRight className="size-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-3 right-3 bg-black/50 text-white text-xs px-2 py-1 rounded-full tabular-nums">
|
||||
{activeIdx + 1} / {imageIds.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Thumbnail şeridi */}
|
||||
{imageIds.length > 1 && (
|
||||
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto scrollbar-hide bg-black/80">
|
||||
{imageIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActiveIdx(i)}
|
||||
className={`shrink-0 w-14 h-10 rounded overflow-hidden border-2 transition-all ${
|
||||
i === activeIdx ? "border-white" : "border-white/20 hover:border-white/50"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={getPropertyImagePreviewUrl(id, 140, 100)} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="aspect-[16/9] flex items-center justify-center">
|
||||
<House className="size-16 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kapat */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 size-8 rounded-full bg-black/50 hover:bg-black/70 text-white flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* İçerik */}
|
||||
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
||||
{/* Başlık + durum */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-xl font-bold text-gray-900 leading-snug">{p.title}</h2>
|
||||
<span className={`shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_STYLE[p.status] ?? "bg-gray-200 text-gray-700"}`}>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fiyat */}
|
||||
<p className="text-3xl font-bold text-slate-900 tracking-tight">
|
||||
{p.price.toLocaleString("tr-TR")}
|
||||
<span className="text-base font-normal text-gray-400 ml-2">{p.currency ?? "TRY"}</span>
|
||||
</p>
|
||||
|
||||
{/* Konum */}
|
||||
{(p.city || p.district || p.neighborhood) && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<MapPin className="size-3.5 shrink-0 text-gray-400" />
|
||||
<span>{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Özellik pilleri */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">
|
||||
{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||
</span>
|
||||
{p.roomCount && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.roomCount}</span>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.netM2} m²</span>
|
||||
)}
|
||||
{p.grossM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.grossM2} m² brüt</span>
|
||||
)}
|
||||
{p.floor != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.floor}. kat</span>
|
||||
)}
|
||||
{p.totalFloors != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.totalFloors} katlı</span>
|
||||
)}
|
||||
{p.buildingAge != null && (
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-medium">{p.buildingAge === 0 ? "Sıfır bina" : `${p.buildingAge} yaşında`}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Açıklama */}
|
||||
{p.description && (
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-semibold text-gray-700 mb-1.5">Açıklama</p>
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line">{p.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Harita linki */}
|
||||
{p.mapLat != null && p.mapLng != null && (
|
||||
<div className="border-t pt-4">
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${p.mapLat},${p.mapLng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<MapPin className="size-4" />
|
||||
Google Maps'te Göster
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
initialIndex={activeIdx}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ana kart ── */
|
||||
function PropertyCard({ p }: { p: Property }) {
|
||||
const imageIds = parseImageIds(p.imageIds);
|
||||
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const isSold = p.status === "satildi" || p.status === "kiralandit";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-shadow duration-300 overflow-hidden border border-gray-100 flex flex-col ${isSold ? "opacity-70" : ""}`}
|
||||
>
|
||||
{/* Görsel alanı */}
|
||||
<div className="relative bg-slate-100 overflow-hidden shrink-0 flex flex-col" style={{ height: imageIds.length > 1 ? "calc(13rem + 2.75rem)" : "13rem" }}>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<CardGallery
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
onOpenLightbox={(idx) => setLightboxIdx(idx)}
|
||||
onOpenDetail={() => setDetailOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Statü */}
|
||||
<span
|
||||
className={`absolute top-3 left-3 text-xs font-semibold px-2.5 py-1 rounded-full backdrop-blur-sm z-10 ${STATUS_STYLE[p.status] ?? "bg-gray-500/80 text-white"}`}
|
||||
>
|
||||
{PROPERTY_STATUS_LABELS[p.status] ?? p.status}
|
||||
</span>
|
||||
|
||||
{/* Listing type */}
|
||||
<span className="absolute top-3 right-3 text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-900/70 text-white backdrop-blur-sm z-10">
|
||||
{LISTING_TYPE_LABELS[p.listingType] ?? p.listingType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="font-semibold text-gray-900 leading-snug line-clamp-2 text-left hover:text-blue-700 transition-colors"
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
|
||||
{(p.city || p.district || p.neighborhood) && (
|
||||
<div className="flex items-start gap-1 text-xs text-gray-500">
|
||||
<MapPin className="size-3 shrink-0 mt-0.5 text-gray-400" />
|
||||
<span className="line-clamp-1">
|
||||
{[p.neighborhood, p.district, p.city].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">
|
||||
{PROPERTY_TYPE_LABELS[p.propertyType] ?? p.propertyType}
|
||||
</span>
|
||||
{p.roomCount && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">{p.roomCount}</span>
|
||||
)}
|
||||
{p.netM2 && (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full font-medium">{p.netM2} m²</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
|
||||
<p className="text-2xl font-bold text-slate-900 tracking-tight">
|
||||
{p.price.toLocaleString("tr-TR")}
|
||||
<span className="text-sm font-normal text-gray-400 ml-1.5">{p.currency ?? "TRY"}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="shrink-0 text-xs font-medium text-blue-600 hover:text-blue-700 hover:underline transition-colors"
|
||||
>
|
||||
Detayları gör →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxIdx !== null && (
|
||||
<Lightbox
|
||||
imageIds={imageIds}
|
||||
title={p.title}
|
||||
initialIndex={lightboxIdx}
|
||||
onClose={() => setLightboxIdx(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailOpen && (
|
||||
<DetailModal p={p} onClose={() => setDetailOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Export ── */
|
||||
export function SunumClient({ properties }: { properties: Property[] }) {
|
||||
return (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{properties.map((p) => (
|
||||
<PropertyCard key={p.$id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,9 +159,9 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
|
||||
{
|
||||
label: "Özellikler",
|
||||
content: (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="roomCount">Oda</Label>
|
||||
<Label htmlFor="roomCount">Oda sayısı</Label>
|
||||
<select name="roomCount" defaultValue={property?.roomCount ?? ""}
|
||||
className="border-input bg-background h-9 rounded-md border px-3 text-sm">
|
||||
<option value="">—</option>
|
||||
@@ -186,11 +186,11 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
|
||||
<Input id="floor" name="floor" type="number" defaultValue={property?.floor ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="totalFloors">Top. kat</Label>
|
||||
<Label htmlFor="totalFloors">Toplam kat</Label>
|
||||
<Input id="totalFloors" name="totalFloors" type="number" defaultValue={property?.totalFloors ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="buildingAge">Yaş</Label>
|
||||
<div className="grid gap-1.5 col-span-2">
|
||||
<Label htmlFor="buildingAge">Bina yaşı</Label>
|
||||
<Input id="buildingAge" name="buildingAge" type="number" min="0" defaultValue={property?.buildingAge ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,20 +69,19 @@ export const ACADEMY_MODULES: AcademyModule[] = [
|
||||
{
|
||||
element: "[data-tour='properties-table']",
|
||||
title: "İlan Tablosu",
|
||||
description: "Tüm ilanlar burada listelenir. Bir satıra tıklayarak detay sayfasına geçebilir, sağdaki menüden düzenleyebilir veya silebilirsiniz.",
|
||||
description: "Tüm ilanlar burada listelenir. Her satırın solunda ilan görseli yer alır — görsele tıklayınca tam ekran galeri açılır. Satırın herhangi bir yerine tıklayarak detay sayfasına geçebilir, sağdaki menüden düzenleyebilir veya silebilirsiniz.",
|
||||
side: "top",
|
||||
},
|
||||
{
|
||||
element: "[data-tour='properties-add']",
|
||||
title: "Yeni İlan Ekle",
|
||||
description: "Bu düğmeyle 4 adımlı ilan ekleme formu açılır: İlan bilgileri → Konum → Özellikler → Medya. Şimdi formun içini inceleyelim.",
|
||||
title: "Görünüm Modları & Yeni İlan",
|
||||
description: "Sağ üstteki Liste / Galeri / Harita düğmeleriyle portföyü farklı görünümlerde inceleyebilirsiniz. Galeri modunda her ilanın fotoğraflarını doğrudan listeden gezebilirsiniz. 'Yeni İlan' ile 4 adımlı form açılır.",
|
||||
side: "bottom",
|
||||
// next click opens form
|
||||
},
|
||||
{
|
||||
element: "[data-tour='form-step-nav']",
|
||||
title: "Form Adımları — Genel Bakış",
|
||||
description: "Form 4 adımdan oluşur. Dairelere tıklayarak adımlar arasında serbestçe geçiş yapabilirsiniz. Tüm alanlara girdiğiniz veriler her adımda korunur.",
|
||||
description: "Form 4 adımdan oluşur: İlan Bilgileri → Konum → Özellikler → Medya. Dairelere tıklayarak adımlar arasında serbestçe geçiş yapabilirsiniz.",
|
||||
side: "bottom",
|
||||
triggerEvent: "kovak:open-form-properties",
|
||||
triggerDelay: 750,
|
||||
@@ -112,7 +111,7 @@ export const ACADEMY_MODULES: AcademyModule[] = [
|
||||
{
|
||||
element: "[data-tour='form-step-content-3']",
|
||||
title: "4. Adım — Medya",
|
||||
description: "İlan açıklamasını yazın ve fotoğraf yükleyin. Birden fazla fotoğraf sürükleyip sıralayabilirsiniz. Son adımda 'Oluştur' ile kaydedilir.",
|
||||
description: "İlan açıklamasını yazın ve fotoğraf yükleyin. Fotoğrafları sürükle-bırak ile ekleyebilirsiniz — yükleme sırasında otomatik sıkıştırılır (maks. 1920px). Son adımda 'Oluştur' ile kaydedilir.",
|
||||
side: "bottom",
|
||||
clickBefore: "[data-tour='form-circle-3']",
|
||||
clickDelay: 350,
|
||||
|
||||
Reference in New Issue
Block a user