edited middle ware and calender component
This commit is contained in:
246
app/components/property/DateSelectionCalendar.js
Normal file
246
app/components/property/DateSelectionCalendar.js
Normal file
@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"month.january",
|
||||
"month.february",
|
||||
"month.march",
|
||||
"month.april",
|
||||
"month.may",
|
||||
"month.june",
|
||||
"month.july",
|
||||
"month.august",
|
||||
"month.september",
|
||||
"month.october",
|
||||
"month.november",
|
||||
"month.december",
|
||||
];
|
||||
|
||||
const DAY_KEYS = [
|
||||
"dayAbbr.sun",
|
||||
"dayAbbr.mon",
|
||||
"dayAbbr.tue",
|
||||
"dayAbbr.wed",
|
||||
"dayAbbr.thu",
|
||||
"dayAbbr.fri",
|
||||
"dayAbbr.sat",
|
||||
];
|
||||
|
||||
export default function DateSelectionCalendar({
|
||||
mode,
|
||||
availableDates,
|
||||
selectedStart,
|
||||
selectedEnd,
|
||||
onSelectStart,
|
||||
onSelectEnd,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date().getMonth());
|
||||
const [calendarYear, setCalendarYear] = useState(() => new Date().getFullYear());
|
||||
const [rangeError, setRangeError] = useState(null);
|
||||
|
||||
const isPastDate = useCallback((dateStr) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return new Date(dateStr) < today;
|
||||
}, []);
|
||||
|
||||
const isDateAvailable = useCallback(
|
||||
(dateStr) => (availableDates ? availableDates.has(dateStr) : true),
|
||||
[availableDates]
|
||||
);
|
||||
|
||||
const isMonthFullyAvailable = useCallback(
|
||||
(year, monthIdx) => {
|
||||
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
if (isPastDate(dateStr) || !isDateAvailable(dateStr)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[isPastDate, isDateAvailable]
|
||||
);
|
||||
|
||||
const isPastMonth = useCallback((year, monthIdx) => {
|
||||
const now = new Date();
|
||||
return (
|
||||
year < now.getFullYear() ||
|
||||
(year === now.getFullYear() && monthIdx < now.getMonth())
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDayClick = (dateStr) => {
|
||||
setRangeError(null);
|
||||
if (!selectedStart || selectedEnd) {
|
||||
onSelectStart(dateStr);
|
||||
onSelectEnd(null);
|
||||
return;
|
||||
}
|
||||
if (new Date(dateStr) <= new Date(selectedStart)) {
|
||||
onSelectStart(dateStr);
|
||||
onSelectEnd(null);
|
||||
return;
|
||||
}
|
||||
const start = new Date(selectedStart);
|
||||
const end = new Date(dateStr);
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const ds = d.toISOString().split("T")[0];
|
||||
if (!isDateAvailable(ds)) {
|
||||
setRangeError(t("unavailableInRange", { defaultValue: "توجد أيام غير متاحة ضمن الفترة المحددة" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSelectEnd(dateStr);
|
||||
};
|
||||
|
||||
const handleMonthClick = (year, monthIdx) => {
|
||||
setRangeError(null);
|
||||
const monthStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}`;
|
||||
const firstDay = `${monthStr}-01`;
|
||||
if (!selectedStart || selectedEnd || monthStr <= (selectedStart || "").substring(0, 7)) {
|
||||
onSelectStart(firstDay);
|
||||
onSelectEnd(null);
|
||||
} else {
|
||||
const lastDay = new Date(year, monthIdx + 1, 0).getDate();
|
||||
onSelectEnd(`${monthStr}-${String(lastDay).padStart(2, "0")}`);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateMonth = (delta) => {
|
||||
let month = calendarMonth + delta;
|
||||
let year = calendarYear;
|
||||
if (month < 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
}
|
||||
if (month > 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setCalendarMonth(month);
|
||||
setCalendarYear(year);
|
||||
};
|
||||
|
||||
const isEntryStep = !selectedStart || !!selectedEnd;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${isEntryStep ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isEntryStep ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||
{t("selectStartDate")}
|
||||
</div>
|
||||
<div className="text-gray-300 text-xs">←</div>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${!isEntryStep ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${!isEntryStep ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||
{t("selectEndDate")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "daily" ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => navigateMonth(-1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{t(MONTH_KEYS[calendarMonth])} {calendarYear}
|
||||
</span>
|
||||
<button onClick={() => navigateMonth(1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{DAY_KEYS.map((dk, i) => (
|
||||
<div key={i} className="text-center text-[10px] text-gray-400 font-medium py-1">
|
||||
{t(dk)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const firstDay = new Date(calendarYear, calendarMonth, 1).getDay();
|
||||
const daysInMonth = new Date(calendarYear, calendarMonth + 1, 0).getDate();
|
||||
const cells = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
cells.push(<div key={`e-${i}`} />);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${calendarYear}-${String(calendarMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const past = isPastDate(dateStr);
|
||||
const available = isDateAvailable(dateStr);
|
||||
const isSelStart = dateStr === selectedStart;
|
||||
const isSelEnd = dateStr === selectedEnd;
|
||||
const inRange =
|
||||
selectedStart &&
|
||||
selectedEnd &&
|
||||
new Date(dateStr) > new Date(selectedStart) &&
|
||||
new Date(dateStr) < new Date(selectedEnd);
|
||||
const disabled = past || !available;
|
||||
cells.push(
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => !disabled && handleDayClick(dateStr)}
|
||||
disabled={disabled}
|
||||
className={`text-center py-1.5 text-xs rounded-lg transition-all ${disabled ? "text-gray-300 cursor-not-allowed" : ""} ${isSelStart || isSelEnd ? "bg-amber-500 text-white font-bold shadow-sm" : ""} ${inRange ? "bg-amber-100 text-amber-800" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && available ? "hover:bg-amber-50 text-gray-700" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && !available ? "text-red-300" : ""}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className="grid grid-cols-7 gap-0.5">{cells}</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => setCalendarYear((prev) => prev - 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-gray-900">{calendarYear}</span>
|
||||
<button onClick={() => setCalendarYear((prev) => prev + 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTH_KEYS.map((mk, idx) => {
|
||||
const monthStr = `${calendarYear}-${String(idx + 1).padStart(2, "0")}`;
|
||||
const isSelStart = selectedStart && selectedStart.startsWith(monthStr);
|
||||
const isSelEnd = selectedEnd && selectedEnd.startsWith(monthStr);
|
||||
const inRange =
|
||||
selectedStart &&
|
||||
selectedEnd &&
|
||||
monthStr > selectedStart.substring(0, 7) &&
|
||||
monthStr < selectedEnd.substring(0, 7);
|
||||
const disabled = isPastMonth(calendarYear, idx) || !isMonthFullyAvailable(calendarYear, idx);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => !disabled && handleMonthClick(calendarYear, idx)}
|
||||
disabled={disabled}
|
||||
className={`p-2.5 rounded-xl text-center text-xs font-medium transition-all border ${disabled ? "text-gray-300 border-gray-100 bg-gray-50 cursor-not-allowed" : ""} ${isSelStart || isSelEnd ? "bg-amber-500 text-white border-amber-500 shadow-sm" : ""} ${!disabled && inRange ? "bg-amber-100 text-amber-800 border-amber-200" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange ? "bg-white text-gray-700 border-gray-200 hover:border-amber-300" : ""}`}
|
||||
>
|
||||
{t(mk)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rangeError && (
|
||||
<div className="bg-red-50 text-red-600 p-2.5 rounded-xl text-xs mt-3">
|
||||
{rangeError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -60,6 +60,7 @@ import AuthService from "../../services/AuthService";
|
||||
import { useFavorites } from "@/app/contexts/FavoritesContext";
|
||||
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from "../../enums";
|
||||
import PropertyRatingList from "@/app/components/ratings/PropertyRatingList";
|
||||
import DateSelectionCalendar from "@/app/components/property/DateSelectionCalendar";
|
||||
import { getPropertyAverageRating } from "../../utils/ratings";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import Loading from "@/app/loading";
|
||||
@ -257,11 +258,8 @@ export default function PropertyDetailsPage() {
|
||||
const [bookingError, setBookingError] = useState(null);
|
||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||
const [availableRanges, setAvailableRanges] = useState([]);
|
||||
const [bookingStep, setBookingStep] = useState("entry");
|
||||
const [selectedStart, setSelectedStart] = useState(null);
|
||||
const [selectedEnd, setSelectedEnd] = useState(null);
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date().getMonth());
|
||||
const [calendarYear, setCalendarYear] = useState(() => new Date().getFullYear());
|
||||
const [pricingMode, setPricingMode] = useState("daily");
|
||||
const [isOwnProperty, setIsOwnProperty] = useState(false);
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
@ -402,22 +400,6 @@ export default function PropertyDetailsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"month.january",
|
||||
"month.february",
|
||||
"month.march",
|
||||
"month.april",
|
||||
"month.may",
|
||||
"month.june",
|
||||
"month.july",
|
||||
"month.august",
|
||||
"month.september",
|
||||
"month.october",
|
||||
"month.november",
|
||||
"month.december",
|
||||
];
|
||||
const DAY_KEYS = ["dayAbbr.sun", "dayAbbr.mon", "dayAbbr.tue", "dayAbbr.wed", "dayAbbr.thu", "dayAbbr.fri", "dayAbbr.sat"];
|
||||
|
||||
const availableDatesSet = useMemo(() => {
|
||||
const dates = new Set();
|
||||
if (!Array.isArray(availableRanges)) return dates;
|
||||
@ -431,30 +413,6 @@ export default function PropertyDetailsPage() {
|
||||
return dates;
|
||||
}, [availableRanges]);
|
||||
|
||||
const isDateAvailable = (dateStr) => availableDatesSet.has(dateStr);
|
||||
const isPastDate = (dateStr) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return new Date(dateStr) < today;
|
||||
};
|
||||
|
||||
const handleDayClick = (dateStr) => {
|
||||
if (bookingStep === "entry") {
|
||||
setSelectedStart(dateStr);
|
||||
setSelectedEnd(null);
|
||||
setBookingStep("exit");
|
||||
} else {
|
||||
if (new Date(dateStr) <= new Date(selectedStart)) {
|
||||
setSelectedStart(dateStr);
|
||||
setSelectedEnd(null);
|
||||
setBookingStep("exit");
|
||||
} else {
|
||||
setSelectedEnd(dateStr);
|
||||
setBookingStep("entry");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBookingConfirm = async () => {
|
||||
if (!AuthService.isAuthenticated()) {
|
||||
setShowLoginDialog(true);
|
||||
@ -480,21 +438,6 @@ export default function PropertyDetailsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const navigateMonth = (delta) => {
|
||||
let month = calendarMonth + delta;
|
||||
let year = calendarYear;
|
||||
if (month < 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
}
|
||||
if (month > 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setCalendarMonth(month);
|
||||
setCalendarYear(year);
|
||||
};
|
||||
|
||||
const handleRatingSuccess = () => {
|
||||
setShowRatingForm(false);
|
||||
if (property) fetchAvgRating(property.id);
|
||||
@ -1028,115 +971,18 @@ export default function PropertyDetailsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${bookingStep === "entry" ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${bookingStep === "entry" ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||
{t("selectStartDate")}
|
||||
</div>
|
||||
<div className="text-gray-300 text-xs">←</div>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${bookingStep === "exit" ? "bg-amber-100 text-amber-800" : "bg-gray-100 text-gray-500"}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${bookingStep === "exit" ? "bg-amber-500" : "bg-gray-400"}`} />
|
||||
{t("selectEndDate")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
{effectivePricingMode === "daily" ? (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => navigateMonth(-1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{t(MONTH_KEYS[calendarMonth])} {calendarYear}
|
||||
</span>
|
||||
<button onClick={() => navigateMonth(1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{DAY_KEYS.map((dk, i) => (
|
||||
<div key={i} className="text-center text-[10px] text-gray-400 font-medium py-1">
|
||||
{t(dk)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const firstDay = new Date(calendarYear, calendarMonth, 1).getDay();
|
||||
const daysInMonth = new Date(calendarYear, calendarMonth + 1, 0).getDate();
|
||||
const adjustedFirstDay = (firstDay + 1) % 7;
|
||||
const cells = [];
|
||||
for (let i = 0; i < adjustedFirstDay; i++) {
|
||||
cells.push(<div key={`e-${i}`} />);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${calendarYear}-${String(calendarMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const past = isPastDate(dateStr);
|
||||
const available = isDateAvailable(dateStr);
|
||||
const isSelStart = dateStr === selectedStart;
|
||||
const isSelEnd = dateStr === selectedEnd;
|
||||
const inRange = selectedStart && selectedEnd && new Date(dateStr) > new Date(selectedStart) && new Date(dateStr) < new Date(selectedEnd);
|
||||
const disabled = past || !available;
|
||||
cells.push(
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => !disabled && handleDayClick(dateStr)}
|
||||
disabled={disabled}
|
||||
className={`text-center py-1.5 text-xs rounded-lg transition-all ${disabled ? "text-gray-300 cursor-not-allowed" : ""} ${isSelStart || isSelEnd ? "bg-amber-500 text-white font-bold shadow-sm" : ""} ${inRange ? "bg-amber-100 text-amber-800" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && available ? "hover:bg-amber-50 text-gray-700" : ""} ${!disabled && !isSelStart && !isSelEnd && !inRange && !available ? "text-red-300" : ""}`}
|
||||
>
|
||||
{day}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
return <div className="grid grid-cols-7 gap-0.5">{cells}</div>;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => setCalendarYear((prev) => prev - 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-gray-900">{calendarYear}</span>
|
||||
<button onClick={() => setCalendarYear((prev) => prev + 1)} className="p-1 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTH_KEYS.map((mk, idx) => {
|
||||
const monthStr = `${calendarYear}-${String(idx + 1).padStart(2, "0")}`;
|
||||
const isSelStart = selectedStart && selectedStart.startsWith(monthStr);
|
||||
const isSelEnd = selectedEnd && selectedEnd.startsWith(monthStr);
|
||||
const inRange = selectedStart && selectedEnd && monthStr > selectedStart.substring(0, 7) && monthStr < selectedEnd.substring(0, 7);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
const firstDay = `${monthStr}-01`;
|
||||
if (bookingStep === "entry" || (selectedStart && monthStr <= selectedStart.substring(0, 7))) {
|
||||
setSelectedStart(firstDay);
|
||||
setSelectedEnd(null);
|
||||
setBookingStep("exit");
|
||||
} else {
|
||||
const lastDay = new Date(calendarYear, idx + 1, 0).getDate();
|
||||
setSelectedEnd(`${monthStr}-${String(lastDay).padStart(2, "0")}`);
|
||||
setBookingStep("entry");
|
||||
}
|
||||
}}
|
||||
className={`p-2.5 rounded-xl text-center text-xs font-medium transition-all border ${isSelStart || isSelEnd ? "bg-amber-500 text-white border-amber-500 shadow-sm" : inRange ? "bg-amber-100 text-amber-800 border-amber-200" : "bg-white text-gray-700 border-gray-200 hover:border-amber-300"}`}
|
||||
>
|
||||
{t(mk)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DateSelectionCalendar
|
||||
mode={effectivePricingMode}
|
||||
availableDates={availableDatesSet}
|
||||
selectedStart={selectedStart}
|
||||
selectedEnd={selectedEnd}
|
||||
onSelectStart={(d) => {
|
||||
setSelectedStart(d);
|
||||
setSelectedEnd(null);
|
||||
}}
|
||||
onSelectEnd={setSelectedEnd}
|
||||
/>
|
||||
|
||||
{/* Summary */}
|
||||
{selectedStart && (
|
||||
@ -1156,7 +1002,7 @@ export default function PropertyDetailsPage() {
|
||||
<span className="text-gray-500">{effectivePricingMode === "daily" ? t("numberOfDays") : t("numberOfMonths")}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{effectivePricingMode === "daily"
|
||||
? Math.max(1, Math.round((new Date(selectedEnd) - new Date(selectedStart)) / (1000 * 60 * 60 * 24)) + 1)
|
||||
? Math.max(1, Math.round((new Date(selectedEnd) - new Date(selectedStart)) / (1000 * 60 * 60 * 24)))
|
||||
: new Date(selectedEnd).getMonth() - new Date(selectedStart).getMonth() + (new Date(selectedEnd).getFullYear() - new Date(selectedStart).getFullYear()) * 12 + 1}
|
||||
</span>
|
||||
</div>
|
||||
@ -1165,7 +1011,7 @@ export default function PropertyDetailsPage() {
|
||||
<span className="text-amber-600">
|
||||
{formatCurrency(
|
||||
effectivePricingMode === "daily"
|
||||
? Math.max(1, Math.round((new Date(selectedEnd) - new Date(selectedStart)) / (1000 * 60 * 60 * 24)) + 1) * property.priceDisplay.daily
|
||||
? Math.max(1, Math.round((new Date(selectedEnd) - new Date(selectedStart)) / (1000 * 60 * 60 * 24))) * property.priceDisplay.daily
|
||||
: (new Date(selectedEnd).getMonth() - new Date(selectedStart).getMonth() + (new Date(selectedEnd).getFullYear() - new Date(selectedStart).getFullYear()) * 12 + 1) *
|
||||
property.priceDisplay.monthly,
|
||||
)}{" "}
|
||||
|
||||
@ -1,442 +1,331 @@
|
||||
# SweetHome — Full Project Analysis
|
||||
# SweetHome — Full Project Analysis (Updated Aug 2026)
|
||||
|
||||
**Project:** SweetHome Next.js Real Estate Application
|
||||
**Date:** June 16, 2026
|
||||
**Framework:** Next.js 16.1.6 (App Router) + React 18.3.1
|
||||
**Framework:** Next.js 16.1.6 (App Router) + React 18.3.1 + React Compiler enabled
|
||||
**Language:** JavaScript (no TypeScript)
|
||||
**Styling:** Tailwind CSS v4 + DaisyUI (unused) + Flowbite (unused)
|
||||
**API:** Custom `.NET` backend at `https://45.93.137.91.nip.io/api`
|
||||
**Styling:** Tailwind CSS v4, Framer Motion, lucide-react
|
||||
**Backend:** .NET REST API at `https://45.93.137.91.nip.io/api` (+ report API at `http://45.93.137.91/api`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Structure
|
||||
|
||||
### Directory Tree (Top 3 Levels)
|
||||
|
||||
```
|
||||
SweetHome/
|
||||
├── .gitea/workflows/ # CI/CD deployer
|
||||
├── app/ # ★ MAIN APP (112 source files)
|
||||
│ ├── account-verification/ # OTP email/phone verification
|
||||
│ ├── auth/choose-role/ # Owner/Tenant/Agent role picker
|
||||
│ ├── blocked/ # Blocked account page
|
||||
│ ├── booked-properties/ # User's booked properties
|
||||
│ ├── change-password/ # Change password form
|
||||
│ ├── components/ # ★ 14 reusable components
|
||||
│ │ ├── home/ # HeroSearch, PropertyMap
|
||||
│ │ ├── property/ # BookingCalendar, PropertyMap
|
||||
│ │ ├── ratings/ # StarRating, PropertyRatingForm, etc.
|
||||
│ │ ├── BottomNav.js # Mobile bottom nav
|
||||
│ │ ├── FloatingSidebar.js # Desktop floating sidebar
|
||||
│ │ ├── NavLinks.js # Nav link atoms
|
||||
│ │ ├── NotificationHandler.js # Firebase FCM handler
|
||||
│ │ └── PropertyMapWithMarkers.js # Standalone Leaflet map
|
||||
│ ├── contexts/ # ★ 3 context providers
|
||||
│ │ ├── FavoritesContext.js
|
||||
│ │ ├── NotificationsContext.js
|
||||
│ │ └── PropertyContext.js # ORPHANED (never mounted)
|
||||
│ ├── enums/ # 20 enum constant files
|
||||
│ ├── faq/ # FAQ accordion page
|
||||
│ ├── favorites/ # Favorites list page
|
||||
│ ├── forgot-password/ # 3-step forgot password
|
||||
│ ├── i18n/ # i18next config (Arabic/English)
|
||||
│ ├── login/ # 2-step login (credential + OTP)
|
||||
│ ├── my-rates/ # Ratings received from owners
|
||||
│ ├── notifications/ # Notifications list page
|
||||
│ ├── onboarding/ # 3-step intro wizard
|
||||
│ ├── owner/ # ★ Owner dashboard (6 sub-routes)
|
||||
│ │ ├── account-book/ # Financial dashboard
|
||||
│ │ ├── bookings/ # Booking management
|
||||
│ │ ├── calendar/ # Monthly calendar view
|
||||
│ │ ├── profits/ # Revenue/profit stats
|
||||
│ │ ├── properties/ # CRUD property management
|
||||
│ │ └── reservations/ # Reservation requests
|
||||
│ ├── payments/ # Deposit payment page
|
||||
│ ├── privacy/ # Privacy policy
|
||||
│ ├── profile/ # User profile CRUD
|
||||
│ ├── properties/ # Property listings grid
|
||||
│ ├── property/[id]/ # Property detail (dynamic route)
|
||||
│ ├── register/ # Registration (tenant/owner/agent)
|
||||
│ ├── reports/ # 3-tab report submission
|
||||
│ ├── reservations/ # Customer reservations
|
||||
│ ├── services/ # AuthService.js
|
||||
│ ├── settings/ # Account settings
|
||||
│ ├── support/ # Support contact form
|
||||
│ ├── terms/ # Terms of service
|
||||
│ ├── utils/ # ★ api.js, calculations, firebase, etc.
|
||||
│ ├── ClientLayout.js # Client layout shell (804 lines)
|
||||
│ ├── layout.js # Root server layout
|
||||
│ ├── globals.css # Tailwind + custom fonts
|
||||
│ ├── page.js # Home page
|
||||
│ ├── error.js # Error boundary
|
||||
│ ├── loading.js # Loading state
|
||||
│ └── not-found.js # 404 page
|
||||
├── public/ # Static files (fonts, images, APK)
|
||||
│ ├── files/SweetHome.apk # Android APK
|
||||
│ ├── fonts/ # Madani Arabic (woff2 + ttf)
|
||||
│ └── firebase-messaging-sw.js # FCM service worker
|
||||
├── package.json # 18 prod + 7 dev dependencies
|
||||
├── next.config.mjs # Next.js config
|
||||
├── postcss.config.mjs # PostCSS + Tailwind
|
||||
├── jsconfig.json # Path aliases (@/* → ./)
|
||||
└── security-audit-report.md # Previous security audit
|
||||
├── middleware.js # Root-level route protection (54 lines)
|
||||
├── next.config.mjs # reactCompiler + images remotePatterns
|
||||
├── full-project-analysis.md # This document
|
||||
├── edit-property-flow.md # Flutter edit-property investigation (backend bug doc)
|
||||
├── security-audit-report.md # Security findings
|
||||
├── app/
|
||||
│ ├── layout.js # Root layout: fonts, metadata, providers
|
||||
│ ├── ClientLayout.js # Nav (desktop+mobile), language, user menu
|
||||
│ ├── page.js # Home page (hero, map, features)
|
||||
│ ├── components/ # 20+ reusable components
|
||||
│ ├── contexts/ # Favorites, Notifications, Theme (+orphan Property)
|
||||
│ ├── enums/ # 24 enum files (index.js barrel)
|
||||
│ ├── utils/
|
||||
│ │ ├── api.js # 70+ API functions + 4 fetch helpers
|
||||
│ │ ├── ratings.js # Rating API (own private apiFetch)
|
||||
│ │ └── constants.js
|
||||
│ ├── services/AuthService.js # JWT cookie storage, role detection
|
||||
│ ├── i18n/config.js # AR/EN translations (3173 lines)
|
||||
│ ├── validations/ # OwnerValidatoin.js (email/phone/step validation)
|
||||
│ ├── HelperFunction/ # ApiProperty.js mapper, UploadImage.js
|
||||
│ ├── hooks/ # useApplyFilters, useAuth, UseFilterOfHeroSearch
|
||||
│ ├── auth/ blocked/ faq/ favorites/ login/ onboarding/ ... # pages
|
||||
│ └── owner/ # Dashboard (7 sub-routes)
|
||||
├── public/ # APK, fonts, firebase-messaging-sw.js
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### Route Summary (33 pages)
|
||||
|
||||
| Type | Routes | Count |
|
||||
|------|--------|-------|
|
||||
| Public | `/`, `/login`, `/register/*`, `/forgot-password`, `/auth/choose-role`, `/properties`, `/property/[id]`, `/terms`, `/privacy`, `/faq`, `/support`, `/onboarding` | 14 |
|
||||
| Protected (auth only) | `/profile`, `/settings`, `/change-password`, `/account-verification`, `/favorites`, `/booked-properties`, `/reservations`, `/notifications`, `/payments`, `/my-rates`, `/reports`, `/blocked` | 12 |
|
||||
| Owner only | `/owner/properties`, `/owner/properties/add`, `/owner/reservations`, `/owner/bookings`, `/owner/calendar`, `/owner/profits`, `/owner/account-book` | 7 |
|
||||
**Routes (~33 pages):** Public (`/`, `/login`, `/register/*`, `/properties`, `/property/[id]`, `/terms`, `/privacy`, `/faq`, `/support`, `/onboarding`) | Auth-protected (`/profile`, `/reservations`, `/payments`, `/favorites`, `/booked-properties`, `/my-rates`, `/notifications`, `/reports`, `/settings`, `/change-password`) | Owner-only (`/owner/properties`, `/owner/properties/add`, `/owner/reservations`, `/owner/bookings`, `/owner/calendar`, `/owner/profits`, `/owner/account-book`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
### Layout Hierarchy
|
||||
## 2. Architecture
|
||||
|
||||
### Layout hierarchy
|
||||
```
|
||||
app/layout.js (Server Component — root HTML, fonts, metadata)
|
||||
└── app/ClientLayout.js (Client Component — 804 lines)
|
||||
├── <NotificationsProvider> (context)
|
||||
│ └── <FavoritesProvider> (context)
|
||||
│ └── <main>{children}</main>
|
||||
├── <BottomNav /> (mobile, authenticated only)
|
||||
└── <NotificationHandler /> (FCM push notifications)
|
||||
app/layout.js (server) — fonts (Geist + Madani Arabic), metadata, <html lang="ar" dir="rtl">
|
||||
└── <ThemeProvider>
|
||||
└── <NotificationsProvider>
|
||||
└── <FavoritesProvider>
|
||||
└── <ClientLayout> # nav, user menu, language switcher
|
||||
└── <main>{children}</main>
|
||||
├── <BottomNav /> # mobile (authenticated)
|
||||
└── <NotificationHandler /> # Firebase FCM
|
||||
```
|
||||
|
||||
- **No nested layouts** — flat route structure
|
||||
- **No `middleware.js`** — all route protection is client-side only
|
||||
- Nav bar hidden on auth pages (`/login`, `/register/*`, `/blocked`, `/forgot-password`, `/auth/choose-role`)
|
||||
|
||||
### Data Flow Pattern
|
||||
|
||||
### Route protection (root middleware.js)
|
||||
```js
|
||||
// middleware.js — reads cookies: auth_token + cached_user
|
||||
if (!token && !isPublicRoute && !isAuthRoute) return NextResponse.redirect(new URL("/login", request.url));
|
||||
if (token && isAuthRoute) return NextResponse.redirect(new URL("/", request.url));
|
||||
if (IsOwner && !isOwner) return NextResponse.redirect(new URL("/", request.url)); // owner routes
|
||||
if (IsPrivateRoute && !token) return NextResponse.redirect(new URL("/login", request.url));
|
||||
```
|
||||
Page (useEffect on mount)
|
||||
→ API call (apiFetch from app/utils/api.js)
|
||||
→ AuthService.getToken() → Authorization: Bearer <JWT>
|
||||
→ fetch() to https://45.93.137.91.nip.io/api/{endpoint}
|
||||
→ Response: auto-unwrap { data: ... } envelope
|
||||
→ Return parsed data or throw on non-ok
|
||||
→ Component state (useState)
|
||||
→ Render UI with Tailwind classes
|
||||
- Owner detection relies on `cached_user` cookie's `roles` array (not the JWT).
|
||||
- Static assets excluded via matcher regex.
|
||||
|
||||
### Data flow
|
||||
```
|
||||
Page (useEffect) → apiFetch(endpoint, options) → AuthService.getToken() → Bearer JWT
|
||||
→ fetch → unwrap { data } envelope → useState → render Tailwind UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API Layer
|
||||
## 3. API Layer (`app/utils/api.js`, 783 lines)
|
||||
|
||||
**File:** `app/utils/api.js` (1198 lines — 370 commented legacy + 828 active)
|
||||
**Ratings API:** `app/utils/ratings.js` (separate file with own `apiFetch`)
|
||||
### Fetch helpers
|
||||
|
||||
### Fetch Helpers
|
||||
|
||||
| Function | Lines | Method | Auth | Error Handling | Return Type |
|
||||
|----------|-------|--------|------|----------------|-------------|
|
||||
| `apiFetch(endpoint, options)` | 421-478 | Any | Auto Bearer | Throws on non-ok (except 206) | `data` (auto-unwrapped) |
|
||||
| `authFetch(endpoint, body, token)` | 483-524 | POST | Manual param | Returns `{status,data,ok,message}` | Object |
|
||||
| `reportFetch(endpoint, body)` | 526-558 | POST | None | Returns `{status,data,ok,message}` | Object |
|
||||
| `multipartAuthFetch(endpoint, formData)` | 772-803 | POST | Auto Bearer | Returns `{status,data,ok,message}` | Object |
|
||||
|
||||
### Exported API Functions (47 total)
|
||||
|
||||
| Category | Functions | Count |
|
||||
|----------|-----------|-------|
|
||||
| Rent Properties | `getRentProperties`, `getRentProperty`, `getRentPropertyLocations`, `filterRentProperties` | 4 |
|
||||
| Sale Properties | `getSaleProperties`, `getSaleProperty`, `getSalePropertyById` | 3 |
|
||||
| Generic Property | `getProperty` | 1 |
|
||||
| Recommendations | `getRecommendations`, `getTopRecommendations` | 2 |
|
||||
| Reservations | `getAvailableDateRanges`, `getReservations`, `getReservation`, `checkAvailability`, `bookReservation`, `getUserReservations`, `getOwnerReservationRequests`, `getOwnerReservationsByStatuses`, `ownerConfirmReservation`, `confirmDepositPayment`, `adminConfirmDeposit`, `updateBookingStatus`, `payDeposit` | 13 |
|
||||
| Owner Management | `getMyRentListings`, `getMySaleListings`, `addRentProperty`, `addSaleProperty`, `editRentProperty`, `editSaleProperty`, `updateRentPropertyStatus`, `updateSalePropertyStatus`, `getOwnerContactInformation`, `getOwnerStatistics` | 10 |
|
||||
| Auth | `loginWithEmail`, `loginWithPhone`, `sendEmailOTP`, `sendPhoneOTP`, `verifyEmail`, `verifyPhone`, `changePassword`, `requestForgetPasswordOtp`, `verifyForgetPasswordOtp`, `resetPassword`, `deleteMyAccount` | 11 |
|
||||
| Registration | `addOwner`, `addCustomer`, `registerRealEstateAgent` | 3 |
|
||||
| Favorites | `getUserFavoriteProperties`, `addFavoriteProperty`, `removeFavoriteProperty` | 3 |
|
||||
| Notifications | `getUserNotifications`, `setFCMToken` | 2 |
|
||||
| Terms | `getARTerms`, `getENTerms`, `addOrUpdateTerms` | 3 |
|
||||
| Currencies | `getCurrencies` | 1 |
|
||||
| Files | `uploadPicture` | 1 |
|
||||
| Reports | `sendGeneralReport`, `submitReport`, `submitReservationReport`, `updateReservationReport`, `submitSaleReport`, `updateSaleReport` | 6 |
|
||||
| Profile | `getCustomerByUserId`, `getOwnerByUserId` | 2 |
|
||||
|
||||
### Ratings API (5 functions)
|
||||
|
||||
`addPropertyRating`, `addCustomerRating`, `getPropertyRatings`, `getCustomerRatings`, `getPropertyAverageRating`
|
||||
|
||||
### API Endpoint Coverage
|
||||
|
||||
| HTTP Method | Count | Examples |
|
||||
|-------------|-------|----------|
|
||||
| GET | 26 | `/RentProperties/GetRentProperties`, `/Reservations/GetAvailableDates/available/${id}` |
|
||||
| POST | 17 | `/Auth/LogInWithEmail`, `/Reservations/PayDeposit/pay-deposit`, `/Rating/AddPropertyRating` |
|
||||
| PUT | 8 | `/RentProperties/EditRentProperty/${id}`, `/Reservations/AdminConfirmDeposit/admin-confirm-deposit` |
|
||||
| DELETE | 2 | `/FavoriteProperty/Remove`, `/User/DeleteMyAccount` |
|
||||
|
||||
---
|
||||
|
||||
## 4. State Management
|
||||
|
||||
### Context Providers
|
||||
|
||||
| Context | File | State | Consumers | Status |
|
||||
|---------|------|-------|-----------|--------|
|
||||
| **FavoritesProvider** | `app/contexts/FavoritesContext.js` | `favorites[]`, `isLoading` | `FloatingSidebar`, `/favorites`, `/properties`, `/property/[id]` | ✅ Active |
|
||||
| **NotificationsProvider** | `app/contexts/NotificationsContext.js` | `notifications[]`, `unreadCount`, `isLoading` | `BottomNav`, `FloatingSidebar` | ✅ Active |
|
||||
| **PropertyContext** | `app/contexts/PropertyContext.js` | `properties[]`, `loading`, `error` | None | ❌ Orphaned (never mounted) |
|
||||
| **PropertyContext** | `app/utils/PropertyContext.js` | Same as above | None | ❌ Orphaned duplicate |
|
||||
|
||||
### localStorage Keys (10 actively written, ~5 legacy read-only)
|
||||
|
||||
| Key | Data | Written By | Read By |
|
||||
|-----|------|------------|---------|
|
||||
| `auth_token` | JWT string | `AuthService.addToken()` | `AuthService.getToken()`, `firebase.js` |
|
||||
| `cached_user` | User profile JSON | `AuthService.cacheUser()` | `AuthService.getCachedUser()` |
|
||||
| `language` | "en" or "ar" | `ClientLayout.js` | `ClientLayout.js` |
|
||||
| `sweethome_onboarding_completed` | "true" | `/onboarding/page.js` | `/onboarding/page.js` |
|
||||
| `ownerProperties` | Properties array JSON | `/owner/properties/page.js` | `/owner/calendar/page.js` |
|
||||
| `ownerBookings` | Bookings array JSON | `/owner/bookings/page.js` | `/owner/bookings/page.js` |
|
||||
| `ownerProfitsTable` | Profit data JSON | `/owner/profits/page.js` | `/owner/profits/page.js` |
|
||||
| `userProfile` | Profile form data JSON | `/profile/page.js` | `/profile/page.js` |
|
||||
| `userAvatar` | Base64 data URL | `/profile/page.js` | `/profile/page.js` |
|
||||
| `token`, `accessToken`, `authToken` | (legacy reads only) | — | `/settings`, `/payments`, `/reservations` |
|
||||
|
||||
### Auth Flow
|
||||
| Helper | Lines | Behavior |
|
||||
|---|---|---|
|
||||
| `apiFetch` | 38-87 | Auto Bearer, auto JSON-stringify (skips FormData), unwraps `{data}`, 206 tolerated, 451 → `/blocked` redirect, Arabic network error |
|
||||
| `authFetch` | 89-134 | POST only, returns `{status,data,ok,message}` (no throw) |
|
||||
| `reportFetch` | 136-172 | POST to REPORT_API_BASE (no auth) |
|
||||
| `multipartAuthFetch` | 418-448 | POST + FormData + Bearer |
|
||||
|
||||
```js
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = AuthService.getToken();
|
||||
const headers = { ...(token && { Authorization: `Bearer ${token}` }), ...(options.headers || {}) };
|
||||
// ... FormData passthrough, JSON.stringify for objects
|
||||
// unwraps: if (json && "data" in json) return json.data;
|
||||
}
|
||||
```
|
||||
Login (app/login/page.js)
|
||||
→ loginWithEmail() / loginWithPhone()
|
||||
→ authFetch(POST /Auth/LogInWithEmail, { credential, password, device: 0 })
|
||||
→ 200: AuthService.addToken(token) → localStorage.setItem('auth_token', token)
|
||||
→ 206: OTP required → sendEmailOTP() → verifyEmail(code) → get final token
|
||||
→ AuthService.cacheUser(user) → localStorage.setItem('cached_user', ...)
|
||||
→ router.push('/')
|
||||
|
||||
ClientLayout.js (every page navigation)
|
||||
→ AuthService.getUser() → decode JWT payload (base64 atob)
|
||||
→ setUser({ name, email, phone, role: isOwner() ? 'owner' : 'customer' })
|
||||
→ UI branches on user.role (show owner nav / customer nav / guest nav)
|
||||
### Key API functions (endpoints)
|
||||
|
||||
Page-level checks
|
||||
→ Each protected page independently calls:
|
||||
AuthService.isAuthenticated() → else router.push('/login')
|
||||
AuthService.isOwner() → else router.push('/')
|
||||
| Function | Endpoint | Method |
|
||||
|---|---|---|
|
||||
| `getRentProperties` | `/RentProperties/GetRentProperties` | GET |
|
||||
| `getRentProperty` | `/RentProperties/GetRentPropertyById/{id}` | GET |
|
||||
| `getMyRentListings` / `getMySaleListings` | `/RentProperties/GetMyRentListings`, `/SaleProperties/GetMySaleListings` | GET |
|
||||
| `addRentProperty` | `/RentProperties/AddRentProperty` (uses `buildRentPropertyPayload`) | POST |
|
||||
| `editRentProperty` | `/RentProperties/EditRentProperty/{id}` — strict camelCase schema | PUT |
|
||||
| `editSaleProperty` | `/SaleProperties/EditSaleProperty/{id}` | PUT |
|
||||
| `updateRentPropertyStatus` | `/RentProperties/UpdateStatus/{id}` `{status}` | PUT |
|
||||
| `uploadPicture` | `/Files/UploadPicture` (FormData `image`) → `{value: "/Pictures/xxx.jpg"}` | POST |
|
||||
| `bookReservation` | `/Reservations/BookReservation/book` | POST |
|
||||
| `getUserReservations` | `/Reservations/GetUserResevations` | GET |
|
||||
| `getOwnerReservationRequests` | `/Reservations/GetOwnerResevationRequests` | GET |
|
||||
| `ownerConfirmReservation` | `/Reservations/OwnerConfirmReservation/owner-confirm/{id}` | PUT |
|
||||
| `payDeposit` | `/Reservations/PayDeposit/pay-deposit` (FormData) | POST |
|
||||
| `getPaymentTypes` | `/PaymentType/GetAll` | GET |
|
||||
| `getMyTransaction` | `/Customer/GetMyTransaction` | GET |
|
||||
| `getOwnerStatistics` | `/Statistics/GetOwnerStatistics` | GET |
|
||||
| `getOwnerContactInformation` | `/Owner/GetOwnerContactInformation?propertyInformationId=` | GET |
|
||||
| `loginWithEmail` | `/Auth/LogInWithEmail` `{credential,password,device:0}` | POST |
|
||||
| `loginWithPhone` | `/Auth/LogInWithPhoneNumber` | POST |
|
||||
| `getCurrencies` | `/Currency/GetAll` | GET |
|
||||
| `filterRentProperties` | `/RentProperties/FilterRentProperties?{params}` | GET |
|
||||
| `getUserNotifications` | `/Notifications/GetUserNotifications` | GET |
|
||||
| `setFCMToken` | `/User/SetFCMToken` `{token, deviceType:2}` | POST |
|
||||
| `submitReservationReport` | `/ReservationReports` | POST |
|
||||
|
||||
### `buildRentPropertyPayload` — city/governorate/documentType normalizer
|
||||
```js
|
||||
export function buildRentPropertyPayload(data = {}) {
|
||||
const rawCityValue = data?.city ?? data?.governorate ?? propertyInformation?.city ?? 1;
|
||||
const cityInt = parseInt(rawCityValue, 10) || 1;
|
||||
return { ...data, city: cityInt, governorate: cityInt, documentType: data?.documentType ?? 1,
|
||||
propertyInformation: { ...propertyInformation, city: cityInt, governorate: cityInt, documentType: ... } };
|
||||
}
|
||||
```
|
||||
|
||||
### `editRentProperty` — strict swagger-contract payload (current, works)
|
||||
```js
|
||||
export async function editRentProperty(id, data) {
|
||||
const pi = data?.propertyInformation || {};
|
||||
const images = Array.isArray(pi.images) ? pi.images : [];
|
||||
const body = {
|
||||
propertyInformation: {
|
||||
activityStatus: 1, images: images.length > 0 ? images : [""],
|
||||
cordsX: pi.cordsX ?? null, cordsY: pi.cordsY ?? null,
|
||||
address: pi.address ?? "", description: pi.description ?? null,
|
||||
numberOfBathRooms: pi.numberOfBathRooms ?? 0, numberOfRooms: pi.numberOfRooms ?? 0,
|
||||
numberOfBedRooms: pi.numberOfBedRooms ?? 0, space: pi.space ?? 0,
|
||||
detailsJSON: pi.detailsJSON ?? "", buildingType: pi.buildingType ?? 0,
|
||||
status: pi.status ?? 0, propertyType: pi.propertyType ?? 0, city: data.city ?? pi.city ?? 1,
|
||||
},
|
||||
deposit: data.deposit ?? 0, acceptedCertificate: data.acceptedCertificate ?? 0,
|
||||
monthlyRent: data.monthlyRent ?? 0, dailyRent: data.dailyRent ?? 0,
|
||||
rating: data.rating ?? 1, currencyId: data.currencyId ?? 1, rentType: data.rentType ?? 0,
|
||||
isSmokeAllow: data.isSmokeAllow ?? false, specializedFor: data.specializedFor ?? false,
|
||||
isVisitorAllow: data.isVisitorAllow ?? false, allowedPaymentPeriod: data.allowedPaymentPeriod ?? "",
|
||||
type: data.type ?? 0,
|
||||
};
|
||||
return apiFetch(`/RentProperties/EditRentProperty/${id}`, { method: "PUT", body });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Architecture
|
||||
## 4. Auth (`app/services/AuthService.js`, 219 lines)
|
||||
|
||||
### Component Inventory (14 reusable components)
|
||||
- **Storage:** `js-cookie` — `auth_token` (7d, secure, sameSite=lax), `cached_user`.
|
||||
- **Role detection:** JWT claims via `atob(payload)`; roles from `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` (string or array).
|
||||
- **`getUser()`** merges cookie profile (name/email/phone) + JWT (`id` from `nameidentifier`/`sub`).
|
||||
- **`login()`** flow: `loginWithEmail/Phone` → 200 = SUCCESS + `cacheCurrentUser()`; 206 = OTP_REQUIRED.
|
||||
|
||||
| Component | File | Lines | Purpose | State |
|
||||
|-----------|------|-------|---------|-------|
|
||||
| HeroSearch | `components/home/HeroSearch.js` | 321 | Hero search with filters | `filters`, login dialog |
|
||||
| PropertyMap (home) | `components/home/PropertyMap.js` | 287 | Map with markers + popup | `selectedProperty`, map ref |
|
||||
| PropertyMap (property) | `components/property/PropertyMap.js` | 166 | Map at property detail | Tooltip state |
|
||||
| BookingCalendar | `components/property/BookingCalendar.js` | 124 | Monthly booking grid | `currentMonth`, `selectedStart` |
|
||||
| PropertyMapWithMarkers | `components/PropertyMapWithMarkers.js` | 100 | Generic Leaflet map | Map ref, markers ref |
|
||||
| StarRating | `components/ratings/StarRating.js` | 134 | 5-star input | `hoverRating` |
|
||||
| PropertyRatingList | `components/ratings/PropertyRatingList.js` | 286 | Paginated reviews | `ratings`, `page`, `loading` |
|
||||
| PropertyRatingForm | `components/ratings/PropertyRatingForm.js` | 317 | 4-field rating form | Clean/services/owner/experience |
|
||||
| CustomerRatingForm | `components/ratings/CustomerRatingForm.js` | 92 | 3-field rating form | Furn/terms/behavior |
|
||||
| BottomNav | `components/BottomNav.js` | 58 | Mobile bottom bar | Badge from context |
|
||||
| FloatingSidebar | `components/FloatingSidebar.js` | 158 | Quick-access buttons | Framer-motion variants |
|
||||
| NavLinks | `components/NavLinks.js` | 42 | Nav link atoms | `usePathname` active |
|
||||
| NotificationHandler | `components/NotificationHandler.js` | 151 | FCM push handler | Permission state |
|
||||
```js
|
||||
getRoles() {
|
||||
const payload = this.decodeToken();
|
||||
const roles = payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
|
||||
return Array.isArray(roles) ? roles : typeof roles === "string" ? [roles] : [];
|
||||
}
|
||||
isOwner() { return this.getRoles().includes("Owner"); }
|
||||
isAgent() { return this.getRoles().includes("RealEstateAgent"); }
|
||||
```
|
||||
|
||||
### Largest Page Files
|
||||
|
||||
| Rank | File | Lines | Complexity |
|
||||
|------|------|-------|------------|
|
||||
| 1 | `api.js` | 1198 | 60+ functions, 4 fetch helpers, 370 lines commented |
|
||||
| 2 | `PropertyDetail.js` | 1743 | 56 imports, 8+ state vars, booking calendar, image gallery, owner check, ratings, map |
|
||||
| 3 | `owner/properties/page.js` | 2057 | CRUD + modals + localStorage caching + API calls |
|
||||
| 4 | `ClientLayout.js` | 804 | Nav (desktop+mobile), user menu, language switcher, context providers |
|
||||
| 5 | `owner/properties/add/page.js` | 700+ | Multi-step form with map, image upload, pricing |
|
||||
| 6 | `app/page.js` (Home) | 623 | Property fetching, multi-filter, hero, map, feature cards |
|
||||
> ⚠ **Bug:** `login()` calls `sendEmailOTP()`/`sendPhoneOTP()` (lines 203-204) but they are **not imported** → ReferenceError on OTP-required login.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key Patterns
|
||||
## 5. Owner Dashboard
|
||||
|
||||
### Data Fetching (4 Patterns)
|
||||
| Page | Lines | Data source | Endpoints |
|
||||
|---|---|---|---|
|
||||
| `/owner/reservations` | 1537 (active from 646) | **Real API** | GET `GetOwnerResevationRequests` + batch `GetRentProperties` enrichment; PUT `OwnerConfirmReservation/owner-confirm/{id}`; POST `ChangeReservationStatus?id=&newStatus=5`; POST `ReservationReports/ReportReservation` |
|
||||
| `/owner/properties` | 2270 | **Real API + localStorage** | GET `GetMyRentListings`/`GetMySaleListings`; PUT `EditRentProperty/{id}`; PUT `EditSaleProperty/{id}`; PUT `UpdateStatus/{id}`; POST `UploadPicture`. **Delete is local-only** |
|
||||
| `/owner/properties/add` | 1712 | **Real API** | POST `AddRentProperty`/`AddSaleProperty`; POST `UploadPicture`; GET `Currency/GetAll`; Nominatim geocoding |
|
||||
| `/owner/bookings` | 600 | **Real API** | GET `GetOwnerResevationRequests` |
|
||||
| `/owner/calendar` | 743 | **Mock/localStorage** | None (seeds fake properties; calls undefined `loadCalendar()`) |
|
||||
| `/owner/profits` | 594 | **Mock/localStorage** | None (hard-coded sampleData, 5% commission, XLSX export) |
|
||||
| `/owner/account-book` | 142 | **Real API** | GET `Statistics/GetOwnerStatistics` |
|
||||
|
||||
| Pattern | Usage | Found In |
|
||||
|---------|-------|----------|
|
||||
| `useEffect` + `useState` | ~90% of pages | Most pages |
|
||||
| Context-level fetch | 2 providers | `FavoritesContext`, `NotificationsContext` |
|
||||
| localStorage cache | Owner dashboards | `owner/bookings`, `owner/calendar`, `owner/profits` |
|
||||
| Mock data fallback | 3 owner pages | `owner/bookings`, `owner/calendar`, `owner/profits` |
|
||||
### Owner reservations page — key code
|
||||
```js
|
||||
// loadReservations (1259-1314): parallel fetch + batch enrich
|
||||
const [resResult, rentProps] = await Promise.all([
|
||||
fetch(`${API_BASE}/Reservations/GetOwnerResevationRequests`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(async (res) => { ... return list; }),
|
||||
getRentProperties().catch(() => []),
|
||||
]);
|
||||
const propMap = {};
|
||||
propsList.forEach(rp => {
|
||||
const info = rp?.propertyInformation ?? {};
|
||||
propMap[rp.propertyInformationId] = info;
|
||||
if (rp?.propertyInformation?.id) propMap[rp.propertyInformation.id] = info;
|
||||
});
|
||||
const enriched = resResult.map(r => { if (r.propertyId && propMap[r.propertyId]) r._prop = propMap[r.propertyId]; return r; });
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
- 100% Tailwind CSS v4 (utility classes, no CSS modules)
|
||||
- Custom `globals.css`: Madani Arabic fonts (9 weights), Leaflet overrides, keyframe animations
|
||||
- Color palette: `amber-500/600` (primary), `#ede6e6` (bg), `#156874` (accent teal)
|
||||
- RTL-first with dynamic LTR switching via `currentLanguage` state
|
||||
|
||||
### Animations (Framer Motion v12.29.2)
|
||||
|
||||
- `initial/animate` opacity + y/x translations (section reveals)
|
||||
- `whileHover`/`whileTap` scale (buttons, interactive elements)
|
||||
- `AnimatePresence` (property popups, user menus)
|
||||
- `whileInView` + `viewport` (scroll-triggered animations on home page)
|
||||
- `staggerChildren` (hero text)
|
||||
- Spring transitions `{ type: "spring", damping: 25, stiffness: 300 }`
|
||||
|
||||
### Form Handling
|
||||
|
||||
- **All forms**: Controlled `useState` — no React Hook Form, Formik, or `useReducer`
|
||||
- **Validation**: Inline `value === 0 && <p className="text-red-500">مطلوب</p>`
|
||||
- **No form libraries** — all hand-rolled
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Layout |
|
||||
|------------|--------|
|
||||
| Default (mobile) | Single column, BottomNav, hamburger menu |
|
||||
| `md:` (768px) | Desktop nav, 2-3 column grids |
|
||||
| `lg:` (1024px) | PropertyDetail sidebar, 3-column grids |
|
||||
### Owner properties — edit modal
|
||||
- **formData**: propertyType, furnished, description, bedrooms/bathrooms/floor/salons/balconies/livingRooms/area, services `{}`, serviceDetails `{}`, terms `{}`, customTerms[], 6 nearby distances, purpose, currencyId, daily/monthlyPrice, deposit, rentType, allowedPaymentPeriod, salePrice.
|
||||
- **Services**: 13 enum-based (`PropertyService`) checkboxes with detail inputs.
|
||||
- **Terms**: 3 enum-based (`PropertyTerm`) + custom terms chips.
|
||||
- **Images**: `existingImages` (API) + `newImages` (upload via `uploadPicture`, preview via `URL.createObjectURL`, max 10, 5MB each) with delete/reorder.
|
||||
- **Validation**: description, bedrooms≥1, bathrooms≥1, area>0, price required.
|
||||
- **Save**: builds `detailsJSON` → `propInfo` → `editRentProperty(property.id, payload)`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Owner Pages Analysis (Dashboard Section)
|
||||
## 6. Customer Pages
|
||||
|
||||
| Route | File | Lines | Status | Data Source |
|
||||
|-------|------|-------|--------|-------------|
|
||||
| `/owner/properties` | `owner/properties/page.js` | 2057 | ✅ Active | API (`getMyRentListings`, `getMySaleListings`) + localStorage cache |
|
||||
| `/owner/properties/add` | `owner/properties/add/page.js` | 700+ | ✅ Active | API (`addRentProperty`, `addSaleProperty`, `uploadPicture`) |
|
||||
| `/owner/reservations` | `owner/reservations/page.js` | 1400+ | ❌ Commented out | Mock data (commented) |
|
||||
| `/owner/bookings` | `owner/bookings/page.js` | 560+ | ⚠️ Demo | localStorage mock data only |
|
||||
| `/owner/calendar` | `owner/calendar/page.js` | 580+ | ⚠️ Demo | localStorage mock data only |
|
||||
| `/owner/profits` | `owner/profits/page.js` | 600+ | ⚠️ Demo | localStorage mock data only |
|
||||
| `/owner/account-book` | `owner/account-book/page.js` | 500+ | ✅ Active | API (`getOwnerStatistics`) |
|
||||
| Page | Lines | Data source |
|
||||
|---|---|---|
|
||||
| `/reservations` | 1826 (active from 391) | `getUserReservations` + `getRentProperties` enrich + `getPaymentTypes` |
|
||||
| `/payments` | 600 | `getMyTransaction` + `getPaymentTypes` |
|
||||
| `/property/[id]` | 1231 | `getRentProperty` → `getSalePropertyById` → `getSaleProperty` cascade |
|
||||
| `/properties` | 851 | `FilterRentProperties` + `getSaleProperties` hybrid |
|
||||
| `/` home | HomeClient 71 + hooks | `getRentProperties` + `getSaleProperties` client-side filter |
|
||||
| `/booked-properties` | 220 | `getUserReservations` |
|
||||
| `/favorites` | 128 | `FavoritesContext` |
|
||||
| `/my-rates` | 148 | `getCustomerRatings` |
|
||||
| `/notifications` | 159 | `getUserNotifications` (mark-read client-only) |
|
||||
|
||||
### Customer reservations — PaymentDialog (Haram receipt flow)
|
||||
```js
|
||||
const selectedMethod = paymentMethods?.find((m) => String(m?.id ?? m?.name) === String(selectedPayment));
|
||||
const isHaram = selectedMethod?.name?.toLowerCase?.() === "haram";
|
||||
const canPay = payingId !== reservation.id && hasSelectedMethod && (!isHaram || !!receiptImage);
|
||||
// handleConfirmPay: payDeposit({ reservationId, paymentTypeId: selectedPayment, comment, paymentImage: receiptImage })
|
||||
// payDeposit → FormData: ReservationId, PaymentTypeId, Comment, paymentImage (multipart)
|
||||
```
|
||||
- CountdownTimer: deadline = `ownerApprovalDate + allowedPaymentPeriod` (`parseTimeSpan` handles `d.hh:mm:ss`).
|
||||
- Inline rating form (4 categories) for `depositPaid`/`completed`.
|
||||
|
||||
### Property detail
|
||||
- Image gallery (chevron prev/next, thumbnails, counter), Leaflet map, availability calendar (`availableDatesSet` from `GetAvailableDates/available/{id}`), booking flow (daily/monthly toggle, calendar month grid, summary, `bookReservation`).
|
||||
- **Unused**: owner-contact feature (`getOwnerContactInformation` defined but no UI), `handleBookNow`/`bookingDates` dead code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin Functionality
|
||||
## 7. i18n (`app/i18n/config.js`, 3173 lines)
|
||||
|
||||
| Component | Status | Data Source |
|
||||
|-----------|--------|-------------|
|
||||
| BookingRequests | ✅ Active | Real API (`getReservations`, `adminConfirmDeposit`) |
|
||||
| Users | ❌ Not implemented | — |
|
||||
| Properties | ❌ Not implemented | — |
|
||||
| LedgerBook | ⚠️ Mock only | 3 hardcoded mock transactions |
|
||||
| Dashboard | ❌ Not implemented | — |
|
||||
|
||||
Designed for internal admin use at `/admin` — tabs for Bookings/Users/Properties/Ledger/Dashboard.
|
||||
- 2 languages: `en` (1-1662), `ar` (1663-3155); flat dot-namespaced keys; `fallbackLng: "en"`.
|
||||
- Init: `i18n.use(LanguageDetector).use(initReactI18next).init({ resources, fallbackLng: "en", detection: { order: ["localStorage", "navigator"], caches: ["localStorage"] } })`.
|
||||
- ClientLayout overrides: `localStorage("language")` default `"ar"`, flips `document.documentElement.dir` (rtl/ltr).
|
||||
- Active language toggle: mobile header + authenticated sticky bar (desktop navbar one commented out).
|
||||
|
||||
---
|
||||
|
||||
## 9. Known Technical Debt
|
||||
## 8. State Management
|
||||
|
||||
### Dead Code
|
||||
|
||||
| File | Dead Lines | Issue |
|
||||
|------|-----------|-------|
|
||||
| `api.js` | Lines 1-370 (370 lines) | Entire legacy version commented out |
|
||||
| `StarRating.js` | Lines 1-93 (93 lines) | Old framer-motion version commented out |
|
||||
| `PropertyRatingList.js` | ~150 lines | Old version commented out |
|
||||
| `PropertyRatingForm.js` | ~220 lines | Old version commented out |
|
||||
| `contexts/PropertyContext.js` | Entire file | Never mounted |
|
||||
| `utils/PropertyContext.js` | Entire file | Duplicate orphan |
|
||||
|
||||
### Code Duplication
|
||||
|
||||
| Pattern | Files | Description |
|
||||
|---------|-------|-------------|
|
||||
| Leaflet maps | 3 separate files | `PropertyMapWithMarkers.js`, `home/PropertyMap.js`, `property/PropertyMap.js` — all setup Leaflet identically |
|
||||
| Rating forms | `PropertyRatingForm.js` (317) + `CustomerRatingForm.js` (92) | ~75% code overlap; both define identical `RatingField` inline |
|
||||
| formatCurrency | 4+ locations | Scattered across files with slightly different logic |
|
||||
| Image URL building | 4+ locations | Different implementations for same task |
|
||||
| Notifications page | `/notifications/page.js` vs `NotificationsContext` | Page duplicates all context state/logic |
|
||||
|
||||
### Architecture Issues
|
||||
|
||||
| Issue | Impact |
|
||||
|-------|--------|
|
||||
| No middleware.js | All route protection is client-side only |
|
||||
| JWT in localStorage | XSS-vulnerable token storage |
|
||||
| Client-side role checks | Users can modify JWT to elevate privileges |
|
||||
| Owner pages use mock data | `/owner/bookings`, `/owner/calendar`, `/owner/profits` are demo-only |
|
||||
| 3 unused UI libraries | DaisyUI, Flowbite, Yandex Maps imported but never used |
|
||||
| No TypeScript | 112 JS files, no type safety |
|
||||
| No ESLint | No linting configured |
|
||||
| No testing framework | Zero tests |
|
||||
| Flat route structure | No nested layouts; all pages share root layout |
|
||||
| Active console.log | ~30+ files with console.log statements (security risk) |
|
||||
| Context | Status |
|
||||
|---|---|
|
||||
| `FavoritesProvider` (app/contexts/FavoritesContext.js) | ✅ Mounted in layout.js; favorites list, add/remove (optimistic w/ rollback), refetch |
|
||||
| `NotificationsProvider` (app/contexts/NotificationsContext.js) | ✅ Mounted; unreadCount = full array length; markAsRead is client-only |
|
||||
| `ThemeProvider` | ✅ Mounted |
|
||||
| `PropertyContext` (contexts/ + utils/ duplicate) | ❌ Orphaned — never mounted/imported; pure in-memory mock |
|
||||
|
||||
---
|
||||
|
||||
## 10. Dependencies
|
||||
## 9. Enums (24 files in `app/enums/`)
|
||||
|
||||
### Production (18)
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| next | 16.1.6 | Framework |
|
||||
| react / react-dom | ^18.3.1 | UI |
|
||||
| firebase | ^12.11.0 | FCM push notifications |
|
||||
| leaflet / react-leaflet | 1.9.4 / 4.2.1 | Maps |
|
||||
| @pbe/react-yandex-maps | ^1.2.5 | Yandex Maps (unused) |
|
||||
| framer-motion | ^12.29.2 | Animations |
|
||||
| lucide-react | ^0.563.0 | Icons |
|
||||
| flowbite / flowbite-react | 4.0.1 / 0.12.16 | UI library (unused) |
|
||||
| react-hot-toast | ^2.6.0 | Toasts |
|
||||
| i18next / react-i18next / i18next-browser-languagedetector | 25.8 / 16.5 / 8.2 | i18n |
|
||||
| jspdf / html2canvas | 4.2.1 / 1.4.1 | PDF generation |
|
||||
| xlsx | ^0.18.5 | Excel export |
|
||||
| react-intersection-observer | ^10.0.3 | Scroll detection |
|
||||
|
||||
### Dev (7)
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| tailwindcss | ^4.1.18 | CSS framework |
|
||||
| @tailwindcss/postcss | ^4 | PostCSS plugin |
|
||||
| postcss / autoprefixer | ^8.5.6 / ^10.4.23 | CSS processing |
|
||||
| daisyui | ^5.5.14 | UI components (unused) |
|
||||
| babel-plugin-react-compiler | 1.0.0 | React compiler optimization |
|
||||
|
||||
### Missing (notable absences)
|
||||
- No TypeScript
|
||||
- No ESLint
|
||||
- No testing (Jest, Playwright, Cypress)
|
||||
- No state management library (Redux, Zustand)
|
||||
- No data fetching library (React Query, SWR)
|
||||
- No form library (React Hook Form, Formik)
|
||||
- No security lib (helmet, cors, csurf)
|
||||
- No HTTP client (axios, ky)
|
||||
- No JWT lib (jsonwebtoken, jose)
|
||||
Key ones: `BuildingType` (0-9 numeric), `PropertyService` (13 string), `PropertyTerm` (3 string), `PropertyStatus` (0/1/2), `BookingStatus` (string, ⚠ mixed casing `PENDING` vs `ownerConfirmed`), `RentType` (MONTHLY 0, DAILY 1), `Currency` (SYP 1, USD 2, EUR 3, TRY 4), `Governorate` (13), `DocumentType` (Passport/IDCard/Both), `UserRole`, `City` (Arabic values + CityEnum 0-12), `RentPropertyCondition`, `RentPropertyType`, `TransactionType`, `CancellationReason`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Security Summary
|
||||
## 10. Known Bugs & Issues
|
||||
|
||||
*(See `security-audit-report.md` for full details)*
|
||||
| # | Issue | Location |
|
||||
|---|---|---|
|
||||
| 1 | `sendEmailOTP`/`sendPhoneOTP` used but not imported → ReferenceError on 206 | AuthService.js 203-204 |
|
||||
| 2 | Edit endpoint ignores `images` + `detailsJSON` (backend bug) | Backend PUT handler |
|
||||
| 3 | Delete property is local-only (no API call) | owner/properties/page.js 1657-1666 |
|
||||
| 4 | `/owner/calendar` calls undefined `loadCalendar()` (line 499) | owner/calendar/page.js |
|
||||
| 5 | `/owner/calendar` + `/owner/profits` are mock/localStorage only | — |
|
||||
| 6 | `useApplyFilters.js:62` price bucket `"2000-3000"` compares `> 300` (typo, should be 3000) | hooks |
|
||||
| 7 | HeroSearch propertyType onChange writes to `filters.city` (line 69) | components/home/HeroSearch.js |
|
||||
| 8 | `addOwner` appends both back-ID + license images under same key `RearIdCarImagePath` | api.js 470-471 |
|
||||
| 9 | `getSaleProperty` fetches whole list and filters client-side | api.js 194 |
|
||||
| 10 | Notifications markAsRead client-only (no persistence) | contexts + page |
|
||||
| 11 | `BookingCalendar.js` date click is a no-op | components/property |
|
||||
| 12 | BookingStatus enum mixed casing | enums/BookingStatus.js |
|
||||
| 13 | Ratings API has own private apiFetch without 451 handling | utils/ratings.js |
|
||||
| 14 | `home/PropertyMap.js:162` quick-book uses `alert()` stub | components/home |
|
||||
| 15 | Hardcoded phone `+963567823411` in payments + reservations | multiple |
|
||||
| 16 | Firebase config hard-coded in NotificationHandler.js | components |
|
||||
| 17 | Owner-contact feature dead on property detail | PropertyDetail.js 269-361 |
|
||||
|
||||
## 11. Dead Code
|
||||
|
||||
- `app/contexts/PropertyContext.js` + `app/utils/PropertyContext.js` (both orphaned)
|
||||
- `reservations/page.js` lines 1-390, `profits/page.js` lines 1-291, `StarRating.js` 1-94, `PropertyRatingList.js` 1-151, `PropertyRatingForm.js` 1-219 (commented legacy)
|
||||
- `handleBookNow`/`bookingDates` in PropertyDetail.js (383-403)
|
||||
- Desktop language switcher (ClientLayout 259-268, commented)
|
||||
|
||||
## 12. Dependencies
|
||||
|
||||
**Prod (20):** next 16.1.6, react 18.3.1, firebase, leaflet/react-leaflet, framer-motion, lucide-react, react-hot-toast, i18next/react-i18next/browser-languagedetector, jspdf, html2canvas, xlsx, react-intersection-observer, flowbite/flowbite-react (unused), @pbe/react-yandex-maps (unused), js-cookie.
|
||||
|
||||
**Dev (7):** tailwindcss v4, @tailwindcss/postcss, postcss, autoprefixer, daisyui (unused), babel-plugin-react-compiler.
|
||||
|
||||
**Missing:** TypeScript, ESLint, tests, state library, data-fetching library, form library, HTTP client, JWT lib.
|
||||
|
||||
## 13. Security (summary — see security-audit-report.md)
|
||||
|
||||
| # | Finding | Severity |
|
||||
|---|---------|----------|
|
||||
| 1 | JWT in localStorage (XSS-theft) | 🔴 Critical |
|
||||
| 2 | Stored XSS via Leaflet popup HTML | 🔴 Critical |
|
||||
| 3 | OTP code logged to console | 🔴 Critical |
|
||||
| 4 | No server-side middleware | 🔴 Critical |
|
||||
| 5 | Passwords in URL query params | 🔴 Critical |
|
||||
| 6 | IDOR on property edit/status APIs | 🔴 Critical |
|
||||
| 7 | Client-only role checks | 🟠 High |
|
||||
| 8 | HTTP endpoints leak tokens | 🟠 High |
|
||||
| 9 | No CSP/security headers | 🟠 High |
|
||||
| 10 | Mass assignment via spread | 🟠 High |
|
||||
| 11 | Error messages leak internals | 🟠 High |
|
||||
| 12 | Hardcoded IPs (10+ files) | 🟡 Medium |
|
||||
|
||||
---
|
||||
|
||||
## 12. File Size Heatmap
|
||||
|
||||
| Size Range | Files | Examples |
|
||||
|------------|-------|----------|
|
||||
| 1500-2100 lines | 2 | `PropertyDetail.js` (1743), `owner/properties/page.js` (2057) |
|
||||
| 600-1200 lines | 6 | `api.js` (1198), `ClientLayout.js` (804), `login/page.js` (800+), `owner/properties/add/page.js` (700+), `app/page.js` (623), `owner/bookings/page.js` (560+) |
|
||||
| 200-600 lines | 15 | Most page files |
|
||||
| < 200 lines | ~70 | Enum files, smaller components, error/loading pages |
|
||||
|---|---|---|
|
||||
| 1 | JWT in cookie (secure flag OK) but roles in client-decodable cookie | High |
|
||||
| 2 | Client-side role gating (cached_user cookie editable) | High |
|
||||
| 3 | Passwords passed via URL query params (`changePassword`, `resetPassword`) | Critical |
|
||||
| 4 | Stored XSS via Leaflet HTML popups | Critical |
|
||||
| 5 | OTP codes logged to console | Critical |
|
||||
| 6 | IDOR on property edit/status APIs (no server ownership check) | Critical |
|
||||
| 7 | Firebase config public | Medium |
|
||||
| 8 | Hardcoded IPs/credentials across files | Medium |
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const publicRoutes = ["/", "/properties"];
|
||||
const publicRoutes = ["/", "/properties", "/terms", "/privacy", "/faq", "/support"];
|
||||
|
||||
const authRoutes = ["/login", "/auth/choose-role", "/forgot-password", "/register/owner", "/register/tenant", "/register/agent"];
|
||||
|
||||
const ownerRoutes = ["/owner/account-book", "/owner/bookings", "/owner/calendar", "/owner/profits", "/owner/properties/add", "/owner/reservations"];
|
||||
//ممكن كبو لانو زيادة
|
||||
const priveteRoute = ["/account-verification", "/booked-properties", "/change-password", "/favorites", "/my-rates", "/notifications", "/payments", "/profile", "/reservations"];
|
||||
const ownerRoutes = ["/owner", "/owner/account-book", "/owner/bookings", "/owner/calendar", "/owner/profits", "/owner/properties", "/owner/reservations"];
|
||||
|
||||
function getRolesFromToken(token) {
|
||||
if (!token) return [];
|
||||
try {
|
||||
const base64 = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const payload = JSON.parse(atob(base64));
|
||||
const roles = payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
|
||||
if (Array.isArray(roles)) return roles;
|
||||
return roles ? [roles] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function middleware(request) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const token = request.cookies.get("auth_token")?.value;
|
||||
const userCookie = request.cookies.get("cached_user")?.value;
|
||||
|
||||
let isOwner = null;
|
||||
|
||||
if (userCookie) {
|
||||
try {
|
||||
const user = JSON.parse(decodeURIComponent(userCookie));
|
||||
isOwner = user.roles?.includes("Owner");
|
||||
} catch (error) {
|
||||
console.error("Cookie parse error:", error);
|
||||
}
|
||||
}
|
||||
const isOwner = getRolesFromToken(token).includes("Owner");
|
||||
|
||||
const isPublicRoute = publicRoutes.some((route) => pathname === route || (route !== "/" && pathname.startsWith(route)));
|
||||
|
||||
const isAuthRoute = authRoutes.some((route) => pathname === route || pathname.startsWith(route));
|
||||
|
||||
const IsOwner = ownerRoutes.some((route) => route === pathname || pathname.startsWith(route));
|
||||
const isOwnerRoute = ownerRoutes.some((route) => pathname === route || pathname.startsWith(route));
|
||||
|
||||
const IsPrivateRoute = priveteRoute.some((route) => pathname === route || pathname.startsWith(route));
|
||||
if (!token && !isPublicRoute && !isAuthRoute) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
@ -39,13 +39,10 @@ export function middleware(request) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
if (IsOwner && !isOwner) {
|
||||
if (isOwnerRoute && !isOwner) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
if (IsPrivateRoute && !token) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -54,7 +54,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -64,7 +64,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@ -83,7 +83,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@ -2374,7 +2374,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
|
||||
Reference in New Issue
Block a user