fix: add force-dynamic to auth-protected pages, sunum gallery+detail modal, form step 3 layout, academy tour updates

This commit is contained in:
egecankomur
2026-05-12 05:02:44 +03:00
parent 3554b39800
commit 8dd970d2ad
9 changed files with 496 additions and 135 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import { GraduationCap } from '@/lib/icons'; import { GraduationCap } from '@/lib/icons';
import { AcademyClient } from "@/components/academy/academy-client"; import { AcademyClient } from "@/components/academy/academy-client";
@@ -5,7 +7,7 @@ export const metadata = { title: "Akademi | KovakEmlak" };
export default function AcademyPage() { export default function AcademyPage() {
return ( 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="flex items-center gap-3">
<div className="bg-primary/10 text-primary p-2 rounded-lg"> <div className="bg-primary/10 text-primary p-2 rounded-lg">
<GraduationCap className="size-6" /> <GraduationCap className="size-6" />
+2
View File
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { createAdminClient } from "@/lib/appwrite/server"; import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema"; import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CheckCircle, XCircle } from '@/lib/icons'; import { CheckCircle, XCircle } from '@/lib/icons';
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
+7 -123
View File
@@ -1,16 +1,11 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Query } from "node-appwrite"; 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 { DATABASE_ID, TABLES, type Presentation, type Property } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server"; import { createAdminClient } from "@/lib/appwrite/server";
import { incrementPresentationViewCount } from "@/lib/appwrite/presentation-actions"; import { incrementPresentationViewCount } from "@/lib/appwrite/presentation-actions";
import { import { SunumClient } from "./sunum-client";
PROPERTY_TYPE_LABELS,
LISTING_TYPE_LABELS,
PROPERTY_STATUS_LABELS,
} from "@/lib/appwrite/schema";
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
interface Props { interface Props {
params: Promise<{ token: string }>; params: Promise<{ token: string }>;
@@ -102,15 +97,14 @@ export default async function SunumPage({ params }: Props) {
</header> </header>
{/* Grid */} {/* Grid */}
<main className="max-w-5xl mx-auto px-4 py-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> <main className="max-w-5xl mx-auto px-4 py-10">
{properties.map((p) => ( {properties.length === 0 ? (
<PropertyCard key={p.$id} property={p} /> <div className="text-center py-20">
))}
{properties.length === 0 && (
<div className="col-span-full text-center py-20">
<House className="size-10 text-gray-200 mx-auto mb-3" /> <House className="size-10 text-gray-200 mx-auto mb-3" />
<p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p> <p className="text-gray-400">Bu sunumda ilan bulunmuyor.</p>
</div> </div>
) : (
<SunumClient properties={properties} />
)} )}
</main> </main>
@@ -131,113 +125,3 @@ export default async function SunumPage({ params }: Props) {
</div> </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>
);
}
+468
View File
@@ -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&apos;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", label: "Özellikler",
content: ( content: (
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label htmlFor="roomCount">Oda</Label> <Label htmlFor="roomCount">Oda sayısı</Label>
<select name="roomCount" defaultValue={property?.roomCount ?? ""} <select name="roomCount" defaultValue={property?.roomCount ?? ""}
className="border-input bg-background h-9 rounded-md border px-3 text-sm"> className="border-input bg-background h-9 rounded-md border px-3 text-sm">
<option value=""></option> <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 ?? ""} /> <Input id="floor" name="floor" type="number" defaultValue={property?.floor ?? ""} />
</div> </div>
<div className="grid gap-1.5"> <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 ?? ""} /> <Input id="totalFloors" name="totalFloors" type="number" defaultValue={property?.totalFloors ?? ""} />
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5 col-span-2">
<Label htmlFor="buildingAge">Y</Label> <Label htmlFor="buildingAge">Bina yı</Label>
<Input id="buildingAge" name="buildingAge" type="number" min="0" defaultValue={property?.buildingAge ?? ""} /> <Input id="buildingAge" name="buildingAge" type="number" min="0" defaultValue={property?.buildingAge ?? ""} />
</div> </div>
</div> </div>
+5 -6
View File
@@ -69,20 +69,19 @@ export const ACADEMY_MODULES: AcademyModule[] = [
{ {
element: "[data-tour='properties-table']", element: "[data-tour='properties-table']",
title: "İlan Tablosu", 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", side: "top",
}, },
{ {
element: "[data-tour='properties-add']", element: "[data-tour='properties-add']",
title: "Yeni İlan Ekle", title: "Görünüm Modları & Yeni İlan",
description: "Bu düğmeyle 4 adımlı ilan ekleme formu açılır: İlan bilgileri → Konum → Özellikler → Medya. Şimdi formun içini inceleyelim.", 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", side: "bottom",
// next click opens form
}, },
{ {
element: "[data-tour='form-step-nav']", element: "[data-tour='form-step-nav']",
title: "Form Adımları — Genel Bakış", 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", side: "bottom",
triggerEvent: "kovak:open-form-properties", triggerEvent: "kovak:open-form-properties",
triggerDelay: 750, triggerDelay: 750,
@@ -112,7 +111,7 @@ export const ACADEMY_MODULES: AcademyModule[] = [
{ {
element: "[data-tour='form-step-content-3']", element: "[data-tour='form-step-content-3']",
title: "4. Adım — Medya", 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", side: "bottom",
clickBefore: "[data-tour='form-circle-3']", clickBefore: "[data-tour='form-circle-3']",
clickDelay: 350, clickDelay: 350,