Compare commits

..

122 Commits

Author SHA1 Message Date
5d3ead55ca Added API for rating
All checks were successful
Build frontend / build (push) Successful in 54s
2026-04-26 13:46:30 +03:00
97126c5776 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 54s
2026-04-22 10:52:19 +03:00
1e167c447a Edit profits for owner 2026-04-22 10:52:08 +03:00
dd0a9c401d readdded the getuserId function
All checks were successful
Build frontend / build (push) Successful in 58s
2026-04-17 14:40:47 +03:00
32f6c7af5a fixed the api request
All checks were successful
Build frontend / build (push) Successful in 1m7s
2026-04-16 22:49:15 +03:00
7e0d5eaf8d edited the api request
All checks were successful
Build frontend / build (push) Successful in 40s
2026-04-16 22:40:59 +03:00
beccd8b24f added debugging on the admin confirm
All checks were successful
Build frontend / build (push) Successful in 41s
2026-04-16 22:33:19 +03:00
7e9a9d79f2 there is no endpoint in name /Reservations/GetReservations
All checks were successful
Build frontend / build (push) Successful in 41s
2026-04-16 22:13:14 +03:00
39f494aecb fixed some things
All checks were successful
Build frontend / build (push) Successful in 42s
2026-04-16 22:06:57 +03:00
485baffdc2 fixed some things
All checks were successful
Build frontend / build (push) Successful in 55s
2026-04-16 21:30:22 +03:00
c46173d7c6 fixed some things
All checks were successful
Build frontend / build (push) Successful in 45s
2026-04-16 21:18:31 +03:00
04fa34107b linked the admin confirm deposte
All checks were successful
Build frontend / build (push) Successful in 1m9s
2026-04-16 21:15:21 +03:00
5a7d0ef265 Added confirm button for admin
All checks were successful
Build frontend / build (push) Successful in 46s
2026-04-15 12:28:01 +03:00
0ba435fd7e Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 1m56s
2026-04-15 12:10:45 +03:00
9c2a748ae9 Added API for notifications and edit style 2026-04-15 12:07:39 +03:00
db949aaeba Fix build errors: corrected import paths, added missing RatingList component, fixed syntax errors in rating components
All checks were successful
Build frontend / build (push) Successful in 54s
2026-04-14 14:23:17 +00:00
ae600ad41b Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
Some checks failed
Build frontend / build (push) Failing after 43s
2026-04-13 00:38:59 +03:00
16b1c7c6f6 Edit home 2026-04-13 00:25:29 +03:00
f761ab6f48 Removed double <<
Some checks failed
Build frontend / build (push) Failing after 46s
2026-04-12 20:50:02 +00:00
78138e6445 Commited by hamza on openclaw's belalf
Some checks failed
Build frontend / build (push) Failing after 54s
2026-04-12 20:44:04 +00:00
0891974440 fix: properly enrich reservations with property data using status integer codes
All checks were successful
Build frontend / build (push) Successful in 1m21s
2026-04-05 19:11:59 +00:00
f925af0272 fix: access property via propertyInformation nested object
All checks were successful
Build frontend / build (push) Successful in 48s
2026-04-05 18:46:47 +00:00
2346f518ce feat: enrich reservation pages with property details via GetRentPropertyById
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-05 18:40:09 +00:00
149058ddfc fix: route حجزات nav links to new reservations pages
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-05 18:09:11 +00:00
e6249e845e fix: match backend typos GetOwnerResevationRequests (missing r)
All checks were successful
Build frontend / build (push) Successful in 42s
2026-04-05 17:52:08 +00:00
3bdb99f2e5 feat: add user reservations page and owner reservation requests page
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-05 14:42:11 +00:00
2a1f00740f disable export report in owner
All checks were successful
Build frontend / build (push) Successful in 1m8s
2026-04-04 22:47:05 +03:00
50836d3ec7 Edit AddPropertyForm
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-04 21:50:31 +03:00
d850f921b5 Edit add admin and privacy
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-04 21:01:02 +03:00
77dd052951 Added add admin and privacy in sidebar for admin
All checks were successful
Build frontend / build (push) Successful in 1m20s
2026-04-04 20:56:49 +03:00
1207dbe20d removed the validation from the email
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-02 16:05:20 +03:00
c9f52f64cb removed the validation from the email
All checks were successful
Build frontend / build (push) Successful in 1m23s
2026-04-02 16:00:05 +03:00
5fd22f0e01 disabled the validation on email
All checks were successful
Build frontend / build (push) Successful in 1m9s
2026-04-02 14:44:23 +03:00
2998a6bd75 fix: import useMapEvents as hook instead of dynamic component
All checks were successful
Build frontend / build (push) Successful in 45s
2026-04-01 19:07:22 +00:00
571c85f14f fix: detect auth changes via polling and visibility change
All checks were successful
Build frontend / build (push) Successful in 52s
2026-04-01 15:29:41 +00:00
9e1f8f517b Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 59s
2026-04-01 02:18:08 +03:00
700b446463 Edit register 2026-04-01 02:18:03 +03:00
be14250a08 fix: request permission synchronously from user click gesture
All checks were successful
Build frontend / build (push) Successful in 1m2s
2026-03-31 23:16:05 +00:00
eec7a9a75d fix: show notification permission prompt on user click instead of auto-request
All checks were successful
Build frontend / build (push) Successful in 57s
2026-03-31 23:07:15 +00:00
4ca7106b48 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
Some checks failed
Build frontend / build (push) Failing after 1m0s
2026-04-01 02:05:39 +03:00
7685134a39 Edit register 2026-04-01 02:05:32 +03:00
6ad2457e74 fix: use HTTPS URL in firebase.js
All checks were successful
Build frontend / build (push) Successful in 40s
2026-03-31 22:53:34 +00:00
98c3f51df2 fix: switch API base URL to HTTPS (nip.io)
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-31 22:48:50 +00:00
5d44fb56ec Edit profits
All checks were successful
Build frontend / build (push) Successful in 49s
2026-04-01 01:46:48 +03:00
ba389042c2 chore: add nip.io domain with SSL for HTTPS notifications
All checks were successful
Build frontend / build (push) Successful in 53s
2026-03-31 22:38:11 +00:00
c546e11ed3 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 43s
2026-04-01 01:34:54 +03:00
8d7efe82a4 Edit profits for owner 2026-04-01 01:34:51 +03:00
52758eae9d fix: wait for hydration before checking auth in NotificationHandler
All checks were successful
Build frontend / build (push) Successful in 51s
2026-03-31 22:12:55 +00:00
a824fb0c7c fix: send FCM token to User/SetFCMToken endpoint
All checks were successful
Build frontend / build (push) Successful in 43s
2026-03-31 21:24:43 +00:00
9e87aa90e8 feat: send FCM token to backend on permission grant
Some checks failed
Build frontend / build (push) Failing after 25s
2026-03-31 20:20:52 +00:00
199e78d6b1 chore: set VAPID key for FCM
All checks were successful
Build frontend / build (push) Successful in 1m11s
2026-03-31 20:09:07 +00:00
df9711f539 fix: only request notification permissions for signed-in users
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-31 19:52:47 +00:00
2bea2d190c feat: integrate Firebase Cloud Messaging for push notifications
Some checks failed
Build frontend / build (push) Failing after 24s
2026-03-31 19:50:48 +00:00
81674c4aa7 fix: add remote image pattern for next.config.mjs
All checks were successful
Build frontend / build (push) Successful in 55s
2026-03-31 19:45:03 +00:00
cf7f51b514 fix: update GetMyRentListings endpoint (userId removed from URL)
All checks were successful
Build frontend / build (push) Successful in 1m3s
2026-03-31 18:46:12 +00:00
0171c7a2bf fix: prepend /Pictures/ to image paths for nginx static serving
All checks were successful
Build frontend / build (push) Successful in 40s
2026-03-30 18:52:08 +00:00
9f6a730a94 Show login dialog when favoriting without auth
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-30 18:34:19 +00:00
2c04cd751f Fix favorites: optimistic remove + no loading flash
All checks were successful
Build frontend / build (push) Successful in 54s
2026-03-30 18:18:09 +00:00
db184bbace Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 1m13s
2026-03-30 21:02:43 +03:00
230805e02b Edit phone in footer 2026-03-30 21:02:34 +03:00
3b9831a513 Integrate FavoriteProperty API: add/remove/get favorites with real backend
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-30 17:54:42 +00:00
1f40c6a4fd Edit footer
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-30 20:36:09 +03:00
4dd60ec14a Fix copy link & Instagram sharing: add clipboard fallback for HTTP
All checks were successful
Build frontend / build (push) Successful in 52s
2026-03-30 16:28:12 +00:00
68cb802d60 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 47s
2026-03-30 19:26:08 +03:00
d22248248d Added sidebar 2026-03-30 19:26:03 +03:00
d375ed9d89 Add share dropdown: Facebook, WhatsApp, Telegram, Instagram, Copy Link
All checks were successful
Build frontend / build (push) Successful in 1m9s
- Replaced single Facebook button with dropdown menu
- WhatsApp: shares via wa.me with structured text + link
- Telegram: shares via t.me with text + URL
- Instagram: copies link + opens Instagram
- Copy Link: copies URL with toast notification
- Each share includes: property type, price, rooms, area, link
2026-03-30 16:08:35 +00:00
e6d754d014 perf: convert fonts to WOFF2 (10x smaller), fix preload to match, add html,body CSS font rule
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-30 15:34:36 +00:00
dee74d335f fix: font-display block → swap to prevent FOIT in header
All checks were successful
Build frontend / build (push) Successful in 1m14s
2026-03-30 15:32:08 +00:00
8f2679253b Fix font loading flash: font-display block + preload
Some checks failed
Build frontend / build (push) Has been cancelled
- Changed @font-face from font-display: swap to block (hides text until font loads)
- Added preload links for 3 most used font weights (Regular, Bold, Medium)
- Eliminates FOUC (Flash of Unstyled Content) where default font shows first
2026-03-30 15:31:35 +00:00
9bf67ffa38 Add SweetHome logo as OG image fallback for Facebook share
All checks were successful
Build frontend / build (push) Successful in 38s
- Always includes logo.png as OG image
- Property images shown first if available, logo as second
- If no property images, shows logo only
2026-03-30 14:07:52 +00:00
d56b4d2a11 Fix Facebook share: server-side OG metadata via generateMetadata
All checks were successful
Build frontend / build (push) Successful in 41s
- Split page.js into server component + PropertyDetail client component
- Server component exports generateMetadata that fetches property data
- OG tags now rendered in initial HTML (visible to Facebook/Twitter crawlers)
- Removes client-side useEffect OG tag injection (crawlers don't execute JS)
2026-03-30 13:44:52 +00:00
4d43cdaba2 Improve Facebook share: structured text + OG meta tags
All checks were successful
Build frontend / build (push) Successful in 1m3s
- Share quote now includes: property type, price, rooms, area, description snippet
- Added Open Graph meta tags (og:title, og:description, og:image, og:url)
- Added Twitter card meta tags
- OG tags set dynamically via useEffect for client-side rendering
2026-03-30 13:36:12 +00:00
891756e092 Edit background in register
All checks were successful
Build frontend / build (push) Successful in 1m39s
2026-03-30 15:28:39 +03:00
722d69cc92 Fix image URLs: handle paths with or without leading slash
All checks were successful
Build frontend / build (push) Successful in 39s
Some API paths have / (e.g. /Pictures/abc.jpg) and some don't (e.g. scaled_photo.jpg).
Now always inserts / between API base and path.
2026-03-30 01:35:20 +00:00
9d671f1985 Fix: add missing imports and currencies fetch in add property form
All checks were successful
Build frontend / build (push) Successful in 41s
- Import getCurrencies and uploadPicture were missing from import statement
- Added currencies state and fetch call in useEffect
- Added debug log in handleImageUpload
2026-03-30 01:25:48 +00:00
505dcd4bb0 Fix image upload field name: 'file' -> 'image'
All checks were successful
Build frontend / build (push) Successful in 40s
Endpoint expects field named 'image', returns path like /Pictures/abc123.jpg
2026-03-30 01:15:57 +00:00
8f700d0957 Fix missing uploadedImagePaths state declaration
All checks were successful
Build frontend / build (push) Successful in 38s
2026-03-30 01:06:23 +00:00
39193337b3 Display property images from API using full URLs
All checks were successful
Build frontend / build (push) Successful in 1m3s
- All mappers extract images from propertyInformation.images
- Paths prefixed with API base URL (http://45.93.137.91/api)
- Falls back to placeholder if no images
- Updated: main page, properties listing, property detail, owner properties
2026-03-30 01:01:42 +00:00
4299968764 Add image upload to property form via FilesController
All checks were successful
Build frontend / build (push) Successful in 52s
- Added uploadPicture() API function for POST /Files/UploadPicture
- Images uploaded immediately on selection, paths stored
- PropertyInformation.images sent with server-side paths
- Remove image also removes from uploaded paths
2026-03-30 00:57:52 +00:00
1a96e457ca Share button now shows Facebook icon with pre-filled post text
All checks were successful
Build frontend / build (push) Successful in 42s
- Facebook SVG icon instead of generic share icon
- Post includes property title + address + link
2026-03-30 00:26:28 +00:00
d3242a4147 Share button opens Facebook post composer
All checks were successful
Build frontend / build (push) Successful in 57s
2026-03-30 00:22:28 +00:00
ff589e4b0a Implement share button with Web Share API + clipboard fallback
All checks were successful
Build frontend / build (push) Successful in 41s
- Uses navigator.share() on mobile (native share sheet)
- Falls back to clipboard copy on desktop with toast confirmation
2026-03-30 00:17:39 +00:00
0c3b454015 Owner properties page fetches from API via GetMyRentListings
All checks were successful
Build frontend / build (push) Successful in 41s
- Calls /RentProperties/GetMyRentListings/{userId} with user ID from JWT
- Maps API response (nested propertyInformation + detailsJSON) to UI format
- Removed mock data and localStorage fallback
2026-03-29 22:17:49 +00:00
6245965c1c Allow unauthenticated users to view and select dates, login only on book
All checks were successful
Build frontend / build (push) Successful in 43s
2026-03-29 21:40:32 +00:00
829491cc30 Calendar fully blocked for unauthenticated users
All checks were successful
Build frontend / build (push) Successful in 46s
- Overlay blocks entire calendar with 'login to view dates' message
- Date cells disabled when not authenticated
- Clicking overlay or any disabled date shows login/register dialog
2026-03-29 21:38:02 +00:00
059c7194d8 Show login/register dialog instead of 401 for unauthenticated users
All checks were successful
Build frontend / build (push) Successful in 41s
- Auth check on calendar click and booking attempt
- Modal dialog with create account + login buttons
- Click backdrop or cancel to dismiss
2026-03-29 21:34:25 +00:00
f22bc45a4f Fix booking: use correct BookReservation endpoint + price from selected dates
All checks were successful
Build frontend / build (push) Successful in 38s
- Fixed endpoint: /Reservations/BookReservation/book (was /Reservations/Book)
- bookReservation now takes (propertyId, startDate, endDate) params
- Pricing updates dynamically based on selected date range
- Deposit read from API response instead of hardcoded
- Removed demo fallback that always showed success
2026-03-29 21:23:51 +00:00
86b8fc591b Add availability calendar to property detail page
All checks were successful
Build frontend / build (push) Successful in 42s
- Fetches available date ranges from /Reservations/GetAvailableDates/available/{id}
- Custom month calendar with green (available), amber (selected), gray (unavailable)
- Click start date then end date to select a range
- Validates entire range is available before confirming
- Shows selected dates and day count
- Month navigation with prev/next arrows
2026-03-29 21:16:00 +00:00
ca1d83967e Fix duplicate addRentProperty definition in api.js
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-29 15:58:54 +00:00
00dab824c3 Fix add property page to match Flutter request body structure
Some checks failed
Build frontend / build (push) Failing after 39s
- Remove 'For sale' offer type (rent only)
- Remove salePrice field and UI
- Fix rentTypeMap: 0=Monthly, 1=Daily (was wrong)
- Fix propertyType: uses RentPropertyCondition (furnished/unfurnished)
- Fix type field: uses RentPropertyType (furnished/unfurnished)
- Fix services: use enum API names in detailsJSON (Electricity, Internet...)
- Fix terms: use enum API names in detailsJSON (NoSmoking, NoAnimals...)
- Fix detailsJSON structure to match Flutter (services array, terms array, room object)
- Replace getCurrencies with static Currency enum dropdown
- Remove duplicate MapClickHandler
- Use all new enums from enums/index.js
2026-03-29 15:48:48 +00:00
5d7b3e3b0f Add new enums to match Flutter project structure
All checks were successful
Build frontend / build (push) Successful in 39s
- Add RentPropertyCondition (WithFurniture/WithoutFurniture)
- Add RentPropertyType (Furnished/Unfurnished/SemiFurnished)
- Add RentType (Monthly/Daily)
- Add PropertyService (13 services for detailsJSON)
- Add PropertyTerm (NoSmoking/NoAnimals/NoParties)
- Add Currency (SYP/USD)
- Update enums barrel file
2026-03-29 15:27:48 +00:00
412ccbf8b8 Show user full name in navbar and homepage after login
All checks were successful
Build frontend / build (push) Successful in 53s
- AuthService: added cacheUser/getCachedUser methods
- AuthService.getUser() prefers cached name over JWT claims
- Login page: fetches full profile from API after login and caches it
- Fixes navbar dropdown and homepage showing email instead of name
2026-03-29 12:42:57 +00:00
253bb875ab Fix post-login: re-read user role on every route change
All checks were successful
Build frontend / build (push) Successful in 55s
- ClientLayout: separated user loading into useEffect with pathname dependency
- Homepage: same fix - re-read user from JWT on route change
- Fixes issue where navbar/dashboard links didn't appear until page reload
2026-03-29 12:22:16 +00:00
16038a80dd Add currency dropdown and deposit field to add property form
All checks were successful
Build frontend / build (push) Successful in 53s
- Added getCurrencies() API function for /Currency/GetAll
- Currency dropdown fetched on mount, populated with available currencies
- Added deposit input field (مبلغ الضمان)
- CurrencyId sent in RentPropertyDto instead of hardcoded 1
2026-03-28 19:40:03 +00:00
6df7548611 Fix missing mapZoom state variable in add property page
All checks were successful
Build frontend / build (push) Successful in 37s
2026-03-28 18:12:41 +00:00
d94b32a670 Add property form submits to API as RentPropertyDto
All checks were successful
Build frontend / build (push) Successful in 43s
- Added addRentProperty() API function for POST /RentProperties/AddRentProperty
- handleSubmit builds correct RentPropertyDto with nested PropertyInformation
- Maps UI fields to API enums (BuildingType, RentType, RentPropertyType, PropertyStatus)
- Services/terms stored in DetailsJSON as JSON string
- Console logs the full payload before sending
2026-03-28 18:00:44 +00:00
da0c36727f Remove all fallback dummy data - API-only
All checks were successful
Build frontend / build (push) Successful in 38s
- Removed FALLBACK_PROPERTIES from main page, properties listing, and property detail
- Pages now start empty and populate only from API responses
- Show empty state / error on API failure instead of dummy data
2026-03-28 17:48:00 +00:00
b6e9f01938 Profile page fetches full data from API via GetByUserId
All checks were successful
Build frontend / build (push) Successful in 39s
- Added getCustomerByUserId and getOwnerByUserId API functions
- Profile page extracts user ID (SID) from JWT, calls appropriate endpoint
- Falls back to JWT/localStorage if API call fails
- Maps API fields (firstName, lastName, whatsAppNumber, phone, etc.) to form
2026-03-28 17:03:40 +00:00
48523067fc Use local Madani Arabic font files instead of CDN
All checks were successful
Build frontend / build (push) Successful in 1m2s
- Added @font-face for all 9 weights (Thin to Black)
- Removed CDN links from layout.js
- Font loads locally from public/fonts/
2026-03-28 16:52:31 +00:00
f6f0f5a5ea added the fonts folder in the public
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-28 19:47:29 +03:00
e0f80f3dee Clean up debug logging in login flow
All checks were successful
Build frontend / build (push) Successful in 57s
2026-03-28 16:44:37 +00:00
b8117093af Add debug logging to login flow to trace token storage
All checks were successful
Build frontend / build (push) Successful in 41s
2026-03-28 16:38:04 +00:00
de7636f852 Fix token key mismatch in verify functions
All checks were successful
Build frontend / build (push) Successful in 40s
AuthService stores token as 'auth_token', but verifyEmail/verifyPhone
were reading 'token'. Now uses AuthService.getToken() consistently.
2026-03-28 16:31:27 +00:00
5a4b018c07 Send JWT token with verify email/phone endpoints
All checks were successful
Build frontend / build (push) Successful in 39s
- authFetch now accepts optional token parameter
- verifyEmail/verifyPhone read token from localStorage and send as Bearer header
2026-03-28 16:17:14 +00:00
c14c28141f Add loading.js and error.js for all routes, secure admin page with 404
All checks were successful
Build frontend / build (push) Successful in 40s
- Added loading.js (dark/light variants) for all 14 routes
- Added error.js (dark/light variants) for all 14 routes
- Added global not-found.js and loading.js at root
- Admin page shows 404 illustration for non-admin users instead of redirecting
2026-03-28 16:12:21 +00:00
c99689a995 Add Phone field to FormData in addCustomer and addOwner
All checks were successful
Build frontend / build (push) Successful in 38s
2026-03-28 15:51:25 +00:00
f7fa3c723d Add Phone field (7 digits) to both registration forms
All checks were successful
Build frontend / build (push) Successful in 36s
Maps to API 'Phone' field, validated to exactly 7 digits
2026-03-28 15:41:18 +00:00
0621f51676 Add WhatsApp and National Number fields to registration forms
All checks were successful
Build frontend / build (push) Successful in 38s
- Tenant form: added WhatsApp number + National number inputs
- Owner form: added National number input (already had WhatsApp)
- Both forms send whatsAppNumber and nationalNumber in payload
- Added validation for required fields
2026-03-28 15:38:40 +00:00
d698305d79 Update registration to match new API schema
All checks were successful
Build frontend / build (push) Successful in 37s
- FullName split into FirstName + LastName in both forms and API
- File fields renamed: FrontIdCarImage -> FrontIdCarImagePath, RearIdCarImage -> RearIdCarImagePath
- Added Language field to form data
- getRentProperty now uses /RentProperties/GetRentPropertyById/{id}
2026-03-28 15:29:06 +00:00
bb15a7934e Redesign app download section: dropdown for Android + iOS coming soon
All checks were successful
Build frontend / build (push) Successful in 46s
- Desktop: hover dropdown with Android APK download + iOS coming soon
- Mobile: download links in hamburger menu
2026-03-28 15:18:19 +00:00
2424da2d45 Fix registration 415: send multipart form data with ID images
All checks were successful
Build frontend / build (push) Successful in 1m10s
- addCustomer/addOwner now use FormData with multipart upload
- Front and back ID images appended as FrontIdCarImage/RearIdCarImage
- Registration pages pass idImages.front and idImages.back to API
- Field names mapped to PascalCase for .NET API (FullName, Email, etc.)
2026-03-28 15:15:09 +00:00
c2235cf575 Fix build: syntax errors, duplicate useEffects, import paths
All checks were successful
Build frontend / build (push) Successful in 1m26s
- Fixed broken useEffect syntax in 4 owner pages (bookings, calendar, profits, properties)
- Removed duplicate useEffect blocks
- Fixed ClientLayout import path for AuthService (../ -> ./)
2026-03-28 14:53:45 +00:00
6394f1d71a Fix CustomerType and OwnerType enums: send int instead of string
Some checks failed
Build frontend / build (push) Failing after 45s
- CustomerType: PERSONAL=0, FAMILY=1 (was 'Personal', 'Family')
- OwnerType: PERSON=0, REAL_ESTATE_AGENCY=1 (was 'peerson', 'RealEstateAgency')
- Backend Type column is int(11), sending strings caused 415 errors
2026-03-28 14:15:40 +00:00
9cddee841b Add FullName field to owner and customer signup request payloads
All checks were successful
Build frontend / build (push) Successful in 38s
2026-03-27 22:01:00 +00:00
3c21c1873e Fix register pages: both have 2 steps with ID upload, OTP as modal overlay
All checks were successful
Build frontend / build (push) Successful in 40s
2026-03-27 18:19:32 +00:00
eff0b41b78 Add enums, AuthService, and integrate backend registration endpoints
All checks were successful
Build frontend / build (push) Successful in 57s
- Add separate enum files: BuildingType, PropertyStatus, BookingStatus, CommissionType, IdentityType, UserRole, City, LoginMethod, OwnerType, CustomerType
- Add AuthService (addToken/getToken/deleteToken)
- Update api.js: use AuthService, add Owner/Add and Customer/Add endpoints
- Update login page to use AuthService for token storage
- Rewrite owner register: 3-step flow with OwnerType dropdown, backend integration, OTP verification
- Rewrite tenant register: 2-step flow with CustomerType dropdown, backend integration, OTP verification
- Update homepage and property detail to use enums instead of hardcoded maps
- Update AddPropertyForm to import from enums directly
- Add console logs and status toasts linked to API response messages
2026-03-27 18:03:12 +00:00
2fb55db360 changed the appVersion
All checks were successful
Build frontend / build (push) Successful in 1m6s
2026-03-27 00:06:13 +00:00
b613bde682 Implement login with email/phone + OTP verification flow
All checks were successful
Build frontend / build (push) Successful in 40s
Login page:
- Email/phone tabs with auto-detect from input
- Calls LogInWithEmail or LogInWithPhoneNumber API
- On 206 (Partial Content): shows OTP step, sends OTP, then verifies
- On 200: stores JWT in localStorage, decodes user info
- OTP step with resend button and back navigation
- Console logs throughout all auth flows

API client:
- Added authFetch() for raw status code handling (200/206)
- Added loginWithEmail, loginWithPhone, sendEmailOTP, sendPhoneOTP,
  verifyEmail, verifyPhone, isEmail, isPhoneNumber
- apiFetch now accepts 206 as non-error
2026-03-26 23:56:18 +00:00
211ac42ad9 Clean up API client - use nested propertyInformation directly
All checks were successful
Build frontend / build (push) Successful in 42s
- Removed manual enrichment calls (Properties/Get fallback no longer needed)
- Removed unused hasNestedInfo variables
- API now returns propertyInformation nested in RentProperties/SaleProperties
2026-03-26 23:27:28 +00:00
fd3dcf4cc3 Update mappers for flat API response + enrich with property info
All checks were successful
Build frontend / build (push) Successful in 38s
- api.js: getRentProperties/getSaleProperties now fetch PropertyInformation
  for each property's propInfoId (when Properties/Get endpoint is fixed)
- Updated all 3 mapApiProperty functions to handle flat response format
  (no nested propertyInformation) - uses defaults for missing fields
- Status/type mapping checks both flat and nested fields
2026-03-26 22:59:08 +00:00
bdcb98a047 Fix API endpoint paths to match controller routing
All checks were successful
Build frontend / build (push) Successful in 43s
- Endpoints now use /Controller/Action format (e.g. /RentProperties/GetRentProperties)
- Unwrap API response envelope ({ data, isSuccess, ... } -> data)
- Use query params for single-property fetch (?id=N)
- Marked locations endpoint as unconfirmed (not yet deployed)
2026-03-26 22:46:57 +00:00
cfb9c0058b Add API client and wire up live data fetching
All checks were successful
Build frontend / build (push) Successful in 43s
- Created app/utils/api.js with functions for all OpenAPI endpoints
- Updated main page to fetch RentProperties + SaleProperties from API
- Updated properties listing page with API integration
- Updated property detail page to fetch by ID from API
- Added mapApiProperty() adapter to transform API responses to UI format
- All pages gracefully fall back to dummy data if API is unavailable
2026-03-26 22:20:33 +00:00
111 changed files with 11655 additions and 3449 deletions

View File

@ -5,6 +5,9 @@ import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { NavLink, MobileNavLink } from "./components/NavLinks"; import { NavLink, MobileNavLink } from "./components/NavLinks";
import { FavoritesProvider } from '@/app/contexts/FavoritesContext';
import { NotificationsProvider } from '@/app/contexts/NotificationsContext';
import FloatingSidebar from '@/app/components/FloatingSidebar';
import { import {
Globe, Globe,
LogIn, LogIn,
@ -36,7 +39,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import AuthService from "./services/AuthService";
import { UserRole, UserRoleLabels } from "./enums/UserRole";
import "./i18n/config"; import "./i18n/config";
import NotificationHandler from "./components/NotificationHandler";
export default function ClientLayout({ children }) { export default function ClientLayout({ children }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -55,13 +61,6 @@ export default function ClientLayout({ children }) {
setCurrentLanguage(savedLanguage); setCurrentLanguage(savedLanguage);
i18n.changeLanguage(savedLanguage); i18n.changeLanguage(savedLanguage);
const storedUser = localStorage.getItem("user");
if (storedUser) {
const userData = JSON.parse(storedUser);
console.log("User data loaded:", userData);
setUser(userData);
}
if (savedLanguage === "ar") { if (savedLanguage === "ar") {
document.documentElement.dir = "rtl"; document.documentElement.dir = "rtl";
document.documentElement.lang = "ar"; document.documentElement.lang = "ar";
@ -71,6 +70,23 @@ export default function ClientLayout({ children }) {
} }
}, [i18n]); }, [i18n]);
// Re-read user from JWT on every route change (handles post-login)
useEffect(() => {
const authUser = AuthService.getUser();
if (authUser) {
setUser({
name: authUser.name || authUser.email,
email: authUser.email,
phone: authUser.phone,
role: AuthService.isAdmin() ? UserRole.ADMIN
: AuthService.isOwner() ? UserRole.OWNER
: UserRole.CUSTOMER,
});
} else {
setUser(null);
}
}, [pathname]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) { if (menuRef.current && !menuRef.current.contains(event.target)) {
@ -104,7 +120,7 @@ export default function ClientLayout({ children }) {
}; };
const logout = () => { const logout = () => {
localStorage.removeItem("user"); AuthService.deleteToken();
setUser(null); setUser(null);
setShowUserMenu(false); setShowUserMenu(false);
window.location.href = "/"; window.location.href = "/";
@ -119,11 +135,10 @@ export default function ClientLayout({ children }) {
const isProfilePage = pathname === "/profile"; const isProfilePage = pathname === "/profile";
const isOwner = user?.role === "owner"; const isOwner = user?.role === UserRole.OWNER;
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === UserRole.ADMIN;
const isCustomer = user?.role === UserRole.CUSTOMER;
console.log("User role:", user?.role); const isAuthenticated = !!user;
console.log("Is Admin:", isAdmin);
const getUserInitial = () => { const getUserInitial = () => {
if (user?.name) { if (user?.name) {
@ -175,24 +190,45 @@ export default function ClientLayout({ children }) {
<div <div
className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`} className={`flex items-center space-x-1 ${currentLanguage === "ar" ? "flex-row-reverse space-x-reverse" : ""}`}
> >
<Link {/* Download App Dropdown */}
href="/files/SweetHome.apk" <div className="relative group">
className="group flex items-center gap-2 text-gray-700 hover:text-green-600 transition-colors" <button className="flex items-center gap-2 px-3 py-2 text-gray-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all">
> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<svg <path d="M11 2a3 3 0 0 0-3 3v6.5a.5.5 0 0 0 1 0V5a2 2 0 1 1 4 0v6.5a.5.5 0 0 0 1 0V5a3 3 0 0 0-3-3z"/>
xmlns="http://www.w3.org/2000/svg" <path d="M1.5 12.5A1.5 1.5 0 0 0 3 14h10a1.5 1.5 0 0 0 0-3H3a1.5 1.5 0 0 0-1.5 1.5z"/>
width="24" </svg>
height="24" <span className="text-sm font-semibold">تحميل التطبيق</span>
fill="currentColor" <svg className="w-4 h-4 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"/></svg>
className="bi bi-android" </button>
viewBox="0 0 16 16" <div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 translate-y-2 group-hover:translate-y-0">
> <div className="p-2">
<a href="/files/SweetHome.apk" download
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-green-50 transition-colors">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="#16a34a" viewBox="0 0 16 16">
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/> <path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg> </svg>
<span className="text-green-600 text-sm font-semibold opacity-0 max-w-0 overflow-hidden whitespace-nowrap group-hover:opacity-100 group-hover:max-w-xs transition-all duration-300"> </div>
حمل التطببيق الان <div>
</span> <p className="font-semibold text-gray-900 text-sm">Android</p>
</Link> <p className="text-xs text-green-600">تحميل APK</p>
</div>
</a>
<div className="flex items-center gap-3 px-4 py-3 rounded-lg opacity-50 cursor-not-allowed">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#6b7280" viewBox="0 0 16 16">
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-400 text-sm">iOS</p>
<p className="text-xs text-gray-400">قريباً</p>
</div>
</div>
</div>
</div>
</div>
<NavLink href="/">الرئيسية</NavLink> <NavLink href="/">الرئيسية</NavLink>
<NavLink href="/properties">عقاراتنا</NavLink> <NavLink href="/properties">عقاراتنا</NavLink>
@ -213,18 +249,18 @@ export default function ClientLayout({ children }) {
عقاراتي عقاراتي
</span> </span>
</NavLink> </NavLink>
<NavLink href="/owner/bookings"> <NavLink href="/owner/reservations">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
الحجوزات الحجوزات
</span> </span>
</NavLink> </NavLink>
<NavLink href="/owner/calendar"> {/* <NavLink href="/owner/calendar">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<CalendarDays className="w-4 h-4" /> <CalendarDays className="w-4 h-4" />
التقويم التقويم
</span> </span>
</NavLink> </NavLink> */}
<NavLink href="/owner/profits"> <NavLink href="/owner/profits">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
@ -295,11 +331,7 @@ export default function ClientLayout({ children }) {
{user?.email || ""} {user?.email || ""}
</p> </p>
<p className="text-xs text-amber-100 mt-1"> <p className="text-xs text-amber-100 mt-1">
{isOwner {UserRoleLabels[user?.role] || 'زائر'}
? "مالك عقار"
: isAdmin
? "مدير النظام"
: "مستأجر"}
</p> </p>
</div> </div>
</div> </div>
@ -367,7 +399,7 @@ export default function ClientLayout({ children }) {
</Link> </Link>
<Link <Link
href="/owner/bookings" href="/owner/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)} onClick={() => setShowUserMenu(false)}
> >
@ -486,12 +518,12 @@ export default function ClientLayout({ children }) {
</> </>
)} )}
{!isOwner && !isAdmin && user && ( {isCustomer && (
<> <>
<div className="border-t border-gray-100 my-2"></div> <div className="border-t border-gray-100 my-2"></div>
<Link <Link
href="/tenant/bookings" href="/reservations"
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors" className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-amber-50 rounded-lg transition-colors"
onClick={() => setShowUserMenu(false)} onClick={() => setShowUserMenu(false)}
> >
@ -580,6 +612,24 @@ export default function ClientLayout({ children }) {
{t("ourProducts")} {t("ourProducts")}
</MobileNavLink> </MobileNavLink>
{/* Download App - Mobile */}
<div className="border-t border-gray-200 my-2"></div>
<p className="px-3 py-1 text-xs text-gray-400 font-medium">تحميل التطبيق</p>
<a href="/files/SweetHome.apk" download onClick={closeMobileMenu}
className="flex items-center gap-2 px-3 py-2 rounded-md text-green-600 hover:bg-green-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.76 3.061a.5.5 0 0 1 .679.2l1.283 2.352A8.9 8.9 0 0 1 8 5a8.9 8.9 0 0 1 3.278.613l1.283-2.352a.5.5 0 1 1 .878.478l-1.252 2.295C14.475 7.266 16 9.477 16 12H0c0-2.523 1.525-4.734 3.813-5.966L2.56 3.74a.5.5 0 0 1 .2-.678ZM5 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2m6 0a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>
<span className="text-sm font-medium">Android - تحميل APK</span>
</a>
<div className="flex items-center gap-2 px-3 py-2 rounded-md text-gray-400 cursor-not-allowed opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.385 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
</svg>
<span className="text-sm">iOS - قريباً</span>
</div>
<div className="border-t border-gray-200 my-2"></div>
{isAdmin && ( {isAdmin && (
<MobileNavLink href="/admin" onClick={closeMobileMenu}> <MobileNavLink href="/admin" onClick={closeMobileMenu}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -601,7 +651,7 @@ export default function ClientLayout({ children }) {
</span> </span>
</MobileNavLink> </MobileNavLink>
<MobileNavLink <MobileNavLink
href="/owner/bookings" href="/owner/reservations"
onClick={closeMobileMenu} onClick={closeMobileMenu}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -659,7 +709,12 @@ export default function ClientLayout({ children }) {
<main <main
className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`} className={`${!isAuthPage && !isProfilePage ? "pt-20" : ""} min-h-screen bg-gradient-to-b from-gray-50 to-white ${currentLanguage === "ar" ? "text-right" : "text-left"}`}
> >
<NotificationsProvider>
<FavoritesProvider>
{children} {children}
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
</FavoritesProvider>
</NotificationsProvider>
</main> </main>
{!isAuthPage && !isProfilePage && ( {!isAuthPage && !isProfilePage && (
@ -730,7 +785,7 @@ export default function ClientLayout({ children }) {
<ul className="space-y-3 text-gray-400"> <ul className="space-y-3 text-gray-400">
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<Phone className="w-5 h-5" /> <Phone className="w-5 h-5" />
<span>{t("phone")}</span> <span dir="ltr" className="text-right">{t("phone")}</span>
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<Mail className="w-5 h-5" /> <Mail className="w-5 h-5" />
@ -747,6 +802,7 @@ export default function ClientLayout({ children }) {
</div> </div>
</footer> </footer>
)} )}
<NotificationHandler />
</> </>
); );
} }

113
app/admin/add-admin/page.js Normal file
View File

@ -0,0 +1,113 @@
'use client';
import { useEffect, useState } from 'react';
import AuthService from '@/app/services/AuthService';
import Link from 'next/link';
export default function AddAdminPage() {
const [isAdmin, setIsAdmin] = useState(false);
const [checked, setChecked] = useState(false);
const [formState, setFormState] = useState({ fullName: '', email: '', password: '' });
const [saved, setSaved] = useState(false);
useEffect(() => {
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
setChecked(true);
}, []);
const handleChange = (field) => (event) => {
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
};
const handleSubmit = (event) => {
event.preventDefault();
setSaved(true);
console.log('Add admin payload', formState);
};
if (!checked) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isAdmin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
العودة للرئيسية
</Link>
</div>
</div>
);
}
return (
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
<h1 className="text-3xl font-bold text-slate-900 mt-3">إضافة مدير جديد</h1>
<p className="text-slate-500 mt-2">انشئ حساب مسؤول جديد مع صلاحيات الإدارة.</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-[1.5fr_0.8fr]">
<section className="rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
<h2 className="text-xl font-semibold mb-6">بيانات المدير</h2>
<form onSubmit={handleSubmit} className="space-y-5">
<label className="block">
<span className="text-sm font-medium text-slate-700">الاسم الكامل</span>
<input
type="text"
value={formState.fullName}
onChange={handleChange('fullName')}
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
placeholder="مثال: محمد الأحمد"
required
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">البريد الإلكتروني</span>
<input
type="email"
value={formState.email}
onChange={handleChange('email')}
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
placeholder="admin@example.com"
required
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">كلمة المرور</span>
<input
type="password"
value={formState.password}
onChange={handleChange('password')}
className="mt-2 w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
placeholder="••••••••"
required
/>
</label>
<button type="submit" className="inline-flex items-center justify-center rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
حفظ المدير الجديد
</button>
</form>
{saved && (
<div className="mt-6 rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
تم حفظ بيانات المدير بنجاح
</div>
)}
</section>
</div>
</div>
</main>
);
}

27
app/admin/error.js Normal file
View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

14
app/admin/loading.js Normal file
View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,15 +1,17 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { import {
Home, Home,
Calendar, Calendar,
Users, Users,
DollarSign, DollarSign,
TrendingUp, TrendingUp,
Bell Bell,
Frown
} from 'lucide-react'; } from 'lucide-react';
import DashboardStats from '../components/admin/DashboardStats'; import DashboardStats from '../components/admin/DashboardStats';
import PropertiesTable from '../components/admin/PropertiesTable'; import PropertiesTable from '../components/admin/PropertiesTable';
@ -18,6 +20,7 @@ import UsersList from '../components/admin/UsersList';
import LedgerBook from '../components/admin/LedgerBook'; import LedgerBook from '../components/admin/LedgerBook';
import AddPropertyForm from '../components/admin/AddPropertyForm'; import AddPropertyForm from '../components/admin/AddPropertyForm';
import { PropertyProvider } from '../contexts/PropertyContext'; import { PropertyProvider } from '../contexts/PropertyContext';
import AuthService from '../services/AuthService';
import '../i18n/config'; import '../i18n/config';
export default function AdminPage() { export default function AdminPage() {
@ -25,6 +28,54 @@ export default function AdminPage() {
const [activeTab, setActiveTab] = useState('dashboard'); const [activeTab, setActiveTab] = useState('dashboard');
const [showAddProperty, setShowAddProperty] = useState(false); const [showAddProperty, setShowAddProperty] = useState(false);
const [notifications, setNotifications] = useState(3); const [notifications, setNotifications] = useState(3);
const [isAdmin, setIsAdmin] = useState(false);
const [checked, setChecked] = useState(false);
useEffect(() => {
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
setChecked(true);
}, []);
// ─── 404 for non-admins ───
if (checked && !isAdmin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-md"
>
<div className="mb-6">
<svg viewBox="0 0 200 180" className="w-72 h-52 mx-auto">
<circle cx="100" cy="70" r="60" fill="#fef3c7" />
<circle cx="80" cy="60" r="8" fill="#92400e" />
<circle cx="120" cy="60" r="8" fill="#92400e" />
<path d="M80 85 Q100 75 120 85" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
<text x="100" y="140" textAnchor="middle" fontSize="16" fontWeight="bold" fill="#6b7280">عذراً!</text>
<text x="100" y="160" textAnchor="middle" fontSize="12" fill="#9ca3af">الصفحة غير موجودة</text>
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">404 - الصفحة غير موجودة</h2>
<p className="text-gray-500 mb-8">عذراً، لا يمكنك الوصول إلى هذه الصفحة</p>
<Link
href="/"
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
>
<Home className="w-5 h-5" />
العودة للرئيسية
</Link>
</motion.div>
</div>
);
}
if (!checked) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
const tabs = [ const tabs = [
{ id: 'dashboard', label: 'لوحة التحكم', icon: Home }, { id: 'dashboard', label: 'لوحة التحكم', icon: Home },

85
app/admin/privacy/page.js Normal file
View File

@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import AuthService from '@/app/services/AuthService';
import Link from 'next/link';
const initialPolicy = `1. نحترم خصوصيتك ونلتزم بحماية بياناتك الشخصية.
2. يتم استخدام المعلومات لتحسين تجربة المستخدم وتأمين الخدمة.
3. لا نشارك البيانات مع أطراف خارجية بدون موافقتك.
4. يمكنك طلب حذف بياناتك من النظام في أي وقت.`;
export default function PrivacyPolicyAdminPage() {
const [isAdmin, setIsAdmin] = useState(false);
const [checked, setChecked] = useState(false);
const [policyText, setPolicyText] = useState(initialPolicy);
const [saved, setSaved] = useState(false);
useEffect(() => {
setIsAdmin(AuthService.isAuthenticated() && AuthService.isAdmin());
setChecked(true);
}, []);
const handleSave = (event) => {
event.preventDefault();
setSaved(true);
console.log('Privacy policy updated:', policyText);
};
if (!checked) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isAdmin) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="max-w-md text-center bg-white rounded-3xl shadow-lg border border-gray-200 p-8">
<p className="text-gray-600 mb-6">هذه الصفحة لتحرير سياسة الخصوصية ولا يمكن الوصول إليها إلا للمدير.</p>
<Link href="/" className="inline-flex items-center justify-center px-6 py-3 rounded-full bg-amber-500 text-white hover:bg-amber-600 transition-colors">
العودة للرئيسية
</Link>
</div>
</div>
);
}
return (
<main className="min-h-screen bg-slate-50 p-6 md:p-10">
<div className="max-w-4xl mx-auto">
<div className="mb-8 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm text-amber-600 uppercase tracking-[0.2em]">لوحة المدير</p>
<p className="text-slate-500 mt-2">قم بتحديث نص سياسة الخصوصية</p>
</div>
</div>
</div>
<form onSubmit={handleSave} className="space-y-6 rounded-[28px] bg-white p-8 shadow-sm border border-slate-200">
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">نص سياسة الخصوصية</label>
<textarea
value={policyText}
onChange={(e) => setPolicyText(e.target.value)}
rows={12}
className="w-full rounded-3xl border border-slate-200 bg-slate-50 px-5 py-4 text-slate-700 outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-100"
/>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<button type="submit" className="rounded-2xl bg-amber-600 px-6 py-3 text-white font-semibold shadow-lg shadow-amber-100 transition hover:bg-amber-700">
حفظ السياسة
</button>
</div>
{saved && (
<div className="rounded-3xl bg-emerald-50 border border-emerald-200 p-4 text-emerald-700">
تمت حفظ سياسة الخصوصية بنجاح
</div>
)}
</form>
</div>
</main>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,190 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { Heart, Bell, CreditCard, Shield, UserPlus } from 'lucide-react';
import { useFavorites } from '@/app/contexts/FavoritesContext';
import { useNotifications } from '@/app/contexts/NotificationsContext';
export default function FloatingSidebar({ isRTL, isAdmin }) {
const { favorites } = useFavorites();
const { unreadCount } = useNotifications();
const [tooltip, setTooltip] = useState(null);
let timeoutId = null;
const showTooltip = (id) => {
timeoutId = setTimeout(() => {
setTooltip(id);
}, 300);
};
const hideTooltip = () => {
clearTimeout(timeoutId);
setTooltip(null);
};
const side = isRTL ? 'left' : 'right';
const positionStyle = {
[side]: 0,
top: '50%',
transform: 'translateY(-50%)',
};
const cardVariants = {
initial: { opacity: 0, x: isRTL ? -20 : 20 },
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: 'easeOut' } },
};
const buttonVariants = {
rest: { scale: 1, backgroundColor: 'rgba(255,255,255,0)' },
hover: { scale: 1.05, backgroundColor: 'rgba(245,158,11,0.1)', transition: { duration: 0.2 } },
tap: { scale: 0.95 },
};
const renderTooltip = (id, label) => {
if (tooltip !== id) return null;
return (
<div
className={`absolute ${isRTL ? 'right-full mr-2' : 'left-full ml-2'} top-1/2 -translate-y-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded-lg whitespace-nowrap z-20 shadow-lg flex items-center`}
>
<span className="relative">
{label}
<span
className={`absolute ${isRTL ? 'right-full -mr-1' : 'left-full -ml-1'} top-1/2 -translate-y-1/2 w-0 h-0 border-t-4 border-b-4 border-transparent ${
isRTL ? 'border-r-4 border-r-gray-800' : 'border-l-4 border-l-gray-800'
}`}
></span>
</span>
</div>
);
};
return (
<motion.div
className="fixed z-50"
style={positionStyle}
variants={cardVariants}
initial="initial"
animate="animate"
>
<div className="bg-white/90 backdrop-blur-md rounded-2xl shadow-lg border border-gray-200/60 py-3 px-2 flex flex-col gap-3 transition-all duration-300 hover:shadow-xl hover:bg-white/95">
{isAdmin ? (
<>
<motion.div
className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('addAdmin')}
onMouseLeave={hideTooltip}
>
<Link
href="/admin/add-admin"
className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-50 border border-amber-200 text-amber-600 hover:bg-amber-100 transition-colors"
>
<UserPlus className="w-6 h-6" />
</Link>
{renderTooltip('addAdmin', 'إضافة أدمن')}
</motion.div>
<motion.div
className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('editPrivacy')}
onMouseLeave={hideTooltip}
>
<Link
href="/admin/privacy"
className="flex items-center justify-center w-12 h-12 rounded-xl bg-slate-50 border border-slate-200 text-slate-700 hover:bg-slate-100 transition-colors"
>
<Shield className="w-6 h-6" />
</Link>
{renderTooltip('editPrivacy', 'تعديل سياسة الخصوصية')}
</motion.div>
</>
) : (
<>
<motion.div
className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('favorites')}
onMouseLeave={hideTooltip}
>
<Link
href="/favorites"
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
>
<div className="relative">
<Heart className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
{favorites.length > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-amber-500 to-amber-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
>
{favorites.length}
</motion.span>
)}
</div>
</Link>
{renderTooltip('favorites', 'المفضلة')}
</motion.div>
<motion.div
className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('notifications')}
onMouseLeave={hideTooltip}
>
<Link
href="/notifications"
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
>
<div className="relative">
<Bell className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
{unreadCount > 0 && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute -right-1 -top-1 w-5 h-5 bg-linear-to-r from-red-500 to-red-600 text-white text-xs rounded-full flex items-center justify-center shadow-md"
>
{unreadCount}
</motion.span>
)}
</div>
</Link>
{renderTooltip('notifications', 'الإشعارات')}
</motion.div>
<motion.div
className="relative group"
variants={buttonVariants}
initial="rest"
whileHover="hover"
whileTap="tap"
onMouseEnter={() => showTooltip('payments')}
onMouseLeave={hideTooltip}
>
<Link
href="/payments"
className="flex items-center justify-center w-12 h-12 rounded-xl transition-colors"
>
<CreditCard className="w-6 h-6 text-gray-600 transition-colors group-hover:text-amber-600" />
</Link>
{renderTooltip('payments', 'المدفوعات')}
</motion.div>
</>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,165 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { initializeApp, getApps } from "firebase/app";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
import AuthService from "../services/AuthService";
const firebaseConfig = {
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
};
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export default function NotificationHandler() {
const [notification, setNotification] = useState(null);
const [showPrompt, setShowPrompt] = useState(false);
const initialized = useRef(false);
useEffect(() => {
function checkAuth() {
if (initialized.current) return;
if (!AuthService.getToken()) return;
initialized.current = true;
if ("Notification" in window) {
if (Notification.permission === "default") {
setShowPrompt(true);
} else if (Notification.permission === "granted") {
setupFCM();
}
}
}
// Check immediately
checkAuth();
// Also check when auth token changes (login via client-side navigation)
const interval = setInterval(() => {
if (!initialized.current && AuthService.getToken()) {
checkAuth();
}
}, 1000);
// Check on route change (visibility)
const onVisibility = () => {
if (document.visibilityState === "visible") checkAuth();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
async function setupFCM() {
try {
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
const messaging = getMessaging(app);
const fcmToken = await getToken(messaging, {
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
serviceWorkerRegistration: registration,
});
if (fcmToken) {
console.log("[FCM] Token:", fcmToken.substring(0, 20) + "...");
const authToken = AuthService.getToken();
if (authToken) {
const apiBase = "https://45.93.137.91.nip.io/api";
await fetch(`${apiBase}/User/SetFCMToken`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ token: fcmToken, deviceType: 2 }),
});
console.log("[FCM] Token sent to backend");
}
}
onMessage(messaging, (payload) => {
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
const body = payload.notification?.body || payload.data?.body || "";
setNotification({ title, body });
setTimeout(() => setNotification(null), 5000);
});
} catch (err) {
console.error("[FCM] Setup error:", err);
}
}
async function handleEnable() {
setShowPrompt(false);
// This MUST be synchronous from a user gesture
const permission = await Notification.requestPermission();
console.log("[FCM] Permission result:", permission);
if (permission === "granted") {
await setupFCM();
}
}
return (
<>
{showPrompt && (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999]">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-xl">🔔</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 text-sm">تفعيل الإشعارات</p>
<p className="text-gray-600 text-sm mt-0.5">اسمح بالإشعارات للبقاء على اطلاع بحجوزاتك وعروضنا.</p>
<div className="flex gap-2 mt-3">
<button
onClick={handleEnable}
className="px-4 py-1.5 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
>
تفعيل
</button>
<button
onClick={() => setShowPrompt(false)}
className="px-4 py-1.5 text-gray-500 text-sm hover:text-gray-700 transition-colors"
>
لاحقاً
</button>
</div>
</div>
</div>
</div>
)}
{notification && (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-[9999] animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-xl">🏠</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-gray-900 text-sm">{notification.title}</p>
<p className="text-gray-600 text-sm mt-0.5">{notification.body}</p>
</div>
<button
onClick={() => setNotification(null)}
className="text-gray-400 hover:text-gray-600 flex-shrink-0"
>
</button>
</div>
</div>
)}
</>
);
}

View File

@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useProperties } from '@/app/contexts/PropertyContext'; import { useProperties } from '@/app/contexts/PropertyContext';
import { COMMISSION_TYPE, CITIES } from '@/app/utils/constants'; import { CommissionType, CitiesList } from '@/app/enums';
import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react'; import { X, MapPin, Home, DollarSign, Percent } from 'lucide-react';
export default function AddPropertyForm({ onClose, onSuccess }) { export default function AddPropertyForm({ onClose, onSuccess }) {
@ -25,7 +25,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
dailyPrice: 0, dailyPrice: 0,
commissionRate: 5, commissionRate: 5,
commissionType: COMMISSION_TYPE.FROM_OWNER, commissionType: CommissionType.FROM_OWNER,
securityDeposit: 0, securityDeposit: 0,
@ -86,11 +86,11 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
const commission = (dailyPrice * commissionRate) / 100; const commission = (dailyPrice * commissionRate) / 100;
switch(commissionType) { switch(commissionType) {
case COMMISSION_TYPE.FROM_TENANT: case CommissionType.FROM_TENANT:
return dailyPrice + commission; return dailyPrice + commission;
case COMMISSION_TYPE.FROM_OWNER: case CommissionType.FROM_OWNER:
return dailyPrice; return dailyPrice;
case COMMISSION_TYPE.FROM_BOTH: case CommissionType.FROM_BOTH:
return dailyPrice + (commission / 2); return dailyPrice + (commission / 2);
default: default:
return dailyPrice; return dailyPrice;
@ -131,7 +131,7 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
required required
> >
<option value="">اختر المدينة</option> <option value="">اختر المدينة</option>
{Object.values(CITIES).map(city => ( {CitiesList.map(city => (
<option key={city} value={city}>{city}</option> <option key={city} value={city}>{city}</option>
))} ))}
</select> </select>
@ -232,8 +232,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
<input <input
type="radio" type="radio"
name="commissionType" name="commissionType"
value={COMMISSION_TYPE.FROM_OWNER} value={CommissionType.FROM_OWNER}
checked={formData.commissionType === COMMISSION_TYPE.FROM_OWNER} checked={formData.commissionType === CommissionType.FROM_OWNER}
onChange={(e) => setFormData({...formData, commissionType: e.target.value})} onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
/> />
<span>من المالك</span> <span>من المالك</span>
@ -242,8 +242,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
<input <input
type="radio" type="radio"
name="commissionType" name="commissionType"
value={COMMISSION_TYPE.FROM_TENANT} value={CommissionType.FROM_TENANT}
checked={formData.commissionType === COMMISSION_TYPE.FROM_TENANT} checked={formData.commissionType === CommissionType.FROM_TENANT}
onChange={(e) => setFormData({...formData, commissionType: e.target.value})} onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
/> />
<span>من المستأجر</span> <span>من المستأجر</span>
@ -252,8 +252,8 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
<input <input
type="radio" type="radio"
name="commissionType" name="commissionType"
value={COMMISSION_TYPE.FROM_BOTH} value={CommissionType.FROM_BOTH}
checked={formData.commissionType === COMMISSION_TYPE.FROM_BOTH} checked={formData.commissionType === CommissionType.FROM_BOTH}
onChange={(e) => setFormData({...formData, commissionType: e.target.value})} onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
/> />
<span>من الاثنين</span> <span>من الاثنين</span>

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,24 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, MapPin, Home, DollarSign } from 'lucide-react'; import { Search, MapPin, Home, DollarSign, ShieldCheck } from 'lucide-react';
export default function HeroSearch({ onSearch }) { export default function HeroSearch({ onSearch, isAuthenticated }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('rent'); const [activeTab, setActiveTab] = useState('buy');
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
city: '', city: 'all',
propertyType: '', propertyType: 'all',
priceRange: '', priceRange: 'all',
identityType: 'syrian' identityType: 'syrian',
ownerSource: 'all',
rentPeriod: 'all',
availableToday: false
}); });
const [showLoginDialog, setShowLoginDialog] = useState(false);
const cities = [ const cities = [
{ id: 'all', label: 'جميع المدن' }, { id: 'all', label: 'جميع المدن' },
@ -26,10 +31,10 @@ export default function HeroSearch({ onSearch }) {
const propertyTypes = [ const propertyTypes = [
{ id: 'all', label: 'الكل' }, { id: 'all', label: 'الكل' },
{ id: 'apartment', label: 'شقة' }, { id: 'apartment', label: 'شقق سكنية' },
{ id: 'villa', label: 'فيلا' }, { id: 'studio', label: 'استوديو' },
{ id: 'house', label: 'بيت' }, { id: 'commercial', label: 'عقار تجاري' },
{ id: 'studio', label: 'استوديو' } { id: 'villa', label: 'فيلا / مزرعة' }
]; ];
const priceRanges = [ const priceRanges = [
@ -46,16 +51,44 @@ export default function HeroSearch({ onSearch }) {
{ id: 'passport', label: 'جواز سفر' } { id: 'passport', label: 'جواز سفر' }
]; ];
const ownerSources = [
{ id: 'all', label: 'الكل' },
{ id: 'owner', label: 'من المالك' },
{ id: 'agency', label: 'من مكتب عقاري' }
];
const rentPeriods = [
{ id: 'all', label: 'الكل' },
{ id: 'daily', label: 'إيجار يومي' },
{ id: 'monthly', label: 'إيجار شهري' }
];
const handleTabClick = (tab) => {
setActiveTab(tab);
if ((tab === 'rent' || tab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
}
};
const handleSearch = () => { const handleSearch = () => {
if ((activeTab === 'rent' || activeTab === 'sell') && !isAuthenticated) {
setShowLoginDialog(true);
return;
}
onSearch({ onSearch({
...filters, ...filters,
propertyType: filters.propertyType || 'all', mode: activeTab,
city: filters.city || 'all', city: filters.city || 'all',
priceRange: filters.priceRange || 'all' propertyType: filters.propertyType || 'all',
priceRange: filters.priceRange || 'all',
ownerSource: filters.ownerSource || 'all',
rentPeriod: filters.rentPeriod || 'all'
}); });
}; };
return ( return (
<>
<motion.div <motion.div
className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl" className="bg-white/10 backdrop-blur-lg rounded-2xl p-6 sm:p-8 border border-white/20 shadow-2xl"
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@ -66,7 +99,7 @@ export default function HeroSearch({ onSearch }) {
{['rent', 'buy', 'sell'].map((tab) => ( {['rent', 'buy', 'sell'].map((tab) => (
<motion.button <motion.button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => handleTabClick(tab)}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${ className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
activeTab === tab activeTab === tab
? 'bg-amber-500 text-white' ? 'bg-amber-500 text-white'
@ -176,6 +209,63 @@ export default function HeroSearch({ onSearch }) {
</select> </select>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
<div>
<label className="block text-sm font-medium text-white mb-2">مصدر العرض</label>
<select
value={filters.ownerSource}
onChange={(e) => setFilters({ ...filters, ownerSource: e.target.value })}
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left 1rem center',
backgroundSize: '1rem',
paddingLeft: '2.5rem'
}}
>
{ownerSources.map((source) => (
<option key={source.id} value={source.id}>
{source.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">نوع الإيجار</label>
<select
value={filters.rentPeriod}
onChange={(e) => setFilters({ ...filters, rentPeriod: e.target.value })}
className="w-full px-4 py-3 bg-white/90 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-500 text-sm appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23666'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left 1rem center',
backgroundSize: '1rem',
paddingLeft: '2.5rem'
}}
>
{rentPeriods.map((period) => (
<option key={period.id} value={period.id}>
{period.label}
</option>
))}
</select>
</div>
<div className="md:col-span-2 flex flex-col justify-between p-4 rounded-2xl border border-dashed border-white/30 bg-white/5">
<label className="mt-4 flex items-center gap-3 text-white text-sm">
<input
type="checkbox"
checked={filters.availableToday}
onChange={(e) => setFilters({ ...filters, availableToday: e.target.checked })}
className="w-5 h-5 text-amber-500 rounded border-gray-300 bg-white"
/>
<span className="font-medium">عرض فقط العقارات المتاحة من اليوم</span>
</label>
</div>
</div>
<div className="mt-6"> <div className="mt-6">
<motion.button <motion.button
onClick={handleSearch} onClick={handleSearch}
@ -188,5 +278,40 @@ export default function HeroSearch({ onSearch }) {
</motion.button> </motion.button>
</div> </div>
</motion.div> </motion.div>
{showLoginDialog && !isAuthenticated && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4 py-8">
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-2xl border border-gray-200">
<div className="flex items-center gap-3 mb-5">
<ShieldCheck className="w-7 h-7 text-amber-500" />
<div>
<h3 className="text-lg font-semibold text-gray-900">يرجى تسجيل الدخول</h3>
<p className="text-sm text-gray-600">للوصول إلى خيارات التأجير والبيع، يجب أن تكون مسجلاً.</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl bg-gray-50 p-4">
<p className="text-sm text-gray-700">اضغط على تسجيل الدخول لاستكمال البحث أو إدارة عقاراتك.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setShowLoginDialog(false)}
className="w-full sm:w-auto px-5 py-3 rounded-xl border border-gray-300 text-gray-700 hover:bg-gray-100 transition-colors"
>
إغلاق
</button>
<Link
href="/login"
className="w-full sm:w-auto px-5 py-3 rounded-xl bg-amber-500 text-white font-semibold text-center hover:bg-amber-600 transition-colors"
>
تسجيل الدخول
</Link>
</div>
</div>
</div>
</div>
)}
</>
); );
} }

View File

@ -0,0 +1,317 @@
// import { useState } from 'react';
// import { motion } from 'framer-motion';
// import { Star, Edit2, X, Check, Clock } from 'lucide-react';
// import StarRating from './StarRating.js';
// import toast, { Toaster } from 'react-hot-toast';
// import { rateProperty, rateCustomer, getUserPropertyRating, canRateProperty } from '../../utils/ratings.js';
// const RatingForm = ({
// propertyId,
// userId,
// propertyOwner = false,
// initialRating = 0,
// initialComment = '',
// onSubmitSuccess
// }) => {
// const [rating, setRating] = useState(initialRating);
// const [comment, setComment] = useState(initialComment);
// const [loading, setLoading] = useState(false);
// const [showForm, setShowForm] = useState(false);
// const [userRating, setUserRating] = useState(null);
// // Check if user has already rated
// useState(() => {
// async function fetchUserRating() {
// try {
// const rating = await getUserPropertyRating(propertyId, userId);
// if (rating) {
// setUserRating(rating);
// setRating(rating.rating);
// setComment(rating.comment || '');
// }
// } catch (error) {
// console.error('[RatingForm] Failed to fetch user rating:', error);
// }
// }
// if (propertyId && userId) {
// fetchUserRating();
// }
// }, [propertyId, userId]);
// const handleSubmit = async (e) => {
// e.preventDefault();
// if (!rating) {
// toast.error('يرجى إعطاء تقييم من 1 إلى 5 نجوم');
// return;
// }
// setLoading(true);
// try {
// const ratingData = {
// propertyId,
// customerId: userId,
// rating,
// comment: comment.trim() || null
// };
// await rateProperty(ratingData);
// toast.success('تم إرسال التقييم بنجاح!');
// // Reset form
// setRating(0);
// setComment('');
// setShowForm(false);
// if (onSubmitSuccess) {
// onSubmitSuccess();
// }
// } catch (error) {
// console.error('[RatingForm] Failed to submit rating:', error);
// toast.error('حدث خطأ أثناء إرسال التقييم. يرجى المحاولة مرة أخرى.');
// } finally {
// setLoading(false);
// }
// };
// const handleEdit = () => {
// setShowForm(true);
// setRating(userRating?.rating || 0);
// setComment(userRating?.comment || '');
// };
// const handleCancel = () => {
// setShowForm(false);
// setRating(userRating?.rating || 0);
// setComment(userRating?.comment || '');
// };
// if (!propertyId || !userId) {
// return null;
// }
// return (
// <div className="space-y-4">
// <Toaster position="top-center" reverseOrder={false} />
// {/* Display existing rating */}
// {userRating && !showForm && (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-gray-50 rounded-xl p-4 border border-gray-200"
// >
// <div className="flex items-center justify-between mb-3">
// <div className="flex items-center gap-2">
// <Star className="w-5 h-5 text-amber-500" />
// <span className="font-medium text-gray-900">{userRating.rating}</span>
// <span className="text-sm text-gray-500">من 5</span>
// </div>
// <button
// onClick={handleEdit}
// className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm hover:bg-gray-200 transition-colors flex items-center gap-1"
// >
// <Edit2 className="w-4 h-4" />
// تعديل
// </button>
// </div>
// {userRating.comment && (
// <div className="text-gray-600 text-sm mb-3 line-clamp-3">
// "{userRating.comment}"
// </div>
// )}
// <div className="flex items-center gap-2 text-xs text-gray-400">
// <Clock className="w-3 h-3" />
// <span>{userRating.createdAt ? new Date(userRating.createdAt).toLocaleDateString('ar-SA') : ''}</span>
// </div>
// </motion.div>
// )}
// {/* Rating form */}
// {showForm && (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-xl p-6 border border-gray-200 shadow-sm"
// >
// <form onSubmit={handleSubmit}>
// <div className="mb-4">
// <label className="block text-sm font-medium text-gray-700 mb-2">
// تقييمك للعقار
// </label>
// <div className="flex items-center gap-2">
// <StarRating
// rating={rating}
// onRatingChange={setRating}
// readOnly={false}
// size={28}
// color="#ffc107"
// />
// <span className="text-lg font-bold text-gray-900">{rating || '1'}</span>
// <span className="text-sm text-gray-400">/5</span>
// </div>
// </div>
// <div className="mb-4">
// <label className="block text-sm font-medium text-gray-700 mb-2">
// تعليق (اختياري)
// </label>
// <textarea
// rows="3"
// value={comment}
// onChange={(e) => setComment(e.target.value)}
// placeholder="شارك تجربتك مع العقار..."
// className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-all resize-none"
// />
// </div>
// <div className="flex gap-3">
// <button
// type="button"
// onClick={handleCancel}
// disabled={loading}
// className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
// >
// إلغاء
// </button>
// <button
// type="submit"
// disabled={loading || !rating}
// className="flex-1 px-4 py-2 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
// >
// {loading ? (
// <div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
// ) : (
// <Check className="w-5 h-5" />
// )}
// {loading ? 'إرسال' : 'إرسال التقييم'}
// </button>
// </div>
// </form>
// </motion.div>
// )}
// {/* Add rating button */}
// {!userRating && !showForm && (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-amber-50 border-2 border-amber-200 rounded-xl p-4 text-center cursor-pointer hover:border-amber-300 transition-all"
// onClick={() => setShowForm(true)}
// >
// <Star className="w-8 h-8 text-amber-500 mx-auto mb-2" />
// <h3 className="font-bold text-amber-700 mb-2">قيّم هذا العقار</h3>
// <p className="text-sm text-amber-600">شارك تجربتك مع المستأجرين الآخرين</p>
// </motion.div>
// )}
// </div>
// );
// };
// export default RatingForm;
'use client';
import { useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import StarRating from './StarRating';
import { addPropertyRating } from '../../utils/ratings';
const RatingField = ({ label, value, onChange }) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">{label} <span className="text-red-500">*</span></label>
<StarRating rating={value} onRatingChange={onChange} size={28} />
{value === 0 && <p className="text-xs text-red-500">مطلوب</p>}
</div>
);
export default function PropertyRatingForm({ reservationId, onSuccess, onCancel }) {
const [cleanRating, setCleanRating] = useState(0);
const [servicesRating, setServicesRating] = useState(0);
const [ownerBehaviorRating, setOwnerBehaviorRating] = useState(0);
const [experienceRating, setExperienceRating] = useState(0);
const [comment, setComment] = useState('');
const [loading, setLoading] = useState(false);
const validate = () => {
if (cleanRating === 0) return 'نظافة العقار';
if (servicesRating === 0) return 'جودة الخدمات';
if (ownerBehaviorRating === 0) return 'سلوك المالك';
if (experienceRating === 0) return 'التجربة العامة';
return null;
};
const handleSubmit = async (e) => {
e.preventDefault();
const missing = validate();
if (missing) {
toast.error(`يرجى تقييم: ${missing}`);
return;
}
setLoading(true);
try {
await addPropertyRating({
reservationId,
cleanRating,
servicesRating,
ownerBehaviorRating,
experienceRating,
comment: comment.trim() || null,
});
toast.success('تم إرسال التقييم بنجاح!');
onSuccess?.();
} catch (err) {
console.error(err);
toast.error('حدث خطأ، حاول مرة أخرى');
} finally {
setLoading(false);
}
};
return (
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-900">تقييم العقار</h3>
{onCancel && (
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<RatingField label="نظافة العقار" value={cleanRating} onChange={setCleanRating} />
<RatingField label="جودة الخدمات" value={servicesRating} onChange={setServicesRating} />
<RatingField label="سلوك المالك" value={ownerBehaviorRating} onChange={setOwnerBehaviorRating} />
<RatingField label="التجربة العامة" value={experienceRating} onChange={setExperienceRating} />
<div>
<label className="block text-sm font-medium text-gray-700">تعليق (اختياري)</label>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full mt-1 px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
placeholder="شارك تجربتك..."
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold py-3 rounded-xl transition flex items-center justify-center gap-2 disabled:opacity-50"
>
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
{loading ? 'جاري الإرسال...' : 'إرسال التقييم'}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,286 @@
// 'use client';
// import { useState, useEffect } from 'react';
// import { motion } from 'framer-motion';
// import { Star } from 'lucide-react';
// import { getPropertyRatings } from '../../utils/ratings.js';
// import toast, { Toaster } from 'react-hot-toast';
// const RatingList = ({ propertyId, userId }) => {
// const [reviews, setReviews] = useState([]);
// const [loading, setLoading] = useState(true);
// const [error, setError] = useState(null);
// useEffect(() => {
// const fetchReviews = async () => {
// if (!propertyId) {
// setLoading(false);
// return;
// }
// try {
// setLoading(true);
// const data = await getPropertyReviews(propertyId);
// setReviews(data || []);
// setError(null);
// } catch (err) {
// console.error('[RatingList] Failed to fetch reviews:', err);
// setError('فشل تحميل التقييمات');
// setReviews([]);
// } finally {
// setLoading(false);
// }
// };
// fetchReviews();
// }, [propertyId]);
// if (loading) {
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="text-center py-8"
// >
// <div className="w-10 h-10 border-2 border-gray-200 border-t-gray-500 rounded-full animate-spin mx-auto mb-4" />
// <p className="text-gray-500">جاري تحميل التقييمات...</p>
// </motion.div>
// );
// }
// if (error) {
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="text-center py-8"
// >
// <p className="text-red-500">{error}</p>
// </motion.div>
// );
// }
// if (reviews.length === 0) {
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="text-center py-8"
// >
// <p className="text-gray-500">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار!</p>
// </motion.div>
// );
// }
// // Calculate average rating
// const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
// >
// <Toaster position="top-center" reverseOrder={false} />
// <div className="space-y-4">
// {/* Header with average rating */}
// <div className="flex items-center justify-between pb-3 border-b border-gray-100">
// <div>
// <h2 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h2>
// <p className="text-sm text-gray-500">
// {reviews.length} تقييمات
// </p>
// </div>
// <div className="flex items-center gap-2">
// <div className="flex items-center gap-1">
// {Array.from({ length: 5 }).map((_, index) => (
// <Star
// key={index}
// className={`w-5 h-5 ${index < Math.floor(averageRating) ? 'text-amber-500' : 'text-gray-300'}`}
// />
// ))}
// {averageRating % 1 !== 0 && (
// <Star className="w-5 h-5 text-amber-400" />
// )}
// <span className="font-bold text-gray-900 ml-2">{averageRating.toFixed(1)}</span>
// </div>
// </div>
// </div>
// {/* Reviews list */}
// <div className="space-y-4">
// {reviews.map((review, index) => (
// <div key={index} className="border-t border-gray-100 pt-4 first:border-t-0 first:pt-0">
// <div className="flex justify-between items-start mb-2">
// <div className="flex items-start gap-3">
// <div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
// <Star className="w-6 h-6 text-gray-600" />
// </div>
// <div>
// <div className="font-medium text-gray-900">{review.userName || 'مستأجر'}</div>
// <div className="flex items-center gap-1 mt-1 text-sm">
// {Array.from({ length: 5 }).map((_, starIndex) => (
// <Star
// key={starIndex}
// className={`w-4 h-4 ${starIndex < review.rating ? 'text-amber-500' : 'text-gray-300'}`}
// />
// ))}
// <span className="ml-1 text-xs text-gray-500">({review.rating}/5)</span>
// </div>
// </div>
// </div>
// <div className="text-xs text-gray-400">
// {review.createdAt ? new Date(review.createdAt).toLocaleDateString('ar-SA') : ''}
// </div>
// </div>
// {review.comment && (
// <p className="text-gray-700 text-sm leading-relaxed">{review.comment}</p>
// )}
// </div>
// ))}
// </div>
// </div>
// </motion.div>
// );
// };
// export default RatingList;
'use client';
import { useState, useEffect } from 'react';
import { Star, User, Calendar, ChevronDown, Loader2 } from 'lucide-react';
import { getPropertyRatings, getPropertyAverageRating } from '../../utils/ratings';
const RatingItem = ({ rating }) => {
const overall = (
rating.cleanRating + rating.servicesRating +
rating.ownerBehaviorRating + rating.experienceRating
) / 4;
return (
<div className="border-b border-gray-100 py-4 last:border-0">
<div className="flex justify-between items-start mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-amber-600" />
</div>
<span className="font-medium text-gray-800">{rating.customerName || 'مستأجر'}</span>
</div>
<div className="flex items-center gap-1 bg-amber-50 px-2 py-1 rounded-full">
<Star className="w-3 h-3 text-amber-500 fill-amber-500" />
<span className="text-sm font-bold">{overall.toFixed(1)}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
<div>النظافة: {rating.cleanRating}/5</div>
<div>الخدمات: {rating.servicesRating}/5</div>
<div>سلوك المالك: {rating.ownerBehaviorRating}/5</div>
<div>التجربة: {rating.experienceRating}/5</div>
</div>
{rating.comment && (
<p className="text-gray-700 text-sm mt-2 pr-4 border-r-2 border-amber-200">"{rating.comment}"</p>
)}
<div className="flex items-center gap-1 text-xs text-gray-400 mt-2">
<Calendar className="w-3 h-3" />
<span>{new Date(rating.createdAt).toLocaleDateString('ar-SA')}</span>
</div>
</div>
);
};
export default function PropertyRatingList({ propertyId }) {
const [ratings, setRatings] = useState([]);
const [average, setAverage] = useState(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchRatings = async (reset = false) => {
const currentPage = reset ? 1 : page;
try {
if (reset) setLoading(true);
else setLoadingMore(true);
const result = await getPropertyRatings(propertyId, currentPage, 10);
const items = result?.items || result?.data?.items || result || [];
const totalPages = result?.totalPages || result?.data?.totalPages || 1;
setRatings(prev => reset ? items : [...prev, ...items]);
setHasMore(currentPage < totalPages);
if (reset) setPage(1);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
setLoadingMore(false);
}
};
const fetchAverage = async () => {
try {
const avg = await getPropertyAverageRating(propertyId);
setAverage(avg);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
if (propertyId) {
fetchRatings(true);
fetchAverage();
}
}, [propertyId]);
const loadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchRatings(false);
};
if (loading && ratings.length === 0) {
return (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-amber-500 mx-auto" />
<p className="text-gray-500 mt-2">جاري تحميل التقييمات...</p>
</div>
);
}
return (
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div className="flex justify-between items-center mb-4 pb-3 border-b border-gray-100">
<h3 className="text-xl font-bold text-gray-900">تقييمات المستأجرين</h3>
{average !== null && (
<div className="flex items-center gap-1 bg-amber-50 px-3 py-1 rounded-full">
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
<span className="font-bold text-lg">{average.toFixed(1)}</span>
<span className="text-gray-500 text-sm">/5</span>
</div>
)}
</div>
{ratings.length === 0 ? (
<p className="text-center text-gray-500 py-6">لا توجد تقييمات حتى الآن. كن أول من يقيم هذا العقار.</p>
) : (
<>
{ratings.map((r, idx) => <RatingItem key={idx} rating={r} />)}
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="w-full mt-4 py-2 text-amber-600 hover:text-amber-700 font-medium text-sm flex items-center justify-center gap-1"
>
{loadingMore ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronDown className="w-4 h-4" />}
{loadingMore ? 'جاري التحميل...' : 'عرض المزيد'}
</button>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,134 @@
// import { useState } from 'react';
// import { motion } from 'framer-motion';
// import { Star } from 'lucide-react';
// const StarRating = ({
// rating,
// onRatingChange,
// maxStars = 5,
// size = 24,
// color = '#ffc107',
// readOnly = false,
// className = ''
// }) => {
// const [hoverRating, setHoverRating] = useState(null);
// const handleClick = (value) => {
// if (!readOnly && onRatingChange) {
// onRatingChange(value);
// }
// };
// const handleMouseEnter = (value) => {
// if (!readOnly) {
// setHoverRating(value);
// }
// };
// const handleMouseLeave = () => {
// if (!readOnly) {
// setHoverRating(null);
// }
// };
// const getStarIcon = (index) => {
// const currentRating = hoverRating !== null ? hoverRating : rating;
// if (currentRating > index) {
// const hasHalfStar = currentRating % 1 > 0.5 && index + 0.5 <= currentRating;
// if (hasHalfStar) {
// // For half star, we'll use a combination approach or just show full star
// // Since we don't have StarOutline, we'll approximate with full stars
// return <Star className={`w-${size} h-${size} text-${color}`} />;
// }
// return <Star className={`w-${size} h-${size} text-${color}`} />;
// }
// return <Star className={`w-${size} h-${size} text-gray-400`} />;
// };
// return (
// <div className={`flex gap-1 ${className}`} onMouseLeave={handleMouseLeave}>
// {[...Array(maxStars)].map((_, index) => (
// <motion.div
// key={index}
// whileHover={{ scale: readOnly ? 1 : 1.1 }}
// onClick={() => handleClick(index + 1)}
// onMouseEnter={() => handleMouseEnter(index + 1)}
// >
// {getStarIcon(index)}
// </motion.div>
// ))}
// </div>
// );
// };
// export default StarRating;
// // Helper functions
// export function getStarCount(rating, maxStars = 5) {
// return Math.round(rating * maxStars) / maxStars;
// }
// export function formatRating(rating) {
// if (rating === 0) return 'لا يوجد تقييم';
// return `${rating.toFixed(1)}`; // Show 1 decimal place
// }
// export function getRatingColor(rating) {
// if (rating >= 4.5) return 'text-green-600';
// if (rating >= 3.5) return 'text-yellow-600';
// if (rating >= 2.5) return 'text-orange-600';
// return 'text-red-600';
// }
// export function getRatingText(rating) {
// if (rating >= 4.5) return 'ممتاز';
// if (rating >= 3.5) return 'جيد جداً';
// if (rating >= 2.5) return 'جيد';
// if (rating >= 1.5) return 'مقبول';
// return 'ضعيف';
// }
'use client';
import { useState } from 'react';
import { Star } from 'lucide-react';
const StarRating = ({ rating = 0, onRatingChange, size = 28, readOnly = false }) => {
const [hoverRating, setHoverRating] = useState(0);
const handleClick = (value) => {
if (!readOnly && onRatingChange) onRatingChange(value);
};
return (
<div
className="flex gap-1"
onMouseLeave={() => !readOnly && setHoverRating(0)}
>
{[1, 2, 3, 4, 5].map((star) => {
const filled = (hoverRating || rating) >= star;
return (
<button
key={star}
type="button"
onClick={() => handleClick(star)}
onMouseEnter={() => !readOnly && setHoverRating(star)}
className="focus:outline-none transition-transform hover:scale-110"
disabled={readOnly}
>
<Star
size={size}
className={`${filled ? 'fill-amber-500 text-amber-500' : 'text-gray-300 fill-transparent'}`}
/>
</button>
);
})}
</div>
);
};
export default StarRating;

View File

@ -0,0 +1,123 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { getUserFavoriteProperties, addFavoriteProperty, removeFavoriteProperty } from '../utils/api';
import AuthService from '../services/AuthService';
const FavoritesContext = createContext();
export const useFavorites = () => {
const context = useContext(FavoritesContext);
if (!context) {
throw new Error('useFavorites must be used within FavoritesProvider');
}
return context;
};
function mapApiFavorite(item) {
const info = item.propertyInformation || {};
let details = {};
try { details = JSON.parse(info.detailsJSON || '{}'); } catch {}
const price = item.monthlyRent || item.dailyRent || 0;
const priceUnit = item.monthlyRent ? 'monthly' : 'daily';
const buildingType = info.buildingType ?? 0;
const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
const address = info.address || '';
const addressParts = address.split(',').map(s => s.trim()).filter(Boolean);
const images = info.images || [];
const resolvedImages = images.map(img => {
if (!img) return '';
if (img.startsWith('http')) return img;
return `http://45.93.137.91${img.startsWith('/') ? '' : '/'}${img}`;
});
return {
id: info.id || item.propertyInformationId,
faveId: item.id, // needed to remove from favorites
title: `${typeLabel} في ${addressParts[0] || address}`,
type,
typeLabel,
price,
priceUnit,
bedrooms: info.numberOfBedRooms || 0,
bathrooms: info.numberOfBathRooms || 0,
area: info.space || 0,
location: {
city: addressParts[addressParts.length - 1] || '',
district: addressParts[0] || '',
},
images: resolvedImages,
rating: item.rating || 0,
deposit: item.deposit || 0,
};
}
export const FavoritesProvider = ({ children }) => {
const [favorites, setFavorites] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const fetchFavorites = useCallback(async () => {
if (!AuthService.isAuthenticated()) {
setFavorites([]);
return;
}
setIsLoading(true);
try {
const data = await getUserFavoriteProperties();
const items = Array.isArray(data) ? data : [];
setFavorites(items.map(mapApiFavorite));
} catch (err) {
console.error('[Favorites] Failed to fetch:', err);
setFavorites([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchFavorites();
}, [fetchFavorites]);
const addFavorite = async (propId) => {
if (!AuthService.isAuthenticated()) return false;
try {
await addFavoriteProperty(propId);
// Refresh to get the full object with faveId
await fetchFavorites();
return true;
} catch (err) {
console.error('[Favorites] Add failed:', err);
return false;
}
};
const removeFavorite = async (propId) => {
if (!AuthService.isAuthenticated()) return false;
const fav = favorites.find(f => f.id === propId);
if (!fav) return false;
// Optimistic update — remove immediately from UI
const previous = [...favorites];
setFavorites(prev => prev.filter(f => f.id !== propId));
try {
await removeFavoriteProperty(fav.faveId);
return true;
} catch (err) {
console.error('[Favorites] Remove failed:', err);
// Rollback on failure
setFavorites(previous);
return false;
}
};
const isFavorite = (propId) => {
return favorites.some(f => f.id === propId);
};
return (
<FavoritesContext.Provider value={{ favorites, isLoading, addFavorite, removeFavorite, isFavorite, refreshFavorites: fetchFavorites }}>
{children}
</FavoritesContext.Provider>
);
};

View File

@ -0,0 +1,75 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { getUserNotifications } from '../utils/api';
import AuthService from '../services/AuthService';
const NotificationsContext = createContext();
export const useNotifications = () => {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error('useNotifications must be used within NotificationsProvider');
}
return context;
};
export function NotificationsProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const fetchNotifications = useCallback(async () => {
if (!AuthService.isAuthenticated()) {
setNotifications([]);
setUnreadCount(0);
return;
}
setIsLoading(true);
try {
const data = await getUserNotifications();
const notificationsArray = Array.isArray(data) ? data : [];
setNotifications(notificationsArray);
// Assuming all are unread for now, or add logic to check 'read' field if exists
setUnreadCount(notificationsArray.length);
} catch (error) {
console.error('Error fetching notifications:', error);
setNotifications([]);
setUnreadCount(0);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
const markAsRead = useCallback((id) => {
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, read: true } : n))
);
setUnreadCount(prev => Math.max(0, prev - 1));
}, []);
const markAllAsRead = useCallback(() => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
setUnreadCount(0);
}, []);
const value = {
notifications,
unreadCount,
isLoading,
fetchNotifications,
markAsRead,
markAllAsRead,
};
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}

View File

@ -0,0 +1,38 @@
/**
* BookingStatus Enum
* Backend values are strings
* Used in: Reservation workflow
*/
const BookingStatus = Object.freeze({
PENDING: 'pending',
OWNER_APPROVED: 'owner_approved',
ADMIN_APPROVED: 'admin_approved',
ACTIVE: 'active',
COMPLETED: 'completed',
REJECTED: 'rejected',
CANCELLED: 'cancelled',
});
// Map status → Arabic label
const BookingStatusLabels = Object.freeze({
[BookingStatus.PENDING]: 'بانتظار الموافقة',
[BookingStatus.OWNER_APPROVED]: 'موافقة المالك',
[BookingStatus.ADMIN_APPROVED]: 'موافقة الإدارة',
[BookingStatus.ACTIVE]: 'إيجار نشط',
[BookingStatus.COMPLETED]: 'منتهي',
[BookingStatus.REJECTED]: 'مرفوض',
[BookingStatus.CANCELLED]: 'ملغي',
});
// Map status → color class (Tailwind bg)
const BookingStatusColors = Object.freeze({
[BookingStatus.PENDING]: 'yellow',
[BookingStatus.OWNER_APPROVED]: 'blue',
[BookingStatus.ADMIN_APPROVED]: 'green',
[BookingStatus.ACTIVE]: 'purple',
[BookingStatus.COMPLETED]: 'gray',
[BookingStatus.REJECTED]: 'red',
[BookingStatus.CANCELLED]: 'red',
});
export { BookingStatus, BookingStatusLabels, BookingStatusColors };

33
app/enums/BuildingType.js Normal file
View File

@ -0,0 +1,33 @@
/**
* BuildingType Enum
* Backend values are numeric (0, 1, 2)
* Used in: PropertyInformation.buildingType
*/
const BuildingType = Object.freeze({
APARTMENT: 0,
VILLA: 1,
HOUSE: 2,
});
// Map numeric value → Arabic label
const BuildingTypeLabels = Object.freeze({
[BuildingType.APARTMENT]: 'شقة',
[BuildingType.VILLA]: 'فيلا',
[BuildingType.HOUSE]: 'بيت',
});
// Map numeric value → English key (for UI filters)
const BuildingTypeKeys = Object.freeze({
[BuildingType.APARTMENT]: 'apartment',
[BuildingType.VILLA]: 'villa',
[BuildingType.HOUSE]: 'house',
});
// Reverse map: English key → numeric value
const BuildingTypeByKey = Object.freeze({
apartment: BuildingType.APARTMENT,
villa: BuildingType.VILLA,
house: BuildingType.HOUSE,
});
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey };

38
app/enums/City.js Normal file
View File

@ -0,0 +1,38 @@
/**
* City Enum
* Syrian cities used in property locations
* Used in: Property search filters, location display
*/
const City = Object.freeze({
DAMASCUS: 'دمشق',
ALEPPO: 'حلب',
HOMS: 'حمص',
LATAKIA: 'اللاذقية',
DARAA: 'درعا',
TARTOUS: 'طرطوس',
SUWEIDA: 'السويداء',
DEIR_EZZOR: 'دير الزور',
RAQQA: 'الرقة',
IDLIB: 'إدلب',
HASAKAH: 'الحسكة',
QAMISHLI: 'القامشلي',
RURAL_DAMASCUS: 'ريف دمشق',
});
// All cities as a flat array
const CitiesList = Object.freeze(Object.values(City));
/**
* Extract city name from a full address string
* @param {string} address
* @returns {string}
*/
function extractCity(address) {
if (!address) return '';
for (const city of CitiesList) {
if (address.includes(city)) return city;
}
return '';
}
export { City, CitiesList, extractCity };

View File

@ -0,0 +1,19 @@
/**
* CommissionType Enum
* Defines who pays the platform commission
* Used in: Property pricing, booking financials
*/
const CommissionType = Object.freeze({
FROM_OWNER: 'from_owner',
FROM_TENANT: 'from_tenant',
FROM_BOTH: 'from_both',
});
// Map type → Arabic label
const CommissionTypeLabels = Object.freeze({
[CommissionType.FROM_OWNER]: 'من المالك',
[CommissionType.FROM_TENANT]: 'من المستأجر',
[CommissionType.FROM_BOTH]: 'من الاثنين',
});
export { CommissionType, CommissionTypeLabels };

20
app/enums/Currency.js Normal file
View File

@ -0,0 +1,20 @@
/**
* Currency Enum
* Currency IDs used in the backend
*/
const Currency = Object.freeze({
SYP: 1,
USD: 2,
});
const CurrencyLabels = Object.freeze({
[Currency.SYP]: 'ليرة سورية',
[Currency.USD]: 'دولار أمريكي',
});
const CurrencySymbols = Object.freeze({
[Currency.SYP]: 'SYP',
[Currency.USD]: 'USD',
});
export { Currency, CurrencyLabels, CurrencySymbols };

17
app/enums/CustomerType.js Normal file
View File

@ -0,0 +1,17 @@
/**
* CustomerType Enum
* Backend values for customer sub-types
* Used in: Customer registration (Customer/Add)
*/
const CustomerType = Object.freeze({
PERSONAL: 0,
FAMILY: 1,
});
// Map value → Arabic label
const CustomerTypeLabels = Object.freeze({
[CustomerType.PERSONAL]: 'شخصي',
[CustomerType.FAMILY]: 'عائلي',
});
export { CustomerType, CustomerTypeLabels };

23
app/enums/IdentityType.js Normal file
View File

@ -0,0 +1,23 @@
/**
* IdentityType Enum
* Tenant identity document type
* Used in: Property booking, allowedIdentities filter
*/
const IdentityType = Object.freeze({
SYRIAN: 'syrian',
PASSPORT: 'passport',
});
// Map type → Arabic label
const IdentityTypeLabels = Object.freeze({
[IdentityType.SYRIAN]: 'هوية سورية',
[IdentityType.PASSPORT]: 'جواز سفر',
});
// Map type → flag emoji
const IdentityTypeFlags = Object.freeze({
[IdentityType.SYRIAN]: '🇸🇾',
[IdentityType.PASSPORT]: '🛂',
});
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags };

11
app/enums/LoginMethod.js Normal file
View File

@ -0,0 +1,11 @@
/**
* LoginMethod Enum
* Authentication method type
* Used in: Login page, OTP verification
*/
const LoginMethod = Object.freeze({
EMAIL: 'email',
PHONE: 'phone',
});
export { LoginMethod };

17
app/enums/OwnerType.js Normal file
View File

@ -0,0 +1,17 @@
/**
* OwnerType Enum
* Backend values for owner sub-types
* Used in: Owner registration (Owner/Add)
*/
const OwnerType = Object.freeze({
PERSON: 0,
REAL_ESTATE_AGENCY: 1,
});
// Map value → Arabic label
const OwnerTypeLabels = Object.freeze({
[OwnerType.PERSON]: 'شخص',
[OwnerType.REAL_ESTATE_AGENCY]: 'وكالة عقارية',
});
export { OwnerType, OwnerTypeLabels };

View File

@ -0,0 +1,41 @@
/**
* PropertyService Enum
* Services available at the property
* Used in detailsJSON.services array
*/
const PropertyService = Object.freeze({
ELECTRICITY: 'Electricity',
INTERNET: 'Internet',
HEATING: 'Heating',
WATER: 'Water',
POOL: 'Pool',
PRIVATE_GARDEN: 'PrivateGarden',
PARKING: 'Parking',
SECURITY_247: 'Security247',
CENTRAL_HEATING: 'CentralHeating',
CENTRAL_AIR_CONDITIONING: 'CentralAirConditioning',
EQUIPPED_KITCHEN: 'EquippedKitchen',
MAIDS_ROOM: 'MaidsRoom',
ELEVATOR: 'Elevator',
});
const PropertyServiceLabels = Object.freeze({
[PropertyService.ELECTRICITY]: 'كهرباء',
[PropertyService.INTERNET]: 'إنترنت',
[PropertyService.HEATING]: 'تدفئة',
[PropertyService.WATER]: 'ماء',
[PropertyService.POOL]: 'مسبح',
[PropertyService.PRIVATE_GARDEN]: 'حديقة خاصة',
[PropertyService.PARKING]: 'موقف سيارات',
[PropertyService.SECURITY_247]: 'حراسة 24 ساعة',
[PropertyService.CENTRAL_HEATING]: 'تدفئة مركزية',
[PropertyService.CENTRAL_AIR_CONDITIONING]: 'تكييف مركزي',
[PropertyService.EQUIPPED_KITCHEN]: 'مطبخ مجهز',
[PropertyService.MAIDS_ROOM]: 'غرفة خادمة',
[PropertyService.ELEVATOR]: 'مصعد',
});
// All services as array
const PropertyServicesList = Object.freeze(Object.values(PropertyService));
export { PropertyService, PropertyServiceLabels, PropertyServicesList };

View File

@ -0,0 +1,33 @@
/**
* PropertyStatus Enum
* Backend values are numeric (0, 1, 2)
* Used in: PropertyInformation.status
*/
const PropertyStatus = Object.freeze({
AVAILABLE: 0,
BOOKED: 1,
MAINTENANCE: 2,
});
// Map numeric value → Arabic label
const PropertyStatusLabels = Object.freeze({
[PropertyStatus.AVAILABLE]: 'متاح',
[PropertyStatus.BOOKED]: 'محجوز',
[PropertyStatus.MAINTENANCE]: 'صيانة',
});
// Map numeric value → English key (for UI filters)
const PropertyStatusKeys = Object.freeze({
[PropertyStatus.AVAILABLE]: 'available',
[PropertyStatus.BOOKED]: 'booked',
[PropertyStatus.MAINTENANCE]: 'maintenance',
});
// Reverse map: English key → numeric value
const PropertyStatusByKey = Object.freeze({
available: PropertyStatus.AVAILABLE,
booked: PropertyStatus.BOOKED,
maintenance: PropertyStatus.MAINTENANCE,
});
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey };

21
app/enums/PropertyTerm.js Normal file
View File

@ -0,0 +1,21 @@
/**
* PropertyTerm Enum
* Usage terms/conditions for the property
* Used in detailsJSON.terms array
*/
const PropertyTerm = Object.freeze({
NO_SMOKING: 'NoSmoking',
NO_ANIMALS: 'NoAnimals',
NO_PARTIES: 'NoParties',
});
const PropertyTermLabels = Object.freeze({
[PropertyTerm.NO_SMOKING]: 'ممنوع التدخين',
[PropertyTerm.NO_ANIMALS]: 'ممنوع الحيوانات',
[PropertyTerm.NO_PARTIES]: 'ممنوع الحفلات',
});
// All terms as array
const PropertyTermsList = Object.freeze(Object.values(PropertyTerm));
export { PropertyTerm, PropertyTermLabels, PropertyTermsList };

View File

@ -0,0 +1,16 @@
/**
* RentPropertyCondition Enum
* Furniture condition of the property
* Sent as `propertyType` field in API
*/
const RentPropertyCondition = Object.freeze({
WITH_FURNITURE: 0,
WITHOUT_FURNITURE: 1,
});
const RentPropertyConditionLabels = Object.freeze({
[RentPropertyCondition.WITH_FURNITURE]: 'مفروش',
[RentPropertyCondition.WITHOUT_FURNITURE]: 'غير مفروش',
});
export { RentPropertyCondition, RentPropertyConditionLabels };

View File

@ -0,0 +1,17 @@
/**
* RentPropertyType Enum
* Sent as `type` field in RentPropertyDto
*/
const RentPropertyType = Object.freeze({
FURNISHED: 0,
UNFURNISHED: 1,
SEMI_FURNISHED: 2,
});
const RentPropertyTypeLabels = Object.freeze({
[RentPropertyType.FURNISHED]: 'مفروش بالكامل',
[RentPropertyType.UNFURNISHED]: 'غير مفروش',
[RentPropertyType.SEMI_FURNISHED]: 'مفروش جزئياً',
});
export { RentPropertyType, RentPropertyTypeLabels };

16
app/enums/RentType.js Normal file
View File

@ -0,0 +1,16 @@
/**
* RentType Enum
* Rental period type
* Sent as `rentType` field in API
*/
const RentType = Object.freeze({
MONTHLY: 0,
DAILY: 1,
});
const RentTypeLabels = Object.freeze({
[RentType.MONTHLY]: 'شهري',
[RentType.DAILY]: 'يومي',
});
export { RentType, RentTypeLabels };

27
app/enums/UserRole.js Normal file
View File

@ -0,0 +1,27 @@
/**
* UserRole Enum
* User account roles in the system
* Derived from JWT token claims
*/
const UserRole = Object.freeze({
GUEST: 'guest',
CUSTOMER: 'customer',
OWNER: 'owner',
ADMIN: 'admin',
});
const UserRoleLabels = Object.freeze({
[UserRole.GUEST]: 'زائر',
[UserRole.CUSTOMER]: 'مستأجر',
[UserRole.OWNER]: 'مالك عقار',
[UserRole.ADMIN]: 'مدير النظام',
});
const UserRoleColors = Object.freeze({
[UserRole.GUEST]: 'gray',
[UserRole.CUSTOMER]: 'blue',
[UserRole.OWNER]: 'amber',
[UserRole.ADMIN]: 'red',
});
export { UserRole, UserRoleLabels, UserRoleColors };

21
app/enums/index.js Normal file
View File

@ -0,0 +1,21 @@
/**
* Enums Index
* Central export for all enum modules
*/
export { BuildingType, BuildingTypeLabels, BuildingTypeKeys, BuildingTypeByKey } from './BuildingType';
export { PropertyStatus, PropertyStatusLabels, PropertyStatusKeys, PropertyStatusByKey } from './PropertyStatus';
export { BookingStatus, BookingStatusLabels, BookingStatusColors } from './BookingStatus';
export { CommissionType, CommissionTypeLabels } from './CommissionType';
export { IdentityType, IdentityTypeLabels, IdentityTypeFlags } from './IdentityType';
export { UserRole, UserRoleLabels, UserRoleColors } from './UserRole';
export { City, CitiesList, extractCity } from './City';
export { LoginMethod } from './LoginMethod';
export { OwnerType, OwnerTypeLabels } from './OwnerType';
export { CustomerType, CustomerTypeLabels } from './CustomerType';
export { RentPropertyCondition, RentPropertyConditionLabels } from './RentPropertyCondition';
export { RentPropertyType, RentPropertyTypeLabels } from './RentPropertyType';
export { RentType, RentTypeLabels } from './RentType';
export { PropertyService, PropertyServiceLabels, PropertyServicesList } from './PropertyService';
export { PropertyTerm, PropertyTermLabels, PropertyTermsList } from './PropertyTerm';
export { Currency, CurrencyLabels, CurrencySymbols } from './Currency';

39
app/error.js Normal file
View File

@ -0,0 +1,39 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function GlobalError({ error, reset }) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-md"
>
<div className="w-24 h-24 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-12 h-12 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ غير متوقع</h2>
<p className="text-gray-500 mb-8">نعتذر عن هذا الإزعاج، يرجى المحاولة مرة أخرى</p>
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
>
<RefreshCw className="w-5 h-5" />
إعادة المحاولة
</button>
<Link
href="/"
className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors"
>
<Home className="w-5 h-5" />
الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

144
app/favorites/page.js Normal file
View File

@ -0,0 +1,144 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import Link from 'next/link';
import Image from 'next/image';
import { Heart, MapPin, Bed, Bath, Square, X, ImageIcon } from 'lucide-react';
import { useFavorites } from '@/app/contexts/FavoritesContext';
import AuthService from '@/app/services/AuthService';
export default function FavoritesPage() {
const router = useRouter();
const { favorites, isLoading: favoritesLoading, removeFavorite } = useFavorites();
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
if (AuthService.isAdmin()) {
router.push('/');
return;
}
setIsAdmin(AuthService.isAdmin());
}, [router]);
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
if (favoritesLoading && favorites.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-600">جاري التحميل...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-6xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">المفضلة</h1>
<p className="text-gray-600">العقارات التي قمت بحفظها</p>
</div>
{favorites.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد عقارات في المفضلة</h3>
<p className="text-gray-500 mb-6">يمكنك إضافة العقارات التي تعجبك بالنقر على أيقونة القلب</p>
<Link
href="/properties"
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
>
استعرض العقارات
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{favorites.map((property) => (
<motion.div
key={property.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-gray-200"
>
<div className="relative h-48 bg-gray-100">
{property.images && property.images[0] ? (
<Image
src={property.images[0]}
alt={property.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-12 h-12 text-gray-400" />
</div>
)}
<button
onClick={() => removeFavorite(property.id)}
className="absolute top-2 right-2 w-8 h-8 bg-white/90 rounded-full flex items-center justify-center hover:bg-red-50 transition-colors shadow-sm"
>
<X className="w-4 h-4 text-red-500" />
</button>
</div>
<div className="p-5">
<div className="flex justify-between items-start mb-3">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium">
{property.type === 'apartment' ? 'شقة' : property.type === 'villa' ? 'فيلا' : 'بيت'}
</span>
</div>
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
<div className="flex items-center gap-1 text-gray-500 text-xs mb-2">
<MapPin className="w-3 h-3" />
<span className="line-clamp-1">
{property.location.city}، {property.location.district}
</span>
</div>
</div>
<div className="text-left">
<div className="text-xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
<div className="text-xs text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
</div>
</div>
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-3 text-gray-600 text-sm">
<div className="flex items-center gap-1">
<Bed className="w-4 h-4" />
<span>{property.bedrooms}</span>
</div>
<div className="flex items-center gap-1">
<Bath className="w-4 h-4" />
<span>{property.bathrooms}</span>
</div>
<div className="flex items-center gap-1">
<Square className="w-4 h-4" />
<span>{property.area}م²</span>
</div>
</div>
</div>
<Link
href={`/property/${property.id}`}
className="block w-full bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
>
عرض التفاصيل
</Link>
</div>
</motion.div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,5 +1,78 @@
@import "tailwindcss"; @import "tailwindcss";
/* ─── Madani Arabic Font ─── */
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Extra Light.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani-Arabic-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Semi Bold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani-Arabic-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Extra Bold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: block;
}
@font-face {
font-family: 'Madani Arabic';
src: url('/fonts/Madani Arabic Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: block;
}
:root { :root {
--background: #ede6e6; --background: #ede6e6;
--foreground: #156874; --foreground: #156874;
@ -19,10 +92,14 @@
} }
} }
html, body {
font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
}
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: 'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif;
} }
.leaflet-container { .leaflet-container {

View File

@ -25,9 +25,32 @@ export const metadata = {
export default function Layout({ children }) { export default function Layout({ children }) {
return ( return (
<html lang="ar" dir="rtl"> <html lang="ar" dir="rtl">
<head /> <head>
<link
rel="preload"
as="font"
href="/fonts/Madani-Arabic-Regular.woff2"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
as="font"
href="/fonts/Madani-Arabic-Bold.woff2"
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
as="font"
href="/fonts/Madani Arabic Medium.woff2"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
> >
<ClientLayout>{children}</ClientLayout> <ClientLayout>{children}</ClientLayout>
</body> </body>

18
app/loading.js Normal file
View File

@ -0,0 +1,18 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
>
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

27
app/login/error.js Normal file
View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

14
app/login/loading.js Normal file
View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,11 +1,10 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from "framer-motion";
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from "react-hot-toast";
import Link from 'next/link'; import Link from "next/link";
import Image from 'next/image'; import { useRouter } from "next/navigation";
import { useRouter } from 'next/navigation';
import { import {
Mail, Mail,
Lock, Lock,
@ -16,106 +15,277 @@ import {
CheckCircle, CheckCircle,
Loader2, Loader2,
Home, Home,
Shield Shield,
} from 'lucide-react'; Phone,
KeyRound,
} from "lucide-react";
import {
loginWithEmail,
loginWithPhone,
sendEmailOTP,
sendPhoneOTP,
verifyEmail,
verifyPhone,
isEmail,
isPhoneNumber,
getOwnerByUserId,
getCustomerByUserId,
} from "../utils/api";
import AuthService from "../services/AuthService";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
// Step: 'login' | 'otp'
const [step, setStep] = useState("login");
const [loginMethod, setLoginMethod] = useState("email"); // 'email' | 'phone'
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false); const [isSuccess, setIsSuccess] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', credential: "",
password: '', password: "",
rememberMe: false rememberMe: false,
}); });
const [otpCode, setOtpCode] = useState("");
const [otpError, setOtpError] = useState("");
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const ADMIN_EMAIL = 'admin@gmail.com';
const ADMIN_PASSWORD = '123';
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validateForm = () => { const validateForm = () => {
const newErrors = {}; const newErrors = {};
if (!formData.email) { if (!formData.credential) {
newErrors.email = 'البريد الإلكتروني مطلوب'; newErrors.credential =
} else if (!validateEmail(formData.email)) { loginMethod === "email"
newErrors.email = 'البريد الإلكتروني غير صالح'; ? "البريد الإلكتروني مطلوب"
: "رقم الهاتف مطلوب";
// } else if (loginMethod === 'email' && !isEmail(formData.credential)) {
// newErrors.credential = 'البريد الإلكتروني غير صالح';
// } else if (loginMethod === 'phone' && !isPhoneNumber(formData.credential)) {
newErrors.credential = "رقم الهاتف غير صالح";
} }
if (!formData.password) { if (!formData.password) {
newErrors.password = 'كلمة المرور مطلوبة'; newErrors.password = "كلمة المرور مطلوبة";
} }
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleSubmit = async (e) => { const handleLogin = async (e) => {
e.preventDefault(); e.preventDefault();
if (!validateForm()) return;
if (!validateForm()) { setIsLoading(true);
toast.error('يرجى تصحيح الأخطاء في النموذج', { setErrors({});
style: { background: '#fee2e2', color: '#991b1b' }
try {
const loginFn = loginMethod === "email" ? loginWithEmail : loginWithPhone;
console.log(
"[Login] Attempting login via",
loginMethod,
":",
formData.credential,
);
const result = await loginFn(formData.credential, formData.password);
console.log("[Login] Response status:", result.status);
if (result.status === 200) {
const token =
typeof result.data === "string"
? result.data
: result.data?.token || result.data?.accessToken;
AuthService.addToken(token);
console.log("[Login] Token stored");
// Fetch user profile to get full name
const authUser = AuthService.getUser();
if (authUser?.id) {
try {
const isOwner = AuthService.isOwner();
const fetchFn = isOwner ? getOwnerByUserId : getCustomerByUserId;
const profile = await fetchFn(authUser.id);
if (profile) {
AuthService.cacheUser({
name:
profile.fullName ||
profile.name ||
`${profile.firstName || ""} ${profile.lastName || ""}`.trim(),
email: profile.email || authUser.email,
phone: profile.phone || profile.phoneNumber || authUser.phone,
}); });
console.log("[Login] User profile cached");
}
} catch (err) {
console.warn("[Login] Failed to fetch profile:", err);
}
}
const userRole = AuthService.isAdmin()
? "admin"
: AuthService.isOwner()
? "owner"
: "customer";
console.log("[Login] User role:", userRole);
setIsSuccess(true);
toast.success("تم تسجيل الدخول بنجاح!", {
style: { background: "#dcfce7", color: "#166534" },
});
setTimeout(() => {
if (userRole === "admin") {
router.push("/admin");
} else {
router.push("/");
}
}, 1500);
} else if (result.status === 206) {
console.log("[Login] 206 — OTP required");
const tempToken =
typeof result.data === "string"
? result.data
: result.data?.token || result.data?.accessToken;
if (tempToken) {
AuthService.addToken(tempToken);
console.log("[Login] Temp token stored for OTP");
}
toast("يرجى إدخال رمز التحقق", {
icon: "🔐",
style: { background: "#fef3c7", color: "#92400e" },
});
// Send OTP
try {
if (loginMethod === "email") {
await sendEmailOTP();
} else {
await sendPhoneOTP();
}
console.log("[Login] OTP sent successfully");
} catch (otpErr) {
console.warn("[Login] OTP send failed, proceeding anyway:", otpErr);
}
setStep("otp");
} else {
// Other error
console.error("[Login] Unexpected status:", result.status, result.data);
toast.error(
result.data?.message || result.data || "بيانات الدخول غير صحيحة",
{
style: { background: "#fee2e2", color: "#991b1b" },
},
);
}
} catch (err) {
console.error("[Login] Error:", err);
toast.error(err.message || "حدث خطأ في الاتصال", {
style: { background: "#fee2e2", color: "#991b1b" },
});
} finally {
setIsLoading(false);
}
};
const handleVerifyOTP = async (e) => {
e.preventDefault();
if (!otpCode || otpCode.length < 4) {
setOtpError("يرجى إدخال رمز التحقق");
return; return;
} }
setIsLoading(true); setIsLoading(true);
setOtpError("");
try {
const verifyFn = loginMethod === "email" ? verifyEmail : verifyPhone;
console.log("[OTP] Verifying code:", otpCode);
const result = await verifyFn(otpCode);
console.log("[OTP] Verify response status:", result.status);
if (result.ok) {
const finalToken =
typeof result.data === "string"
? result.data
: result.data?.token || result.data?.accessToken;
if (finalToken && typeof finalToken === "string") {
AuthService.addToken(finalToken);
console.log("[OTP] Final token stored");
}
setTimeout(() => {
if (formData.email.toLowerCase() === ADMIN_EMAIL && formData.password === ADMIN_PASSWORD) {
setIsLoading(false);
setIsSuccess(true); setIsSuccess(true);
toast.success("تم التحقق بنجاح!", {
toast.success('تم تسجيل الدخول كأدمن!', { style: { background: "#dcfce7", color: "#166534" },
style: { background: '#dcfce7', color: '#166534' },
duration: 3000
}); });
localStorage.setItem('user', JSON.stringify({
name: 'مدير النظام',
email: ADMIN_EMAIL,
role: 'admin',
avatar: 'أ'
}));
setTimeout(() => { setTimeout(() => {
router.push('/admin'); console.log("[OTP] Redirecting to home");
router.push("/");
}, 1500); }, 1500);
} else { } else {
setIsLoading(false); console.error("[OTP] Verification failed:", result.data);
toast.error('بيانات الدخول غير صحيحة. حاول مع admin@gmail.com / 123', { setOtpError(result.data?.message || "رمز التحقق غير صحيح");
style: { background: '#fee2e2', color: '#991b1b' }, }
duration: 4000 } catch (err) {
}); console.error("[OTP] Error:", err);
setOtpError(err.message || "حدث خطأ في التحقق");
} finally {
setIsLoading(false);
} }
}, 1500);
}; };
const particles = Array.from({ length: 30 }, (_, i) => ({ const resendOTP = async () => {
try {
console.log("[OTP] Resending OTP via", loginMethod);
if (loginMethod === "email") {
await sendEmailOTP();
} else {
await sendPhoneOTP();
}
toast.success("تم إرسال رمز التحقق مجدداً", {
style: { background: "#dcfce7", color: "#166534" },
});
} catch (err) {
console.error("[OTP] Resend failed:", err);
toast.error("فشل إرسال الرمز");
}
};
// Auto-detect login method from input
const handleCredentialChange = (value) => {
setFormData({ ...formData, credential: value });
if (errors.credential) setErrors({ ...errors, credential: null });
// Auto-switch method
// if (isEmail(value)) {
// setLoginMethod('email');
// } else if (isPhoneNumber(value)) {
// setLoginMethod('phone');
// }
};
const particles = Array.from({ length: 20 }, (_, i) => ({
id: i, id: i,
x: Math.random() * 100, x: Math.random() * 100,
y: Math.random() * 100, y: Math.random() * 100,
size: Math.random() * 3 + 1, size: Math.random() * 3 + 1,
duration: Math.random() * 15 + 10, duration: Math.random() * 15 + 10,
delay: Math.random() * 5 delay: Math.random() * 5,
})); }));
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: { staggerChildren: 0.1, delayChildren: 0.2 },
staggerChildren: 0.1, },
delayChildren: 0.2
}
}
}; };
const itemVariants = { const itemVariants = {
@ -123,24 +293,25 @@ export default function LoginPage() {
visible: { visible: {
y: 0, y: 0,
opacity: 1, opacity: 1,
transition: { type: 'spring', stiffness: 100 } transition: { type: "spring", stiffness: 100 },
} },
}; };
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden"> <div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
<Toaster position="top-center" reverseOrder={false} /> <Toaster position="top-center" reverseOrder={false} />
{/* Particles */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
{particles.map((particle) => ( {particles.map((p) => (
<motion.div <motion.div
key={particle.id} key={p.id}
className="absolute rounded-full bg-amber-500/20" className="absolute rounded-full bg-amber-500/20"
style={{ style={{
left: `${particle.x}%`, left: `${p.x}%`,
top: `${particle.y}%`, top: `${p.y}%`,
width: particle.size, width: p.size,
height: particle.size, height: p.size,
}} }}
animate={{ animate={{
y: [0, -20, 0], y: [0, -20, 0],
@ -148,31 +319,24 @@ export default function LoginPage() {
opacity: [0.2, 0.4, 0.2], opacity: [0.2, 0.4, 0.2],
}} }}
transition={{ transition={{
duration: particle.duration, duration: p.duration,
repeat: Infinity, repeat: Infinity,
delay: particle.delay, delay: p.delay,
ease: "linear" ease: "linear",
}} }}
/> />
))} ))}
</div> </div>
{/* Glow orbs */}
<motion.div <motion.div
className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl" className="absolute top-20 left-20 w-64 h-64 bg-amber-500/10 rounded-full blur-3xl"
animate={{ animate={{ scale: [1, 1.2, 1], x: [0, 30, 0], y: [0, -20, 0] }}
scale: [1, 1.2, 1],
x: [0, 30, 0],
y: [0, -20, 0],
}}
transition={{ duration: 12, repeat: Infinity }} transition={{ duration: 12, repeat: Infinity }}
/> />
<motion.div <motion.div
className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl" className="absolute bottom-20 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"
animate={{ animate={{ scale: [1, 1.3, 1], x: [0, -30, 0], y: [0, 20, 0] }}
scale: [1, 1.3, 1],
x: [0, -30, 0],
y: [0, 20, 0],
}}
transition={{ duration: 15, repeat: Infinity }} transition={{ duration: 15, repeat: Infinity }}
/> />
@ -182,17 +346,15 @@ export default function LoginPage() {
animate="visible" animate="visible"
className="relative w-full max-w-md z-10" className="relative w-full max-w-md z-10"
> >
<motion.div {/* Back link */}
variants={itemVariants} <motion.div variants={itemVariants} className="absolute -top-16 left-0">
className="absolute -top-16 left-0"
>
<Link <Link
href="/" href="/"
className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors" className="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
> >
<motion.div <motion.div
whileHover={{ x: -5 }} whileHover={{ x: -5 }}
transition={{ type: 'spring', stiffness: 400 }} transition={{ type: "spring", stiffness: 400 }}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</motion.div> </motion.div>
@ -204,6 +366,7 @@ export default function LoginPage() {
variants={itemVariants} variants={itemVariants}
className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden" className="bg-white/10 backdrop-blur-2xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"
> >
{/* Header */}
<div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden"> <div className="bg-gradient-to-r from-amber-500 to-amber-600 p-8 text-center relative overflow-hidden">
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
@ -229,81 +392,136 @@ export default function LoginPage() {
transition={{ duration: 2, repeat: Infinity }} transition={{ duration: 2, repeat: Infinity }}
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm" className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
> >
{step === "otp" ? (
<KeyRound className="w-10 h-10 text-white" />
) : (
<Home className="w-10 h-10 text-white" /> <Home className="w-10 h-10 text-white" />
)}
</motion.div> </motion.div>
<h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1> <h1 className="text-3xl font-bold text-white mb-2">SweetHome</h1>
<p className="text-amber-100">مرحباً بعودتك!</p> <p className="text-amber-100">
{step === "otp" ? "أدخل رمز التحقق" : "مرحباً بعودتك!"}
</p>
</motion.div> </motion.div>
</div> </div>
<div className="p-8"> <div className="p-8">
<AnimatePresence mode="wait">
{step === "login" ? (
<motion.form <motion.form
variants={itemVariants} key="login"
onSubmit={handleSubmit} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
onSubmit={handleLogin}
className="space-y-6" className="space-y-6"
> >
<motion.div variants={itemVariants}> {/* Login method tabs */}
<div className="flex gap-2 bg-white/5 p-1 rounded-xl">
<button
type="button"
onClick={() => {
setLoginMethod("email");
setFormData({ ...formData, credential: "" });
}}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
loginMethod === "email"
? "bg-amber-500 text-white shadow-lg"
: "text-gray-400 hover:text-white"
}`}
>
<Mail className="w-4 h-4" />
بريد إلكتروني
</button>
<button
type="button"
onClick={() => {
setLoginMethod("phone");
setFormData({ ...formData, credential: "" });
}}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center justify-center gap-2 ${
loginMethod === "phone"
? "bg-amber-500 text-white shadow-lg"
: "text-gray-400 hover:text-white"
}`}
>
<Phone className="w-4 h-4" />
رقم الهاتف
</button>
</div>
{/* Credential input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
البريد الإلكتروني {loginMethod === "email"
? "البريد الإلكتروني"
: "رقم الهاتف"}
</label> </label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${ {loginMethod === "email" ? (
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500' <Mail
}`} /> className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
</div> />
<input ) : (
type="email" <Phone
value={formData.email} className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
onChange={(e) => {
setFormData({...formData, email: e.target.value});
if (errors.email) setErrors({...errors, email: null});
}}
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.email ? 'border-red-500' : 'border-gray-700'
}`}
placeholder="أدخل بريدك الإلكتروني"
/> />
{formData.email && validateEmail(formData.email) && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute inset-y-0 left-0 pl-3 flex items-center"
>
<CheckCircle className="w-5 h-5 text-green-500" />
</motion.div>
)} )}
</div> </div>
{errors.email && ( <input
type="text"
// type={loginMethod === 'email' ? 'email' : 'tel'}
value={formData.credential}
onChange={(e) => handleCredentialChange(e.target.value)}
className={`w-full pr-12 pl-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.credential
? "border-red-500"
: "border-gray-700"
}`}
placeholder={
loginMethod === "email"
? "example@email.com"
: "+963XXXXXXXXX"
}
dir="ltr"
/>
</div>
{errors.credential && (
<motion.p <motion.p
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1" className="text-red-500 text-sm mt-1"
> >
{errors.email} {errors.credential}
</motion.p> </motion.p>
)} )}
</motion.div> </div>
<motion.div variants={itemVariants}> {/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
كلمة المرور كلمة المرور
</label> </label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 transition-colors ${ <Lock
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500' className={`w-5 h-5 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
}`} /> />
</div> </div>
<input <input
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
value={formData.password} value={formData.password}
onChange={(e) => { onChange={(e) => {
setFormData({...formData, password: e.target.value}); setFormData({
if (errors.password) setErrors({...errors, password: null}); ...formData,
password: e.target.value,
});
if (errors.password)
setErrors({ ...errors, password: null });
}} }}
className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${ className={`w-full pr-12 pl-12 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.password ? 'border-red-500' : 'border-gray-700' errors.password ? "border-red-500" : "border-gray-700"
}`} }`}
placeholder="أدخل كلمة المرور" placeholder="أدخل كلمة المرور"
/> />
@ -328,19 +546,20 @@ export default function LoginPage() {
{errors.password} {errors.password}
</motion.p> </motion.p>
)} )}
</motion.div> </div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between">
<motion.div
variants={itemVariants}
className="flex items-center justify-between"
>
<label className="flex items-center gap-2 cursor-pointer group"> <label className="flex items-center gap-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={formData.rememberMe} checked={formData.rememberMe}
onChange={(e) => setFormData({...formData, rememberMe: e.target.checked})} onChange={(e) =>
setFormData({
...formData,
rememberMe: e.target.checked,
})
}
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0" className="w-4 h-4 rounded border-gray-600 bg-white/5 text-amber-500 focus:ring-amber-500 focus:ring-offset-0"
/> />
<span className="text-sm text-gray-400 group-hover:text-white transition-colors"> <span className="text-sm text-gray-400 group-hover:text-white transition-colors">
@ -353,22 +572,16 @@ export default function LoginPage() {
> >
نسيت كلمة المرور؟ نسيت كلمة المرور؟
</Link> </Link>
</motion.div> </div>
{/* Submit */}
<motion.button <motion.button
variants={itemVariants}
type="submit" type="submit"
disabled={isLoading || isSuccess} disabled={isLoading || isSuccess}
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed" className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<motion.div
className="absolute inset-0 bg-gradient-to-r from-amber-600 to-amber-700"
initial={{ x: '100%' }}
whileHover={{ x: 0 }}
transition={{ duration: 0.3 }}
/>
<span className="relative z-10 flex items-center justify-center gap-2"> <span className="relative z-10 flex items-center justify-center gap-2">
{isLoading ? ( {isLoading ? (
<> <>
@ -389,12 +602,112 @@ export default function LoginPage() {
</span> </span>
</motion.button> </motion.button>
</motion.form> </motion.form>
) : (
/* OTP Verification Step */
<motion.form
key="otp"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleVerifyOTP}
className="space-y-6"
>
<div className="text-center mb-4">
<Shield className="w-12 h-12 text-amber-500 mx-auto mb-3" />
<p className="text-gray-300 text-sm">
تم إرسال رمز التحقق إلى{" "}
<span className="text-white font-medium" dir="ltr">
{formData.credential}
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
رمز التحقق
</label>
<input
type="text"
value={otpCode}
onChange={(e) => {
setOtpCode(e.target.value);
if (otpError) setOtpError("");
}}
className={`w-full px-4 py-4 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent text-white text-center text-2xl tracking-[0.5em] placeholder-gray-500 transition-all ${
otpError ? "border-red-500" : "border-gray-700"
}`}
placeholder="______"
maxLength={6}
dir="ltr"
/>
{otpError && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1 text-center"
>
{otpError}
</motion.p>
)}
</div>
<motion.button
type="submit"
disabled={isLoading || isSuccess}
className="relative w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white py-4 rounded-xl font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span className="flex items-center justify-center gap-2">
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
جاري التحقق...
</>
) : isSuccess ? (
<>
<CheckCircle className="w-5 h-5" />
تم بنجاح!
</>
) : (
<>
<KeyRound className="w-5 h-5" />
تحقق
</>
)}
</span>
</motion.button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={() => {
setStep("login");
setOtpCode("");
setOtpError("");
console.log("[OTP] Going back to login");
}}
className="text-gray-400 hover:text-white transition-colors"
>
العودة
</button>
<button
type="button"
onClick={resendOTP}
className="text-amber-400 hover:text-amber-300 transition-colors"
>
إعادة إرسال الرمز
</button>
</div>
</motion.form>
)}
</AnimatePresence>
<motion.p <motion.p
variants={itemVariants} variants={itemVariants}
className="text-center text-gray-400 mt-6" className="text-center text-gray-400 mt-6"
> >
ليس لديك حساب؟{' '} ليس لديك حساب؟{" "}
<Link <Link
href="/auth/choose-role" href="/auth/choose-role"
className="text-amber-400 hover:text-amber-300 font-medium transition-colors" className="text-amber-400 hover:text-amber-300 font-medium transition-colors"
@ -409,12 +722,18 @@ export default function LoginPage() {
variants={itemVariants} variants={itemVariants}
className="text-center text-gray-500 text-xs mt-4" className="text-center text-gray-500 text-xs mt-4"
> >
بتسجيل الدخول، أنت توافق على{' '} بتسجيل الدخول، أنت توافق على{" "}
<Link href="/terms" className="text-amber-400 hover:text-amber-300 transition-colors"> <Link
href="/terms"
className="text-amber-400 hover:text-amber-300 transition-colors"
>
شروط الاستخدام شروط الاستخدام
</Link> </Link>{" "}
{' '}و{' '} و{" "}
<Link href="/privacy" className="text-amber-400 hover:text-amber-300 transition-colors"> <Link
href="/privacy"
className="text-amber-400 hover:text-amber-300 transition-colors"
>
سياسة الخصوصية سياسة الخصوصية
</Link> </Link>
</motion.p> </motion.p>

36
app/not-found.js Normal file
View File

@ -0,0 +1,36 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function NotFound() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-md"
>
<div className="mb-6">
<svg viewBox="0 0 200 160" className="w-64 h-48 mx-auto">
<circle cx="100" cy="80" r="70" fill="#fef3c7" />
<text x="100" y="95" textAnchor="middle" fontSize="60" fontWeight="bold" fill="#f59e0b">404</text>
<circle cx="80" cy="110" r="8" fill="#92400e" />
<circle cx="120" cy="110" r="8" fill="#92400e" />
<path d="M85 130 Q100 120 115 130" stroke="#92400e" strokeWidth="3" fill="none" strokeLinecap="round" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">الصفحة غير موجودة</h2>
<p className="text-gray-500 mb-8">عذراً، الصفحة التي تبحث عنها غير متوفرة</p>
<Link
href="/"
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors"
>
<Home className="w-5 h-5" />
العودة للرئيسية
</Link>
</motion.div>
</div>
);
}

102
app/notifications/page.js Normal file
View File

@ -0,0 +1,102 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bell, CheckCircle, XCircle, Calendar, MessageCircle } from 'lucide-react';
import AuthService from '@/app/services/AuthService';
import { useNotifications } from '@/app/contexts/NotificationsContext';
export default function NotificationsPage() {
const router = useRouter();
const { notifications, unreadCount, isLoading } = useNotifications();
const [error, setError] = useState(null);
useEffect(() => {
if (!AuthService.isAuthenticated()) {
router.push('/login');
return;
}
}, [router]);
const markAsRead = (id) => {
// This will be handled by context if needed
};
const markAllAsRead = () => {
// This will be handled by context if needed
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-600">جاري التحميل...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-700 mb-2">خطأ في التحميل</h3>
<p className="text-gray-500">{error}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-4xl">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">الإشعارات</h1>
<p className="text-gray-600">
{unreadCount > 0 ? `لديك ${unreadCount} إشعار غير مقروء` : 'جميع الإشعارات مقروءة'}
</p>
</div>
</div>
{notifications.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد إشعارات</h3>
<p className="text-gray-500">ستظهر هنا الإشعارات المتعلقة بحجوزاتك ومدفوعاتك</p>
</div>
) : (
<div className="space-y-4">
{notifications.map((notification, index) => (
<div
key={index}
className="bg-white rounded-2xl shadow-sm border transition-all hover:shadow-md border-gray-200"
>
<div className="p-5 flex gap-4">
<div className="w-12 h-12 bg-blue-50 rounded-full flex items-center justify-center shrink-0">
<Bell className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-gray-900">{notification.title}</h3>
{notification.message && (
<p className="text-gray-600 text-sm mt-1">{notification.message}</p>
)}
{notification.date && (
<p className="text-xs text-gray-400 mt-2">{notification.date}</p>
)}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -33,6 +33,7 @@ import {
Building Building
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
import Image from 'next/image'; import Image from 'next/image';
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => { const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
@ -424,20 +425,20 @@ export default function OwnerBookingsPage() {
const [showCalendar, setShowCalendar] = useState(false); const [showCalendar, setShowCalendar] = useState(false);
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('user'); const authUser = AuthService.getUser();
if (storedUser) { if (authUser && AuthService.isOwner()) {
const userData = JSON.parse(storedUser); setUser({
if (userData.role !== 'owner') { name: authUser.name || authUser.email,
router.push('/'); email: authUser.email,
} else { role: 'owner',
setUser(userData); });
loadBookings(); loadBookings();
}
} else { } else {
router.push('/auth/choose-role'); router.push('/auth/choose-role');
} }
}, [router]); }, [router]);
const loadBookings = () => { const loadBookings = () => {
const storedBookings = localStorage.getItem('ownerBookings'); const storedBookings = localStorage.getItem('ownerBookings');
if (storedBookings) { if (storedBookings) {
@ -510,30 +511,7 @@ export default function OwnerBookingsPage() {
setIsLoading(false); setIsLoading(false);
}; };
useEffect(() => {
let filtered = [...bookings];
if (filterStatus !== 'all') {
filtered = filtered.filter(b => b.status === filterStatus);
}
if (searchTerm) {
filtered = filtered.filter(b =>
b.propertyTitle.includes(searchTerm) ||
b.tenantName.includes(searchTerm) ||
b.id.includes(searchTerm)
);
}
if (dateRange.start) {
filtered = filtered.filter(b => b.startDate >= dateRange.start);
}
if (dateRange.end) {
filtered = filtered.filter(b => b.endDate <= dateRange.end);
}
setFilteredBookings(filtered);
}, [filterStatus, searchTerm, dateRange, bookings]);
const handleViewDetails = (booking) => { const handleViewDetails = (booking) => {
setSelectedBooking(booking); setSelectedBooking(booking);

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -36,6 +36,7 @@ import {
Calendar as CalendarIcon Calendar as CalendarIcon
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => { const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
const [currentMonth, setCurrentMonth] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date());
@ -483,20 +484,21 @@ export default function OwnerCalendarPage() {
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('user'); const authUser = AuthService.getUser();
if (storedUser) { if (authUser && AuthService.isOwner()) {
const userData = JSON.parse(storedUser); setUser({
if (userData.role !== 'owner') { name: authUser.name || authUser.email,
router.push('/'); email: authUser.email,
} else { role: 'owner',
setUser(userData); });
loadProperties(); loadCalendar();
}
} else { } else {
router.push('/auth/choose-role'); router.push('/auth/choose-role');
} }
}, [router]); }, [router]);
const loadProperties = () => { const loadProperties = () => {
const storedProperties = localStorage.getItem('ownerProperties'); const storedProperties = localStorage.getItem('ownerProperties');
if (storedProperties) { if (storedProperties) {

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,326 +1,459 @@
// 'use client';
// import { useState, useEffect } from 'react';
// import { motion } from 'framer-motion';
// import { useRouter } from 'next/navigation';
// import {
// DollarSign,
// TrendingUp,
// Wallet,
// Star,
// Eye,
// Download,
// CalendarDays
// } from 'lucide-react';
// import toast, { Toaster } from 'react-hot-toast';
// import AuthService from '@/app/services/AuthService';
// const StatCard = ({ title, value, icon: Icon, color }) => {
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
// >
// <div className="flex items-center justify-between mb-4">
// <div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
// <Icon className="w-6 h-6 text-white" />
// </div>
// </div>
// <h3 className="text-sm text-gray-500 mb-1">{title}</h3>
// <div className="text-2xl font-bold text-gray-900">{value}</div>
// </motion.div>
// );
// };
// const PropertyProfitCard = ({ property, onViewDetails }) => {
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
// return (
// <motion.div
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
// >
// <div className="p-5">
// <div className="flex justify-between items-start mb-4">
// <div>
// <h3 className="font-bold text-lg text-gray-900">{property.title}</h3>
// {property.isNotSeized && (
// <span className="inline-block mt-1 px-2 py-0.5 bg-amber-100 text-amber-800 rounded-full text-xs font-medium">
// غير محجوز
// </span>
// )}
// </div>
// <span className="text-xs text-gray-500">{property.location}</span>
// </div>
// <div className="grid grid-cols-3 gap-4 mb-4">
// <div className="text-center">
// <div className="text-sm text-gray-500">الإيرادات</div>
// <div className="text-lg font-bold text-amber-600">{formatCurrency(property.revenue)}</div>
// </div>
// <div className="text-center">
// <div className="text-sm text-gray-500">العمولة</div>
// <div className="text-lg font-bold text-blue-600">{formatCurrency(property.commission)}</div>
// </div>
// <div className="text-center">
// <div className="text-sm text-gray-500">المتبقي</div>
// <div className="text-lg font-bold text-green-600">{formatCurrency(property.remaining)}</div>
// </div>
// </div>
// <div className="flex justify-between items-center pt-3 border-t border-gray-100">
// <div className="flex items-center gap-2">
// <Star className="w-4 h-4 text-amber-500" />
// <span className="text-sm font-medium text-gray-700">التقييم العام:</span>
// <span className="text-sm font-medium text-gray-900">{property.valuation}</span>
// </div>
// <div className="flex items-center gap-1 text-sm text-gray-500">
// <CalendarDays className="w-4 h-4" />
// <span>مؤجر {property.rentedCount} مرة</span>
// </div>
// </div>
// <button
// onClick={() => onViewDetails(property)}
// className="w-full mt-4 py-2 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
// >
// <Eye className="w-4 h-4" />
// عرض التفاصيل
// </button>
// </div>
// </motion.div>
// );
// };
// const PropertyCalendar = ({ year, month }) => {
// const [currentMonth, setCurrentMonth] = useState(new Date(year, month - 1));
// const monthNames = ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'];
// const weekDays = ['إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت', 'أحد'];
// const getDaysInMonth = (date) => {
// return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
// };
// const getFirstDayOfMonth = (date) => {
// const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
// return day === 0 ? 6 : day - 1;
// };
// const daysInMonth = getDaysInMonth(currentMonth);
// const firstDayIndex = getFirstDayOfMonth(currentMonth);
// const cells = [];
// for (let i = 0; i < firstDayIndex; i++) {
// cells.push(<div key={`empty-${i}`} className="p-2 md:p-3 text-center" />);
// }
// for (let d = 1; d <= daysInMonth; d++) {
// cells.push(
// <div
// key={d}
// className="p-2 md:p-3 text-center rounded-xl hover:bg-gray-100 transition-colors"
// >
// {d}
// </div>
// );
// }
// return (
// <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
// <div className="flex justify-between items-center mb-6">
// <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
// <CalendarDays className="w-5 h-5 text-amber-500" />
// {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
// </h3>
// <div className="flex gap-2">
// <button
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
// className="p-2 hover:bg-gray-100 rounded-lg"
// >
// &larr;
// </button>
// <button
// onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
// className="p-2 hover:bg-gray-100 rounded-lg"
// >
// &rarr;
// </button>
// </div>
// </div>
// <div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
// {weekDays.map(day => (
// <div key={day}>{day}</div>
// ))}
// </div>
// <div className="grid grid-cols-7 gap-1">
// {cells}
// </div>
// </div>
// );
// };
// export default function OwnerProfitsPage() {
// const router = useRouter();
// const [user, setUser] = useState(null);
// const [isLoading, setIsLoading] = useState(true);
// const [summary] = useState({
// totalRevenue: 4290,
// totalCommission: 644,
// remainingBalance: 3647,
// });
// const [properties] = useState([
// {
// id: 1,
// title: 'Damascus Olive Residence',
// location: 'دمشق، المزة',
// isNotSeized: true,
// revenue: 3240,
// commission: 486,
// remaining: 2754,
// valuation: 'جيد جدا',
// rentedCount: 18,
// },
// ]);
// useEffect(() => {
// if (AuthService.isGuest()) {
// router.push('/auth/choose-role');
// return;
// }
// if (!AuthService.isOwner()) {
// router.push('/');
// return;
// }
// const authUser = AuthService.getUser();
// if (authUser) {
// setUser({
// name: authUser.name || authUser.email,
// email: authUser.email,
// });
// }
// setIsLoading(false);
// }, [router]);
// const formatCurrency = (amount) => `$${amount?.toLocaleString()}`;
// const handleViewDetails = (property) => {
// toast.info(`عرض تفاصيل ${property.title}`);
// };
// const handleExportReport = () => {
// toast.success('جاري تصدير التقرير...');
// };
// if (isLoading) {
// return (
// <div className="min-h-screen flex items-center justify-center">
// <div className="text-center">
// <div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
// <p className="text-gray-600">جاري التحميل...</p>
// </div>
// </div>
// );
// }
// return (
// <div className="min-h-screen bg-gray-50 py-8">
// <Toaster position="top-center" reverseOrder={false} />
// <div className="container mx-auto px-4 max-w-6xl">
// <div className="mb-8">
// <h1 className="text-3xl font-bold text-gray-900 mb-2">دفتر الحسابات</h1>
// <p className="text-gray-600">نظرة عامة على أرباح المالك</p>
// </div>
// <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
// <StatCard
// title="الإيرادات"
// value={formatCurrency(summary.totalRevenue)}
// icon={DollarSign}
// color="bg-green-500"
// />
// <StatCard
// title="العمولة"
// value={formatCurrency(summary.totalCommission)}
// icon={TrendingUp}
// color="bg-blue-500"
// />
// <StatCard
// title="المتبقي"
// value={formatCurrency(summary.remainingBalance)}
// icon={Wallet}
// color="bg-amber-500"
// />
// </div>
// <div className="mb-12">
// <h2 className="text-xl font-bold text-gray-900 mb-4">عقاراتي</h2>
// <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
// {properties.map((property) => (
// <PropertyProfitCard
// key={property.id}
// property={property}
// onViewDetails={handleViewDetails}
// />
// ))}
// </div>
// </div>
// <div className="mb-12">
// <h2 className="text-xl font-bold text-gray-900 mb-4">تقويم العقار</h2>
// <PropertyCalendar year={2026} month={3} />
// </div>
// {/* <div className="flex justify-end">
// <button
// onClick={handleExportReport}
// className="px-6 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
// >
// <Download className="w-5 h-5" />
// تصدير التقرير
// </button>
// </div> */}
// </div>
// </div>
// );
// }
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import { Download, Loader2 } from 'lucide-react';
import {
DollarSign,
TrendingUp,
TrendingDown,
Calendar,
Home,
Building,
Download,
Filter,
ChevronLeft,
ChevronRight,
ArrowLeft,
Loader2,
Eye,
PieChart,
BarChart,
LineChart,
Wallet,
CreditCard,
Clock,
CheckCircle,
XCircle
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import * as XLSX from 'xlsx';
const StatCard = ({ title, value, change, icon: Icon, color, trend }) => { import AuthService from '@/app/services/AuthService';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-all"
>
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 ${color} rounded-xl flex items-center justify-center`}>
<Icon className="w-6 h-6 text-white" />
</div>
<div className={`flex items-center gap-1 text-sm ${trend === 'up' ? 'text-green-600' : 'text-red-600'}`}>
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{Math.abs(change)}%</span>
</div>
</div>
<h3 className="text-sm text-gray-600 mb-1">{title}</h3>
<div className="text-2xl font-bold text-gray-900">{value}</div>
</motion.div>
);
};
const PropertyProfitCard = ({ property, onViewDetails }) => {
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-all"
>
<div className="p-5">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-bold text-gray-900 mb-1">{property.title}</h3>
<p className="text-sm text-gray-500">{property.location}</p>
</div>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
property.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}>
{property.status === 'active' ? 'نشط' : 'غير نشط'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 p-3 rounded-xl text-center">
<DollarSign className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-xs text-gray-500">إجمالي الأرباح</div>
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Calendar className="w-5 h-5 text-blue-500 mx-auto mb-1" />
<div className="text-xs text-gray-500">عدد الحجوزات</div>
<div className="text-lg font-bold text-blue-600">{property.totalBookings}</div>
</div>
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-500">هذا الشهر</span>
<span className="font-medium text-gray-900">{formatCurrency(property.monthlyProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">الأسبوع الماضي</span>
<span className="font-medium text-gray-900">{formatCurrency(property.weeklyProfit)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">متوسط السعر اليومي</span>
<span className="font-medium text-gray-900">{formatCurrency(property.avgDailyPrice)}</span>
</div>
</div>
<button
onClick={() => onViewDetails(property)}
className="w-full bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2"
>
<Eye className="w-4 h-4" />
عرض التفاصيل
</button>
</div>
</motion.div>
);
};
const ProfitDetailsModal = ({ property, isOpen, onClose }) => {
if (!isOpen || !property) return null;
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const monthlyData = [
{ month: 'يناير', profit: 1250000 },
{ month: 'فبراير', profit: 1500000 },
{ month: 'مارس', profit: 1800000 },
{ month: 'إبريل', profit: 2100000 },
{ month: 'مايو', profit: 2500000 },
{ month: 'يونيو', profit: 2300000 }
];
const maxProfit = Math.max(...monthlyData.map(d => d.profit));
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold">{property.title}</h2>
<p className="text-amber-100 text-sm mt-1">{property.location}</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
<XCircle className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-amber-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-amber-600">{formatCurrency(property.totalProfit)}</div>
<div className="text-xs text-gray-600 mt-1">إجمالي الأرباح</div>
</div>
<div className="bg-blue-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-blue-600">{property.totalBookings}</div>
<div className="text-xs text-gray-600 mt-1">عدد الحجوزات</div>
</div>
<div className="bg-green-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-green-600">{property.occupancyRate}%</div>
<div className="text-xs text-gray-600 mt-1">نسبة الإشغال</div>
</div>
<div className="bg-purple-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-purple-600">{formatCurrency(property.avgDailyPrice)}</div>
<div className="text-xs text-gray-600 mt-1">متوسط السعر اليومي</div>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-4">الأرباح الشهرية</h3>
<div className="space-y-3">
{monthlyData.map((data, index) => (
<div key={index}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{data.month}</span>
<span className="font-medium text-gray-900">{formatCurrency(data.profit)}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(data.profit / maxProfit) * 100}%` }}
transition={{ duration: 0.8, delay: index * 0.1 }}
className="bg-amber-500 h-2 rounded-full"
/>
</div>
</div>
))}
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-4">آخر الحجوزات</h3>
<div className="space-y-3">
{property.recentBookings?.map((booking, index) => (
<div key={index} className="bg-white p-3 rounded-lg flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{booking.tenantName}</p>
<p className="text-xs text-gray-500">{booking.startDate} - {booking.endDate}</p>
</div>
<div className="text-right">
<p className="font-bold text-amber-600">{formatCurrency(booking.amount)}</p>
<p className="text-xs text-gray-500">{booking.status === 'completed' ? 'مكتمل' : 'قيد التنفيذ'}</p>
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
</motion.div>
);
};
export default function OwnerProfitsPage() { export default function OwnerProfitsPage() {
const router = useRouter(); const router = useRouter();
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [properties, setProperties] = useState([]);
const [filteredProperties, setFilteredProperties] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedProperty, setSelectedProperty] = useState(null); const [tableData, setTableData] = useState([]);
const [dateRange, setDateRange] = useState({ start: '', end: '' });
const [selectedPeriod, setSelectedPeriod] = useState('month'); // month, year, all
useEffect(() => { const sampleData = [
const storedUser = localStorage.getItem('user');
if (storedUser) {
const userData = JSON.parse(storedUser);
if (userData.role !== 'owner') {
router.push('/');
} else {
setUser(userData);
loadProfitsData();
}
} else {
router.push('/auth/choose-role');
}
}, [router]);
const loadProfitsData = () => {
const storedProfits = localStorage.getItem('ownerProfits');
if (storedProfits) {
setProperties(JSON.parse(storedProfits));
setFilteredProperties(JSON.parse(storedProfits));
} else {
const mockProperties = [
{ {
id: 1, id: 1,
title: 'فيلا فاخرة في المزة', property: 'A000000001',
location: 'دمشق، المزة', bookingNumber: 'XX-101',
status: 'active', fromDate: '2025-05-01',
totalProfit: 12500000, toDate: '2025-05-07',
totalBookings: 24, amountReceived: 500,
monthlyProfit: 3200000, platformCommission: 0,
weeklyProfit: 850000, transferredToOwner: 0,
avgDailyPrice: 500000, transferReceipt: '—',
occupancyRate: 78,
recentBookings: [
{ tenantName: 'أحمد محمد', startDate: '2024-03-10', endDate: '2024-03-15', amount: 2500000, status: 'completed' },
{ tenantName: 'سارة أحمد', startDate: '2024-03-05', endDate: '2024-03-08', amount: 1500000, status: 'completed' }
]
}, },
{ {
id: 2, id: 2,
title: 'شقة حديثة في الشهباء', property: 'A000000002',
location: 'حلب، الشهباء', bookingNumber: 'XX-202',
status: 'active', fromDate: '2025-05-10',
totalProfit: 5800000, toDate: '2025-05-15',
totalBookings: 18, amountReceived: 300,
monthlyProfit: 1500000, platformCommission: 0,
weeklyProfit: 400000, transferredToOwner: 0,
avgDailyPrice: 250000, transferReceipt: '—',
occupancyRate: 65,
recentBookings: [
{ tenantName: 'محمد علي', startDate: '2024-03-12', endDate: '2024-03-14', amount: 750000, status: 'completed' }
]
}, },
{ {
id: 3, id: 3,
title: 'بيت عائلي في بابا عمرو', property: 'A000000003',
location: 'حمص، بابا عمرو', bookingNumber: 'XX-309',
status: 'active', fromDate: '2025-06-01',
totalProfit: 8400000, toDate: '2025-06-05',
totalBookings: 12, amountReceived: 800,
monthlyProfit: 2100000, platformCommission: 150,
weeklyProfit: 525000, transferredToOwner: 0,
avgDailyPrice: 350000, transferReceipt: 'قيد الانتظار',
occupancyRate: 45, },
recentBookings: []
}
]; ];
setProperties(mockProperties);
setFilteredProperties(mockProperties);
localStorage.setItem('ownerProfits', JSON.stringify(mockProperties)); const computeRows = (data) => {
return data.map((item) => {
const platformProfit = item.amountReceived * 0.05;
const ownerDue = item.amountReceived - platformProfit;
return {
...item,
platformProfit,
ownerDue,
};
});
};
useEffect(() => {
if (AuthService.isGuest()) {
router.push('/auth/choose-role');
return;
}
if (!AuthService.isOwner()) {
router.push('/');
return;
}
const authUser = AuthService.getUser();
if (authUser) {
setUser({
name: authUser.name || authUser.email,
email: authUser.email,
});
}
const stored = localStorage.getItem('ownerProfitsTable');
if (stored) {
setTableData(computeRows(JSON.parse(stored)));
} else {
setTableData(computeRows(sampleData));
localStorage.setItem('ownerProfitsTable', JSON.stringify(sampleData));
} }
setIsLoading(false); setIsLoading(false);
}; }, [router]);
const totalStats = { const totals = tableData.reduce(
totalProfit: properties.reduce((sum, p) => sum + p.totalProfit, 0), (acc, row) => {
totalBookings: properties.reduce((sum, p) => sum + p.totalBookings, 0), acc.totalAmountReceived += row.amountReceived;
avgOccupancy: Math.round(properties.reduce((sum, p) => sum + p.occupancyRate, 0) / properties.length), acc.totalCommission += row.platformCommission;
activeProperties: properties.filter(p => p.status === 'active').length acc.totalPlatformProfit += row.platformProfit;
}; acc.totalOwnerDue += row.ownerDue;
acc.totalTransferred += row.transferredToOwner;
const formatCurrency = (amount) => { return acc;
if (amount >= 1000000) { },
return (amount / 1000000).toFixed(1) + ' مليون ل.س'; {
totalAmountReceived: 0,
totalCommission: 0,
totalPlatformProfit: 0,
totalOwnerDue: 0,
totalTransferred: 0,
}
);
const handleExportReport = () => {
try {
const exportData = tableData.map((row) => ({
'العقار': row.property,
'رقم الحجز': row.bookingNumber,
'من تاريخ': row.fromDate,
'حتى تاريخ': row.toDate,
'العروض المستلم': row.amountReceived,
'عمولة المنصة': row.platformCommission,
'ربح المنصة (5%)': row.platformProfit,
'المستحق للمالك': row.ownerDue,
'تم التحويل للمالك': row.transferredToOwner,
'رقم وصل التحويل': row.transferReceipt,
}));
exportData.push({
'العقار': 'الإجمالي العام',
'رقم الحجز': '',
'من تاريخ': '',
'حتى تاريخ': '',
'العروض المستلم': totals.totalAmountReceived,
'عمولة المنصة': totals.totalCommission,
'ربح المنصة (5%)': totals.totalPlatformProfit,
'المستحق للمالك': totals.totalOwnerDue,
'تم التحويل للمالك': totals.totalTransferred,
'رقم وصل التحويل': '—',
});
const worksheet = XLSX.utils.json_to_sheet(exportData);
const colWidths = [
{ wch: 15 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 14 },
{ wch: 14 },
{ wch: 16 },
{ wch: 16 },
{ wch: 16 },
{ wch: 18 },
];
worksheet['!cols'] = colWidths;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'أرباح المالك');
XLSX.writeFile(workbook, `تقرير_الأرباح_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.xlsx`);
toast.success('تم تصدير التقرير بنجاح!');
} catch (error) {
console.error('خطأ في التصدير:', error);
toast.error('حدث خطأ أثناء تصدير التقرير');
} }
return amount?.toLocaleString() + ' ل.س';
}; };
if (isLoading) { if (isLoading) {
@ -335,129 +468,122 @@ export default function OwnerProfitsPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8" dir="rtl">
<Toaster position="top-center" reverseOrder={false} /> <Toaster position="top-center" reverseOrder={false} />
<div className="container mx-auto px-4 max-w-7xl">
<ProfitDetailsModal
property={selectedProperty}
isOpen={!!selectedProperty}
onClose={() => setSelectedProperty(null)}
/>
<div className="container mx-auto px-4">
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4" className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
> >
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">الأرباح والإحصائيات</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
<p className="text-gray-600">مرحباً {user?.name}، إليك ملخص أرباحك</p> <p className="text-gray-600">
مرحباً {user?.name}
</p>
</div> </div>
<button
<div className="flex gap-3"> onClick={handleExportReport}
<select className="px-5 py-2.5 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors flex items-center gap-2 shadow-sm"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
> >
<option value="month">آخر 30 يوم</option>
<option value="year">آخر 12 شهر</option>
<option value="all">جميع الفترات</option>
</select>
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
<Download className="w-5 h-5" /> <Download className="w-5 h-5" />
تصدير التقرير تصدير التقرير
</button> */} </button>
</div>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="إجمالي الأرباح"
value={formatCurrency(totalStats.totalProfit)}
change={12.5}
icon={Wallet}
color="bg-amber-500"
trend="up"
/>
<StatCard
title="عدد الحجوزات"
value={totalStats.totalBookings}
change={8.2}
icon={Calendar}
color="bg-blue-500"
trend="up"
/>
<StatCard
title="متوسط نسبة الإشغال"
value={`${totalStats.avgOccupancy}%`}
change={5.3}
icon={PieChart}
color="bg-green-500"
trend="up"
/>
<StatCard
title="العقارات النشطة"
value={totalStats.activeProperties}
change={0}
icon={Building}
color="bg-purple-500"
trend="up"
/>
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900">أرباح العقارات</h2>
<div className="flex gap-2">
<button
onClick={() => setFilteredProperties(properties)}
className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm hover:bg-gray-200 transition-colors"
>
عرض الكل
</button>
</div>
</div>
{filteredProperties.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<div className="w-24 h-24 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<DollarSign className="w-12 h-12 text-amber-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد بيانات</h3>
<p className="text-gray-600">لا توجد أرباح مسجلة حتى الآن</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredProperties.map((property) => (
<PropertyProfitCard
key={property.id}
property={property}
onViewDetails={setSelectedProperty}
/>
))}
</div>
)}
</div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }} className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
className="bg-gradient-to-r from-amber-500 to-amber-600 rounded-2xl p-6 text-white mt-8"
> >
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="overflow-x-auto">
<div> <table className="min-w-full divide-y divide-gray-200 text-sm">
<h3 className="text-lg font-bold mb-1">احصل على المزيد من الأرباح</h3> <thead className="bg-gray-800 text-gray-100">
<p className="text-amber-100 text-sm">أضف عقارات جديدة وحسّن أسعارك لزيادة الإشغال</p> <tr>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العقار</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم الحجز</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">من تاريخ</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">حتى تاريخ</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">العروض المستلم</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">عمولة المنصة</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider bg-amber-50 text-amber-800">
ربح المنصة <span className="font-normal text-[11px] block">(5% من العربون)</span>
</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">المستحق للمالك</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">تم التحويل للمالك</th>
<th className="px-4 py-4 text-center text-xs font-semibold uppercase tracking-wider">رقم وصل التحويل</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{tableData.map((row, idx) => (
<tr
key={row.id}
className={`hover:bg-amber-50/40 transition-colors ${
idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'
}`}
>
<td className="px-4 py-3 whitespace-nowrap text-center font-medium text-gray-800">
{row.property}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.bookingNumber}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.fromDate}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-700">
{row.toDate}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-gray-800">
{row.amountReceived}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
{row.platformCommission}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-bold text-amber-700 bg-amber-50/50">
{row.platformProfit}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono font-semibold text-emerald-700">
{row.ownerDue}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center font-mono text-gray-700">
{row.transferredToOwner}
</td>
<td className="px-4 py-3 whitespace-nowrap text-center text-gray-500 text-xs">
{row.transferReceipt}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-100 border-t-2 border-gray-300">
<tr>
<td colSpan="4" className="px-4 py-4 text-right font-bold text-gray-800">
الإجمالي العام
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalAmountReceived}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalCommission}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-amber-700 bg-amber-100/60">
{totals.totalPlatformProfit}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-emerald-700">
{totals.totalOwnerDue}
</td>
<td className="px-4 py-4 text-center font-bold font-mono text-gray-800">
{totals.totalTransferred}
</td>
<td className="px-4 py-4 text-center text-gray-500"></td>
</tr>
</tfoot>
</table>
</div> </div>
<Link
href="/owner/properties/add" <div className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
className="px-6 py-2 bg-white text-amber-600 rounded-xl font-medium hover:bg-amber-50 transition-colors" <span className="inline-flex items-center gap-1"></span> ملاحظة:
> <strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
إضافة عقار جديد
</Link>
</div> </div>
</motion.div> </motion.div>
</div> </div>

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -51,12 +51,27 @@ import {
Move Move
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import { addRentProperty, getCurrencies, uploadPicture } from '../../../utils/api';
import {
BuildingType,
RentPropertyCondition,
RentPropertyType,
RentType,
PropertyService,
PropertyServiceLabels,
PropertyServicesList,
PropertyTerm,
PropertyTermLabels,
PropertyTermsList,
Currency,
CurrencyLabels
} from '../../../enums';
const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapContainer), { ssr: false }); const MapContainer = dynamic(() => import('react-leaflet').then(mod => mod.MapContainer), { ssr: false });
const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false }); const TileLayer = dynamic(() => import('react-leaflet').then(mod => mod.TileLayer), { ssr: false });
const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false }); const Marker = dynamic(() => import('react-leaflet').then(mod => mod.Marker), { ssr: false });
const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false }); const Popup = dynamic(() => import('react-leaflet').then(mod => mod.Popup), { ssr: false });
const useMapEvents = dynamic(() => import('react-leaflet').then(mod => mod.useMapEvents), { ssr: false }); import { useMapEvents } from 'react-leaflet';
function MapClickHandler({ onMapClick }) { function MapClickHandler({ onMapClick }) {
const map = useMapEvents({ const map = useMapEvents({
@ -84,29 +99,27 @@ export default function AddPropertyPage() {
livingRooms: 1, livingRooms: 1,
services: { services: {
electricity: false, [PropertyService.ELECTRICITY]: false,
internet: false, [PropertyService.INTERNET]: false,
heating: false, [PropertyService.HEATING]: false,
water: false, [PropertyService.WATER]: false,
airConditioning: false, [PropertyService.CENTRAL_AIR_CONDITIONING]: false,
parking: false, [PropertyService.PARKING]: false,
elevator: false [PropertyService.ELEVATOR]: false
}, },
serviceDetails: {},
terms: { terms: {
noSmoking: false, [PropertyTerm.NO_SMOKING]: false,
noPets: false, [PropertyTerm.NO_ANIMALS]: false,
noParties: false, [PropertyTerm.NO_PARTIES]: false
noAlcohol: false,
suitableForChildren: true,
suitableForElderly: true
}, },
offerType: 'daily', offerType: 'daily',
dailyPrice: '', dailyPrice: '',
monthlyPrice: '', monthlyPrice: '',
salePrice: '',
city: '', city: '',
district: '', district: '',
@ -120,11 +133,15 @@ export default function AddPropertyPage() {
}); });
const [imagePreviews, setImagePreviews] = useState([]); const [imagePreviews, setImagePreviews] = useState([]);
const [uploadedImagePaths, setUploadedImagePaths] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null); const [selectedLocation, setSelectedLocation] = useState(null);
const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]); const [mapCenter, setMapCenter] = useState([33.5138, 36.2765]);
const [mapZoom, setMapZoom] = useState(13);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
const [currencies, setCurrencies] = useState([]);
const [selectedCurrencyId, setSelectedCurrencyId] = useState(Currency.SYP);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
@ -140,30 +157,26 @@ export default function AddPropertyPage() {
]; ];
const serviceList = [ const serviceList = [
{ id: 'electricity', label: 'كهرباء', icon: Zap }, { id: PropertyService.ELECTRICITY, label: PropertyServiceLabels[PropertyService.ELECTRICITY], icon: Zap },
{ id: 'internet', label: 'انترنت', icon: Wifi }, { id: PropertyService.INTERNET, label: PropertyServiceLabels[PropertyService.INTERNET], icon: Wifi },
{ id: 'heating', label: 'تدفئة', icon: Flame }, { id: PropertyService.HEATING, label: PropertyServiceLabels[PropertyService.HEATING], icon: Flame },
{ id: 'water', label: 'ماء', icon: Droplets }, { id: PropertyService.WATER, label: PropertyServiceLabels[PropertyService.WATER], icon: Droplets },
{ id: 'airConditioning', label: 'تكييف', icon: Wind }, { id: PropertyService.CENTRAL_AIR_CONDITIONING, label: PropertyServiceLabels[PropertyService.CENTRAL_AIR_CONDITIONING], icon: Wind },
{ id: 'parking', label: 'موقف سيارات', icon: Warehouse }, { id: PropertyService.PARKING, label: PropertyServiceLabels[PropertyService.PARKING], icon: Warehouse },
{ id: 'elevator', label: 'مصعد', icon: Layers } { id: PropertyService.ELEVATOR, label: PropertyServiceLabels[PropertyService.ELEVATOR], icon: Layers },
]; ];
const termsList = [ const termsList = [
{ id: 'noSmoking', label: 'ممنوع التدخين', icon: Cigarette }, { id: PropertyTerm.NO_SMOKING, label: PropertyTermLabels[PropertyTerm.NO_SMOKING], icon: Cigarette },
{ id: 'noPets', label: 'ممنوع الحيوانات', icon: Dog }, { id: PropertyTerm.NO_ANIMALS, label: PropertyTermLabels[PropertyTerm.NO_ANIMALS], icon: Dog },
{ id: 'noParties', label: 'عدم إقامة حفلات', icon: Music }, { id: PropertyTerm.NO_PARTIES, label: PropertyTermLabels[PropertyTerm.NO_PARTIES], icon: Music },
{ id: 'noAlcohol', label: 'ممنوع الكحول', icon: X },
{ id: 'suitableForChildren', label: 'مناسب للأطفال', icon: Star },
{ id: 'suitableForElderly', label: 'مناسب لكبار السن', icon: Star }
]; ];
const offerTypes = [ const offerTypes = [
{ id: 'daily', label: 'إيجار يومي', icon: Clock }, { id: 'daily', label: 'إيجار يومي', icon: Clock },
{ id: 'monthly', label: 'إيجار شهري', icon: Calendar }, { id: 'monthly', label: 'إيجار شهري', icon: Calendar },
{ id: 'both', label: 'إيجار يومي وشهري', icon: Calendar }, { id: 'both', label: 'إيجار يومي وشهري', icon: Calendar },
{ id: 'sale', label: 'للبيع', icon: DollarSign } ].filter(Boolean);
];
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -176,6 +189,16 @@ export default function AddPropertyPage() {
}); });
} }
setMapLoaded(true); setMapLoaded(true);
// Fetch available currencies
getCurrencies().then((data) => {
if (Array.isArray(data) && data.length > 0) {
setCurrencies(data);
console.log('[AddProperty] Currencies loaded:', data);
}
}).catch((err) => {
console.warn('[AddProperty] Failed to load currencies:', err);
});
}, []); }, []);
const handleSearch = async () => { const handleSearch = async () => {
@ -317,36 +340,48 @@ const handleMapClick = async (coords) => {
toast.info('تم إلغاء تحديد الموقع'); toast.info('تم إلغاء تحديد الموقع');
}; };
const handleImageUpload = (files) => { const handleImageUpload = async (files) => {
const newImages = Array.from(files); const newImages = Array.from(files);
console.log('[AddProperty] handleImageUpload called with', newImages.length, 'files');
if (formData.images.length + newImages.length > 10) { if (formData.images.length + newImages.length > 10) {
toast.error('يمكنك رفع 10 صور كحد أقصى'); toast.error('يمكنك رفع 10 صور كحد أقصى');
return; return;
} }
newImages.forEach(file => { for (const file of newImages) {
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('الرجاء اختيار صور صالحة فقط'); toast.error('الرجاء اختيار صور صالحة فقط');
return; continue;
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت'); toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
return; continue;
} }
// Show preview
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setImagePreviews(prev => [...prev, reader.result]); setImagePreviews(prev => [...prev, reader.result]);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
setFormData({ setFormData(prev => ({
...formData, ...prev,
images: [...formData.images, file] images: [...prev.images, file]
}); }));
});
// Upload to server immediately
try {
const path = await uploadPicture(file);
setUploadedImagePaths(prev => [...prev, path]);
console.log('[AddProperty] Image uploaded:', path);
} catch (err) {
console.error('[AddProperty] Image upload failed:', err);
toast.error('فشل رفع الصورة: ' + file.name);
}
}
}; };
const removeImage = (index) => { const removeImage = (index) => {
@ -356,30 +391,34 @@ const handleMapClick = async (coords) => {
const newPreviews = [...imagePreviews]; const newPreviews = [...imagePreviews];
newPreviews.splice(index, 1); newPreviews.splice(index, 1);
setFormData({ const newPaths = [...uploadedImagePaths];
...formData, newPaths.splice(index, 1);
images: newImages
}); setFormData(prev => ({ ...prev, images: newImages }));
setImagePreviews(newPreviews); setImagePreviews(newPreviews);
setUploadedImagePaths(newPaths);
}; };
const toggleService = (serviceId) => { const toggleService = (serviceId) => {
setFormData({ setFormData(prev => {
...formData, const services = { ...prev.services };
services: { services[serviceId] = !services[serviceId];
...formData.services, return { ...prev, services };
[serviceId]: !formData.services[serviceId]
}
}); });
}; };
const updateServiceDetail = (serviceId, value) => {
setFormData(prev => ({
...prev,
serviceDetails: { ...prev.serviceDetails, [serviceId]: value }
}));
};
const toggleTerm = (termId) => { const toggleTerm = (termId) => {
setFormData({ setFormData(prev => {
...formData, const terms = { ...prev.terms };
terms: { terms[termId] = !terms[termId];
...formData.terms, return { ...prev, terms };
[termId]: !formData.terms[termId]
}
}); });
}; };
@ -464,9 +503,6 @@ const handleMapClick = async (coords) => {
if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب'; if (!formData.dailyPrice) newErrors.dailyPrice = 'السعر اليومي مطلوب';
if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب'; if (!formData.monthlyPrice) newErrors.monthlyPrice = 'السعر الشهري مطلوب';
} }
if (formData.offerType === 'sale' && !formData.salePrice) {
newErrors.salePrice = 'سعر البيع مطلوب';
}
break; break;
case 4: case 4:
@ -499,16 +535,92 @@ const handleMapClick = async (coords) => {
if (!validateStep()) return; if (!validateStep()) return;
setIsLoading(true); setIsLoading(true);
console.log('[AddProperty] Building RentPropertyDto payload...');
setTimeout(() => { // Map UI property type to API BuildingType enum
console.log('Property Data:', formData); const buildingTypeMap = { apartment: BuildingType.APARTMENT, villa: BuildingType.VILLA, suite: BuildingType.APARTMENT, room: BuildingType.APARTMENT };
setIsLoading(false);
// Map offer type to RentType enum: 0=Monthly, 1=Daily
const rentTypeMap = { daily: RentType.DAILY, monthly: RentType.MONTHLY, both: RentType.MONTHLY };
// Services: collect selected service enum names into array
const selectedServices = Object.entries(formData.services)
.filter(([, v]) => v)
.map(([k]) => k); // k is already the enum value (e.g. "Electricity")
// Terms: collect selected term enum names into array
const selectedTerms = Object.entries(formData.terms)
.filter(([, v]) => v)
.map(([k]) => k); // k is already the enum value (e.g. "NoSmoking")
// Build detailsJSON matching Flutter structure
const detailsJSON = JSON.stringify({
services: selectedServices,
serviceDetails: selectedServices.reduce((acc, s) => ({ ...acc, [s]: 'in general' }), {}),
terms: selectedTerms,
displayType: formData.offerType === 'both' ? 'Both' : formData.offerType === 'daily' ? 'Daily' : 'Monthly',
propertyCondition: formData.furnished ? 'Furnished' : 'Unfurnished',
photos: imagePreviews.map((_, i) => `photo_${i}.jpg`),
room: {
areaType: formData.propertyType === 'room' ? 'Shared room' : 'Private room',
peopleAllowed: String(formData.bedrooms),
entranceType: formData.propertyType === 'room' ? 'Shared entrance' : 'Private entrance',
bathroomType: formData.bathrooms > 1 ? 'Private' : 'Shared',
kitchenType: 'Not available',
hasRestrictedOwnerAreas: false,
languageDialect: '',
hasChildren: false,
hasPets: false,
dedicatedTo: 'Everyone',
visitorsAllowed: true,
quietTimesEnabled: false,
quietTimes: '',
}
});
const payload = {
propertyInformation: {
cordsX: formData.lat ? String(formData.lat) : '',
cordsY: formData.lng ? String(formData.lng) : '',
address: `${formData.city} - ${formData.district} - ${formData.address}`.trim(),
description: formData.description || '',
numberOfBathRooms: formData.bathrooms || 0,
numberOfRooms: (formData.bedrooms || 0) + (formData.livingRooms || 0),
numberOfBedRooms: formData.bedrooms || 0,
space: parseFloat(formData.space) || 0,
detailsJSON,
buildingType: buildingTypeMap[formData.propertyType] ?? BuildingType.APARTMENT,
status: 0,
propertyType: formData.furnished ? RentPropertyCondition.WITH_FURNITURE : RentPropertyCondition.WITHOUT_FURNITURE,
images: uploadedImagePaths,
},
deposit: parseFloat(formData.deposit) || 0,
monthlyRent: parseFloat(formData.monthlyPrice) || 0,
dailyRent: parseFloat(formData.dailyPrice) || 0,
rating: 0,
currencyId: selectedCurrencyId,
rentType: rentTypeMap[formData.offerType] ?? RentType.MONTHLY,
isSmokeAllow: !formData.terms[PropertyTerm.NO_SMOKING],
specializedFor: false,
isVisitorAllow: !formData.terms[PropertyTerm.NO_PARTIES],
type: formData.furnished ? RentPropertyType.FURNISHED : RentPropertyType.UNFURNISHED,
};
console.log('[AddProperty] Payload:', JSON.stringify(payload, null, 2));
try {
const res = await addRentProperty(payload);
console.log('[AddProperty] API response:', res);
toast.success('تم إضافة العقار بنجاح!'); toast.success('تم إضافة العقار بنجاح!');
setTimeout(() => { setTimeout(() => {
router.push('/owner/properties'); router.push('/owner/properties');
}, 1500); }, 1500);
}, 2000); } catch (err) {
console.error('[AddProperty] API error:', err);
toast.error(err.message || 'فشل في إضافة العقار');
} finally {
setIsLoading(false);
}
}; };
const fadeInUp = { const fadeInUp = {
@ -517,15 +629,6 @@ const handleMapClick = async (coords) => {
transition: { duration: 0.5 } transition: { duration: 0.5 }
}; };
function MapClickHandler({ onMapClick }) {
const map = useMapEvents({
dblclick: (e) => {
const { lat, lng } = e.latlng;
onMapClick([lat, lng]);
},
});
return null;
}
return ( return (
<div className="min-h-screen bg-gray-50 py-8"> <div className="min-h-screen bg-gray-50 py-8">
<Toaster position="top-center" reverseOrder={false} /> <Toaster position="top-center" reverseOrder={false} />
@ -752,34 +855,37 @@ function MapClickHandler({ onMapClick }) {
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة</h3> <h3 className="text-lg font-bold text-gray-900 mb-4">الخدمات المتوفرة <span className="text-red-500">*</span></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="space-y-3">
{serviceList.map((service) => { {serviceList.map((service) => {
const Icon = service.icon; const Icon = service.icon;
const isSelected = formData.services[service.id];
return ( return (
<label <div key={service.id} className={`border rounded-xl transition-all ${isSelected ? 'border-amber-500 bg-amber-50' : 'border-gray-200'}`}>
key={service.id} <label className="flex items-center gap-3 p-3 cursor-pointer">
className={`flex items-center gap-2 p-3 border rounded-xl cursor-pointer transition-all ${
formData.services[service.id]
? 'border-amber-500 bg-amber-50'
: 'border-gray-200 hover:border-amber-200 hover:bg-amber-50/50'
}`}
>
<input <input
type="checkbox" type="checkbox"
checked={formData.services[service.id]} checked={isSelected}
onChange={() => toggleService(service.id)} onChange={() => toggleService(service.id)}
className="hidden" className="w-4 h-4 text-amber-500 rounded"
/> />
<Icon className={`w-5 h-5 ${ <Icon className={`w-5 h-5 ${isSelected ? 'text-amber-600' : 'text-gray-400'}`} />
formData.services[service.id] ? 'text-amber-600' : 'text-gray-400' <span className={`text-sm font-medium ${isSelected ? 'text-amber-700' : 'text-gray-600'}`}>
}`} />
<span className={`text-sm ${
formData.services[service.id] ? 'text-amber-700' : 'text-gray-600'
}`}>
{service.label} {service.label}
</span> </span>
</label> </label>
{isSelected && (
<div className="px-3 pb-3">
<input
type="text"
value={formData.serviceDetails[service.id] || ''}
onChange={(e) => updateServiceDetail(service.id, e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="تفاصيل الخدمة (مثال: في جميع الغرف)"
/>
</div>
)}
</div>
); );
})} })}
</div> </div>
@ -857,6 +963,41 @@ function MapClickHandler({ onMapClick }) {
</div> </div>
</div> </div>
{/* Currency dropdown */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
العملة <span className="text-red-500">*</span>
</label>
<select
value={selectedCurrencyId}
onChange={(e) => setSelectedCurrencyId(parseInt(e.target.value))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
>
{Object.entries(CurrencyLabels).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</div>
{/* Deposit field */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
مبلغ الضمان (العربون)
</label>
<div className="relative">
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="number"
value={formData.deposit || ''}
onChange={(e) => setFormData({...formData, deposit: e.target.value})}
className="w-full pr-12 pl-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="مثال: 500000"
/>
</div>
</div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{(formData.offerType === 'daily' || formData.offerType === 'both') && ( {(formData.offerType === 'daily' || formData.offerType === 'both') && (
<motion.div <motion.div
@ -919,37 +1060,6 @@ function MapClickHandler({ onMapClick }) {
</div> </div>
</motion.div> </motion.div>
)} )}
{formData.offerType === 'sale' && (
<motion.div
key="sale"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
سعر البيع (ل.س) <span className="text-red-500">*</span>
</label>
<div className="relative">
<DollarSign className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="number"
value={formData.salePrice}
onChange={(e) => setFormData({...formData, salePrice: e.target.value})}
className={`w-full pr-12 pl-4 py-3 border rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500 ${
errors.salePrice ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="أدخل السعر المطلوب"
/>
</div>
{errors.salePrice && (
<p className="text-red-500 text-sm mt-1">{errors.salePrice}</p>
)}
</div>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
</motion.div> </motion.div>
)} )}

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -45,6 +45,8 @@ import {
X X
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
import { getMyRentListings } from '../../utils/api';
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => { const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
if (!isOpen) return null; if (!isOpen) return null;
@ -692,70 +694,84 @@ export default function OwnerPropertiesPage() {
const [editModal, setEditModal] = useState({ isOpen: false, property: null }); const [editModal, setEditModal] = useState({ isOpen: false, property: null });
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('user'); const authUser = AuthService.getUser();
if (storedUser) { if (authUser && AuthService.isOwner()) {
const userData = JSON.parse(storedUser); setUser({
if (userData.role !== 'owner') { name: authUser.name || authUser.email,
router.push('/'); email: authUser.email,
} else { role: 'owner',
setUser(userData); });
loadProperties(); loadProperties();
}
} else { } else {
router.push('/auth/choose-role'); router.push('/auth/choose-role');
} }
}, [router]); }, [router]);
const loadProperties = () => {
const storedProperties = localStorage.getItem('ownerProperties');
if (storedProperties) { const loadProperties = async () => {
setProperties(JSON.parse(storedProperties)); const authUser = AuthService.getUser();
} else { const userId = authUser?.id;
const mockProperties = [
{ if (!userId) {
id: 1, console.warn('[OwnerProperties] No user ID found');
title: 'فيلا فاخرة في المزة',
propertyType: 'villa',
purpose: 'rent',
rentType: 'both',
dailyPrice: 500000,
monthlyPrice: 15000000,
location: 'دمشق، المزة',
bedrooms: 5,
bathrooms: 4,
area: 450,
livingRooms: 3,
status: 'available',
images: ['/villa1.jpg'],
createdAt: new Date().toISOString(),
furnished: true,
description: 'فيلا فاخرة مع حديقة خاصة ومسبح',
address: 'شارع المزة - فيلات غربية',
city: 'دمشق',
district: 'المزة',
services: {
electricity: true,
internet: true,
heating: true,
water: true,
airConditioning: true,
parking: true,
elevator: false
},
terms: {
noSmoking: true,
noPets: false,
noParties: true,
noAlcohol: false,
suitableForChildren: true,
suitableForElderly: true
}
}
];
setProperties(mockProperties);
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
}
setIsLoading(false); setIsLoading(false);
return;
}
try {
console.log('[OwnerProperties] Fetching listings for user:', userId);
const data = await getMyRentListings();
const list = Array.isArray(data) ? data : (data ? [data] : []);
console.log('[OwnerProperties] API returned:', list.length, 'properties');
const mapped = list.map((item) => {
const info = item.propertyInformation || {};
const details = (() => {
try { return JSON.parse(info.detailsJSON || '{}'); } catch { return {}; }
})();
return {
id: item.id,
title: info.address || `عقار #${item.id}`,
propertyType: { 0: 'apartment', 1: 'villa', 2: 'house' }[info.buildingType] || 'apartment',
purpose: 'rent',
rentType: { 0: 'daily', 1: 'weekly', 2: 'monthly' }[item.rentType] || 'daily',
dailyPrice: item.dailyRent || 0,
monthlyPrice: item.monthlyRent || 0,
deposit: item.deposit || 0,
location: info.address || '',
bedrooms: info.numberOfBedRooms || 0,
bathrooms: info.numberOfBathRooms || 0,
area: info.space || 0,
livingRooms: details.livingRooms || 0,
status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
images: (() => {
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const raw = Array.isArray(info.images) ? info.images : [];
return raw.length > 0 ? raw.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`) : ['/property-placeholder.jpg'];
})(),
createdAt: item.createdAt || new Date().toISOString(),
furnished: details.furnished || false,
description: info.description || '',
address: info.address || '',
city: '',
district: '',
services: details.services || {},
terms: details.terms || {},
rating: item.rating || 0,
currencyId: item.currencyId,
_raw: item,
};
});
setProperties(mapped);
} catch (err) {
console.error('[OwnerProperties] Failed to load properties:', err);
toast.error('فشل في تحميل العقارات');
} finally {
setIsLoading(false);
}
}; };
const updatePropertiesInStorage = (newProperties) => { const updatePropertiesInStorage = (newProperties) => {

View File

@ -0,0 +1,262 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
MapPin, DollarSign, Home, ArrowLeft, User, RefreshCw, Mail, Phone,
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
import { getRentProperty } from '../../utils/api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
const STATUS_UI = {
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
ownerConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
depositConfirmed: { label: 'مؤكد نهائياً', color: 'bg-green-100 text-green-800', icon: CheckCircle },
completed: { label: 'منتهي', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
};
const sLabel = c => STATUS_UI[STATUS_MAP[c]]?.label ?? String(c);
const sColor = c => STATUS_UI[STATUS_MAP[c]]?.color ?? 'bg-gray-100 text-gray-700';
const sIcon = c => STATUS_UI[STATUS_MAP[c]]?.icon ?? Clock;
function StatusBadge({ code }) {
const Icon = sIcon(code);
return <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${sColor(code)}`}><Icon className="w-3 h-3"/> {sLabel(code)}</span>;
}
async function enrich(r) {
if (!r.propertyId) return r;
try {
const prop = await getRentProperty(r.propertyId);
r._prop = prop?.propertyInformation ?? prop ?? null;
} catch { /* skip */ }
return r;
}
const pAddr = p => p?.address ?? '';
const pImgs = p => Array.isArray(p?.images) ? p.images : [];
const pBeds = p => p?.numberOfBedRooms ?? 0;
const pBaths = p => p?.numberOfBathRooms ?? 0;
const API = (token, method, path, body) => fetch(`${API_BASE}${path}`, {
method: method || 'GET',
headers: { 'Content-Type': 'application/json', ...(token && { Authorization: `Bearer ${token}` }) },
...(body && { body: JSON.stringify(body) }),
});
function OwnerCard({ r, onViewDetails, onConfirm, onReject }) {
const p = r._prop;
const imgs = pImgs(p);
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
const addr = pAddr(p);
const isPending = r.status === 0; // Pending
return (
<motion.div initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
<div className="p-5">
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover"/></div>}
<div className="flex justify-between items-start mb-3">
<div>
<StatusBadge code={r.status}/>
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
</div>
<div className="text-left">
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString()??'—'}</div>
<div className="text-xs text-gray-500">السعر الإجمالي</div>
</div>
</div>
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{pBeds(p)>0&&<span>{pBeds(p)} غرف</span>}{pBaths(p)>0&&<span>{pBaths(p)} حمامات</span>}</div>}
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
<div className="bg-gray-50 p-2 rounded-lg">
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg">
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
</div>
</div>
<div className={`flex gap-3 pt-3 border-t border-gray-100 ${!isPending?'justify-center':''}`}>
<button onClick={()=>onViewDetails(r)}
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
<Eye className="w-4 h-4"/> التفاصيل
</button>
{isPending && <>
<button onClick={()=>onConfirm(r)}
className="flex-1 bg-green-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-green-600 transition-colors flex items-center justify-center gap-2">
<CheckCircle className="w-4 h-4"/> قبول
</button>
<button onClick={()=>onReject(r)}
className="flex-1 bg-red-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-red-600 transition-colors flex items-center justify-center gap-2">
<XCircle className="w-4 h-4"/> رفض
</button>
</>}
</div>
</div>
</motion.div>
);
}
function DetailsModal({ r, isOpen, onClose }) {
if (!isOpen || !r) return null;
const p = r._prop;
return (
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold">طلب حجز #{r.id}</h2>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
</div>
</div>
<div className="p-6 space-y-6">
{p && <div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
<p><span className="text-gray-500">العنوان:</span> {pAddr(p)||''}</p>
{(pBeds(p)||pBaths(p)) && <div className="flex gap-3 mt-2">
{pBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBeds(p)} غرف</span>}
{pBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{pBaths(p)} حمامات</span>}
</div>}
</div>}
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
<div className="grid grid-cols-2 gap-4">
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
</div>
</div>
<div className="bg-amber-50 p-4 rounded-xl">
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/>المعلومات المالية</h3>
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??''}</span></div>
</div>
</div>
</motion.div>
</motion.div>
);
}
export default function OwnerReservationRequestsPage() {
const router = useRouter();
const [reservations, setReservations] = useState([]);
const [filtered, setFiltered] = useState([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
if (!AuthService.getUser() || !AuthService.isOwner()) { router.push('/auth/choose-role'); return; }
loadReservations();
}, [router]);
const loadReservations = useCallback(async () => {
try {
const token = AuthService.getToken();
const res = await fetch(`${API_BASE}/Reservations/GetOwnerResevationRequests`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
let list = json.data || json || [];
if (!Array.isArray(list)) list = [];
const enriched = await Promise.all(list.map(enrich));
setReservations(enriched);
setFiltered(enriched);
} catch (err) {
console.error(err);
toast.error('فشل تحميل طلبات الحجز');
}
setLoading(false);
}, []);
useEffect(() => {
let r = reservations;
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
if (searchTerm) {
const q = searchTerm.toLowerCase();
r = r.filter(x => pAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q));
}
setFiltered(r);
}, [reservations, filterStatus, searchTerm]);
const handleConfirm = async (r) => {
try {
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/owner-confirm/${r.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast.success('تم قبول الحجز بنجاح');
await loadReservations();
} catch (err) { console.error(err); toast.error('فشل قبول الحجز'); }
};
const handleReject = async (r) => {
if (!confirm('هل أنت متأكد من رفض هذا الحجز؟')) return;
try {
const res = await API(AuthService.getToken(), 'PUT', `/Reservations/reject/${r.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast.success('تم رفض الحجز');
await loadReservations();
} catch (err) { console.error(err); toast.error('فشل رفض الحجز'); }
};
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
return (
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
<Toaster position="top-center" reverseOrder={false} />
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
<div className="container mx-auto px-4">
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-3xl font-bold text-gray-900">طلبات الحجز</h1>
<p className="text-gray-600">لديك {reservations.length} طلب</p>
</div>
<button onClick={loadReservations} className="p-2 bg-white shadow rounded-xl hover:shadow-md transition-all"><RefreshCw className="w-5 h-5 text-gray-600"/></button>
</div>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{Object.entries(counts).map(([s, c]) => (
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
onClick={() => setFilterStatus(s)}>
<div className="text-2xl font-bold text-amber-600">{c}</div>
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
</motion.div>
))}
</div>
<div className="mb-6 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
</div>
{filtered.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد طلبات</h3>
<p className="text-gray-600">لم يتم استلام أي طلبات حجز حتى الآن</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filtered.map(r => <OwnerCard key={r.id} r={r} onViewDetails={setSelected} onConfirm={handleConfirm} onReject={handleReject} />)}
</div>
)}
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { import {
ShieldCheck, ShieldCheck,
Lock, Lock,
@ -28,6 +29,85 @@ import HeroSearch from './components/home/HeroSearch';
import PropertyMap from './components/home/PropertyMap'; import PropertyMap from './components/home/PropertyMap';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { getRentProperties, getSaleProperties } from './utils/api';
import { BuildingTypeKeys, PropertyStatusKeys, extractCity } from './enums';
import AuthService from './services/AuthService';
// Map API property data to the format the UI expects
// API returns { propertyInformationId, deposit, monthlyRent, dailyRent, rating, propertyInformation: {...}, ... }
function mapApiProperty(item, index) {
const info = item.propertyInformation || {};
const dailyPrice = item.dailyRent ?? 0;
const monthlyPrice = item.monthlyRent ?? 0;
const salePrice = item.price ?? 0;
const isRentListing = Boolean(item.dailyRent != null || item.monthlyRent != null);
const price = isRentListing ? (dailyPrice || monthlyPrice || 0) : salePrice;
const priceUnit = isRentListing ? (monthlyPrice ? 'monthly' : 'daily') : 'sale';
const propType = BuildingTypeKeys[info.buildingType] ?? BuildingTypeKeys[item.type] ?? (item.type || 'apartment');
const status = PropertyStatusKeys[info.status] ?? PropertyStatusKeys[item.status] ?? 'available';
const features = [];
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
if (item.isVisitorAllow) features.push('يسمح بالزوار');
if (item.specializedFor) features.push('متخصص');
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
// Extract images from API and build full URLs
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const rawImages = Array.isArray(info.images) ? info.images : [];
const images = rawImages.length > 0
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg'];
const ownerSource = info.ownerType == null && item.ownerType == null
? 'all'
: [info.ownerType, item.ownerType].find((value) => value != null) === 1
? 'agency'
: 'owner';
return {
id: item.id ?? index + 1,
title: info.address || `عقار #${item.id || index + 1}`,
description: info.description || '',
type: propType,
price: price,
priceUSD: price,
priceUnit,
listingType: isRentListing ? 'rent' : 'sale',
location: {
city: extractCity(info.address) || 'دمشق',
district: info.address || '',
address: info.address || '',
lat: parseFloat(info.cordsX) || 0,
lng: parseFloat(info.cordsY) || 0,
},
bedrooms: info.numberOfBedRooms || 0,
bathrooms: info.numberOfBathRooms || 0,
area: info.space || 0,
features,
images,
status,
rating: item.rating || 4.5,
isNew: false,
allowedIdentities: ['syrian', 'passport'],
priceDisplay: {
daily: dailyPrice,
monthly: monthlyPrice,
sale: salePrice,
},
ownerSource,
bookings: [],
_raw: item,
};
}
// extractCity is now imported from @/app/enums
// API-only — no fallback data
export default function HomePage() { export default function HomePage() {
const mapSectionRef = useRef(null); const mapSectionRef = useRef(null);
@ -38,12 +118,55 @@ export default function HomePage() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef(null); const menuRef = useRef(null);
const pathname = usePathname();
const [allProperties, setAllProperties] = useState([]);
const [loading, setLoading] = useState(true);
// Re-read user from JWT on every route change
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('user'); const authUser = AuthService.getUser();
if (storedUser) { if (authUser) {
setUser(JSON.parse(storedUser)); setUser({
name: authUser.name || authUser.email,
email: authUser.email,
role: AuthService.isOwner() ? 'owner' : 'customer',
});
} else {
setUser(null);
} }
}, [pathname]);
// Fetch properties from API on mount
useEffect(() => {
async function fetchProperties() {
try {
const [rentData, saleData] = await Promise.all([
getRentProperties().catch(() => []),
getSaleProperties().catch(() => []),
]);
const rentList = Array.isArray(rentData) ? rentData : [];
const saleList = Array.isArray(saleData) ? saleData : [];
const mapped = [
...rentList.map((p, i) => mapApiProperty(p, i)),
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
];
if (mapped.length > 0) {
setAllProperties(mapped);
}
// If API returns empty, keep fallback
} catch (err) {
console.error('[Home] Failed to fetch properties:', err);
} finally {
setLoading(false);
}
}
fetchProperties();
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -57,173 +180,25 @@ export default function HomePage() {
}, []); }, []);
const logout = () => { const logout = () => {
localStorage.removeItem('user'); AuthService.deleteToken();
setUser(null); setUser(null);
setShowUserMenu(false); setShowUserMenu(false);
}; };
const [allProperties] = useState([
{
id: 1,
title: 'فيلا فاخرة في المزة',
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.',
type: 'villa',
price: 500000,
priceUSD: 50,
priceUnit: 'daily',
location: {
city: 'دمشق',
district: 'المزة',
address: 'شارع المزة - فيلات غربية',
lat: 33.5138,
lng: 36.2765
},
bedrooms: 5,
bathrooms: 4,
area: 450,
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7', 'تدفئة مركزية', 'تكييف مركزي'],
images: ['/villa1.jpg', '/villa2.jpg', '/villa3.jpg'],
status: 'available',
rating: 4.8,
isNew: true,
allowedIdentities: ['syrian', 'passport'],
priceDisplay: {
daily: 500000,
monthly: 15000000
},
bookings: [
{ startDate: '2024-03-10', endDate: '2024-03-15' },
{ startDate: '2024-03-20', endDate: '2024-03-25' }
]
},
{
id: 2,
title: 'شقة حديثة في الشهباء',
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
type: 'apartment',
price: 250000,
priceUSD: 25,
priceUnit: 'daily',
location: {
city: 'حلب',
district: 'الشهباء',
address: 'شارع النيل - بناء الرحاب',
lat: 36.2021,
lng: 37.1347
},
bedrooms: 3,
bathrooms: 2,
area: 180,
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
images: ['/apartment1.jpg', '/apartment2.jpg'],
status: 'available',
rating: 4.5,
isNew: false,
allowedIdentities: ['syrian'],
priceDisplay: {
daily: 250000,
monthly: 7500000
},
bookings: [
{ startDate: '2024-03-05', endDate: '2024-03-08' }
]
},
{
id: 3,
title: 'بيت عائلي في بابا عمرو',
description: 'بيت واسع مناسب للعائلات في حمص.',
type: 'house',
price: 350000,
priceUSD: 35,
priceUnit: 'daily',
location: {
city: 'حمص',
district: 'بابا عمرو',
address: 'حي الزهور',
lat: 34.7265,
lng: 36.7186
},
bedrooms: 4,
bathrooms: 3,
area: 300,
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
images: ['/house1.jpg'],
status: 'booked',
rating: 4.3,
isNew: false,
allowedIdentities: ['syrian', 'passport'],
priceDisplay: {
daily: 350000,
monthly: 10500000
},
bookings: []
},
{
id: 4,
title: 'شقة بجانب البحر',
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
type: 'apartment',
price: 300000,
priceUSD: 30,
priceUnit: 'daily',
location: {
city: 'اللاذقية',
district: 'الشاطئ الأزرق',
address: 'الكورنيش الغربي',
lat: 35.5306,
lng: 35.7801
},
bedrooms: 3,
bathrooms: 2,
area: 200,
features: ['إطلالة بحرية', 'شرفة', 'تكييف', 'أمن'],
images: ['/seaside1.jpg', '/seaside2.jpg', '/seaside3.jpg'],
status: 'available',
rating: 4.9,
isNew: true,
allowedIdentities: ['passport'],
priceDisplay: {
daily: 300000,
monthly: 9000000
},
bookings: []
},
{
id: 5,
title: 'فيلا في درعا',
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
type: 'villa',
price: 400000,
priceUSD: 40,
priceUnit: 'daily',
location: {
city: 'درعا',
district: 'حي الأطباء',
address: 'شارع الشفاء',
lat: 32.6237,
lng: 36.1016
},
bedrooms: 4,
bathrooms: 3,
area: 350,
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح', 'كراج'],
images: ['/villa4.jpg', '/villa5.jpg'],
status: 'available',
rating: 4.6,
isNew: false,
allowedIdentities: ['syrian', 'passport'],
priceDisplay: {
daily: 400000,
monthly: 12000000
},
bookings: []
}
]);
const applyFilters = (filters) => { const applyFilters = (filters) => {
setSearchFilters(filters); setSearchFilters(filters);
const filtered = allProperties.filter(property => { const filtered = allProperties.filter(property => {
if (filters.mode === 'rent' && property.listingType !== 'rent') {
return false;
}
if (filters.mode === 'sell' && property.listingType !== 'sale') {
return false;
}
if (filters.mode === 'buy' && property.listingType !== 'sale') {
return false;
}
if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) { if (filters.city && filters.city !== 'all' && property.location.city !== filters.city) {
return false; return false;
} }
@ -243,6 +218,20 @@ export default function HomePage() {
} }
} }
if (filters.ownerSource && filters.ownerSource !== 'all') {
if (filters.ownerSource === 'owner' && property.ownerSource !== 'owner') return false;
if (filters.ownerSource === 'agency' && property.ownerSource !== 'agency') return false;
}
if (filters.rentPeriod && filters.rentPeriod !== 'all' && property.listingType === 'rent') {
if (filters.rentPeriod === 'daily' && !property.priceDisplay.daily) return false;
if (filters.rentPeriod === 'monthly' && !property.priceDisplay.monthly) return false;
}
if (filters.availableToday) {
if (property.status !== 'available') return false;
}
if (filters.identityType && property.allowedIdentities) { if (filters.identityType && property.allowedIdentities) {
if (!property.allowedIdentities.includes(filters.identityType)) { if (!property.allowedIdentities.includes(filters.identityType)) {
return false; return false;
@ -361,7 +350,7 @@ export default function HomePage() {
</motion.p> </motion.p>
</motion.div> </motion.div>
{!isOwner && <HeroSearch onSearch={applyFilters} />} {!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
{isOwner && ( {isOwner && (
<motion.div <motion.div
@ -526,6 +515,25 @@ export default function HomePage() {
searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'} searchFilters.priceRange === '2000-3000' ? '200$ - 300$' : 'أكثر من 300$'}
</span> </span>
</div> </div>
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="text-gray-600">مصدر العرض: </span>
<span className="font-bold text-gray-900">
{searchFilters.ownerSource === 'all' ? 'الكل' :
searchFilters.ownerSource === 'owner' ? 'من المالك' : 'من مكتب عقاري'}
</span>
</div>
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="text-gray-600">نوع الإيجار: </span>
<span className="font-bold text-gray-900">
{searchFilters.rentPeriod === 'all' ? 'الكل' :
searchFilters.rentPeriod === 'daily' ? 'إيجار يومي' : 'إيجار شهري'}
</span>
</div>
{searchFilters.availableToday && (
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm">
<span className="font-bold text-gray-900">فقط المتاحة من اليوم</span>
</div>
)}
</motion.div> </motion.div>
)} )}
</div> </div>

96
app/payments/page.js Normal file
View File

@ -0,0 +1,96 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { CreditCard, Download, Eye } from 'lucide-react';
import AuthService from '@/app/services/AuthService';
import Link from 'next/link';
const mockPayments = [
{
id: 1,
property: 'فيلا فاخرة في المزة',
amount: 2500000,
date: '2024-03-10',
status: 'completed',
invoiceId: 'INV-001'
},
{
id: 2,
property: 'شقة حديثة في الشهباء',
amount: 750000,
date: '2024-03-05',
status: 'completed',
invoiceId: 'INV-002'
}
];
export default function PaymentsPage() {
const router = useRouter();
const [payments, setPayments] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (AuthService.isAdmin()) {
router.push('/');
return;
}
setTimeout(() => {
setPayments(mockPayments);
setIsLoading(false);
}, 500);
}, [router]);
const formatCurrency = (amount) => amount?.toLocaleString() + ' ل.س';
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-600">جاري التحميل...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">المدفوعات</h1>
<p className="text-gray-600">سجل المعاملات المالية والفواتير</p>
</div>
{payments.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<CreditCard className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد معاملات</h3>
<p className="text-gray-500">ستظهر هنا مدفوعاتك للحجوزات</p>
</div>
) : (
<div className="space-y-4">
{payments.map((payment) => (
<div key={payment.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-all">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h3 className="font-bold text-gray-900">{payment.property}</h3>
<p className="text-sm text-gray-500 mt-1">رقم الفاتورة: {payment.invoiceId}</p>
<p className="text-xs text-gray-400 mt-2">{payment.date}</p>
</div>
<div className="text-right">
<div className="text-xl font-bold text-amber-600">{formatCurrency(payment.amount)}</div>
<span className="inline-block px-2 py-1 bg-green-100 text-green-800 rounded-lg text-xs mt-1">
مكتمل
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

27
app/profile/error.js Normal file
View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

14
app/profile/loading.js Normal file
View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -27,6 +27,8 @@ import {
Pencil Pencil
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../services/AuthService';
import { getCustomerByUserId, getOwnerByUserId } from '../utils/api';
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
@ -62,14 +64,51 @@ export default function ProfilePage() {
}; };
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem('user'); const authUser = AuthService.getUser();
if (storedUser) { if (authUser) {
const userData = JSON.parse(storedUser); const userData = {
id: authUser.id,
name: authUser.name || '',
email: authUser.email || '',
phone: authUser.phone || '',
role: AuthService.isOwner() ? 'owner' : 'customer',
};
setUser(userData); setUser(userData);
console.log('[Profile] User from JWT:', userData);
// Fetch full profile from API using user ID (SID from JWT)
async function fetchProfile() {
try {
const fetchFn = userData.role === 'owner' ? getOwnerByUserId : getCustomerByUserId;
console.log('[Profile] Fetching profile via', userData.role === 'owner' ? 'Owner' : 'Customer', 'GetByUserId:', userData.id);
const profile = await fetchFn(userData.id);
console.log('[Profile] API profile:', profile);
if (profile) {
const profileData = {
name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || userData.name || '',
email: profile.email || userData.email || '',
phone: profile.phone || profile.phoneNumber || userData.phone || '',
whatsapp: profile.whatsAppNumber || profile.whatsapp || '',
bio: profile.bio || '',
location: profile.address || profile.location || '',
joinedDate: profile.createdAt
? new Date(profile.createdAt).toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' }),
};
setFormData(profileData);
setTempValues(profileData);
localStorage.setItem('userProfile', JSON.stringify(profileData));
setIsLoading(false);
return;
}
} catch (err) {
console.warn('[Profile] API fetch failed, falling back to JWT/localStorage:', err);
}
// Fallback to JWT + localStorage
const savedProfile = localStorage.getItem('userProfile'); const savedProfile = localStorage.getItem('userProfile');
let profileData; let profileData;
if (savedProfile) { if (savedProfile) {
profileData = JSON.parse(savedProfile); profileData = JSON.parse(savedProfile);
} else { } else {
@ -83,16 +122,17 @@ export default function ProfilePage() {
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' }) joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
}; };
} }
setFormData(profileData); setFormData(profileData);
setTempValues(profileData); setTempValues(profileData);
setIsLoading(false);
}
const savedAvatar = localStorage.getItem('userAvatar'); const savedAvatar = localStorage.getItem('userAvatar');
if (savedAvatar) { if (savedAvatar) {
setAvatarPreview(savedAvatar); setAvatarPreview(savedAvatar);
} }
setIsLoading(false); fetchProfile();
} else { } else {
router.push('/login'); router.push('/login');
} }
@ -167,7 +207,6 @@ export default function ProfilePage() {
if (field === 'name') { if (field === 'name') {
const updatedUser = { ...user, name: value }; const updatedUser = { ...user, name: value };
localStorage.setItem('user', JSON.stringify(updatedUser));
setUser(updatedUser); setUser(updatedUser);
} }

27
app/properties/error.js Normal file
View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

14
app/properties/loading.js Normal file
View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,8 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { import {
Search, Search,
MapPin, MapPin,
@ -32,12 +31,92 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { getRentProperties, getSaleProperties } from '../utils/api';
import { useFavorites } from '@/app/contexts/FavoritesContext';
import AuthService from '@/app/services/AuthService';
import toast, { Toaster } from 'react-hot-toast';
// Map API data to UI format
function mapApiProperty(item, index) {
const info = item.propertyInformation || {};
const PropertyCard = ({ property, viewMode = 'grid' }) => { const dailyPrice = item.dailyRent ?? item.monthlyRent ?? item.price ?? 0;
const [isFavorite, setIsFavorite] = useState(false); const monthlyPrice = item.monthlyRent ?? 0;
const buildingTypeMap = { 0: 'apartment', 1: 'villa', 2: 'house' };
const propType = buildingTypeMap[info.buildingType] ?? buildingTypeMap[item.type] ?? 'apartment';
const statusMap = { 0: 'available', 1: 'booked', 2: 'maintenance' };
const status = statusMap[info.status] ?? statusMap[item.status] ?? 'available';
const features = [];
if (item.isSmokeAllow) features.push('يسمح بالتدخين');
if (item.isVisitorAllow) features.push('يسمح بالزوار');
if (item.specializedFor) features.push('متخصص');
if (info.numberOfBedRooms) features.push(`${info.numberOfBedRooms} غرف نوم`);
if (info.numberOfBathRooms) features.push(`${info.numberOfBathRooms} حمامات`);
// Extract images from API and build full URLs
const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
const rawImages = Array.isArray(info.images) ? info.images : [];
const images = rawImages.length > 0
? rawImages.map(img => img.startsWith('http') ? img : `${apiBase}${img.startsWith('/') ? '' : '/Pictures/'}${img}`)
: ['/property-placeholder.jpg'];
return {
id: item.id ?? index + 1,
title: info.address || `عقار #${item.id || index + 1}`,
description: info.description || '',
type: propType,
price: dailyPrice,
priceUnit: 'daily',
location: {
city: extractCity(info.address) || 'دمشق',
district: info.address || '',
},
bedrooms: info.numberOfBedRooms || 0,
bathrooms: info.numberOfBathRooms || 0,
area: info.space || 0,
features,
images,
status,
rating: item.rating || 4.5,
isNew: false,
_raw: item,
};
}
function extractCity(address) {
if (!address) return '';
const cities = ['دمشق', 'حلب', 'حمص', 'اللاذقية', 'درعا', 'طرطوس', 'السويداء', 'دير الزور', 'الرقة', 'إدلب', 'الحسكة', 'القامشلي', 'ريف دمشق'];
for (const city of cities) {
if (address.includes(city)) return city;
}
return '';
}
// API-only — no fallback data
const PropertyCard = ({ property, viewMode = 'grid', onLoginRequired }) => {
const { isFavorite: checkFavorite, addFavorite, removeFavorite } = useFavorites();
const [favLoading, setFavLoading] = useState(false);
const [currentImage, setCurrentImage] = useState(0); const [currentImage, setCurrentImage] = useState(0);
const isFav = checkFavorite(property.id);
const toggleFavorite = async (e) => {
e.preventDefault();
e.stopPropagation();
if (!AuthService.isAuthenticated()) { onLoginRequired?.(); return; }
setFavLoading(true);
if (isFav) {
await removeFavorite(property.id);
} else {
await addFavorite(property.id);
}
setFavLoading(false);
};
const formatCurrency = (amount) => { const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س'; return amount?.toLocaleString() + ' ل.س';
}; };
@ -83,26 +162,20 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
<button <button
key={idx} key={idx}
onClick={() => setCurrentImage(idx)} onClick={() => setCurrentImage(idx)}
className={`w-1.5 h-1.5 rounded-full transition-all ${ className={`w-1.5 h-1.5 rounded-full transition-all ${idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'}`}
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
}`}
/> />
))} ))}
</div> </div>
)} )}
<div className="absolute top-2 right-2 flex gap-2"> <div className="absolute top-2 right-2 flex gap-2">
<button <button
onClick={() => setIsFavorite(!isFavorite)} onClick={toggleFavorite}
disabled={favLoading}
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm" className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
> >
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} /> <Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
</button> </button>
</div> </div>
{property.isNew && (
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
جديد
</div>
)}
</div> </div>
<div className="md:w-2/3 p-6"> <div className="md:w-2/3 p-6">
@ -113,11 +186,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
{getPropertyTypeIcon(property.type)} {getPropertyTypeIcon(property.type)}
{getPropertyTypeLabel(property.type)} {getPropertyTypeLabel(property.type)}
</span> </span>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${ <span className={`px-2 py-1 rounded-lg text-xs font-medium ${property.status === 'available' ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-600'}`}>
property.status === 'available'
? 'bg-gray-800 text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{property.status === 'available' ? 'متاح' : 'محجوز'} {property.status === 'available' ? 'متاح' : 'محجوز'}
</span> </span>
</div> </div>
@ -148,22 +217,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
</div> </div>
</div> </div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2"> <p className="text-gray-600 text-sm mb-4 line-clamp-2">{property.description}</p>
{property.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{property.features.slice(0, 4).map((feature, idx) => (
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
{feature}
</span>
))}
{property.features.length > 4 && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
+{property.features.length - 4}
</span>
)}
</div>
<div className="flex gap-3"> <div className="flex gap-3">
<Link <Link
@ -195,32 +249,15 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
fill fill
className="object-cover" className="object-cover"
/> />
{property.images.length > 1 && (
<div className="absolute bottom-2 left-2 right-2 flex justify-center gap-1">
{property.images.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentImage(idx)}
className={`w-1.5 h-1.5 rounded-full transition-all ${
idx === currentImage ? 'bg-gray-800 w-3' : 'bg-white/70'
}`}
/>
))}
</div>
)}
<div className="absolute top-2 right-2 flex gap-2"> <div className="absolute top-2 right-2 flex gap-2">
<button <button
onClick={() => setIsFavorite(!isFavorite)} onClick={toggleFavorite}
disabled={favLoading}
className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm" className="w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white transition-colors shadow-sm"
> >
<Heart className={`w-4 h-4 ${isFavorite ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} /> <Heart className={`w-4 h-4 ${isFav ? 'fill-red-500 text-red-500' : 'text-gray-600'}`} />
</button> </button>
</div> </div>
{property.isNew && (
<div className="absolute top-2 left-2 bg-gray-800 text-white px-2 py-1 rounded-lg text-xs font-medium">
جديد
</div>
)}
</div> </div>
<div className="p-5"> <div className="p-5">
@ -232,9 +269,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
{getPropertyTypeLabel(property.type)} {getPropertyTypeLabel(property.type)}
</span> </span>
{property.status === 'available' && ( {property.status === 'available' && (
<span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium"> <span className="px-2 py-1 bg-gray-800 text-white rounded-lg text-xs font-medium">متاح</span>
متاح
</span>
)} )}
</div> </div>
<h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3> <h3 className="font-bold text-gray-900 mb-1 line-clamp-1">{property.title}</h3>
@ -270,19 +305,6 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2 mb-4">
{property.features.slice(0, 3).map((feature, idx) => (
<span key={idx} className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
{feature}
</span>
))}
{property.features.length > 3 && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs">
+{property.features.length - 3}
</span>
)}
</div>
<Link <Link
href={`/property/${property.id}`} href={`/property/${property.id}`}
className="block w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors text-center" className="block w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors text-center"
@ -302,7 +324,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
{ id: 'apartment', label: 'شقة', icon: Building2 }, { id: 'apartment', label: 'شقة', icon: Building2 },
{ id: 'villa', label: 'فيلا', icon: Home }, { id: 'villa', label: 'فيلا', icon: Home },
{ id: 'house', label: 'بيت', icon: Home }, { id: 'house', label: 'بيت', icon: Home },
{ id: 'studio', label: 'استوديو', icon: Building2 }
]; ];
const priceRanges = [ const priceRanges = [
@ -364,11 +385,7 @@ const FilterBar = ({ filters, onFilterChange }) => {
<button <button
key={type.id} key={type.id}
onClick={() => onFilterChange({ ...filters, propertyType: type.id })} onClick={() => onFilterChange({ ...filters, propertyType: type.id })}
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${ className={`px-3 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${filters.propertyType === type.id ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
filters.propertyType === type.id
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
> >
{Icon && <Icon className="w-4 h-4" />} {Icon && <Icon className="w-4 h-4" />}
{type.label} {type.label}
@ -439,30 +456,6 @@ const FilterBar = ({ filters, onFilterChange }) => {
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">المميزات</label>
<div className="flex flex-wrap gap-2">
{['مسبح', 'حديقة', 'موقف سيارات', 'أمن', 'مصعد', 'تكييف'].map((feature) => (
<button
key={feature}
onClick={() => {
const newFeatures = filters.features.includes(feature)
? filters.features.filter(f => f !== feature)
: [...filters.features, feature];
onFilterChange({ ...filters, features: newFeatures });
}}
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
filters.features.includes(feature)
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{feature}
</button>
))}
</div>
</div>
</div> </div>
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100"> <div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
@ -498,6 +491,9 @@ const FilterBar = ({ filters, onFilterChange }) => {
export default function PropertiesPage() { export default function PropertiesPage() {
const [viewMode, setViewMode] = useState('grid'); const [viewMode, setViewMode] = useState('grid');
const [sortBy, setSortBy] = useState('newest'); const [sortBy, setSortBy] = useState('newest');
const [properties, setProperties] = useState([]);
const [loading, setLoading] = useState(true);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
search: '', search: '',
propertyType: 'all', propertyType: 'all',
@ -509,94 +505,35 @@ export default function PropertiesPage() {
features: [] features: []
}); });
const [properties] = useState([ useEffect(() => {
{ async function fetchProperties() {
id: 1, try {
title: 'فيلا فاخرة في المزة', const [rentData, saleData] = await Promise.all([
description: 'فيلا فاخرة مع حديقة خاصة ومسبح في أفضل أحياء دمشق.', getRentProperties().catch(() => []),
type: 'villa', getSaleProperties().catch(() => []),
price: 500000,
priceUnit: 'daily',
location: { city: 'دمشق', district: 'المزة' },
bedrooms: 5,
bathrooms: 4,
area: 450,
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن'],
images: ['/villa1.jpg'],
status: 'available',
rating: 4.8,
isNew: true
},
{
id: 2,
title: 'شقة حديثة في الشهباء',
description: 'شقة عصرية في حي الشهباء الراقي بحلب.',
type: 'apartment',
price: 250000,
priceUnit: 'daily',
location: { city: 'حلب', district: 'الشهباء' },
bedrooms: 3,
bathrooms: 2,
area: 180,
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
images: ['/apartment1.jpg'],
status: 'available',
rating: 4.5,
isNew: false
},
{
id: 3,
title: 'بيت عائلي في بابا عمرو',
description: 'بيت واسع مناسب للعائلات في حمص.',
type: 'house',
price: 350000,
priceUnit: 'daily',
location: { city: 'حمص', district: 'بابا عمرو' },
bedrooms: 4,
bathrooms: 3,
area: 300,
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة'],
images: ['/house1.jpg'],
status: 'booked',
rating: 4.3,
isNew: false
},
{
id: 4,
title: 'شقة بجانب البحر',
description: 'شقة رائعة مع إطلالة بحرية في اللاذقية.',
type: 'apartment',
price: 300000,
priceUnit: 'daily',
location: { city: 'اللاذقية', district: 'الشاطئ الأزرق' },
bedrooms: 3,
bathrooms: 2,
area: 200,
features: ['إطلالة بحرية', 'شرفة', 'تكييف'],
images: ['/seaside1.jpg'],
status: 'available',
rating: 4.9,
isNew: true
},
{
id: 5,
title: 'فيلا في درعا',
description: 'فيلا فاخرة في حي الأطباء بدرعا.',
type: 'villa',
price: 400000,
priceUnit: 'daily',
location: { city: 'درعا', district: 'حي الأطباء' },
bedrooms: 4,
bathrooms: 3,
area: 350,
features: ['حديقة مثمرة', 'أنظمة أمن', 'مسبح'],
images: ['/villa4.jpg'],
status: 'available',
rating: 4.6,
isNew: false
}
]); ]);
const rentList = Array.isArray(rentData) ? rentData : [];
const saleList = Array.isArray(saleData) ? saleData : [];
const mapped = [
...rentList.map((p, i) => mapApiProperty(p, i)),
...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
];
if (mapped.length > 0) {
setProperties(mapped);
}
} catch (err) {
console.error('[Properties] Failed to fetch properties:', err);
} finally {
setLoading(false);
}
}
fetchProperties();
}, []);
const filteredProperties = properties const filteredProperties = properties
.filter(property => { .filter(property => {
if (filters.search && !property.title.includes(filters.search) && !property.description.includes(filters.search)) { if (filters.search && !property.title.includes(filters.search) && !property.description.includes(filters.search)) {
@ -613,8 +550,8 @@ export default function PropertiesPage() {
if (max) { if (max) {
if (property.price < parseInt(min) || property.price > parseInt(max)) return false; if (property.price < parseInt(min) || property.price > parseInt(max)) return false;
} else if (filters.priceRange.endsWith('+')) { } else if (filters.priceRange.endsWith('+')) {
const min = parseInt(filters.priceRange.replace('+', '')); const minVal = parseInt(filters.priceRange.replace('+', ''));
if (property.price < min) return false; if (property.price < minVal) return false;
} }
} }
if (filters.bedrooms !== 'all' && property.bedrooms < parseInt(filters.bedrooms)) { if (filters.bedrooms !== 'all' && property.bedrooms < parseInt(filters.bedrooms)) {
@ -622,9 +559,6 @@ export default function PropertiesPage() {
} }
if (filters.minArea && property.area < parseInt(filters.minArea)) return false; if (filters.minArea && property.area < parseInt(filters.minArea)) return false;
if (filters.maxArea && property.area > parseInt(filters.maxArea)) return false; if (filters.maxArea && property.area > parseInt(filters.maxArea)) return false;
if (filters.features.length > 0) {
if (!filters.features.every(f => property.features.includes(f))) return false;
}
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
@ -632,7 +566,7 @@ export default function PropertiesPage() {
case 'price_asc': return a.price - b.price; case 'price_asc': return a.price - b.price;
case 'price_desc': return b.price - a.price; case 'price_desc': return b.price - a.price;
case 'rating': return b.rating - a.rating; case 'rating': return b.rating - a.rating;
default: return b.isNew ? 1 : -1; default: return 0;
} }
}); });
@ -646,6 +580,11 @@ export default function PropertiesPage() {
> >
<h1 className="text-4xl font-bold text-gray-900 mb-2">عقارات للإيجار</h1> <h1 className="text-4xl font-bold text-gray-900 mb-2">عقارات للإيجار</h1>
<p className="text-gray-500">أفضل العقارات في سوريا</p> <p className="text-gray-500">أفضل العقارات في سوريا</p>
{loading && (
<div className="mt-4">
<div className="inline-block w-6 h-6 border-2 border-gray-200 border-t-gray-800 rounded-full animate-spin"></div>
</div>
)}
</motion.div> </motion.div>
<FilterBar filters={filters} onFilterChange={setFilters} /> <FilterBar filters={filters} onFilterChange={setFilters} />
@ -668,19 +607,13 @@ export default function PropertiesPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded-xl transition-colors ${ className={`p-2 rounded-xl transition-colors ${viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
viewMode === 'grid' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
title="عرض شبكي"
> >
<Grid3x3 className="w-5 h-5" /> <Grid3x3 className="w-5 h-5" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
className={`p-2 rounded-xl transition-colors ${ className={`p-2 rounded-xl transition-colors ${viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
viewMode === 'list' ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
title="عرض قائمة"
> >
<List className="w-5 h-5" /> <List className="w-5 h-5" />
</button> </button>
@ -693,7 +626,7 @@ export default function PropertiesPage() {
: 'space-y-4' : 'space-y-4'
}> }>
{filteredProperties.map((property) => ( {filteredProperties.map((property) => (
<PropertyCard key={property.id} property={property} viewMode={viewMode} /> <PropertyCard key={property.id} property={property} viewMode={viewMode} onLoginRequired={() => setShowLoginDialog(true)} />
))} ))}
</div> </div>
@ -711,6 +644,37 @@ export default function PropertiesPage() {
</motion.div> </motion.div>
)} )}
</div> </div>
<Toaster position="top-center" />
{showLoginDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setShowLoginDialog(false)}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-xl text-center"
>
<div className="w-14 h-14 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Heart className="w-7 h-7 text-amber-600" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">تسجيل الدخول مطلوب</h3>
<p className="text-gray-500 mb-6">يجب تسجيل الدخول لإضافة العقارات إلى المفضلة</p>
<div className="flex gap-3">
<button
onClick={() => setShowLoginDialog(false)}
className="flex-1 py-3 border border-gray-200 rounded-xl font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
إلغاء
</button>
<Link
href="/login"
className="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium hover:bg-amber-600 transition-colors text-center"
>
تسجيل الدخول
</Link>
</div>
</motion.div>
</div>
)}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">حدث خطأ</h2>
<p className="text-gray-500 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-gray-200 text-gray-700 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,681 +1,96 @@
'use client'; import PropertyDetail from './PropertyDetail';
import { useState, useEffect } from 'react'; // Server-side API fetch for metadata (runs at request time on server)
import { motion } from 'framer-motion'; async function fetchPropertyForMeta(id) {
import Image from 'next/image'; try {
import Link from 'next/link'; const res = await fetch(`http://45.93.137.91/api/RentProperties/GetRentProperties`, {
import { useParams } from 'next/navigation'; next: { revalidate: 60 },
import { });
MapPin, if (!res.ok) return null;
Bed, const text = await res.text();
Bath, const json = JSON.parse(text);
Square, const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
DollarSign, return items.find(p => p.id == id) || items[0] || null;
Heart, } catch {
Share2, return null;
Phone,
Mail,
MessageCircle,
Calendar,
Shield,
Star,
ChevronLeft,
ChevronRight,
Check,
X,
Wifi,
Car,
Coffee,
Wind,
Thermometer,
Lock,
Camera,
Home,
Building2,
Users,
Ruler,
CalendarDays,
Clock,
Award,
FileText,
Printer,
Download,
ArrowLeft
} from 'lucide-react';
export default function PropertyDetailsPage() {
const params = useParams();
const [currentImage, setCurrentImage] = useState(0);
const [showContact, setShowContact] = useState(false);
const [bookingDates, setBookingDates] = useState({ start: '', end: '' });
const [selectedDuration, setSelectedDuration] = useState(1);
const [property, setProperty] = useState(null);
const [loading, setLoading] = useState(true);
const propertiesData = {
1: {
id: 1,
title: 'فيلا فاخرة في المزة',
description: `تتميز هذه الفيلا الفاخرة بتصميمها العصري وموقعها المميز في أفضل أحياء دمشق. تم بناء الفيلا بأعلى المواصفات باستخدام أفضل المواد، مع مساحات واسعة وحديقة خاصة.
المميزات الرئيسية:
• موقع راقي وقريب من جميع الخدمات
• تصميم داخلي عصري مع أثاث فاخر
• إطلالة رائعة على المدينة
• خصوصية تامة وأمن على مدار الساعة
المساحات الداخلية:
• الطابق الأرضي: صالة استقبال كبيرة (80 م²)، مجلس رجال (40 م²)، مجلس نساء (35 م²)، مطبخ (25 م²)، غرفة طعام (30 م²)
• الطابق الأول: 5 غرف نوم ماستر مع حمامات خاصة (كل غرفة 35-45 م²)
• الطابق الثاني: غرفة معيشة عائلية (50 م²)، غرفة ترفيه (40 م²)، سطح مع إطلالة (100 م²)
الخدمات القريبة:
• مدارس وجامعات على بعد 5 دقائق
• مستشفيات ومراكز طبية
• مولات تجارية ومطاعم
• حدائق عامة ومسارات مشي`,
type: 'villa',
price: 500000,
priceUnit: 'daily',
location: {
city: 'دمشق',
district: 'المزة',
address: 'شارع المزة - فيلات غربية',
lat: 33.5,
lng: 36.3
},
bedrooms: 5,
bathrooms: 4,
area: 450,
features: [
{ name: 'مسبح', available: true, description: 'مسبح خاص بمساحة 40 م²' },
{ name: 'حديقة خاصة', available: true, description: 'حديقة بمساحة 200 م² مع نوافير' },
{ name: 'موقف سيارات', available: true, description: 'موقف يتسع لـ 4 سيارات' },
{ name: 'أمن 24/7', available: true, description: 'كاميرات مراقبة وحراسة' },
{ name: 'تدفئة مركزية', available: true, description: 'تدفئة مركزية لجميع الغرف' },
{ name: 'تكييف مركزي', available: true, description: 'تكييف مركزي في جميع الغرف' },
{ name: 'مطبخ مجهز', available: true, description: 'مطبخ أمريكي مجهز بالكامل' },
{ name: 'غرفة خادمة', available: true, description: 'غرفة خادمة مع حمام خاص' },
{ name: 'مصعد', available: false, description: 'قابل للتركيب' },
{ name: 'واي فاي', available: true, description: 'ألياف بصرية' }
],
images: [
'/villa1.jpg',
'/villa2.jpg',
'/villa3.jpg',
'/villa4.jpg',
'/villa5.jpg',
'/villa6.jpg'
],
status: 'available',
rating: 4.8,
reviews: 24,
reviewList: [
{ user: 'أحمد محمد', rating: 5, comment: 'فيلا رائعة ونظيفة، موقع ممتاز', date: '2024-01-15' },
{ user: 'سارة أحمد', rating: 5, comment: 'إقامة مريحة، خدمات ممتازة', date: '2024-01-10' },
{ user: 'خالد عمر', rating: 4, comment: 'مكان جميل ولكن السعر مرتفع قليلاً', date: '2023-12-20' }
],
owner: {
name: 'محمد الخالد',
phone: '0933111222',
email: 'mohamed@example.com',
rating: 4.9,
properties: 5,
memberSince: '2023',
responseRate: '98%',
responseTime: 'خلال ساعة'
},
nearby: [
{ type: 'مدرسة', distance: '500م' },
{ type: 'مستشفى', distance: '1كم' },
{ type: 'مول تجاري', distance: '2كم' },
{ type: 'مطعم', distance: '300م' },
{ type: 'جامعة', distance: '1.5كم' },
{ type: 'حديقة', distance: '800م' }
],
specifications: {
constructionYear: 2022,
floor: 'أرضي + 2',
parking: 4,
gardenArea: 200,
poolArea: 40,
furnished: true,
airConditioning: 'مركزي',
heating: 'مركزي',
electricity: '220V',
water: 'شبكة عامة'
},
rules: [
'لا يسمح بالحيوانات الأليفة',
'لا يسمح بالتدخين داخل الغرف',
'حفلات مسموحة بعد التنسيق',
'وقت المغادرة: 12:00 ظهراً'
]
},
2: {
id: 2,
title: 'شقة حديثة في الشهباء',
description: 'شقة عصرية في حي الشهباء الراقي بحلب. إطلالة رائعة وتشطيب فاخر.',
type: 'apartment',
price: 250000,
priceUnit: 'daily',
location: {
city: 'حلب',
district: 'الشهباء',
address: 'شارع النيل - بناء الرحاب',
lat: 36.2,
lng: 37.1
},
bedrooms: 3,
bathrooms: 2,
area: 180,
features: [
{ name: 'مطبخ مجهز', available: true, description: 'مطبخ أمريكي' },
{ name: 'بلكونة', available: true, description: 'بلكونة بمساحة 10 م²' },
{ name: 'موقف سيارات', available: true, description: 'موقف خاص' },
{ name: 'مصعد', available: true, description: 'مصعد حديث' }
],
images: ['/apartment1.jpg', '/apartment2.jpg'],
status: 'available',
rating: 4.5,
reviews: 12,
owner: {
name: 'أحمد حلبي',
phone: '0944222333',
email: 'ahmad@example.com',
rating: 4.7,
properties: 3,
memberSince: '2023'
},
nearby: [
{ type: 'مدرسة', distance: '300م' },
{ type: 'مستشفى', distance: '1.2كم' },
{ type: 'مول', distance: '500م' }
]
} }
};
useEffect(() => {
setLoading(true);
setTimeout(() => {
setProperty(propertiesData[params.id] || propertiesData[1]);
setLoading(false);
}, 500);
}, [params.id]);
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const calculateTotalPrice = () => {
if (!property) return 0;
const days = bookingDates.start && bookingDates.end
? Math.ceil((new Date(bookingDates.end) - new Date(bookingDates.start)) / (1000 * 60 * 60 * 24))
: selectedDuration;
return property.price * (days > 0 ? days : 1);
};
const handleBooking = () => {
alert('تم إرسال طلب الحجز بنجاح. سيتم التواصل معك قريباً.');
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-gray-200 border-t-gray-800 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">جاري تحميل تفاصيل العقار...</p>
</div>
</div>
);
} }
if (!property) { function mapProperty(item) {
return ( const info = item.propertyInformation || item.PropertyInformation || {};
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> let details = {};
<div className="text-center"> try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
<Home className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">العقار غير موجود</h2> const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
<p className="text-gray-600 mb-4">لم نتمكن من العثور على العقار المطلوب</p> const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
<Link href="/properties" className="bg-gray-800 text-white px-6 py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors"> const buildingType = info.buildingType ?? info.BuildingType ?? 0;
العودة إلى العقارات const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
</Link> const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
</div> const address = info.address || info.Address || '';
</div> const bedrooms = info.numberOfBedRooms || info.NumberOfBedRooms || 0;
); const bathrooms = info.numberOfBathRooms || info.NumberOfBathRooms || 0;
const area = info.space || info.Space || 0;
const desc = info.description || info.Description || '';
const images = info.images || info.Images || [];
const firstImage = Array.isArray(images) && images[0] ? images[0] : '';
return {
title: `${typeLabel} في ${address}`,
description: desc || `${typeLabel} في ${address} · ${bedrooms} غرف نوم · ${bathrooms} حمامات · ${area} م²`,
price,
priceUnit,
typeLabel,
address,
bedrooms,
bathrooms,
area,
image: firstImage,
};
} }
return ( export async function generateMetadata({ params }) {
<div className="min-h-screen bg-gray-50"> const { id } = await params;
<div className="bg-white border-b sticky top-16 z-40 shadow-sm"> const raw = await fetchPropertyForMeta(id);
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<Link href="/properties" className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors">
<ArrowLeft className="w-5 h-5" />
<span>العودة إلى العقارات</span>
</Link>
<div className="flex gap-2">
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<Heart className="w-5 h-5 text-gray-600" />
</button>
<button className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<Share2 className="w-5 h-5 text-gray-600" />
</button>
</div>
</div>
</div>
</div>
<div className="container mx-auto px-4 py-8"> if (!raw) {
<motion.div return {
initial={{ opacity: 0, y: 20 }} title: 'SweetHome - عقار',
animate={{ opacity: 1, y: 0 }} description: 'اكتشف أفضل العقارات للإيجار',
className="mb-8" };
> }
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="relative h-[500px] rounded-2xl overflow-hidden group bg-gray-100"> const p = mapProperty(raw);
<Image const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
src={property.images[currentImage] || '/property-placeholder.jpg'} const propertyImage = p.image
alt={property.title} ? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
fill : '';
className="object-cover" const logoUrl = `http://45.93.137.91/logo.png`;
/>
// Use property image if available, otherwise logo
{property.images.length > 1 && ( const ogImages = propertyImage
<> ? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
<button : [{ url: logoUrl, width: 512, height: 512 }];
onClick={() => setCurrentImage(prev => Math.max(0, prev - 1))}
className="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white" return {
> title: `${p.title} - ${priceStr}`,
<ChevronLeft className="w-5 h-5" /> description: p.description,
</button> openGraph: {
<button title: `${p.title} - ${priceStr}`,
onClick={() => setCurrentImage(prev => Math.min(property.images.length - 1, prev + 1))} description: p.description,
className="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-white/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-white" images: ogImages,
> url: `http://45.93.137.91/property/${id}`,
<ChevronRight className="w-5 h-5" /> type: 'website',
</button> siteName: 'SweetHome',
</> },
)} twitter: {
card: 'summary_large_image',
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2"> title: `${p.title} - ${priceStr}`,
{property.images.map((_, idx) => ( description: p.description,
<button images: ogImages.map(i => i.url),
key={idx} },
onClick={() => setCurrentImage(idx)} };
className={`w-2 h-2 rounded-full transition-all ${ }
idx === currentImage ? 'bg-gray-800 w-4' : 'bg-white/70 hover:bg-white'
}`} export default function PropertyPage({ params }) {
/> return <PropertyDetail params={params} />;
))}
</div>
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-3 py-1 rounded-full text-sm backdrop-blur-sm">
<Camera className="w-4 h-4 inline ml-1" />
{currentImage + 1} / {property.images.length}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{property.images.slice(1, 5).map((img, idx) => (
<div
key={idx}
onClick={() => setCurrentImage(idx + 1)}
className="relative h-[240px] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity bg-gray-100"
>
<Image src={img} alt={`${property.title} ${idx + 2}`} fill className="object-cover" />
</div>
))}
</div>
</div>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{property.title}</h1>
<div className="flex items-center gap-2 text-gray-500">
<MapPin className="w-5 h-5" />
<span>{property.location.address}</span>
</div>
</div>
<div className="text-left">
<div className="text-3xl font-bold text-gray-900">{formatCurrency(property.price)}</div>
<div className="text-sm text-gray-500">/{property.priceUnit === 'daily' ? 'يوم' : 'شهر'}</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<Star className="w-5 h-5 fill-gray-800 text-gray-800" />
<span className="font-bold text-gray-900">{property.rating}</span>
<span className="text-gray-500">({property.reviews} تقييم)</span>
</div>
<div className="w-px h-4 bg-gray-200" />
<span className={`font-medium ${
property.status === 'available' ? 'text-gray-800' : 'text-gray-500'
}`}>
{property.status === 'available' ? 'متاح للإيجار' : 'محجوز حالياً'}
</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">المواصفات الرئيسية</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-xl">
<Bed className="w-6 h-6 text-gray-700 mx-auto mb-2" />
<div className="font-bold text-gray-900">{property.bedrooms}</div>
<div className="text-sm text-gray-500">غرف نوم</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<Bath className="w-6 h-6 text-gray-700 mx-auto mb-2" />
<div className="font-bold text-gray-900">{property.bathrooms}</div>
<div className="text-sm text-gray-500">حمامات</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<Square className="w-6 h-6 text-gray-700 mx-auto mb-2" />
<div className="font-bold text-gray-900">{property.area}</div>
<div className="text-sm text-gray-500">م²</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<Home className="w-6 h-6 text-gray-700 mx-auto mb-2" />
<div className="font-bold text-gray-900">
{property.type === 'villa' ? 'فيلا' :
property.type === 'apartment' ? 'شقة' : 'بيت'}
</div>
<div className="text-sm text-gray-500">نوع العقار</div>
</div>
</div>
{property.specifications && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="w-4 h-4" />
<span>بناء: {property.specifications.constructionYear}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Ruler className="w-4 h-4" />
<span>حديقة: {property.specifications.gardenArea} م²</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Car className="w-4 h-4" />
<span>موقف: {property.specifications.parking}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Wind className="w-4 h-4" />
<span>{property.specifications.airConditioning}</span>
</div>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">وصف العقار</h2>
<p className="text-gray-600 whitespace-pre-line leading-relaxed">{property.description}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">المميزات والخدمات</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{property.features.map((feature, idx) => (
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-xl">
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
feature.available ? 'bg-gray-800 text-white' : 'bg-gray-200 text-gray-500'
}`}>
{feature.available ? (
<Check className="w-4 h-4" />
) : (
<X className="w-4 h-4" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-2xl">{feature.icon}</span>
<span className={`font-medium ${feature.available ? 'text-gray-900' : 'text-gray-400'}`}>
{feature.name}
</span>
</div>
{feature.description && (
<p className={`text-sm mt-1 ${feature.available ? 'text-gray-500' : 'text-gray-400'}`}>
{feature.description}
</p>
)}
</div>
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">القرب من الخدمات</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{property.nearby.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<span className="text-xl">{item.icon}</span>
<span className="text-gray-700">{item.type}</span>
</div>
<span className="font-medium text-gray-900">{item.distance}</span>
</div>
))}
</div>
</motion.div>
{property.reviewList && property.reviewList.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">تقييمات المستأجرين</h2>
<div className="space-y-4">
{property.reviewList.map((review, idx) => (
<div key={idx} className="border-b border-gray-100 last:border-0 pb-4 last:pb-0">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-bold text-gray-900">{review.user}</span>
<div className="flex items-center gap-1 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${
i < review.rating ? 'fill-gray-800 text-gray-800' : 'text-gray-300'
}`} />
))}
</div>
</div>
<span className="text-sm text-gray-500">{review.date}</span>
</div>
<p className="text-gray-600">{review.comment}</p>
</div>
))}
</div>
</motion.div>
)}
{property.rules && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">قوانين المنزل</h2>
<ul className="space-y-2">
{property.rules.map((rule, idx) => (
<li key={idx} className="flex items-center gap-2 text-gray-600">
<div className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
{rule}
</li>
))}
</ul>
</motion.div>
)}
</div>
<div className="space-y-6">
<div className="sticky top-28">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 mb-6"
>
<h2 className="text-xl font-bold mb-4 text-gray-900">احجز هذا العقار</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">اختر المدة (أيام)</label>
<div className="flex gap-2">
{[1, 3, 7, 14, 30].map(days => (
<button
key={days}
onClick={() => setSelectedDuration(days)}
className={`flex-1 py-2 rounded-xl text-sm font-medium transition-colors ${
selectedDuration === days
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{days}
</button>
))}
</div>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ البداية</label>
<input
type="date"
value={bookingDates.start}
onChange={(e) => setBookingDates({ ...bookingDates, start: e.target.value })}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">تاريخ النهاية</label>
<input
type="date"
value={bookingDates.end}
onChange={(e) => setBookingDates({ ...bookingDates, end: e.target.value })}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-gray-300 focus:border-gray-300"
/>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl mb-6">
<div className="flex justify-between mb-2">
<span className="text-gray-600">السعر لـ {selectedDuration} أيام</span>
<span className="font-bold text-gray-900">{formatCurrency(property.price * selectedDuration)}</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-gray-600">سلفة ضمان</span>
<span className="font-bold text-gray-900">{formatCurrency(500000)}</span>
</div>
<div className="flex justify-between pt-2 border-t border-gray-200 font-bold">
<span className="text-gray-900">الإجمالي</span>
<span className="text-gray-900">{formatCurrency(property.price * selectedDuration + 500000)}</span>
</div>
</div>
<button
onClick={handleBooking}
className="w-full bg-gray-800 text-white py-4 rounded-xl font-bold text-lg hover:bg-gray-900 transition-colors mb-4"
>
تأكيد الحجز
</button>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Shield className="w-4 h-4 text-gray-600" />
<span>الدفع آمن ومضمون. سلفة الضمان قابلة للاسترداد.</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100"
>
<h3 className="font-bold mb-4 text-gray-900">معلومات المالك</h3>
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-xl font-bold text-gray-700">
{property.owner.name.charAt(0)}
</span>
</div>
<div>
<div className="font-bold text-gray-900">{property.owner.name}</div>
<div className="flex items-center gap-1 text-sm text-gray-500">
<Star className="w-3 h-3 fill-gray-600 text-gray-600" />
<span>{property.owner.rating}</span>
<span>· {property.owner.properties} عقارات</span>
</div>
{property.owner.responseRate && (
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
<Clock className="w-3 h-3" />
<span>استجابة: {property.owner.responseRate}</span>
</div>
)}
</div>
</div>
{showContact ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
<Phone className="w-4 h-4 text-gray-600" />
<span className="font-medium text-gray-900">{property.owner.phone}</span>
</div>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-xl">
<Mail className="w-4 h-4 text-gray-600" />
<span className="font-medium text-gray-900">{property.owner.email}</span>
</div>
</div>
) : (
<button
onClick={() => setShowContact(true)}
className="w-full bg-gray-800 text-white py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors flex items-center justify-center gap-2"
>
<Phone className="w-5 h-5" />
عرض معلومات الاتصال
</button>
)}
<button className="w-full mt-3 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
<MessageCircle className="w-5 h-5" />
مراسلة المالك
</button>
</motion.div>
</div>
</div>
</div>
</div>
</div>
);
} }

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
'use client';
import { motion } from 'framer-motion';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import Link from 'next/link';
export default function Error({ error, reset }) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center max-w-md">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">حدث خطأ</h2>
<p className="text-gray-400 mb-8">نعتذر، حدث خطأ أثناء تحميل الصفحة</p>
<div className="flex gap-3 justify-center">
<button onClick={reset} className="flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors">
<RefreshCw className="w-5 h-5" /> إعادة المحاولة
</button>
<Link href="/" className="flex items-center gap-2 bg-white/10 text-gray-300 px-6 py-3 rounded-xl font-medium hover:bg-white/20 transition-colors">
<Home className="w-5 h-5" /> الرئيسية
</Link>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-14 h-14 border-4 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-lg">جاري التحميل...</p>
</motion.div>
</div>
);
}

View File

@ -1,117 +1,232 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useMemo } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import { import {
User, User, Mail, Phone, Lock, Eye, EyeOff,
Mail, CheckCircle, XCircle, ArrowLeft, Home, Loader2,
Phone, Shield, KeyRound, Camera, X
Lock,
Eye,
EyeOff,
CheckCircle,
XCircle,
ArrowLeft,
Home,
Loader2
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast'; import toast, { Toaster } from 'react-hot-toast';
import { addCustomer, loginWithEmail, sendEmailOTP, verifyEmail } from '../../utils/api';
import AuthService from '../../services/AuthService';
import { CustomerType, CustomerTypeLabels } from '../../enums';
export default function TenantRegisterPage() { export default function TenantRegisterPage() {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState(1); // 1=form, 2=id images
const [showOtpModal, setShowOtpModal] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', firstName: '',
lastName: '',
email: '', email: '',
phone: '', phone: '',
whatsapp: '',
phone2: '',
nationalNumber: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
customerType: CustomerType.PERSONAL,
agreeTerms: false agreeTerms: false
}); });
const [idImages, setIdImages] = useState({ front: null, back: null });
const [idImagePreviews, setIdImagePreviews] = useState({ front: '', back: '' });
const [otpCode, setOtpCode] = useState('');
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const validateEmail = (email) => { const fileInputFrontRef = useRef(null);
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const fileInputBackRef = useRef(null);
return re.test(email);
const handleImageUpload = (side, file) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('الرجاء اختيار صورة صالحة');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error('حجم الصورة يجب أن يكون أقل من 5 ميجابايت');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setIdImagePreviews(prev => ({ ...prev, [side]: reader.result }));
};
reader.readAsDataURL(file);
setIdImages(prev => ({ ...prev, [side]: file }));
console.log('[CustomerRegister] Image uploaded:', side);
toast.success('تم رفع الصورة بنجاح', { style: { background: '#dcfce7', color: '#166534' } });
}; };
const validatePhone = (phone) => { const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const re = /^(09|05)[0-9]{8}$/; const validatePhone = (phone) => /^(09|05)[0-9]{8}$/.test(phone);
return re.test(phone);
};
const validateForm = () => { const validateStep1 = () => {
const newErrors = {}; const newErrors = {};
if (!formData.firstName) newErrors.firstName = 'الاسم الأول مطلوب';
if (!formData.lastName) newErrors.lastName = 'اسم العائلة مطلوب';
if (!formData.name) {
newErrors.name = 'الاسم الكامل مطلوب';
} else if (formData.name.length < 3) {
newErrors.name = 'الاسم يجب أن يكون 3 أحرف على الأقل';
}
if (!formData.email) { if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
newErrors.email = 'البريد الإلكتروني مطلوب'; else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
} else if (!validateEmail(formData.email)) {
newErrors.email = 'البريد الإلكتروني غير صالح';
}
if (!formData.phone) { if (!formData.phone) newErrors.phone = 'رقم الهاتف مطلوب';
newErrors.phone = 'رقم الهاتف مطلوب'; else if (!validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
} else if (!validatePhone(formData.phone)) {
newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
}
if (!formData.password) { if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
newErrors.password = 'كلمة المرور مطلوبة'; else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
} else if (formData.password.length < 6) {
newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
}
if (formData.password !== formData.confirmPassword) { if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
newErrors.confirmPassword = 'كلمات المرور غير متطابقة'; if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = 'رقم الهاتف يجب أن يكون 7 أرقام';
} if (!formData.nationalNumber) newErrors.nationalNumber = 'الرقم الوطني مطلوب';
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const validateStep2 = () => {
const newErrors = {};
if (!idImages.front) newErrors.front = 'صورة الوجه الأمامي للهوية مطلوبة';
if (!idImages.back) newErrors.back = 'صورة الوجه الخلفي للهوية مطلوبة';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNextStep = () => {
if (validateStep1()) {
console.log('[CustomerRegister] Step 1 valid, moving to step 2');
setStep(2);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
toast.error('يرجى تصحيح الأخطاء في النموذج');
}
};
// ─── Main signup handler ───
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!validateForm()) { if (!validateStep2()) {
toast.error('يرجى تصحيح الأخطاء في النموذج'); toast.error('يرجى إكمال جميع الصور المطلوبة');
return; return;
} }
if (!formData.agreeTerms) { if (!formData.agreeTerms) {
toast.error('يجب الموافقة على الشروط والأحكام'); toast.error('يجب الموافقة على الشروط والأحكام');
return; return;
} }
setIsLoading(true); setIsLoading(true);
console.log('[CustomerRegister] Submitting customer registration...');
setTimeout(() => { const payload = {
setIsLoading(false); firstName: formData.firstName,
toast.success('تم إنشاء الحساب بنجاح!', { lastName: formData.lastName,
style: { background: '#dcfce7', color: '#166534' },
duration: 3000
});
localStorage.setItem('user', JSON.stringify({
name: formData.name,
email: formData.email, email: formData.email,
role: 'tenant', phoneNumber: formData.phone,
avatar: formData.name.charAt(0).toUpperCase() whatsAppNumber: formData.whatsapp,
})); phone: formData.phone2,
nationalNumber: formData.nationalNumber,
password: formData.password,
customerType: formData.customerType,
};
setTimeout(() => { try {
const res = await addCustomer(payload, idImages.front, idImages.back);
console.log('[CustomerRegister] addCustomer response:', res);
if (res.status === 200 || res.ok) {
const tempToken = res.data;
if (tempToken) {
AuthService.addToken(tempToken);
console.log('[CustomerRegister] Temp token stored for OTP');
}
const apiMessage = res.message || res.data?.message;
toast.success(apiMessage || 'تم إنشاء الحساب! يرجى التحقق من بريدك الإلكتروني', { duration: 4000 });
// Auto-login to trigger OTP
console.log('[CustomerRegister] Auto-login to send OTP...');
const loginRes = await loginWithEmail(formData.email, formData.password);
console.log('[CustomerRegister] login response:', loginRes);
if (loginRes.status === 206) {
const otpToken = loginRes.data;
if (otpToken) AuthService.addToken(otpToken);
const loginMsg = loginRes.message || loginRes.data?.message;
toast(loginMsg || 'تم إرسال رمز التحقق إلى بريدك الإلكتروني', { icon: '📧' });
setShowOtpModal(true);
} else if (loginRes.status === 200) {
const loginToken = loginRes.data;
if (loginToken) AuthService.addToken(loginToken);
toast.success(loginRes.message || 'تم تسجيل الدخول بنجاح!');
router.push('/'); router.push('/');
}, 1500); }
}, 2000); } else {
const errMsg = res.message || res.data?.message || 'فشل في إنشاء الحساب';
console.error('[CustomerRegister] Registration failed:', errMsg);
toast.error(errMsg);
}
} catch (err) {
console.error('[CustomerRegister] Error:', err);
toast.error(err.message || 'حدث خطأ أثناء التسجيل');
} finally {
setIsLoading(false);
}
};
// ─── OTP verification handler ───
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length < 4) {
toast.error('يرجى إدخال رمز التحقق');
return;
}
setIsLoading(true);
console.log('[CustomerRegister] Verifying OTP:', otpCode);
try {
const res = await verifyEmail(otpCode);
console.log('[CustomerRegister] VerifyEmail response:', res);
if (res.status === 200) {
AuthService.deleteToken();
console.log('[CustomerRegister] Temp token removed after verification');
toast.success(res.message || 'تم التحقق من البريد الإلكتروني بنجاح!', { duration: 3000 });
setShowOtpModal(false);
setTimeout(() => router.push('/login'), 1500);
} else {
const errMsg = res.message || res.data?.message || 'رمز التحقق غير صحيح';
console.error('[CustomerRegister] Verification failed:', errMsg);
toast.error(errMsg);
}
} catch (err) {
console.error('[CustomerRegister] Verify error:', err);
toast.error(err.message || 'حدث خطأ أثناء التحقق');
} finally {
setIsLoading(false);
}
};
const handleResendOTP = async () => {
setIsLoading(true);
console.log('[CustomerRegister] Resending email OTP...');
try {
await sendEmailOTP();
toast.success('تم إرسال رمز تحقق جديد');
} catch (err) {
console.error('[CustomerRegister] Resend OTP error:', err);
toast.error('فشل في إرسال الرمز');
} finally {
setIsLoading(false);
}
}; };
const fadeInUp = { const fadeInUp = {
@ -121,318 +236,379 @@ export default function TenantRegisterPage() {
}; };
const staggerContainer = { const staggerContainer = {
animate: { animate: { transition: { staggerChildren: 0.1 } }
transition: {
staggerChildren: 0.1
}
}
}; };
const backgroundElements = useMemo(() => {
const circles = [
{ style: { top: '20%', right: '20%', width: '256px', height: '256px' }, className: 'bg-blue-500/10' },
{ style: { bottom: '20%', left: '20%', width: '320px', height: '320px' }, className: 'bg-blue-500/10' },
{ style: { top: '50%', left: '50%', width: '384px', height: '384px', transform: 'translate(-50%, -50%)' }, className: 'bg-blue-500/10' },
];
const dots = [
{ left: '5%', top: '10%', size: '120px' },
{ left: '15%', top: '70%', size: '80px' },
{ left: '25%', top: '30%', size: '150px' },
{ left: '35%', top: '85%', size: '100px' },
{ left: '45%', top: '15%', size: '90px' },
{ left: '55%', top: '60%', size: '130px' },
{ left: '65%', top: '40%', size: '70px' },
{ left: '75%', top: '80%', size: '110px' },
{ left: '85%', top: '20%', size: '140px' },
{ left: '95%', top: '50%', size: '85px' },
];
return (
<>
{circles.map((circle, i) => (
<div
key={`circle-${i}`}
className={`absolute rounded-full ${circle.className}`}
style={circle.style}
/>
))}
{dots.map((dot, i) => (
<div
key={`dot-${i}`}
className="absolute rounded-full bg-blue-500/10"
style={{ left: dot.left, top: dot.top, width: dot.size, height: dot.size }}
/>
))}
</>
);
}, []);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden"> <div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950 flex items-center justify-center p-4 relative overflow-hidden">
<Toaster position="top-center" reverseOrder={false} /> <Toaster position="top-center" reverseOrder={false} />
<div className="absolute inset-0 overflow-hidden"> {/* <div className="absolute inset-0 overflow-hidden">
{[...Array(20)].map((_, i) => ( {[...Array(20)].map((_, i) => (
<motion.div <motion.div key={i} className="absolute rounded-full bg-blue-500/10"
key={i} style={{ left: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`, width: Math.random() * 200 + 50, height: Math.random() * 200 + 50 }}
className="absolute rounded-full bg-blue-500/10" animate={{ x: [0, Math.random() * 100 - 50, 0], y: [0, Math.random() * 100 - 50, 0] }}
style={{ transition={{ duration: Math.random() * 15 + 15, repeat: Infinity, ease: "linear" }} />
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
width: Math.random() * 200 + 50,
height: Math.random() * 200 + 50,
}}
animate={{
x: [0, Math.random() * 100 - 50, 0],
y: [0, Math.random() * 100 - 50, 0],
}}
transition={{
duration: Math.random() * 15 + 15,
repeat: Infinity,
ease: "linear"
}}
/>
))} ))}
</div> */}
<div className="absolute inset-0 overflow-hidden">
{backgroundElements}
</div> </div>
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
<motion.div className="relative z-10 w-full max-w-md">
initial={{ opacity: 0, scale: 0.95 }} {/* Back */}
animate={{ opacity: 1, scale: 1 }} <motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-8">
transition={{ duration: 0.5 }} <Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
className="relative z-10 w-full max-w-md" <motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="absolute -top-16 left-0"
>
<Link
href="/auth/choose-role"
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group"
>
<motion.div whileHover={{ x: -5 }}>
<ArrowLeft className="w-4 h-4" />
</motion.div>
<span>العودة</span> <span>العودة</span>
</Link> </Link>
</motion.div> </motion.div>
{/* Progress */}
<div className="mb-6 flex gap-2">
{[1, 2].map((s) => (
<motion.div key={s} className={`h-2 flex-1 rounded-full ${step >= s ? 'bg-blue-500' : 'bg-gray-700'}`} animate={{ scaleX: step >= s ? 1 : 0.5 }} />
))}
</div>
<div className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden"> <div className="bg-white/5 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/10 overflow-hidden">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden"> <div className="bg-gradient-to-r from-blue-500 to-blue-600 p-8 text-center relative overflow-hidden">
<motion.div <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.2, type: "spring" }}
initial={{ scale: 0 }} className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" />
animate={{ scale: 1 }} <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="relative z-10">
transition={{ delay: 0.2, type: "spring" }} <motion.div animate={{ rotate: [0, 10, -10, 0] }} transition={{ duration: 2, repeat: Infinity }}
className="absolute -top-10 -right-10 w-40 h-40 bg-white/10 rounded-full" className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
/>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="relative z-10"
>
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm"
>
<Home className="w-10 h-10 text-white" /> <Home className="w-10 h-10 text-white" />
</motion.div> </motion.div>
<h1 className="text-3xl font-bold text-white mb-2">إنشاء حساب مستأجر</h1> <h1 className="text-3xl font-bold text-white mb-2">
<p className="text-blue-100">انضم إلينا وابحث عن منزل أحلامك</p> {step === 1 ? 'إنشاء حساب مستأجر' : 'الوثائق الرسمية'}
</h1>
<p className="text-blue-100">
{step === 1 ? 'انضم إلينا وابحث عن منزل أحلامك' : 'يرجى رفع صور الهوية للتحقق'}
</p>
</motion.div> </motion.div>
</div> </div>
<div className="p-8"> <div className="p-8">
<motion.form <motion.form variants={staggerContainer} initial="initial" animate="animate"
variants={staggerContainer} onSubmit={step === 1 ? (e) => { e.preventDefault(); handleNextStep(); } : handleSubmit}
initial="initial" className="space-y-6">
animate="animate"
onSubmit={handleSubmit} {/* ─── STEP 1: Form ─── */}
className="space-y-6" {step === 1 && (
> <>
<motion.div variants={fadeInUp}> <motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
<label className="block text-sm font-medium text-gray-300 mb-2"> <div>
الاسم الكامل <span className="text-red-500">*</span> <label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<User className={`w-5 h-5 ${ <User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
errors.name ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div> </div>
<input <input type="text" value={formData.firstName}
type="text" onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: null}); }}
value={formData.name} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.firstName ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="الاسم الأول" />
setFormData({...formData, name: e.target.value}); </div>
setErrors({...errors, name: null}); {errors.firstName && <p className="text-red-500 text-sm mt-1">{errors.firstName}</p>}
}} </div>
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${ <div>
errors.name ? 'border-red-500' : 'border-gray-700' <label className="block text-sm font-medium text-gray-300 mb-2">اسم العائلة <span className="text-red-500">*</span></label>
}`} <input type="text" value={formData.lastName}
placeholder="أدخل اسمك الكامل" onChange={(e) => { setFormData({...formData, lastName: e.target.value}); setErrors({...errors, lastName: null}); }}
/> className={`w-full px-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.lastName ? 'border-red-500' : 'border-gray-700'}`}
placeholder="اسم العائلة" />
{errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
</div> </div>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</motion.div> </motion.div>
<motion.div variants={fadeInUp}> <motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">البريد الإلكتروني <span className="text-red-500">*</span></label>
البريد الإلكتروني <span className="text-red-500">*</span>
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 ${ <Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div> </div>
<input <input type="email" value={formData.email}
type="email" onChange={(e) => { setFormData({...formData, email: e.target.value}); setErrors({...errors, email: null}); }}
value={formData.email} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.email ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="أدخل بريدك الإلكتروني" />
setFormData({...formData, email: e.target.value});
setErrors({...errors, email: null});
}}
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.email ? 'border-red-500' : 'border-gray-700'
}`}
placeholder="أدخل بريدك الإلكتروني"
/>
</div> </div>
{errors.email && ( {errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
</motion.div> </motion.div>
<motion.div variants={fadeInUp}> <motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف <span className="text-red-500">*</span></label>
رقم الهاتف <span className="text-red-500">*</span>
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Phone className={`w-5 h-5 ${ <Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div> </div>
<input <input type="tel" value={formData.phone}
type="tel" onChange={(e) => { setFormData({...formData, phone: e.target.value}); setErrors({...errors, phone: null}); }}
value={formData.phone} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="أدخل رقم هاتفك" />
setFormData({...formData, phone: e.target.value});
setErrors({...errors, phone: null});
}}
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.phone ? 'border-red-500' : 'border-gray-700'
}`}
placeholder="أدخل رقم هاتفك"
/>
</div> </div>
{errors.phone && ( {errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
)}
</motion.div> </motion.div>
<motion.div variants={fadeInUp}> <motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">رقم الواتساب <span className="text-red-500">*</span></label>
كلمة المرور <span className="text-red-500">*</span>
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 ${ <Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div> </div>
<input <input type="tel" value={formData.whatsapp}
type={showPassword ? "text" : "password"} onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: null}); }}
value={formData.password} className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.whatsapp ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="أدخل رقم الواتساب" />
setFormData({...formData, password: e.target.value}); </div>
setErrors({...errors, password: null}); {errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
}} </motion.div>
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.password ? 'border-red-500' : 'border-gray-700' <motion.div variants={fadeInUp}>
}`} <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
placeholder="أدخل كلمة المرور" <div className="relative group">
/> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<button <Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
type="button" </div>
onClick={() => setShowPassword(!showPassword)} <input type="tel" value={formData.phone2}
className="absolute inset-y-0 left-0 pl-3 flex items-center" onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }}
> className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.phone2 ? 'border-red-500' : 'border-gray-700'}`}
{showPassword ? ( placeholder="أدخل رقم الهاتف" maxLength={7} />
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" /> </div>
) : ( {errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" /> </motion.div>
)}
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
<div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
</div>
<input type="text" value={formData.nationalNumber}
onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }}
className={`w-full pr-12 pl-4 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.nationalNumber ? 'border-red-500' : 'border-gray-700'}`}
placeholder="أدخل الرقم الوطني" />
</div>
{errors.nationalNumber && <p className="text-red-500 text-sm mt-1">{errors.nationalNumber}</p>}
</motion.div>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">نوع العميل <span className="text-red-500">*</span></label>
<select value={formData.customerType}
onChange={(e) => setFormData({...formData, customerType: e.target.value})}
className="w-full py-3 px-4 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white appearance-none cursor-pointer">
{Object.entries(CustomerTypeLabels).map(([value, label]) => (
<option key={value} value={value} className="bg-gray-900 text-white">{label}</option>
))}
</select>
</motion.div>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">كلمة المرور <span className="text-red-500">*</span></label>
<div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 ${errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
</div>
<input type={showPassword ? "text" : "password"} value={formData.password}
onChange={(e) => { setFormData({...formData, password: e.target.value}); setErrors({...errors, password: null}); }}
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.password ? 'border-red-500' : 'border-gray-700'}`}
placeholder="أدخل كلمة المرور" />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
{showPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
</button> </button>
</div> </div>
{errors.password && ( {errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</p>}
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
</motion.div> </motion.div>
<motion.div variants={fadeInUp}> <motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">تأكيد كلمة المرور <span className="text-red-500">*</span></label>
تأكيد كلمة المرور <span className="text-red-500">*</span>
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 ${ <Lock className={`w-5 h-5 ${errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div> </div>
<input <input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
type={showConfirmPassword ? "text" : "password"} onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: null}); }}
value={formData.confirmPassword} className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${errors.confirmPassword ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="أعد إدخال كلمة المرور" />
setFormData({...formData, confirmPassword: e.target.value}); <button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute inset-y-0 left-0 pl-3 flex items-center">
setErrors({...errors, confirmPassword: null}); {showConfirmPassword ? <EyeOff className="w-5 h-5 text-gray-400" /> : <Eye className="w-5 h-5 text-gray-400" />}
}}
className={`w-full pr-12 pl-12 py-3 bg-white/5 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white placeholder-gray-500 transition-all ${
errors.confirmPassword ? 'border-red-500' : 'border-gray-700'
}`}
placeholder="أعد إدخال كلمة المرور"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 left-0 pl-3 flex items-center"
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
)}
</button> </button>
{formData.confirmPassword && ( {formData.confirmPassword && (
<div className="absolute inset-y-0 left-12 flex items-center"> <div className="absolute inset-y-0 left-12 flex items-center">
{formData.password === formData.confirmPassword ? ( {formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div> </div>
)} )}
</div> </div>
{errors.confirmPassword && ( {errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p> </motion.div>
</>
)} )}
{/* ─── STEP 2: ID Images ─── */}
{step === 2 && (
<>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الأمامي <span className="text-red-500">*</span></label>
<div onClick={() => fileInputFrontRef.current?.click()}
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.front ? 'border-green-500 bg-green-500/10' : errors.front ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-blue-500 hover:bg-white/5'}`}>
<input ref={fileInputFrontRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('front', e.target.files?.[0])} className="hidden" />
{idImagePreviews.front ? (
<div className="relative">
<Image src={idImagePreviews.front} alt="Front ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, front: null})); setIdImagePreviews(prev => ({...prev, front: ''})); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
<X className="w-4 h-4 text-white" />
</button>
</div>
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG حتى 5MB</p></>)}
</div>
{errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
</motion.div>
<motion.div variants={fadeInUp}>
<label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الخلفي <span className="text-red-500">*</span></label>
<div onClick={() => fileInputBackRef.current?.click()}
className={`relative border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${idImagePreviews.back ? 'border-green-500 bg-green-500/10' : errors.back ? 'border-red-500 bg-red-500/10' : 'border-gray-700 hover:border-blue-500 hover:bg-white/5'}`}>
<input ref={fileInputBackRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('back', e.target.files?.[0])} className="hidden" />
{idImagePreviews.back ? (
<div className="relative">
<Image src={idImagePreviews.back} alt="Back ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
<button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
<X className="w-4 h-4 text-white" />
</button>
</div>
) : (<><Camera className="w-12 h-12 text-gray-500 mx-auto mb-3" /><p className="text-gray-400">اضغط لرفع الصورة</p><p className="text-xs text-gray-500 mt-2">JPEG, PNG, JPG حتى 5MB</p></>)}
</div>
{errors.back && <p className="text-red-500 text-sm mt-1">{errors.back}</p>}
</motion.div> </motion.div>
<motion.div variants={fadeInUp} className="flex items-center gap-2"> <motion.div variants={fadeInUp} className="flex items-center gap-2">
<input <input type="checkbox" id="terms" checked={formData.agreeTerms}
type="checkbox"
id="terms"
checked={formData.agreeTerms}
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})} onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})}
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500 focus:ring-offset-0" className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500" required />
required
/>
<label htmlFor="terms" className="text-sm text-gray-300"> <label htmlFor="terms" className="text-sm text-gray-300">
أوافق على{' '} أوافق على <Link href="/terms" className="text-blue-400 hover:text-blue-300">شروط الاستخدام</Link> و <Link href="/privacy" className="text-blue-400 hover:text-blue-300">سياسة الخصوصية</Link>
<Link href="/terms" className="text-blue-400 hover:text-blue-300">
شروط الاستخدام
</Link>
{' '}و{' '}
<Link href="/privacy" className="text-blue-400 hover:text-blue-300">
سياسة الخصوصية
</Link>
</label> </label>
</motion.div> </motion.div>
</>
<motion.button
variants={fadeInUp}
type="submit"
disabled={isLoading || !formData.agreeTerms}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 rounded-xl font-bold text-lg hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-500/25"
>
{isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span>جاري إنشاء الحساب...</span>
</div>
) : (
'إنشاء حساب'
)} )}
</motion.button>
{/* ─── Buttons ─── */}
<motion.div variants={fadeInUp} className="flex gap-3 pt-4">
{step === 1 ? (
<>
<button type="button" onClick={() => router.push('/auth/choose-role')}
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">إلغاء</button>
<button type="submit"
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all">التالي</button>
</>
) : (
<>
<button type="button" onClick={() => setStep(1)}
className="flex-1 py-3 px-4 bg-white/5 border border-gray-700 rounded-xl text-gray-300 hover:bg-white/10 transition-colors">السابق</button>
<button type="submit" disabled={isLoading || !formData.agreeTerms}
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 px-4 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
{isLoading ? (<div className="flex items-center justify-center gap-2"><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التسجيل...</span></div>) : 'إنشاء حساب'}
</button>
</>
)}
</motion.div>
{step === 1 && (
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4"> <motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
لديك حساب بالفعل؟{' '} لديك حساب بالفعل؟{' '}
<Link <Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">تسجيل الدخول</Link>
href="/login"
className="text-blue-400 hover:text-blue-300 font-medium transition-colors"
>
تسجيل الدخول
</Link>
</motion.p> </motion.p>
)}
</motion.form> </motion.form>
</div> </div>
</div> </div>
</motion.div> </motion.div>
{/* ─── OTP Modal ─── */}
<AnimatePresence>
{showOtpModal && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<motion.div initial={{ scale: 0.9, y: 20 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.9, y: 20 }}
className="bg-gray-900 border border-white/10 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<Shield className="w-8 h-8 text-blue-500" />
</div>
<h2 className="text-xl font-bold text-white">التحقق من البريد</h2>
<p className="text-gray-400 text-sm mt-1">تم إرسال رمز التحقق إلى</p>
<p className="text-blue-400 font-medium text-sm">{formData.email}</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">رمز التحقق</label>
<div className="relative">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<KeyRound className="w-5 h-5 text-gray-400" />
</div>
<input type="text" value={otpCode} maxLength={6}
onChange={(e) => setOtpCode(e.target.value)}
className="w-full pr-12 pl-4 py-3 bg-white/5 border border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-white text-center tracking-[0.5em] text-xl"
placeholder="------" />
</div>
</div>
<div className="flex gap-3">
<button onClick={handleVerifyOTP} disabled={isLoading || !otpCode}
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-xl font-medium hover:from-blue-600 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{isLoading ? <><Loader2 className="w-5 h-5 animate-spin" /><span>جاري التحقق...</span></> : 'تحقق'}
</button>
</div>
<button onClick={handleResendOTP} disabled={isLoading}
className="w-full text-center text-blue-400 hover:text-blue-300 text-sm mt-3 disabled:opacity-50">
إعادة إرسال الرمز
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }

226
app/reservations/page.js Normal file
View File

@ -0,0 +1,226 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
Calendar, Clock, CheckCircle, XCircle, Eye, Loader2, Search,
MapPin, DollarSign, Home, ArrowLeft,
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../services/AuthService';
import { getRentProperty } from '../utils/api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
const STATUS_MAP = ['pending','ownerConfirmed','depositPaid','depositConfirmed','completed','cancelled'];
const STATUS_UI = {
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
ownerConfirmed: { label: 'مؤكد من المالك', color: 'bg-blue-100 text-blue-800', icon: CheckCircle },
depositPaid: { label: 'تم دفع السلفة', color: 'bg-indigo-100 text-indigo-800', icon: DollarSign },
depositConfirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
completed: { label: 'منتهي', color: 'bg-green-100 text-green-800', icon: CheckCircle },
cancelled: { label: 'ملغي', color: 'bg-gray-100 text-gray-800', icon: XCircle },
};
function statusLabel(code) { return STATUS_UI[STATUS_MAP[code]]?.label ?? String(code); }
function statusColor(code) { return STATUS_UI[STATUS_MAP[code]]?.color ?? 'bg-gray-100 text-gray-700'; }
function statusIcon(code) { return STATUS_UI[STATUS_MAP[code]]?.icon ?? Clock; }
function StatusBadge({ code }) {
const Icon = statusIcon(code);
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${statusColor(code)}`}>
<Icon className="w-3 h-3" /> {statusLabel(code)}
</span>
);
}
async function enrich(reservation) {
if (!reservation.propertyId) return reservation;
try {
const prop = await getRentProperty(reservation.propertyId);
reservation._prop = prop?.propertyInformation ?? prop ?? null;
} catch { /* skip */ }
return reservation;
}
const propAddr = (p) => p?.address ?? '';
const propImages = (p) => Array.isArray(p?.images) ? p.images : [];
const propBeds = (p) => p?.numberOfBedRooms ?? 0;
const propBaths = (p) => p?.numberOfBathRooms ?? 0;
function ReservationCard({ r, onViewDetails }) {
const p = r._prop;
const imgs = propImages(p);
const img = imgs.length > 0 ? `${API_BASE}${imgs[0]}` : null;
const addr = propAddr(p);
const beds = propBeds(p);
const baths = propBaths(p);
return (
<motion.div initial={{ opacity:0,y:20 }} animate={{ opacity:1,y:0 }}
className="bg-white rounded-2xl shadow-sm hover:shadow-md transition-all border border-gray-200 overflow-hidden">
<div className="p-5">
{img && <div className="mb-4 w-full h-40 rounded-xl overflow-hidden"><img src={img} alt="" className="w-full h-full object-cover" /></div>}
<div className="flex justify-between items-start mb-3">
<div>
<StatusBadge code={r.status} />
{addr && <div className="flex items-center gap-1 text-gray-500 text-sm mt-1"><MapPin className="w-4 h-4"/>{addr}</div>}
</div>
<div className="text-left">
<div className="text-lg font-bold text-amber-600">{r.totalPrice?.toLocaleString() ?? '—'}</div>
<div className="text-xs text-gray-500">السعر الإجمالي</div>
</div>
</div>
{(beds||baths) && <div className="flex gap-3 mb-3 text-sm text-gray-600">{beds>0&&<span>{beds} غرف</span>}{baths>0&&<span>{baths} حمامات</span>}</div>}
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
<div className="bg-gray-50 p-2 rounded-lg">
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">من</div>
<div className="text-sm font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg">
<Calendar className="w-4 h-4 text-amber-500 mx-auto mb-1"/><div className="text-xs text-gray-500">إلى</div>
<div className="text-sm font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</div>
</div>
</div>
<div className="flex gap-3 pt-3 border-t border-gray-100">
<button onClick={() => onViewDetails(r)}
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors flex items-center justify-center gap-2">
<Eye className="w-4 h-4"/> التفاصيل
</button>
</div>
</div>
</motion.div>
);
}
function DetailsModal({ r, isOpen, onClose }) {
if (!isOpen || !r) return null;
const p = r._prop;
return (
<motion.div initial={{opacity:0}} animate={{opacity:1}} exit={{opacity:0}}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50" onClick={onClose}>
<motion.div initial={{scale:0.9,y:20}} animate={{scale:1,y:0}} exit={{scale:0.9,y:20}}
className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl" onClick={e=>e.stopPropagation()}>
<div className="sticky top-0 bg-gradient-to-r from-amber-500 to-amber-600 p-6 text-white">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold">تفاصيل الحجز</h2>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full"><XCircle className="w-6 h-6"/></button>
</div>
<p className="text-amber-100 text-sm mt-1">رقم الحجز: #{r.id}</p>
</div>
<div className="p-6 space-y-6">
{p && <div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Home className="w-5 h-5 text-amber-500"/> معلومات العقار</h3>
<p><span className="text-gray-500">العنوان:</span> {propAddr(p)||''}</p>
{(propBeds(p)||propBaths(p)) && <div className="flex gap-3 mt-2">
{propBeds(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBeds(p)} غرف</span>}
{propBaths(p)>0&&<span className="text-sm bg-white px-2 py-1 rounded-lg">{propBaths(p)} حمامات</span>}
</div>}
</div>}
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5 text-amber-500"/> تفاصيل الحجز</h3>
<div className="grid grid-cols-2 gap-4">
<div><p className="text-gray-500">تاريخ البداية</p><p className="font-medium">{new Date(r.startDate).toLocaleDateString('ar')}</p></div>
<div><p className="text-gray-500">تاريخ النهاية</p><p className="font-medium">{new Date(r.endDate).toLocaleDateString('ar')}</p></div>
<div><p className="text-gray-500">الحالة</p><StatusBadge code={r.status}/></div>
<div><p className="text-gray-500">تاريخ الإنشاء</p><p className="font-medium">{new Date(r.createdAt).toLocaleDateString('ar')}</p></div>
</div>
</div>
<div className="bg-amber-50 p-4 rounded-xl">
<h3 className="font-bold text-amber-700 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5"/> المعلومات المالية</h3>
<div className="flex justify-between font-bold"><span className="text-gray-900">الإجمالي</span><span className="text-amber-600 text-lg">{r.totalPrice?.toLocaleString()??''}</span></div>
</div>
</div>
</motion.div>
</motion.div>
);
}
export default function UserReservationsPage() {
const router = useRouter();
const [reservations, setReservations] = useState([]);
const [filtered, setFiltered] = useState([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => { if (!AuthService.getUser()) { router.push('/login'); return; } loadReservations(); }, [router]);
const loadReservations = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/Reservations/GetUserResevations`, {
headers: { Authorization: `Bearer ${AuthService.getToken()}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
let list = json.data || json || [];
if (!Array.isArray(list)) list = [];
const enriched = await Promise.all(list.map(enrich));
setReservations(enriched);
setFiltered(enriched);
} catch (err) {
console.error(err);
toast.error('فشل تحميل الحجوزات');
setReservations([]);
setFiltered([]);
}
setLoading(false);
}, []);
useEffect(() => {
let r = reservations;
if (filterStatus !== 'all') r = r.filter(x => STATUS_MAP[x.status] === filterStatus);
if (searchTerm) { const q = searchTerm.toLowerCase(); r = r.filter(x => propAddr(x._prop).toLowerCase().includes(q) || String(x.id).includes(q)); }
setFiltered(r);
}, [reservations, filterStatus, searchTerm]);
const allStatuses = [...new Set(reservations.map(r => STATUS_MAP[r.status]))];
const counts = { all: reservations.length, ...Object.fromEntries(allStatuses.map(s => [s, reservations.filter(r => STATUS_MAP[r.status] === s).length])) };
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><Loader2 className="w-12 h-12 text-amber-500 animate-spin"/></div>;
return (
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
<Toaster position="top-center" reverseOrder={false} />
<DetailsModal r={selected} isOpen={!!selected} onClose={() => setSelected(null)} />
<div className="container mx-auto px-4">
<motion.div initial={{opacity:0,y:-20}} animate={{opacity:1,y:0}} className="mb-8">
<button onClick={() => router.back()} className="flex items-center gap-2 text-gray-600 hover:text-amber-600 mb-4"><ArrowLeft className="w-5 h-5"/> الرجوع</button>
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
<p className="text-gray-600">لديك {reservations.length} حجز</p>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{Object.entries(counts).map(([s, c]) => (
<motion.div key={s} initial={{opacity:0,y:20}} animate={{opacity:1,y:0}}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${filterStatus===s?'border-amber-500 bg-amber-50':'border-gray-200'}`}
onClick={() => setFilterStatus(s)}>
<div className="text-2xl font-bold text-amber-600">{c}</div>
<div className="text-sm text-gray-600">{s==='all'?'الكل':(STATUS_UI[s]?.label||s)}</div>
</motion.div>
))}
</div>
<div className="mb-6 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"/>
<input type="text" placeholder="ابحث بعنوان العقار أو رقم الحجز..." value={searchTerm} onChange={e=>setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"/>
</div>
{filtered.length === 0 ? (
<div className="bg-white rounded-2xl p-12 text-center border-2 border-dashed border-gray-300">
<Calendar className="w-12 h-12 text-amber-600 mx-auto mb-4"/>
<h3 className="text-xl font-bold text-gray-900 mb-2">لا توجد حجوزات</h3>
<p className="text-gray-600">لم تقم بأي حجز حتى الآن</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filtered.map(r => <ReservationCard key={r.id} r={r} onViewDetails={setSelected} />)}
</div>
)}
</div>
</div>
);
}

162
app/services/AuthService.js Normal file
View File

@ -0,0 +1,162 @@
/**
* AuthService
* Manages authentication tokens and user role detection via JWT decoding.
*
* Roles (from JWT claims):
* - Owner: roles array contains "Owner"
* - Customer: authenticated but no "Owner" role
* - Guest: no token
*
* Methods:
* addToken(token) — store JWT token
* getToken() — retrieve JWT token
* deleteToken() — remove JWT token
* decodeToken() — decode JWT payload
* getUser() — get decoded user info
* getRoles() — get roles array from JWT
* isOwner() — check if user has Owner role
* isCustomer() — check if user is authenticated but not Owner
* isGuest() — check if no token exists
* isAuthenticated() — check if token exists
*/
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'cached_user';
const AuthService = Object.freeze({
addToken(token) {
if (!token || typeof token !== 'string') return;
localStorage.setItem(TOKEN_KEY, token);
},
getToken() {
return localStorage.getItem(TOKEN_KEY);
},
deleteToken() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
},
/**
* Cache full user profile (from API)
* @param {object} user — { name, email, phone, ... }
*/
cacheUser(user) {
if (!user) return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
},
/**
* Get cached user profile
* @returns {object|null}
*/
getCachedUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY));
} catch {
return null;
}
},
/**
* Decode JWT payload (base64)
* @returns {object|null}
*/
decodeToken() {
const token = this.getToken();
if (!token) return null;
try {
const payload = token.split('.')[1];
return JSON.parse(atob(payload));
} catch {
return null;
}
},
/**
* Extract user info from JWT
* @returns {object|null} — { id, name, email, phone, roles }
*/
getUser() {
const payload = this.decodeToken();
if (!payload) return null;
const cached = this.getCachedUser();
return {
id: payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'] || payload.sub || null,
name: cached?.name || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null,
email: cached?.email || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || null,
phone: cached?.phone || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone'] || null,
roles: this.getRoles(),
};
},
/**
* Get current authenticated user id
* @returns {number|string|null}
*/
getUserId() {
const user = this.getUser();
if (!user?.id) return null;
const parsedId = Number(user.id);
return Number.isFinite(parsedId) ? parsedId : user.id;
},
/**
* Get roles array from JWT
* @returns {string[]}
*/
getRoles() {
const payload = this.decodeToken();
if (!payload) return [];
const roles = payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
if (Array.isArray(roles)) return roles;
if (typeof roles === 'string') return [roles];
return [];
},
/**
* User has Owner role
* @returns {boolean}
*/
isOwner() {
return this.getRoles().includes('Owner');
},
/**
* User has Admin role
* @returns {boolean}
*/
isAdmin() {
return this.getRoles().includes('Admin');
},
/**
* Authenticated user without Owner or Admin role (i.e. customer)
* @returns {boolean}
*/
isCustomer() {
return this.isAuthenticated() && !this.isOwner() && !this.isAdmin();
},
/**
* No token — guest user
* @returns {boolean}
*/
isGuest() {
return !this.getToken();
},
/**
* Token exists
* @returns {boolean}
*/
isAuthenticated() {
return !!this.getToken();
},
});
export default AuthService;

450
app/utils/api.js Normal file
View File

@ -0,0 +1,450 @@
import AuthService from '../services/AuthService';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
/**
* Generic API fetch — attaches auth token, unwraps { data } envelope
*/
async function apiFetch(endpoint, options = {}) {
const token = AuthService.getToken();
const headers = {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
};
console.log('[API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
console.log('[API] Response:', res.status, endpoint);
if (!res.ok && res.status !== 206) {
const text = await res.text().catch(() => '');
console.error('[API] Error:', res.status, text);
throw new Error(`API ${res.status}: ${text || res.statusText}`);
}
const text = await res.text();
if (!text) return null;
try {
const json = JSON.parse(text);
if (json && typeof json === 'object' && 'data' in json) {
return json.data;
}
return json;
} catch {
return text;
}
}
/**
* Auth fetch — returns full { status, data, ok } for status-code handling
*/
async function authFetch(endpoint, body, token = null) {
console.log('[Auth] Request:', `${API_BASE}${endpoint}`);
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
console.log('[Auth] Sending with Bearer token');
}
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
console.log('[Auth] Response status:', res.status, endpoint);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
if (data && typeof data === 'object' && 'data' in data) {
data = data.data;
}
} catch {
data = text;
}
// Build message from response for toast display
const message = (typeof data === 'object' && data?.message) ? data.message : null;
return { status: res.status, data, ok: res.ok || res.status === 206, message };
}
// ─── Rent Properties ───
export async function getRentProperties() {
return apiFetch('/RentProperties/GetRentProperties');
}
export async function getRentProperty(id) {
return apiFetch(`/RentProperties/GetRentPropertyById/${id}`);
}
export async function getRentPropertyLocations(params = {}) {
const qs = new URLSearchParams();
if (params.maxOffset != null) qs.set('maxOffset', params.maxOffset);
if (params.minOffset != null) qs.set('minOffset', params.minOffset);
const query = qs.toString();
return apiFetch(`/RentProperties/GetRentPropertiesLocations${query ? `?${query}` : ''}`);
}
// ─── Sale Properties ───
export async function getSaleProperties() {
return apiFetch('/SaleProperties/GetSaleProperties');
}
export async function getSaleProperty(id) {
const items = await apiFetch('/SaleProperties/GetSaleProperties');
if (!Array.isArray(items)) return items;
return items.find(p => p.id == id) || items[0];
}
// ─── Properties (generic) ───
export async function getProperty(id) {
return apiFetch(`/Properties/Get/${id}`);
}
// ─── Recommendations ───
export async function getRecommendations() {
return apiFetch('/Recommendations/GetRecommendations');
}
export async function getTopRecommendations(count = 10) {
return apiFetch(`/Recommendations/GetTopRecommendations?count=${count}`);
}
// ─── Reservations ───
export async function getAvailableDateRanges(propertyId) {
console.log('[API] Fetching available dates for property:', propertyId);
return apiFetch(`/Reservations/GetAvailableDates/available/${propertyId}`);
}
export async function getReservations() {
return apiFetch('/Reservations/GetAllReservations');
}
export async function getReservation(id) {
return apiFetch(`/Reservations/GetReservation?id=${id}`);
}
export async function checkAvailability(propertyId, fromDate = null, toDate = null) {
const qs = new URLSearchParams();
if (fromDate) qs.set('fromDate', fromDate);
if (toDate) qs.set('toDate', toDate);
const query = qs.toString();
return apiFetch(`/Reservations/GetAvailable/${propertyId}${query ? `?${query}` : ''}`);
}
export async function bookReservation(propertyId, startDate, endDate) {
console.log('[API] Booking reservation:', { propertyId, startDate, endDate });
return apiFetch('/Reservations/BookReservation/book', {
method: 'POST',
body: JSON.stringify({ propertyId, startDate, endDate }),
});
}
// ─── Terms ───
export async function getTerms() {
return apiFetch('/Terms/GetTerms');
}
// ─── Profile ───
export async function getCustomerByUserId(userId) {
console.log('[API] Fetching customer by user ID:', userId);
return apiFetch(`/Customer/GetByUserId/${userId}`);
}
export async function getOwnerByUserId(userId) {
console.log('[API] Fetching owner by user ID:', userId);
return apiFetch(`/Owner/GetByUserId/${userId}`);
}
// ─── Properties ───
export async function getMyRentListings() {
console.log('[API] Fetching my rent listings');
return apiFetch(`/RentProperties/GetMyRentListings`);
}
export async function addRentProperty(data) {
console.log('[API] Adding rent property:', data.PropertyInformation?.Address);
return apiFetch('/RentProperties/AddRentProperty', {
method: 'POST',
body: JSON.stringify(data),
});
}
// ─── Currencies ───
export async function getCurrencies() {
return apiFetch('/Currency/GetAll');
}
// ─── Files ───
export async function uploadPicture(file) {
console.log('[API] Uploading picture:', file.name);
const formData = new FormData();
formData.append('image', file);
const token = AuthService.getToken();
const res = await fetch(`${API_BASE}/Files/UploadPicture`, {
method: 'POST',
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
body: formData,
});
const text = await res.text();
console.log('[API] Upload response:', res.status, text?.substring(0, 100));
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${text}`);
// Response is the relative path string (e.g. /Pictures/abc123.jpg)
try {
const json = JSON.parse(text);
return json?.data || json;
} catch {
return text;
}
}
// ─── Auth: Registration ───
/**
* Register a new owner
* @param {Object} data — { name, email, phoneNumber, whatsAppNumber, password, ownerType }
* @returns {Promise<{status, data, ok, message}>}
*/
// Multipart form-data fetch for file uploads
async function multipartAuthFetch(endpoint, formData) {
console.log('[Auth] Multipart request:', `${API_BASE}${endpoint}`);
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
// Don't set Content-Type — browser sets it with boundary
body: formData,
});
console.log('[Auth] Response status:', res.status, endpoint);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
if (data && typeof data === 'object' && 'data' in data) {
data = data.data;
}
} catch {
data = text;
}
return { status: res.status, data, ok: res.ok || res.status === 206, message: data?.message };
}
export async function addOwner(data, frontImage = null, backImage = null) {
console.log('[Auth] Registering owner (multipart):', data.email);
const formData = new FormData();
formData.append('FirstName', data.firstName || data.FirstName || '');
formData.append('LastName', data.lastName || data.LastName || '');
formData.append('Email', data.email || '');
formData.append('PhoneNumber', data.phoneNumber || '');
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
formData.append('Phone', data.phone || '');
formData.append('NationalNumber', data.nationalNumber || '');
formData.append('Password', data.password || '');
formData.append('Type', String(data.ownerType ?? data.Type ?? 0));
formData.append('Language', '0');
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
if (backImage) formData.append('RearIdCarImagePath', backImage);
return multipartAuthFetch('/Owner/Add', formData);
}
export async function addCustomer(data, frontImage = null, backImage = null) {
console.log('[Auth] Registering customer (multipart):', data.email);
const formData = new FormData();
formData.append('FirstName', data.firstName || data.FirstName || '');
formData.append('LastName', data.lastName || data.LastName || '');
formData.append('Email', data.email || '');
formData.append('PhoneNumber', data.phoneNumber || '');
formData.append('WhatsAppNumber', data.whatsAppNumber || '');
formData.append('Phone', data.phone || '');
formData.append('NationalNumber', data.nationalNumber || '');
formData.append('Password', data.password || '');
formData.append('Type', String(data.customerType ?? data.Type ?? 0));
formData.append('Language', '0');
if (frontImage) formData.append('FrontIdCarImagePath', frontImage);
if (backImage) formData.append('RearIdCarImagePath', backImage);
return multipartAuthFetch('/Customer/Add', formData);
}
// ─── Auth: Login ───
export async function loginWithEmail(credential, password) {
console.log('[Auth] Login with email:', credential);
return authFetch('/Auth/LogInWithEmail', {
credential,
password,
device: 0,
appVersion: '',
});
}
export async function loginWithPhone(credential, password) {
console.log('[Auth] Login with phone:', credential);
return authFetch('/Auth/LogInWithPhoneNumber', {
credential,
password,
device: 0,
appVersion: '',
});
}
// ─── Auth: OTP ───
export async function sendEmailOTP() {
console.log('[Auth] Sending email OTP...');
return apiFetch('/Auth/SendEmailOTP', { method: 'POST' });
}
export async function sendPhoneOTP() {
console.log('[Auth] Sending phone OTP...');
return apiFetch('/Auth/SendPhoneNumberOTP', { method: 'POST' });
}
export async function verifyEmail(code) {
console.log('[Auth] Verifying email with code:', code);
const token = AuthService.getToken();
return authFetch(`/Auth/VerifyEmail?code=${encodeURIComponent(code)}`, {}, token);
}
export async function verifyPhone(code) {
console.log('[Auth] Verifying phone with code:', code);
const token = AuthService.getToken();
return authFetch(`/Auth/VerifyPhoneNumber?code=${encodeURIComponent(code)}`, {}, token);
}
// ─── Helpers ───
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export function isPhoneNumber(value) {
return /^\+?\d{7,15}$/.test(value.replace(/[\s\-()]/g, ''));
}
// ─── Favorites ───
export async function getUserFavoriteProperties() {
return apiFetch('/FavoriteProperty/GetUserFavoriteProperties');
}
export async function addFavoriteProperty(propId) {
return apiFetch(`/FavoriteProperty/Add?propId=${propId}`, { method: 'POST' });
}
export async function removeFavoriteProperty(favePropId) {
return apiFetch(`/FavoriteProperty/Remove?favePropId=${favePropId}`, { method: 'DELETE' });
}
export async function getUserNotifications() {
return apiFetch('/Notifications/GetUserNotifications');
}
// ─── Booking/Reservation Management ───
export async function confirmDepositPayment(bookingId) {
return apiFetch('/Reservations/ConfirmDepositPayment', {
method: 'POST',
body: JSON.stringify({ bookingId }),
});
}
export async function adminConfirmDeposit(reservationId, adminId, comment = null) {
const token = AuthService.getToken();
const endpoint = `${API_BASE}/Reservations/AdminConfirmDeposit/admin-confirm-deposit`;
const normalizedComment =
typeof comment === 'string' && comment.trim()
? comment.trim()
: null;
const payload = {
reservationId,
adminId,
comment: normalizedComment,
};
console.log('[API] AdminConfirmDeposit request', {
method: 'PUT',
endpoint,
payload,
adminIdSource: 'jwt-user-id',
hasToken: Boolean(token),
tokenPreview: token ? `${token.slice(0, 18)}...${token.slice(-8)}` : null,
});
const res = await fetch(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify(payload),
});
const text = await res.text();
let data = null;
console.log('[API] AdminConfirmDeposit raw response', {
status: res.status,
ok: res.ok,
endpoint,
rawText: text,
});
try {
data = text ? JSON.parse(text) : null;
if (data && typeof data === 'object' && 'data' in data) {
data = data.data;
}
} catch {
data = text;
}
const message = typeof data === 'object' && data?.message ? data.message : null;
console.log('[API] AdminConfirmDeposit parsed response', {
status: res.status,
ok: res.ok,
message,
data,
});
return { status: res.status, data, ok: res.ok, message };
}
export async function updateBookingStatus(bookingId, status) {
return apiFetch('/Reservations/UpdateStatus', {
method: 'PUT',
body: JSON.stringify({ bookingId, status }),
});
}

View File

@ -1,41 +1,71 @@
export const PROPERTY_STATUS = { /**
AVAILABLE: 'available', * Constants — re-exports from enums for backward compatibility
BOOKED: 'booked', *
MAINTENANCE: 'maintenance' * New code should import directly from:
}; * import { BuildingType, BookingStatus, City, ... } from '@/app/enums';
*
* Old imports from '@/app/utils/constants' continue to work.
*/
export const BOOKING_STATUS = { // Re-export all enums
PENDING: 'pending', export {
OWNER_APPROVED: 'owner_approved', BuildingType,
ADMIN_APPROVED: 'admin_approved', BuildingTypeLabels,
REJECTED: 'rejected', BuildingTypeKeys,
ACTIVE: 'active', BuildingTypeByKey,
COMPLETED: 'completed', } from '../enums/BuildingType';
CANCELLED: 'cancelled'
};
export const COMMISSION_TYPE = { export {
FROM_OWNER: 'from_owner', PropertyStatus,
FROM_TENANT: 'from_tenant', PropertyStatusLabels,
FROM_BOTH: 'from_both' PropertyStatusKeys,
}; PropertyStatusByKey,
} from '../enums/PropertyStatus';
export const IDENTITY_TYPE = { export {
SYRIAN: 'syrian', BookingStatus,
PASSPORT: 'passport' BookingStatusLabels,
}; BookingStatusColors,
} from '../enums/BookingStatus';
export const PAYMENT_METHOD = { export {
CommissionType,
CommissionTypeLabels,
} from '../enums/CommissionType';
export {
IdentityType,
IdentityTypeLabels,
IdentityTypeFlags,
} from '../enums/IdentityType';
export {
UserRole,
UserRoleLabels,
UserRoleColors,
} from '../enums/UserRole';
export {
City,
CitiesList,
extractCity,
} from '../enums/City';
export { LoginMethod } from '../enums/LoginMethod';
export { OwnerType, OwnerTypeLabels } from '../enums/OwnerType';
export { CustomerType, CustomerTypeLabels } from '../enums/CustomerType';
// ─── Legacy aliases (keep old imports working) ───
export const PROPERTY_STATUS = PropertyStatusKeys;
export const BOOKING_STATUS = BookingStatus;
export const COMMISSION_TYPE = CommissionType;
export const IDENTITY_TYPE = IdentityType;
export const CITIES = City;
// ─── Misc constants ───
export const PAYMENT_METHOD = Object.freeze({
CASH: 'cash', CASH: 'cash',
ELECTRONIC: 'electronic' ELECTRONIC: 'electronic',
}; });
export const CITIES = {
DAMASCUS: 'damascus',
ALEPPO: 'aleppo',
HOMS: 'homs',
LATTAKIA: 'latakia',
DARAA: 'daraa'
};
export const DEFAULT_COMMISSION_RATE = 5; export const DEFAULT_COMMISSION_RATE = 5;

85
app/utils/firebase.js Normal file
View File

@ -0,0 +1,85 @@
import { initializeApp, getApps } from "firebase/app";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
const firebaseConfig = {
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
};
// Initialize Firebase (avoid duplicate init in SSR)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
// Get messaging instance (only works in browser)
let messaging = null;
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
try {
messaging = getMessaging(app);
} catch (e) {
console.warn("[Firebase] Messaging init failed:", e.message);
}
}
// Request notification permission and get FCM token
export async function requestNotificationPermission() {
if (typeof window === "undefined") return null;
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.log("[FCM] Notification permission denied");
return null;
}
const registration = await navigator.serviceWorker.register("/firebase-messaging-sw.js");
const token = await getToken(messaging, {
vapidKey: "BGZ4Fo8rRhoTdStLGlCySDZOnAX4ekCA0e3HDWXL5uEi2kOnXynYjbaDbY15002phUrFqxBpPPFHgfH2VhrmFDU",
serviceWorkerRegistration: registration,
});
console.log("[FCM] Token:", token);
// Send token to backend
if (token) {
try {
const authToken = localStorage.getItem("auth_token");
if (authToken) {
const apiBase = process.env.NEXT_PUBLIC_API_URL || "https://45.93.137.91.nip.io/api";
await fetch(`${apiBase}/User/SetFCMToken`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ token, deviceType: 2 }), // 2 = Web
});
console.log("[FCM] Token sent to backend");
}
} catch (err) {
console.error("[FCM] Failed to send token to backend:", err);
}
}
return token;
} catch (err) {
console.error("[FCM] Error getting token:", err);
return null;
}
}
// Listen for foreground messages
export function onForegroundMessage(callback) {
if (!messaging) return () => {};
return onMessage(messaging, (payload) => {
console.log("[FCM] Foreground message:", payload);
callback(payload);
});
}
export { app, messaging };

292
app/utils/ratings.js Normal file
View File

@ -0,0 +1,292 @@
// // Rating API endpoints for SweetHome
// // Handles both customer ratings and property ratings
// import AuthService from '../services/AuthService';
// const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
// /**
// * Rate a property as a customer
// * @param {Object} data - Rating data
// * @param {number} data.propertyId - ID of the property being rated
// * @param {number} data.customerId - ID of the customer doing the rating
// * @param {number} data.rating - Rating value (1-5)
// * @param {string} data.comment - Optional comment
// * @returns {Promise} - API response
// */
// export async function rateProperty(data) {
// console.log('[Rating] Customer rating property:', data);
// return apiFetch('/Ratings/CustomerRateProperty', {
// method: 'POST',
// body: JSON.stringify(data),
// });
// }
// /**
// * Rate a customer as a property owner
// * @param {Object} data - Rating data
// * @param {number} data.propertyId - ID of the property
// * @param {number} data.customerId - ID of the customer being rated
// * @param {number} data.rating - Rating value (1-5)
// * @param {string} data.comment - Optional comment
// * @returns {Promise} - API response
// */
// export async function rateCustomer(data) {
// console.log('[Rating] Property owner rating customer:', data);
// return apiFetch('/Ratings/PropertyRateCustomer', {
// method: 'POST',
// body: JSON.stringify(data),
// });
// }
// /**
// * Get all ratings for a property
// * @param {number} propertyId - ID of the property
// * @returns {Promise} - Array of ratings
// */
// export async function getPropertyRatings(propertyId) {
// console.log('[Rating] Fetching property ratings for:', propertyId);
// return apiFetch(`/Ratings/GetPropertyRatings?propertyId=${propertyId}`);
// }
// /**
// * Get all ratings for a customer
// * @param {number} customerId - ID of the customer
// * @returns {Promise} - Array of ratings
// */
// export async function getCustomerRatings(customerId) {
// console.log('[Rating] Fetching customer ratings for:', customerId);
// return apiFetch(`/Ratings/GetCustomerRatings?customerId=${customerId}`);
// }
// /**
// * Get average rating for a property
// * @param {number} propertyId - ID of the property
// * @returns {Promise} - Average rating
// */
// export async function getPropertyAverageRating(propertyId) {
// console.log('[Rating] Fetching average rating for property:', propertyId);
// const ratings = await getPropertyRatings(propertyId);
// if (!Array.isArray(ratings) || ratings.length === 0) return 0;
// const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
// return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
// }
// /**
// * Get average rating for a customer
// * @param {number} customerId - ID of the customer
// * @returns {Promise} - Average rating
// */
// export async function getCustomerAverageRating(customerId) {
// console.log('[Rating] Fetching average rating for customer:', customerId);
// const ratings = await getCustomerRatings(customerId);
// if (!Array.isArray(ratings) || ratings.length === 0) return 0;
// const total = ratings.reduce((sum, rating) => sum + rating.rating, 0);
// return Math.round((total / ratings.length) * 10) / 10; // Round to 1 decimal
// }
// /**
// * Get user's rating for a specific property (if any)
// * @param {number} propertyId - ID of the property
// * @param {number} userId - ID of the user
// * @returns {Promise} - User's rating or null
// */
// export async function getUserPropertyRating(propertyId, userId) {
// console.log('[Rating] Fetching user rating for property:', propertyId, 'user:', userId);
// const allRatings = await getPropertyRatings(propertyId);
// if (!Array.isArray(allRatings)) return null;
// return allRatings.find(r => r.userId === userId) || null;
// }
// /**
// * Get user's rating for a specific customer (if any)
// * @param {number} customerId - ID of the customer
// * @param {number} userId - ID of the user
// * @returns {Promise} - User's rating or null
// */
// export async function getUserCustomerRating(customerId, userId) {
// console.log('[Rating] Fetching user rating for customer:', customerId, 'user:', userId);
// const allRatings = await getCustomerRatings(customerId);
// if (!Array.isArray(allRatings)) return null;
// return allRatings.find(r => r.userId === userId) || null;
// }
// /**
// * Check if user can rate a property (after renting)
// * @param {number} propertyId - ID of the property
// * @param {number} userId - ID of the user
// * @returns {Promise} - Boolean indicating if rating is allowed
// */
// export async function canRateProperty(propertyId, userId) {
// console.log('[Rating] Checking if user can rate property:', propertyId, 'user:', userId);
// // Logic: User can rate if they have completed a rental in the past
// // This would typically check reservation history
// // For now, we'll simulate this with a simple check
// // In a real implementation, this would check:
// // 1. User's reservation history for this property
// // 2. Whether the rental period has ended
// // 3. Whether they've already rated
// const userRating = await getUserPropertyRating(propertyId, userId);
// return !userRating; // Can rate if no existing rating
// }
// /**
// * Check if user can rate a customer (after renting to them)
// * @param {number} customerId - ID of the customer
// * @param {number} userId - ID of the user (owner)
// * @returns {Promise} - Boolean indicating if rating is allowed
// */
// export async function canRateCustomer(customerId, userId) {
// console.log('[Rating] Checking if user can rate customer:', customerId, 'user:', userId);
// // Logic: Owner can rate if they have rented to this customer
// // This would typically check reservation history
// const userRating = await getUserCustomerRating(customerId, userId);
// return !userRating; // Can rate if no existing rating
// }
// // Helper function for API calls
// async function apiFetch(endpoint, options = {}) {
// const token = AuthService.getToken();
// const headers = {
// 'Content-Type': 'application/json',
// ...(token && { Authorization: `Bearer ${token}` }),
// ...options.headers,
// };
// console.log('[Rating API] Request:', options.method || 'GET', `${API_BASE}${endpoint}`);
// const res = await fetch(`${API_BASE}${endpoint}`, {
// ...options,
// headers,
// });
// console.log('[Rating API] Response:', res.status, endpoint);
// if (!res.ok && res.status !== 206) {
// const text = await res.text().catch(() => '');
// console.error('[Rating API] Error:', res.status, text);
// throw new Error(`Rating API ${res.status}: ${text || res.statusText}`);
// }
// const text = await res.text();
// if (!text) return null;
// try {
// const json = JSON.parse(text);
// if (json && typeof json === 'object' && 'data' in json) {
// return json.data;
// }
// return json;
// } catch {
// return text;
// }
// }
// utils/ratings.js
import AuthService from '../services/AuthService';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api';
async function apiFetch(endpoint, options = {}) {
const token = AuthService.getToken();
const headers = {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
};
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
if (!response.ok && response.status !== 206) {
const errorText = await response.text().catch(() => '');
throw new Error(`API Error ${response.status}: ${errorText}`);
}
const text = await response.text();
if (!text) return null;
try {
const json = JSON.parse(text);
return json && typeof json === 'object' && 'data' in json ? json.data : json;
} catch {
return text;
}
}
/**
* POST /Rating/AddPropertyRating
* @param {Object} data - { reservationId, cleanRating, servicesRating, ownerBehaviorRating, experienceRating, comment? }
*/
export async function addPropertyRating(data) {
return apiFetch('/Rating/AddPropertyRating', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* POST /Rating/AddCustomerRating
* @param {Object} data - { reservationId, furnitureIntegrityRating, termsComplianceRating, renterBehaviorRating, comment? }
*/
export async function addCustomerRating(data) {
return apiFetch('/Rating/AddCustomerRating', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* GET /Rating/GetPropertyRatings
* @param {number} propertyId
* @param {number} page - default 1
* @param {number} pageSize - default 10
* @returns {Promise<{ items: Array, totalPages: number, currentPage: number }>}
*/
export async function getPropertyRatings(propertyId, page = 1, pageSize = 10) {
const query = new URLSearchParams({
propertyId: String(propertyId),
page: String(page),
pageSize: String(pageSize),
}).toString();
return apiFetch(`/Rating/GetPropertyRatings?${query}`);
}
/**
* GET /Rating/GetCustomerRatings
* @param {number} renterId
* @param {number} page
* @param {number} pageSize
*/
export async function getCustomerRatings(renterId, page = 1, pageSize = 10) {
const query = new URLSearchParams({
renterId: String(renterId),
page: String(page),
pageSize: String(pageSize),
}).toString();
return apiFetch(`/Rating/GetCustomerRatings?${query}`);
}
/**
* GET /Rating/GetPropertyAverage
* @param {number} propertyId
* @returns {Promise<number>} average rating (0 if none)
*/
export async function getPropertyAverageRating(propertyId) {
const result = await apiFetch(`/Rating/GetPropertyAverage?propertyId=${propertyId}`);
if (typeof result === 'number') return result;
if (result && typeof result.average === 'number') return result.average;
return 0;
}

View File

@ -2,6 +2,20 @@
const nextConfig = { const nextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "45.93.137.91.nip.io",
pathname: "/api/Pictures/**",
},
{
protocol: "http",
hostname: "45.93.137.91",
pathname: "/api/Pictures/**",
},
],
},
// basePath: "/sweetHome", // basePath: "/sweetHome",
// assetPrefix: "/sweetHome/", // assetPrefix: "/sweetHome/",
}; };

1057
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@pbe/react-yandex-maps": "^1.2.5", "@pbe/react-yandex-maps": "^1.2.5",
"firebase": "^12.11.0",
"flowbite": "^4.0.1", "flowbite": "^4.0.1",
"flowbite-react": "^0.12.16", "flowbite-react": "^0.12.16",
"framer-motion": "^12.29.2", "framer-motion": "^12.29.2",

View File

@ -0,0 +1,38 @@
// Firebase Cloud Messaging Service Worker
// This file MUST be in the public/ directory (served at /firebase-messaging-sw.js)
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
firebase.initializeApp({
apiKey: "AIzaSyBZV7KBLRJSTApahfrO8lBesmIM3zNRSaY",
authDomain: "sweet-home-b2766.firebaseapp.com",
projectId: "sweet-home-b2766",
storageBucket: "sweet-home-b2766.firebasestorage.app",
messagingSenderId: "602865114600",
appId: "1:602865114600:web:ed9b6754940507a6ab585d",
measurementId: "G-M2V95NBJLX",
});
const messaging = firebase.messaging();
// Handle background messages
messaging.onBackgroundMessage((payload) => {
console.log("[FCM SW] Background message:", payload);
const title = payload.notification?.title || payload.data?.title || "Sweet Home";
const options = {
body: payload.notification?.body || payload.data?.body || "",
icon: payload.notification?.icon || "/logo.png",
badge: "/logo.png",
data: payload.data,
tag: "sweethome-notification",
};
self.registration.showNotification(title, options);
});
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(clients.openWindow(url));
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More