Compare commits

...

132 Commits

Author SHA1 Message Date
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
082f20da40 Merge branch 'main' of http://45.93.137.91:3000/Rahaf/SweetHome
All checks were successful
Build frontend / build (push) Successful in 55s
2026-03-27 00:35:03 +03:00
157188d2e6 Edit Admin 2026-03-27 00:34:59 +03:00
ac1241583b added a files folder and changed the href
All checks were successful
Build frontend / build (push) Successful in 43s
2026-03-22 15:31:54 +03:00
552bbdd269 fixing the meta data layout
All checks were successful
Build frontend / build (push) Successful in 39s
2026-03-21 17:45:13 +03:00
485e4c2630 added the android icon in a link with svg and style
All checks were successful
Build frontend / build (push) Successful in 42s
2026-03-21 17:24:50 +03:00
45e46afe21 added the apk file
All checks were successful
Build frontend / build (push) Successful in 44s
2026-03-21 15:44:08 +03:00
e961288b04 added the nav link on the client laylout for the apk app
All checks were successful
Build frontend / build (push) Successful in 37s
2026-03-21 15:36:44 +03:00
f6c6119c18 Edit rtl
All checks were successful
Build frontend / build (push) Successful in 32s
2026-03-20 14:17:59 +03:00
d86cb9e9a1 Delete translation
All checks were successful
Build frontend / build (push) Successful in 30s
2026-03-20 13:33:15 +03:00
6aab85e99f 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-20 13:20:36 +03:00
2ea48df8a8 Added calendar, profits and booking for owner 2026-03-20 13:20:30 +03:00
116 changed files with 14738 additions and 3149 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();
@ -51,17 +57,10 @@ export default function ClientLayout({ children }) {
useEffect(() => { useEffect(() => {
setIsMounted(true); setIsMounted(true);
const savedLanguage = localStorage.getItem("language") || "en"; const savedLanguage = localStorage.getItem("language") || "ar";
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,8 +190,47 @@ 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" : ""}`}
> >
<NavLink href="/">{t("home")}</NavLink> {/* Download App Dropdown */}
<NavLink href="/properties">{t("ourProducts")}</NavLink> <div className="relative group">
<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">
<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"/>
<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"/>
</svg>
<span className="text-sm font-semibold">تحميل التطبيق</span>
<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>
</button>
<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"/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-900 text-sm">Android</p>
<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="/properties">عقاراتنا</NavLink>
{isAdmin && ( {isAdmin && (
<NavLink href="/admin"> <NavLink href="/admin">
@ -195,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" />
@ -234,7 +288,7 @@ export default function ClientLayout({ children }) {
)} )}
</div> </div>
<motion.button {/* <motion.button
whileHover={{ scale: 1.1, rotate: 360 }} whileHover={{ scale: 1.1, rotate: 360 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
onClick={() => onClick={() =>
@ -243,7 +297,7 @@ export default function ClientLayout({ children }) {
className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-all duration-200 ml-4" className="flex items-center justify-center w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full transition-all duration-200 ml-4"
> >
<Globe className="w-5 h-5 text-gray-700" /> <Globe className="w-5 h-5 text-gray-700" />
</motion.button> </motion.button> */}
{user && ( {user && (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
@ -277,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>
@ -349,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)}
> >
@ -468,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)}
> >
@ -562,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">
@ -583,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">
@ -641,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"}`}
> >
{children} <NotificationsProvider>
<FavoritesProvider>
{children}
<FloatingSidebar isRTL={currentLanguage === 'ar'} isAdmin={isAdmin} />
</FavoritesProvider>
</NotificationsProvider>
</main> </main>
{!isAuthPage && !isProfilePage && ( {!isAuthPage && !isProfilePage && (
@ -712,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" />
@ -729,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,17 +1,17 @@
// app/admin/page.js (محدث)
'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';
@ -20,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() {
@ -27,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 },
@ -34,7 +83,7 @@ export default function AdminPage() {
{ id: 'bookings', label: 'طلبات الحجز', icon: Calendar, badge: notifications }, { id: 'bookings', label: 'طلبات الحجز', icon: Calendar, badge: notifications },
{ id: 'users', label: 'المستخدمين', icon: Users }, { id: 'users', label: 'المستخدمين', icon: Users },
{ id: 'ledger', label: 'دفتر الحسابات', icon: DollarSign }, { id: 'ledger', label: 'دفتر الحسابات', icon: DollarSign },
{ id: 'reports', label: 'التقارير', icon: TrendingUp } // { id: 'reports', label: 'التقارير', icon: TrendingUp }
]; ];
return ( return (

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,
@ -38,11 +38,21 @@ export default function AddPropertyForm({ onClose, onSuccess }) {
const [selectedFeatures, setSelectedFeatures] = useState([]); const [selectedFeatures, setSelectedFeatures] = useState([]);
const featuresList = [ const featuresList = [
'swimmingPool', 'privateGarden', 'parking', 'superLuxFinish', 'مسبح',
'equippedKitchen', 'centralHeating', 'balcony', 'securitySystem', 'حديقة خاصة',
'largeGarden', 'receptionHall', 'maidRoom', 'garage', 'موقف سيارات',
'seaView', 'centralAC', 'fruitGarden', 'storage' 'مطبخ مجهز',
]; 'تدفئة مركزية',
'بلكونة',
'نظام أمني',
'حديقة كبيرة',
'صالة استقبال',
'غرفة خادمة',
'كراج',
'إطلالة بحرية',
'تكييف مركزي',
'مخزن'
];
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@ -76,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;
@ -121,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>
@ -222,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>
@ -232,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>
@ -242,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

@ -13,9 +13,15 @@ import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
Wallet, Wallet,
Shield Shield,
FileText,
Printer,
X,
CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { formatCurrency } from '@/app/utils/calculations'; import { formatCurrency } from '@/app/utils/calculations';
import toast, { Toaster } from 'react-hot-toast';
import * as XLSX from 'xlsx';
export default function LedgerBook({ userType = 'admin' }) { export default function LedgerBook({ userType = 'admin' }) {
const [transactions, setTransactions] = useState([]); const [transactions, setTransactions] = useState([]);
@ -28,6 +34,7 @@ export default function LedgerBook({ userType = 'admin' }) {
securityDeposits: 0, securityDeposits: 0,
commissionEarned: 0 commissionEarned: 0
}); });
const [isExporting, setIsExporting] = useState(false);
useEffect(() => { useEffect(() => {
loadTransactions(); loadTransactions();
@ -144,30 +151,239 @@ export default function LedgerBook({ userType = 'admin' }) {
} }
}; };
const exportToExcel = () => { const exportToExcel = async () => {
const csvContent = [ if (filteredTransactions.length === 0) {
['التاريخ', 'الوصف', 'من', 'إلى', 'المبلغ', 'العمولة', 'الحالة'], toast.error('لا توجد معاملات للتصدير');
...filteredTransactions.map(t => [ return;
t.date, }
t.description,
t.fromUser,
t.toUser,
t.amount,
t.commission,
t.status
])
].map(row => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' }); setIsExporting(true);
const url = window.URL.createObjectURL(blob); toast.loading('جاري تصدير البيانات...', { id: 'export' });
const a = document.createElement('a');
a.href = url; try {
a.download = `ledger_${new Date().toISOString()}.csv`; const exportData = filteredTransactions.map(t => ({
a.click(); 'رقم العملية': t.id,
'التاريخ': t.date,
'نوع العملية': t.type === 'rent_payment' ? 'دفعة إيجار' :
t.type === 'security_deposit' ? 'سلفة ضمان' :
t.type === 'commission' ? 'عمولة' : 'أخرى',
'الوصف': t.description,
'من': t.fromUser,
'إلى': t.toUser,
'المبلغ (ل.س)': t.amount,
'العمولة (ل.س)': t.commission || 0,
'الحالة': t.status === 'completed' ? 'مكتمل' :
t.status === 'pending' ? 'معلق' :
t.status === 'pending_refund' ? 'بإنتظار الاسترداد' : 'مؤكد',
}));
const summaryRow = {
'رقم العملية': '',
'التاريخ': '',
'نوع العملية': '',
'الوصف': '',
'من': '',
'إلى': '',
'المبلغ (ل.س)': summary.totalRevenue,
'العمولة (ل.س)': summary.commissionEarned,
'الحالة': ''
};
exportData.push(summaryRow);
const worksheet = XLSX.utils.json_to_sheet(exportData);
const columnWidths = [
{ wch: 12 }, // رقم العملية
{ wch: 12 }, // التاريخ
{ wch: 12 }, // نوع العملية
{ wch: 30 }, // الوصف
{ wch: 20 }, // من
{ wch: 20 }, // إلى
{ wch: 15 }, // المبلغ
{ wch: 15 }, // العمولة
{ wch: 12 }, // الحالة
];
worksheet['!cols'] = columnWidths;
const range = XLSX.utils.decode_range(worksheet['!ref']);
for (let C = range.s.c; C <= range.e.c; ++C) {
const address = XLSX.utils.encode_col(C) + '1';
if (!worksheet[address]) continue;
worksheet[address].s = {
font: { bold: true, sz: 12 },
fill: { fgColor: { rgb: "F59E0B" } },
alignment: { horizontal: "center", vertical: "center" }
};
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'دفتر الحسابات');
const fileName = `دفتر_الحسابات_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, fileName);
toast.success(`تم تصدير ${filteredTransactions.length} معاملة بنجاح!`, { id: 'export' });
} catch (error) {
console.error('Error exporting to Excel:', error);
toast.error('حدث خطأ أثناء تصدير البيانات', { id: 'export' });
} finally {
setIsExporting(false);
}
};
const printReport = () => {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<!DOCTYPE html>
<html dir="rtl">
<head>
<meta charset="UTF-8">
<title>تقرير دفتر الحسابات</title>
<style>
body {
font-family: 'Cairo', Arial, sans-serif;
padding: 20px;
direction: rtl;
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f59e0b;
}
.title {
font-size: 24px;
font-weight: bold;
color: #1f2937;
}
.subtitle {
color: #6b7280;
margin-top: 5px;
}
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
margin-bottom: 30px;
}
.summary-card {
background: #f9fafb;
padding: 15px;
border-radius: 12px;
text-align: center;
}
.summary-value {
font-size: 20px;
font-weight: bold;
color: #f59e0b;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
border: 1px solid #e5e7eb;
padding: 10px;
text-align: right;
}
th {
background: #f59e0b;
color: white;
font-weight: bold;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 12px;
}
@media print {
.no-print {
display: none;
}
}
</style>
</head>
<body>
<div class="header">
<div class="title">تقرير دفتر الحسابات</div>
<div class="subtitle">الفترة: ${dateRange.start || 'بداية السجلات'} - ${dateRange.end || 'حتى الآن'}</div>
<div class="subtitle">تاريخ التقرير: ${new Date().toLocaleDateString('ar-SA')}</div>
</div>
<div class="summary">
<div class="summary-card">
<div>إجمالي الإيرادات</div>
<div class="summary-value">${formatCurrency(summary.totalRevenue)}</div>
</div>
<div class="summary-card">
<div>أرباح المنصة</div>
<div class="summary-value">${formatCurrency(summary.commissionEarned)}</div>
</div>
<div class="summary-card">
<div>سلف الضمان</div>
<div class="summary-value">${formatCurrency(summary.securityDeposits)}</div>
</div>
<div class="summary-card">
<div>المدفوعات المعلقة</div>
<div class="summary-value">${formatCurrency(summary.pendingPayments)}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>التاريخ</th>
<th>الوصف</th>
<th>من</th>
<th>إلى</th>
<th>المبلغ</th>
<th>العمولة</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
${filteredTransactions.map(t => `
<tr>
<td>${t.date}</td>
<td>${t.description}</td>
<td>${t.fromUser}</td>
<td>${t.toUser}</td>
<td>${formatCurrency(t.amount)}</td>
<td>${t.commission ? formatCurrency(t.commission) : '-'}</td>
<td>${t.status === 'completed' ? 'مكتمل' : t.status === 'pending' ? 'معلق' : 'بإنتظار الرد'}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="footer">
<p>تقرير صادر عن نظام SweetHome لإدارة العقارات</p>
<p>جميع الحقوق محفوظة © ${new Date().getFullYear()}</p>
</div>
<div class="no-print" style="text-align: center; margin-top: 20px;">
<button onclick="window.print()" style="padding: 10px 20px; background: #f59e0b; color: white; border: none; border-radius: 8px; cursor: pointer;">
طباعة التقرير
</button>
</div>
</body>
</html>
`);
printWindow.document.close();
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Toaster position="top-center" reverseOrder={false} />
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -224,13 +440,13 @@ export default function LedgerBook({ userType = 'admin' }) {
<div className="bg-white rounded-xl p-5 shadow-sm border"> <div className="bg-white rounded-xl p-5 shadow-sm border">
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
type="text" type="text"
placeholder="بحث في المعاملات..." placeholder="بحث في المعاملات..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
@ -239,25 +455,63 @@ export default function LedgerBook({ userType = 'admin' }) {
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={(e) => setDateRange({...dateRange, start: e.target.value})} onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
className="px-3 py-2 border rounded-lg" className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<span className="text-gray-500 self-center">إلى</span> <span className="text-gray-500 self-center">إلى</span>
<input <input
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={(e) => setDateRange({...dateRange, end: e.target.value})} onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
className="px-3 py-2 border rounded-lg" className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
<button <div className="flex gap-2">
onClick={exportToExcel} <button
className="px-4 py-2 bg-green-600 text-white rounded-lg flex items-center gap-2 hover:bg-green-700" onClick={exportToExcel}
> disabled={isExporting || filteredTransactions.length === 0}
<Download className="w-4 h-4" /> className="px-5 py-3 bg-green-600 text-white rounded-xl flex items-center gap-2 hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
تصدير >
</button> {isExporting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
جاري التصدير...
</>
) : (
<>
<Download className="w-5 h-5" />
تصدير Excel
</>
)}
</button>
<button
onClick={printReport}
disabled={filteredTransactions.length === 0}
className="px-5 py-3 bg-blue-600 text-white rounded-xl flex items-center gap-2 hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<Printer className="w-5 h-5" />
طباعة
</button>
</div>
</div> </div>
{(dateRange.start || dateRange.end || searchTerm) && (
<div className="mt-4 pt-4 border-t flex justify-between items-center">
<div className="text-sm text-gray-500">
<span className="font-medium">{filteredTransactions.length}</span> معاملة من إجمالي <span className="font-medium">{transactions.length}</span>
</div>
<button
onClick={() => {
setDateRange({ start: '', end: '' });
setSearchTerm('');
}}
className="text-sm text-red-500 hover:text-red-600 flex items-center gap-1"
>
<X className="w-4 h-4" />
إلغاء الفلترة
</button>
</div>
)}
</div> </div>
<div className="bg-white rounded-xl shadow-sm border overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border overflow-hidden">
@ -307,14 +561,14 @@ export default function LedgerBook({ userType = 'admin' }) {
<span className="text-sm">{transaction.toUser}</span> <span className="text-sm">{transaction.toUser}</span>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm font-bold"> <td className="px-6 py-4 text-sm font-bold text-green-600">
{formatCurrency(transaction.amount)} {formatCurrency(transaction.amount)}
</td> </td>
<td className="px-6 py-4 text-sm text-amber-600"> <td className="px-6 py-4 text-sm text-amber-600">
{transaction.commission ? formatCurrency(transaction.commission) : '-'} {transaction.commission ? formatCurrency(transaction.commission) : '-'}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={`px-2 py-1 rounded-full text-xs ${ <span className={`px-2 py-1 rounded-full text-xs font-medium ${
transaction.status === 'completed' ? 'bg-green-100 text-green-800' : transaction.status === 'completed' ? 'bg-green-100 text-green-800' :
transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' : transaction.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
'bg-blue-100 text-blue-800' 'bg-blue-100 text-blue-800'
@ -344,6 +598,7 @@ export default function LedgerBook({ userType = 'admin' }) {
أرصدة المستأجرين أرصدة المستأجرين
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-gray-500 text-sm">لا توجد أرصدة حالياً</p>
</div> </div>
</div> </div>
)} )}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Edit, Edit,
Trash2, Trash2,
@ -12,14 +12,404 @@ import {
Square, Square,
DollarSign, DollarSign,
Percent, Percent,
MoreVertical MoreVertical,
X,
CheckCircle,
AlertCircle,
Calendar,
User,
Home,
Building,
Clock
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
const DeleteConfirmationModal = ({ isOpen, onClose, onConfirm, propertyTitle }) => {
if (!isOpen) return null;
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-md p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-center mb-4">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-3">
<AlertCircle className="w-8 h-8 text-red-600" />
</div>
<h3 className="text-xl font-bold text-gray-900">تأكيد الحذف</h3>
<p className="text-sm text-gray-500 mt-2">
هل أنت متأكد من حذف العقار: <span className="font-bold text-gray-700">"{propertyTitle}"</span>؟
</p>
<p className="text-xs text-red-500 mt-1">هذا الإجراء لا يمكن التراجع عنه</p>
</div>
<div className="flex gap-3 pt-3">
<button
onClick={onClose}
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
إلغاء
</button>
<button
onClick={onConfirm}
className="flex-1 bg-red-600 text-white py-3 rounded-xl font-medium hover:bg-red-700 transition-colors"
>
نعم، احذف
</button>
</div>
</motion.div>
</motion.div>
);
};
const PropertyViewModal = ({ property, isOpen, onClose }) => {
if (!isOpen || !property) return null;
const formatCurrency = (amount) => {
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
};
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">
<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">
<X 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-gray-50 p-3 rounded-xl text-center">
<Home className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.type === 'villa' ? 'فيلا' : property.type === 'apartment' ? 'شقة' : 'بيت'}</div>
<div className="text-xs text-gray-500">نوع العقار</div>
</div>
<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-sm font-bold">{formatCurrency(property.price)}</div>
<div className="text-xs text-gray-500">السعر اليومي</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Percent className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.commission}%</div>
<div className="text-xs text-gray-500">نسبة العمولة</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Calendar className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.bookings || 0}</div>
<div className="text-xs text-gray-500">عدد الحجوزات</div>
</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">
<MapPin className="w-5 h-5 text-amber-500" />
الموقع
</h3>
<p className="text-gray-700">{property.location}</p>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3">المواصفات</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-lg font-bold">{property.bedrooms}</div>
<div className="text-xs text-gray-500">غرف نوم</div>
</div>
<div className="text-center">
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-lg font-bold">{property.bathrooms}</div>
<div className="text-xs text-gray-500">حمامات</div>
</div>
<div className="text-center">
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-lg font-bold">{property.area}</div>
<div className="text-xs text-gray-500">م²</div>
</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">
<Percent className="w-5 h-5" />
معلومات العمولة
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500">نسبة العمولة</label>
<div className="font-bold text-amber-600">{property.commission}%</div>
</div>
<div>
<label className="text-xs text-gray-500">مصدر العمولة</label>
<div className="font-bold text-amber-600">{property.commissionType}</div>
</div>
<div>
<label className="text-xs text-gray-500">قيمة العمولة</label>
<div className="font-bold text-amber-600">
{formatCurrency((property.price * property.commission) / 100)}
</div>
</div>
<div>
<label className="text-xs text-gray-500">حالة العقار</label>
<div className={`inline-block px-2 py-1 rounded-lg text-xs font-medium ${
property.status === 'available'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{property.status === 'available' ? 'متاح' : 'محجوز'}
</div>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
);
};
const PropertyEditModal = ({ property, isOpen, onClose, onSave }) => {
const [formData, setFormData] = useState({ ...property });
const [isSaving, setIsSaving] = useState(false);
const formatCurrency = (amount) => {
return amount?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
onSave(formData);
setIsSaving(false);
onClose();
toast.success('تم تحديث العقار بنجاح');
}, 1000);
};
if (!isOpen || !property) return null;
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">
<X className="w-6 h-6" />
</button>
</div>
<p className="text-amber-100 text-sm mt-1">يمكنك تعديل معلومات العقار</p>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
اسم العقار
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({...formData, title: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
نوع العقار
</label>
<select
value={formData.type}
onChange={(e) => setFormData({...formData, type: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
>
<option value="villa">فيلا</option>
<option value="apartment">شقة</option>
<option value="house">بيت</option>
<option value="studio">استوديو</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
الموقع
</label>
<input
type="text"
value={formData.location}
onChange={(e) => setFormData({...formData, location: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
السعر اليومي (ل.س)
</label>
<input
type="number"
value={formData.price}
onChange={(e) => setFormData({...formData, price: parseInt(e.target.value)})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
نسبة العمولة (%)
</label>
<input
type="number"
step="0.1"
value={formData.commission}
onChange={(e) => setFormData({...formData, commission: parseFloat(e.target.value)})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
مصدر العمولة
</label>
<select
value={formData.commissionType}
onChange={(e) => setFormData({...formData, commissionType: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
>
<option value="من المالك">من المالك</option>
<option value="من المستأجر">من المستأجر</option>
<option value="من الاثنين">من الاثنين</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
عدد الغرف
</label>
<input
type="number"
value={formData.bedrooms}
onChange={(e) => setFormData({...formData, bedrooms: parseInt(e.target.value)})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
عدد الحمامات
</label>
<input
type="number"
value={formData.bathrooms}
onChange={(e) => setFormData({...formData, bathrooms: parseInt(e.target.value)})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
المساحة (م²)
</label>
<input
type="number"
value={formData.area}
onChange={(e) => setFormData({...formData, area: parseInt(e.target.value)})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حالة العقار
</label>
<select
value={formData.status}
onChange={(e) => setFormData({...formData, status: e.target.value})}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-amber-500"
>
<option value="available">متاح</option>
<option value="booked">محجوز</option>
<option value="maintenance">صيانة</option>
</select>
</div>
</div>
</div>
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
<button
onClick={onClose}
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
إلغاء
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex-1 bg-amber-500 text-white py-3 rounded-xl font-medium hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{isSaving ? 'جاري الحفظ...' : 'حفظ التغييرات'}
</button>
</div>
</motion.div>
</motion.div>
);
};
const MoreActionsMenu = ({ property, isOpen, onClose, onViewBookings, onViewReports }) => {
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="absolute left-0 mt-2 w-56 bg-white rounded-xl shadow-xl border border-gray-200 overflow-hidden z-50"
>
</motion.div>
</>
);
};
export default function PropertiesTable() { export default function PropertiesTable() {
const [properties, setProperties] = useState([ const [properties, setProperties] = useState([
{ {
id: 1, id: 1,
title: 'luxuryVillaDamascus', title: 'فيلا فاخرة في المزة',
type: 'villa', type: 'villa',
location: 'دمشق, المزة', location: 'دمشق, المزة',
price: 500000, price: 500000,
@ -33,7 +423,7 @@ export default function PropertiesTable() {
}, },
{ {
id: 2, id: 2,
title: 'modernApartmentAleppo', title: 'شقة حديثة في الشهباء',
type: 'apartment', type: 'apartment',
location: 'حلب, الشهباء', location: 'حلب, الشهباء',
price: 250000, price: 250000,
@ -47,6 +437,11 @@ export default function PropertiesTable() {
} }
]); ]);
const [viewModal, setViewModal] = useState({ isOpen: false, property: null });
const [editModal, setEditModal] = useState({ isOpen: false, property: null });
const [deleteModal, setDeleteModal] = useState({ isOpen: false, property: null });
const [moreMenu, setMoreMenu] = useState({ isOpen: false, property: null, anchorEl: null });
const formatCurrency = (amount) => { const formatCurrency = (amount) => {
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س'; return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + ' ل.س';
}; };
@ -71,8 +466,50 @@ export default function PropertiesTable() {
); );
}; };
const handleView = (property) => {
setViewModal({ isOpen: true, property });
};
const handleEdit = (property) => {
setEditModal({ isOpen: true, property });
};
const handleDelete = (property) => {
setDeleteModal({ isOpen: true, property });
};
const confirmDelete = () => {
if (deleteModal.property) {
setProperties(prev => prev.filter(p => p.id !== deleteModal.property.id));
setDeleteModal({ isOpen: false, property: null });
toast.success('تم حذف العقار بنجاح');
}
};
const handleSaveEdit = (updatedProperty) => {
setProperties(prev => prev.map(p =>
p.id === updatedProperty.id ? updatedProperty : p
));
toast.success('تم تحديث العقار بنجاح');
};
const handleMoreClick = (event, property) => {
event.stopPropagation();
setMoreMenu({ isOpen: true, property, anchorEl: event.currentTarget });
};
const handleViewBookings = (property) => {
toast.success(`جاري عرض حجوزات ${property.title}`);
};
const handleViewReports = (property) => {
toast.success(`جاري عرض تقرير أرباح ${property.title}`);
};
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Toaster position="top-center" reverseOrder={false} />
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-50 border-b">
<tr> <tr>
@ -97,7 +534,11 @@ export default function PropertiesTable() {
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium">{property.title}</div> <div className="font-medium">{property.title}</div>
<div className="text-xs text-gray-500">{property.type}</div> <div className="text-xs text-gray-500">
{property.type === 'villa' ? 'فيلا' :
property.type === 'apartment' ? 'شقة' :
property.type === 'house' ? 'بيت' : 'استوديو'}
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1 text-sm">
@ -125,20 +566,38 @@ export default function PropertiesTable() {
<td className="px-4 py-3"> <td className="px-4 py-3">
{getStatusBadge(property.status)} {getStatusBadge(property.status)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3 relative">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button className="p-1 hover:bg-blue-100 rounded text-blue-600"> <button
onClick={() => handleView(property)}
className="p-1 hover:bg-blue-100 rounded text-blue-600 transition-colors"
title="عرض التفاصيل"
>
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</button> </button>
<button className="p-1 hover:bg-amber-100 rounded text-amber-600"> <button
onClick={() => handleEdit(property)}
className="p-1 hover:bg-amber-100 rounded text-amber-600 transition-colors"
title="تعديل العقار"
>
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</button> </button>
<button className="p-1 hover:bg-red-100 rounded text-red-600"> <button
onClick={() => handleDelete(property)}
className="p-1 hover:bg-red-100 rounded text-red-600 transition-colors"
title="حذف العقار"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
<button className="p-1 hover:bg-gray-100 rounded"> {moreMenu.isOpen && moreMenu.property?.id === property.id && (
<MoreVertical className="w-4 h-4" /> <MoreActionsMenu
</button> property={property}
isOpen={moreMenu.isOpen}
onClose={() => setMoreMenu({ isOpen: false, property: null, anchorEl: null })}
onViewBookings={handleViewBookings}
onViewReports={handleViewReports}
/>
)}
</div> </div>
</td> </td>
</motion.tr> </motion.tr>
@ -152,6 +611,26 @@ export default function PropertiesTable() {
<p className="text-gray-500">لا توجد عقارات مضافة بعد</p> <p className="text-gray-500">لا توجد عقارات مضافة بعد</p>
</div> </div>
)} )}
<PropertyViewModal
property={viewModal.property}
isOpen={viewModal.isOpen}
onClose={() => setViewModal({ isOpen: false, property: null })}
/>
<PropertyEditModal
property={editModal.property}
isOpen={editModal.isOpen}
onClose={() => setEditModal({ isOpen: false, property: null })}
onSave={handleSaveEdit}
/>
<DeleteConfirmationModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, property: null })}
onConfirm={confirmDelete}
propertyTitle={deleteModal.property?.title}
/>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
User, User,
Mail, Mail,
@ -11,8 +11,445 @@ import {
DollarSign, DollarSign,
Search, Search,
Filter, Filter,
Eye Eye,
X,
CheckCircle,
XCircle,
ChevronDown,
Users,
Award,
Clock,
TrendingUp,
CalendarDays,
Shield
} from 'lucide-react'; } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
const FilterDialog = ({ isOpen, onClose, filters, onApplyFilters, onResetFilters }) => {
const [localFilters, setLocalFilters] = useState({ ...filters });
const identityTypes = [
{ id: 'all', label: 'الكل' },
{ id: 'syrian', label: 'هوية سورية' },
{ id: 'passport', label: 'جواز سفر' }
];
const bookingRanges = [
{ id: 'all', label: 'الكل' },
{ id: '0-5', label: '0 - 5 حجوزات' },
{ id: '5-10', label: '5 - 10 حجوزات' },
{ id: '10-20', label: '10 - 20 حجوزات' },
{ id: '20+', label: 'أكثر من 20 حجز' }
];
const spendingRanges = [
{ id: 'all', label: 'الكل' },
{ id: '0-500000', label: 'أقل من 500,000 ل.س' },
{ id: '500000-1000000', label: '500,000 - 1,000,000 ل.س' },
{ id: '1000000-5000000', label: '1,000,000 - 5,000,000 ل.س' },
{ id: '5000000+', label: 'أكثر من 5,000,000 ل.س' }
];
const dateRanges = [
{ id: 'all', label: 'الكل' },
{ id: 'today', label: 'اليوم' },
{ id: 'week', label: 'آخر 7 أيام' },
{ id: 'month', label: 'آخر 30 يوم' },
{ id: 'year', label: 'آخر 12 شهر' }
];
const applyFilters = () => {
onApplyFilters(localFilters);
onClose();
toast.success('تم تطبيق الفلاتر بنجاح');
};
const resetFilters = () => {
const resetData = {
identityType: 'all',
minBookings: '',
maxBookings: '',
minSpending: '',
maxSpending: '',
dateRange: 'all',
activeOnly: false,
inactiveOnly: false
};
setLocalFilters(resetData);
onResetFilters();
onClose();
toast.success('تم إعادة تعيين الفلاتر');
};
if (!isOpen) return null;
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-blue-600 to-blue-700 p-6 text-white">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Filter className="w-5 h-5" />
تصفية متقدمة
</h2>
<p className="text-blue-100 text-sm mt-1">حدد معايير التصفية المطلوبة</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
نوع الهوية
</label>
<div className="grid grid-cols-3 gap-2">
{identityTypes.map((type) => (
<button
key={type.id}
onClick={() => setLocalFilters({...localFilters, identityType: type.id})}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
localFilters.identityType === type.id
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{type.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
عدد الحجوزات
</label>
<div className="grid grid-cols-2 gap-2 mb-3">
<input
type="number"
placeholder="من"
value={localFilters.minBookings}
onChange={(e) => setLocalFilters({...localFilters, minBookings: e.target.value})}
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
/>
<input
type="number"
placeholder="إلى"
value={localFilters.maxBookings}
onChange={(e) => setLocalFilters({...localFilters, maxBookings: e.target.value})}
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-wrap gap-2">
{bookingRanges.slice(1).map((range) => (
<button
key={range.id}
onClick={() => {
const [min, max] = range.id.split('-');
setLocalFilters({
...localFilters,
minBookings: min,
maxBookings: max === '5' ? '5' : max === '10' ? '10' : max === '20' ? '20' : '1000'
});
}}
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
>
{range.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
إجمالي الإنفاق (ل.س)
</label>
<div className="grid grid-cols-2 gap-2 mb-3">
<input
type="number"
placeholder="من"
value={localFilters.minSpending}
onChange={(e) => setLocalFilters({...localFilters, minSpending: e.target.value})}
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
/>
<input
type="number"
placeholder="إلى"
value={localFilters.maxSpending}
onChange={(e) => setLocalFilters({...localFilters, maxSpending: e.target.value})}
className="px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-wrap gap-2">
{spendingRanges.slice(1).map((range) => (
<button
key={range.id}
onClick={() => {
const [min, max] = range.id.split('-');
setLocalFilters({
...localFilters,
minSpending: min,
maxSpending: max === '500000' ? '500000' : max === '1000000' ? '1000000' : max === '5000000' ? '5000000' : '999999999'
});
}}
className="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
>
{range.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
فترة التسجيل
</label>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{dateRanges.map((range) => (
<button
key={range.id}
onClick={() => setLocalFilters({...localFilters, dateRange: range.id})}
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all ${
localFilters.dateRange === range.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{range.label}
</button>
))}
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={localFilters.activeOnly}
onChange={(e) => setLocalFilters({...localFilters, activeOnly: e.target.checked, inactiveOnly: false})}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">مستخدمون لديهم حجوزات نشطة فقط</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={localFilters.inactiveOnly}
onChange={(e) => setLocalFilters({...localFilters, inactiveOnly: e.target.checked, activeOnly: false})}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">مستخدمون بدون حجوزات نشطة</span>
</label>
</div>
</div>
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
<button
onClick={resetFilters}
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl font-medium hover:bg-gray-200 transition-colors"
>
إعادة تعيين
</button>
<button
onClick={applyFilters}
className="flex-1 bg-blue-600 text-white py-3 rounded-xl font-medium hover:bg-blue-700 transition-colors"
>
تطبيق الفلاتر
</button>
</div>
</motion.div>
</motion.div>
);
};
const UserDetailsModal = ({ user, isOpen, onClose }) => {
if (!isOpen || !user) return null;
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const userBookings = [
{
id: 'BK001',
property: 'فيلا فاخرة في المزة',
startDate: '2024-03-10',
endDate: '2024-03-15',
amount: 2500000,
status: 'completed'
},
{
id: 'BK002',
property: 'شقة حديثة في الشهباء',
startDate: '2024-02-20',
endDate: '2024-02-25',
amount: 1250000,
status: 'completed'
},
{
id: 'BK003',
property: 'بيت عائلي في بابا عمرو',
startDate: '2024-04-01',
endDate: '2024-04-10',
amount: 3500000,
status: 'confirmed'
}
];
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-blue-600 to-blue-700 p-6 text-white">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<User className="w-5 h-5" />
تفاصيل المستخدم
</h2>
<p className="text-blue-100 text-sm mt-1">{user.name}</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-full">
<X className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
<User className="w-4 h-4 text-blue-500" />
معلومات شخصية
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">الاسم الكامل:</span>
<span className="font-medium">{user.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">البريد الإلكتروني:</span>
<span className="font-medium">{user.email}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">رقم الهاتف:</span>
<span className="font-medium">{user.phone}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">تاريخ التسجيل:</span>
<span className="font-medium">{user.joinDate}</span>
</div>
</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">
<Shield className="w-4 h-4 text-blue-500" />
معلومات الهوية
</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-500">نوع الهوية:</span>
<span className="font-medium">
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">رقم الهوية:</span>
<span className="font-medium">{user.identityNumber}</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-blue-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-blue-600">{user.totalBookings}</div>
<div className="text-sm text-gray-600">إجمالي الحجوزات</div>
</div>
<div className="bg-green-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-green-600">{user.activeBookings}</div>
<div className="text-sm text-gray-600">حجوزات نشطة</div>
</div>
<div className="bg-amber-50 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-amber-600">{formatCurrency(user.totalSpent)}</div>
<div className="text-sm text-gray-600">إجمالي المنصرف</div>
</div>
</div>
<div>
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-500" />
سجل الحجوزات
</h3>
<div className="space-y-3">
{userBookings.map((booking) => (
<div key={booking.id} className="bg-gray-50 p-4 rounded-xl flex flex-col md:flex-row justify-between items-start md:items-center gap-3">
<div>
<p className="font-medium text-gray-900">{booking.property}</p>
<div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
<CalendarDays className="w-3 h-3" />
{booking.startDate} - {booking.endDate}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.amount)}</div>
<div className="text-xs text-gray-500">المبلغ الإجمالي</div>
</div>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
booking.status === 'completed'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{booking.status === 'completed' ? 'مكتمل' : 'مؤكد'}
</span>
</div>
</div>
))}
{userBookings.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>لا توجد حجوزات سابقة</p>
</div>
)}
</div>
</div>
</div>
<div className="sticky bottom-0 bg-gray-50 border-t p-4 flex gap-3">
</div>
</motion.div>
</motion.div>
);
};
export default function UsersList() { export default function UsersList() {
const [users, setUsers] = useState([ const [users, setUsers] = useState([
@ -44,30 +481,194 @@ export default function UsersList() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedUser, setSelectedUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null);
const [showFilterDialog, setShowFilterDialog] = useState(false);
const [filters, setFilters] = useState({
identityType: 'all',
minBookings: '',
maxBookings: '',
minSpending: '',
maxSpending: '',
dateRange: 'all',
activeOnly: false,
inactiveOnly: false
});
const filteredUsers = users.filter(user => const applyFilters = (newFilters) => {
user.name.includes(searchTerm) || setFilters(newFilters);
user.email.includes(searchTerm) || };
user.phone.includes(searchTerm)
); const resetFilters = () => {
setFilters({
identityType: 'all',
minBookings: '',
maxBookings: '',
minSpending: '',
maxSpending: '',
dateRange: 'all',
activeOnly: false,
inactiveOnly: false
});
setSearchTerm('');
};
const filteredUsers = users.filter(user => {
if (searchTerm && !user.name.includes(searchTerm) && !user.email.includes(searchTerm) && !user.phone.includes(searchTerm)) {
return false;
}
if (filters.identityType !== 'all' && user.identityType !== filters.identityType) {
return false;
}
if (filters.minBookings && user.totalBookings < parseInt(filters.minBookings)) {
return false;
}
if (filters.maxBookings && user.totalBookings > parseInt(filters.maxBookings)) {
return false;
}
if (filters.minSpending && user.totalSpent < parseInt(filters.minSpending)) {
return false;
}
if (filters.maxSpending && user.totalSpent > parseInt(filters.maxSpending)) {
return false;
}
if (filters.activeOnly && user.activeBookings === 0) {
return false;
}
if (filters.inactiveOnly && user.activeBookings > 0) {
return false;
}
if (filters.dateRange !== 'all') {
const joinDate = new Date(user.joinDate);
const today = new Date();
const diffDays = Math.floor((today - joinDate) / (1000 * 60 * 60 * 24));
switch(filters.dateRange) {
case 'today':
if (joinDate.toDateString() !== today.toDateString()) return false;
break;
case 'week':
if (diffDays > 7) return false;
break;
case 'month':
if (diffDays > 30) return false;
break;
case 'year':
if (diffDays > 365) return false;
break;
}
}
return true;
});
const filterStats = {
total: filteredUsers.length,
filtered: filteredUsers.length !== users.length
};
const getActiveFiltersCount = () => {
let count = 0;
if (filters.identityType !== 'all') count++;
if (filters.minBookings || filters.maxBookings) count++;
if (filters.minSpending || filters.maxSpending) count++;
if (filters.dateRange !== 'all') count++;
if (filters.activeOnly || filters.inactiveOnly) count++;
return count;
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-4"> <Toaster position="top-center" reverseOrder={false} />
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
type="text" type="text"
placeholder="بحث عن مستخدم..." placeholder="بحث عن مستخدم بالاسم أو البريد أو الهاتف..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pr-10 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" className="w-full pr-12 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
</div> </div>
<button className="px-4 py-2 border rounded-lg flex items-center gap-2 hover:bg-gray-50"> <div className="flex gap-2">
<Filter className="w-4 h-4" /> <button
تصفية onClick={() => setShowFilterDialog(true)}
</button> className={`px-5 py-3 rounded-xl font-medium flex items-center gap-2 transition-all ${
getActiveFiltersCount() > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Filter className="w-5 h-5" />
تصفية متقدمة
{getActiveFiltersCount() > 0 && (
<span className="ml-1 bg-white text-blue-600 rounded-full w-5 h-5 text-xs flex items-center justify-center">
{getActiveFiltersCount()}
</span>
)}
</button>
{filterStats.filtered && (
<button
onClick={resetFilters}
className="px-5 py-3 bg-gray-100 text-gray-700 rounded-xl font-medium hover:bg-gray-200 transition-colors flex items-center gap-2"
>
<X className="w-4 h-4" />
إعادة تعيين
</button>
)}
</div>
</div>
{getActiveFiltersCount() > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-blue-50 rounded-xl">
<span className="text-sm text-blue-800 font-medium">الفلاتر النشطة:</span>
{filters.identityType !== 'all' && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
{filters.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
</span>
)}
{(filters.minBookings || filters.maxBookings) && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
الحجوزات: {filters.minBookings || '0'} - {filters.maxBookings || '∞'}
</span>
)}
{(filters.minSpending || filters.maxSpending) && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
الإنفاق: {parseInt(filters.minSpending || 0).toLocaleString()} - {parseInt(filters.maxSpending || '∞').toLocaleString()} ل.س
</span>
)}
{filters.dateRange !== 'all' && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">
{filters.dateRange === 'today' ? 'اليوم' :
filters.dateRange === 'week' ? 'آخر 7 أيام' :
filters.dateRange === 'month' ? 'آخر 30 يوم' : 'آخر 12 شهر'}
</span>
)}
{filters.activeOnly && (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-lg text-xs">
لديهم حجوزات نشطة
</span>
)}
{filters.inactiveOnly && (
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
بدون حجوزات نشطة
</span>
)}
</div>
)}
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
عرض <span className="font-bold text-gray-900">{filteredUsers.length}</span> مستخدم
{filterStats.filtered && (
<span className="text-gray-500 mr-1">(من {users.length})</span>
)}
</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -76,40 +677,46 @@ export default function UsersList() {
key={user.id} key={user.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.05 }}
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow" className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md transition-all"
> >
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center"> <div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center text-white text-xl font-bold shadow-lg">
<User className="w-6 h-6 text-blue-600" /> {user.name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<h3 className="font-bold">{user.name}</h3> <h3 className="font-bold text-gray-900 text-lg">{user.name}</h3>
<div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-600"> <div className="flex flex-wrap gap-3 mt-1 text-sm text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Mail className="w-3 h-3" /> <Mail className="w-4 h-4" />
{user.email} {user.email}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Phone className="w-3 h-3" /> <Phone className="w-4 h-4" />
{user.phone} {user.phone}
</div> </div>
<div className="flex items-center gap-1">
<Shield className="w-4 h-4" />
{user.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-6">
<div className="text-center"> <div className="text-center min-w-[80px]">
<div className="text-lg font-bold text-blue-600">{user.totalBookings}</div> <div className="text-xl font-bold text-blue-600">{user.totalBookings}</div>
<div className="text-xs text-gray-500">إجمالي الحجوزات</div> <div className="text-xs text-gray-500">إجمالي الحجوزات</div>
</div> </div>
<div className="text-center"> <div className="text-center min-w-[80px]">
<div className="text-lg font-bold text-green-600">{user.activeBookings}</div> <div className={`text-xl font-bold ${user.activeBookings > 0 ? 'text-green-600' : 'text-gray-400'}`}>
{user.activeBookings}
</div>
<div className="text-xs text-gray-500">حجوزات نشطة</div> <div className="text-xs text-gray-500">حجوزات نشطة</div>
</div> </div>
<div className="text-center"> <div className="text-center min-w-[100px]">
<div className="text-lg font-bold text-amber-600"> <div className="text-xl font-bold text-amber-600">
{user.totalSpent.toLocaleString()} {user.totalSpent.toLocaleString()}
</div> </div>
<div className="text-xs text-gray-500">إجمالي المنصرف</div> <div className="text-xs text-gray-500">إجمالي المنصرف</div>
@ -118,7 +725,7 @@ export default function UsersList() {
<button <button
onClick={() => setSelectedUser(user)} onClick={() => setSelectedUser(user)}
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm flex items-center gap-1 hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
عرض التفاصيل عرض التفاصيل
@ -128,63 +735,39 @@ export default function UsersList() {
))} ))}
</div> </div>
{selectedUser && ( {filteredUsers.length === 0 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> <motion.div
<motion.div initial={{ opacity: 0, scale: 0.9 }}
initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}
animate={{ opacity: 1, scale: 1 }} className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-gray-300"
className="bg-white rounded-xl w-full max-w-2xl p-6" >
> <Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<div className="flex justify-between items-center mb-4"> <h3 className="text-xl font-bold text-gray-700 mb-2">لا توجد نتائج</h3>
<h2 className="text-xl font-bold">تفاصيل المستخدم</h2> <p className="text-gray-500">لا يوجد مستخدمون يطابقون معايير البحث</p>
<button {(searchTerm || getActiveFiltersCount() > 0) && (
onClick={() => setSelectedUser(null)} <button
className="p-1 hover:bg-gray-100 rounded" onClick={resetFilters}
> className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-xl text-sm font-medium hover:bg-blue-700"
>
</button> إعادة تعيين الفلاتر
</div> </button>
)}
<div className="space-y-4"> </motion.div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">الاسم</label>
<div className="font-medium">{selectedUser.name}</div>
</div>
<div>
<label className="text-sm text-gray-500">البريد الإلكتروني</label>
<div className="font-medium">{selectedUser.email}</div>
</div>
<div>
<label className="text-sm text-gray-500">رقم الهاتف</label>
<div className="font-medium">{selectedUser.phone}</div>
</div>
<div>
<label className="text-sm text-gray-500">نوع الهوية</label>
<div className="font-medium">
{selectedUser.identityType === 'syrian' ? 'هوية سورية' : 'جواز سفر'}
</div>
</div>
<div>
<label className="text-sm text-gray-500">رقم الهوية</label>
<div className="font-medium">{selectedUser.identityNumber}</div>
</div>
<div>
<label className="text-sm text-gray-500">تاريخ التسجيل</label>
<div className="font-medium">{selectedUser.joinDate}</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-bold mb-3">سجل الحجوزات</h3>
<p className="text-gray-500 text-center py-4">
لا توجد حجوزات سابقة
</p>
</div>
</div>
</motion.div>
</div>
)} )}
<FilterDialog
isOpen={showFilterDialog}
onClose={() => setShowFilterDialog(false)}
filters={filters}
onApplyFilters={applyFilters}
onResetFilters={resetFilters}
/>
<UserDetailsModal
user={selectedUser}
isOpen={!!selectedUser}
onClose={() => setSelectedUser(null)}
/>
</div> </div>
); );
} }

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,17 +51,45 @@ 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 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -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,216 @@
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;

View File

@ -0,0 +1,149 @@
'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;

View File

@ -0,0 +1,90 @@
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 'ضعيف';
}

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 {

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -13,19 +13,47 @@ const geistMono = Geist_Mono({
}); });
export const metadata = { export const metadata = {
title: "SweetHome", title: "Sweet Home",
description: "Discover premium furniture and home decor", description: "Discover premium furniture and home decor",
icons: {
icon: [{ url: "/logo.png", type: "image/png" }],
shortcut: [{ url: "/logo.png", type: "image/png" }],
apple: [{ url: "/logo.png", type: "image/png" }],
},
}; };
export default function Layout({ children }) { export default function Layout({ children }) {
return ( return (
<html lang="en"> <html lang="ar" dir="rtl">
<head /> <head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> <link
<ClientLayout> rel="preload"
{children} as="font"
</ClientLayout> 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
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
style={{ fontFamily: "'Madani Arabic', 'Noto Sans Arabic', 'Cairo', Arial, sans-serif" }}
>
<ClientLayout>{children}</ClientLayout>
</body> </body>
</html> </html>
); );
} }

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()) {
toast.error('يرجى تصحيح الأخطاء في النموذج', {
style: { background: '#fee2e2', color: '#991b1b' }
});
return;
}
setIsLoading(true); setIsLoading(true);
setErrors({});
setTimeout(() => {
if (formData.email.toLowerCase() === ADMIN_EMAIL && formData.password === ADMIN_PASSWORD) { try {
setIsLoading(false); 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); 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'); if (userRole === "admin") {
router.push("/admin");
} else {
router.push("/");
}
}, 1500); }, 1500);
} else { } else if (result.status === 206) {
setIsLoading(false); console.log("[Login] 206 — OTP required");
toast.error('بيانات الدخول غير صحيحة. حاول مع admin@gmail.com / 123', { const tempToken =
style: { background: '#fee2e2', color: '#991b1b' }, typeof result.data === "string"
duration: 4000 ? 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" },
},
);
} }
}, 1500); } catch (err) {
console.error("[Login] Error:", err);
toast.error(err.message || "حدث خطأ في الاتصال", {
style: { background: "#fee2e2", color: "#991b1b" },
});
} finally {
setIsLoading(false);
}
}; };
const particles = Array.from({ length: 30 }, (_, i) => ({ const handleVerifyOTP = async (e) => {
e.preventDefault();
if (!otpCode || otpCode.length < 4) {
setOtpError("يرجى إدخال رمز التحقق");
return;
}
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");
}
setIsSuccess(true);
toast.success("تم التحقق بنجاح!", {
style: { background: "#dcfce7", color: "#166534" },
});
setTimeout(() => {
console.log("[OTP] Redirecting to home");
router.push("/");
}, 1500);
} else {
console.error("[OTP] Verification failed:", result.data);
setOtpError(result.data?.message || "رمز التحقق غير صحيح");
}
} catch (err) {
console.error("[OTP] Error:", err);
setOtpError(err.message || "حدث خطأ في التحقق");
} finally {
setIsLoading(false);
}
};
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 }}
@ -217,7 +380,7 @@ export default function LoginPage() {
transition={{ delay: 0.3, type: "spring" }} transition={{ delay: 0.3, type: "spring" }}
className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full" className="absolute -bottom-10 -left-10 w-40 h-40 bg-white/10 rounded-full"
/> />
<motion.div <motion.div
initial={{ y: 20, opacity: 0 }} initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
@ -229,172 +392,322 @@ 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"
> >
<Home className="w-10 h-10 text-white" /> {step === "otp" ? (
<KeyRound 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">
<motion.form <AnimatePresence mode="wait">
variants={itemVariants} {step === "login" ? (
onSubmit={handleSubmit} <motion.form
className="space-y-6" key="login"
> initial={{ opacity: 0, x: -20 }}
<motion.div variants={itemVariants}> animate={{ opacity: 1, x: 0 }}
<label className="block text-sm font-medium text-gray-300 mb-2"> exit={{ opacity: 0, x: 20 }}
البريد الإلكتروني onSubmit={handleLogin}
</label> className="space-y-6"
<div className="relative group">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
}`} />
</div>
<input
type="email"
value={formData.email}
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>
{errors.email && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1"
>
{errors.email}
</motion.p>
)}
</motion.div>
<motion.div variants={itemVariants}>
<label className="block text-sm font-medium text-gray-300 mb-2">
كلمة المرور
</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 transition-colors ${
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-amber-500'
}`} />
</div>
<input
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => {
setFormData({...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 ${
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 hover:text-gray-300 transition-colors" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
)}
</button>
</div>
{errors.password && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1"
>
{errors.password}
</motion.p>
)}
</motion.div>
<motion.div
variants={itemVariants}
className="flex items-center justify-between"
>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={formData.rememberMe}
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"
/>
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
تذكرني
</span>
</label>
<Link
href="/forgot-password"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
> >
نسيت كلمة المرور؟ {/* Login method tabs */}
</Link> <div className="flex gap-2 bg-white/5 p-1 rounded-xl">
</motion.div> <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>
<motion.button {/* Credential input */}
variants={itemVariants} <div>
type="submit" <label className="block text-sm font-medium text-gray-300 mb-2">
disabled={isLoading || isSuccess} {loginMethod === "email"
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 }} : "رقم الهاتف"}
whileTap={{ scale: 0.98 }} </label>
> <div className="relative group">
<motion.div <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
className="absolute inset-0 bg-gradient-to-r from-amber-600 to-amber-700" {loginMethod === "email" ? (
initial={{ x: '100%' }} <Mail
whileHover={{ x: 0 }} className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
transition={{ duration: 0.3 }} />
/> ) : (
<span className="relative z-10 flex items-center justify-center gap-2"> <Phone
{isLoading ? ( className={`w-5 h-5 transition-colors ${errors.credential ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
<> />
<Loader2 className="w-5 h-5 animate-spin" /> )}
جاري تسجيل الدخول... </div>
</> <input
) : isSuccess ? ( type="text"
<> // type={loginMethod === 'email' ? 'email' : 'tel'}
<CheckCircle className="w-5 h-5" /> 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"
<LogIn className="w-5 h-5" /> : "border-gray-700"
تسجيل الدخول }`}
</> placeholder={
)} loginMethod === "email"
</span> ? "example@email.com"
</motion.button> : "+963XXXXXXXXX"
</motion.form> }
dir="ltr"
/>
</div>
{errors.credential && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1"
>
{errors.credential}
</motion.p>
)}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
كلمة المرور
</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 transition-colors ${errors.password ? "text-red-500" : "text-gray-400 group-focus-within:text-amber-500"}`}
/>
</div>
<input
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => {
setFormData({
...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 ${
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 hover:text-gray-300 transition-colors" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300 transition-colors" />
)}
</button>
</div>
{errors.password && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-red-500 text-sm mt-1"
>
{errors.password}
</motion.p>
)}
</div>
{/* Remember + Forgot */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={formData.rememberMe}
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"
/>
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">
تذكرني
</span>
</label>
<Link
href="/forgot-password"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
نسيت كلمة المرور؟
</Link>
</div>
{/* Submit */}
<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 overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span className="relative z-10 flex items-center justify-center gap-2">
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
جاري تسجيل الدخول...
</>
) : isSuccess ? (
<>
<CheckCircle className="w-5 h-5" />
تم بنجاح!
</>
) : (
<>
<LogIn className="w-5 h-5" />
تسجيل الدخول
</>
)}
</span>
</motion.button>
</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,16 +722,22 @@ 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>
</motion.div> </motion.div>
</div> </div>
); );
} }

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>
);
}

740
app/owner/bookings/page.js Normal file
View File

@ -0,0 +1,740 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Calendar,
Home,
User,
Mail,
Phone,
DollarSign,
CheckCircle,
XCircle,
Clock,
MapPin,
Bed,
Bath,
Square,
CalendarDays,
ChevronLeft,
ChevronRight,
Eye,
MessageCircle,
ArrowLeft,
Loader2,
Filter,
Search,
Download,
TrendingUp,
Users,
Building
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
import Image from 'next/image';
const OwnerBookingCalendar = ({ property, onDateSelect, selectedDates }) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [hoverDate, setHoverDate] = useState(null);
const daysInMonth = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth() + 1,
0
).getDate();
const firstDayOfMonth = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
1
).getDay();
const monthNames = [
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
];
const isDateBooked = (date) => {
if (!property?.bookings) return false;
const dateStr = date.toISOString().split('T')[0];
return property.bookings.some(booking => {
const start = new Date(booking.startDate);
const end = new Date(booking.endDate);
const current = new Date(date);
return current >= start && current <= end;
});
};
const isDateSelected = (date) => {
if (!selectedDates) return false;
const dateStr = date.toISOString().split('T')[0];
return dateStr === selectedDates.start || dateStr === selectedDates.end;
};
const isInRange = (date) => {
if (!selectedDates?.start || !selectedDates?.end) return false;
const dateStr = date.toISOString().split('T')[0];
return dateStr > selectedDates.start && dateStr < selectedDates.end;
};
const handleDateClick = (date) => {
if (isDateBooked(date)) return;
onDateSelect?.(date);
};
const renderDays = () => {
const days = [];
const totalDays = daysInMonth + firstDayOfMonth;
for (let i = 0; i < totalDays; i++) {
if (i < firstDayOfMonth) {
days.push(<div key={`empty-${i}`} className="p-2" />);
} else {
const dayNumber = i - firstDayOfMonth + 1;
const date = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
dayNumber
);
const isBooked = isDateBooked(date);
const isSelected = isDateSelected(date);
const inRange = isInRange(date);
const isToday = date.toDateString() === new Date().toDateString();
days.push(
<button
key={dayNumber}
onClick={() => handleDateClick(date)}
disabled={isBooked}
onMouseEnter={() => setHoverDate(dayNumber)}
onMouseLeave={() => setHoverDate(null)}
className={`
p-2 rounded-lg text-center text-sm transition-all relative
${isBooked ? 'bg-red-100 text-red-500 cursor-not-allowed line-through' : ''}
${isSelected ? 'bg-amber-500 text-white shadow-md' : ''}
${inRange ? 'bg-amber-100' : ''}
${!isBooked && !isSelected ? 'hover:bg-amber-50 hover:text-amber-600 cursor-pointer' : ''}
${isToday && !isSelected && !isBooked ? 'border-2 border-amber-500' : ''}
`}
>
{dayNumber}
{isBooked && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
);
}
}
return days;
};
return (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
{/* رأس التقويم */}
<div className="flex items-center justify-between mb-6">
<button
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1))}
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
>
<ChevronRight className="w-5 h-5 text-gray-600" />
</button>
<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>
<button
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1))}
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
>
<ChevronLeft className="w-5 h-5 text-gray-600" />
</button>
</div>
{/* أيام الأسبوع */}
<div className="grid grid-cols-7 gap-1 mb-3 text-center text-sm font-medium text-gray-500">
<div>أحد</div>
<div>إثنين</div>
<div>ثلاثاء</div>
<div>أربعاء</div>
<div>خميس</div>
<div>جمعة</div>
<div>سبت</div>
</div>
<div className="grid grid-cols-7 gap-1">
{renderDays()}
</div>
<div className="flex flex-wrap gap-4 mt-6 pt-4 border-t border-gray-200 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-100 rounded" />
<span className="text-gray-600">محجوز</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-amber-500 rounded" />
<span className="text-gray-600">محدد</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-amber-100 rounded" />
<span className="text-gray-600">ضمن الفترة</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-amber-500 rounded" />
<span className="text-gray-600">اليوم</span>
</div>
</div>
</div>
);
};
const BookingCard = ({ booking, onViewDetails, onContact }) => {
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const getStatusBadge = (status) => {
const statusConfig = {
pending: { label: 'قيد الانتظار', color: 'bg-yellow-100 text-yellow-800', icon: Clock },
confirmed: { label: 'مؤكد', color: 'bg-green-100 text-green-800', icon: CheckCircle },
cancelled: { label: 'ملغي', color: 'bg-red-100 text-red-800', icon: XCircle },
completed: { label: 'منتهي', color: 'bg-gray-100 text-gray-800', icon: CheckCircle }
};
const config = statusConfig[status] || statusConfig.pending;
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${config.color}`}>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
};
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">
<div className="flex justify-between items-start mb-4">
<div>
<div className="flex items-center gap-2 mb-2">
<h3 className="font-bold text-gray-900">{booking.propertyTitle}</h3>
{getStatusBadge(booking.status)}
</div>
<div className="flex items-center gap-1 text-gray-500 text-sm">
<MapPin className="w-4 h-4" />
{booking.location}
</div>
</div>
<div className="text-left">
<div className="text-lg font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</div>
<div className="text-xs text-gray-500">إجمالي المبلغ</div>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-amber-600" />
</div>
<div>
<p className="font-medium text-gray-900">{booking.tenantName}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Phone className="w-3 h-3" />
{booking.tenantPhone}
<Mail className="w-3 h-3 mr-1" />
{booking.tenantEmail}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-3 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">{booking.startDate}</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">{booking.endDate}</div>
</div>
<div className="bg-gray-50 p-2 rounded-lg">
<Clock 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">{booking.days} يوم</div>
</div>
</div>
<div className="flex gap-3 pt-3 border-t border-gray-100">
<button
onClick={() => onViewDetails(booking)}
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>
{/* <button
onClick={() => onContact(booking)}
className="flex-1 bg-amber-500 text-white py-2 rounded-xl text-sm font-medium hover:bg-amber-600 transition-colors flex items-center justify-center gap-2"
>
<MessageCircle className="w-4 h-4" />
تواصل
</button> */}
</div>
</div>
</motion.div>
);
};
const BookingDetailsModal = ({ booking, isOpen, onClose }) => {
if (!isOpen || !booking) return null;
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
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">رقم الحجز: #{booking.id}</p>
</div>
<div className="p-6 space-y-6">
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3">معلومات العقار</h3>
<div className="space-y-2">
<p><span className="text-gray-500">العقار:</span> {booking.propertyTitle}</p>
<p><span className="text-gray-500">الموقع:</span> {booking.location}</p>
{booking.propertyDetails && (
<div className="flex gap-3 mt-2">
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bedrooms} غرف</span>
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.bathrooms} حمامات</span>
<span className="text-sm bg-white px-2 py-1 rounded-lg">{booking.propertyDetails.area} م²</span>
</div>
)}
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3">معلومات المستأجر</h3>
<div className="space-y-2">
<p><span className="text-gray-500">الاسم:</span> {booking.tenantName}</p>
<p><span className="text-gray-500">البريد الإلكتروني:</span> {booking.tenantEmail}</p>
<p><span className="text-gray-500">رقم الهاتف:</span> {booking.tenantPhone}</p>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-3">تفاصيل الحجز</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-gray-500">تاريخ البداية</p>
<p className="font-medium">{booking.startDate}</p>
</div>
<div>
<p className="text-gray-500">تاريخ النهاية</p>
<p className="font-medium">{booking.endDate}</p>
</div>
<div>
<p className="text-gray-500">عدد الأيام</p>
<p className="font-medium">{booking.days} يوم</p>
</div>
<div>
<p className="text-gray-500">حالة الحجز</p>
<p className="font-medium">{booking.status === 'pending' ? 'قيد الانتظار' :
booking.status === 'confirmed' ? 'مؤكد' :
booking.status === 'cancelled' ? 'ملغي' : 'منتهي'}</p>
</div>
</div>
</div>
<div className="bg-amber-50 p-4 rounded-xl">
<h3 className="font-bold text-amber-700 mb-3">المعلومات المالية</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">السعر اليومي</span>
<span className="font-medium">{formatCurrency(booking.dailyPrice)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">المدة ({booking.days} أيام)</span>
<span className="font-medium">{formatCurrency(booking.dailyPrice * booking.days)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">سلفة الضمان</span>
<span className="font-medium">{formatCurrency(booking.securityDeposit || 0)}</span>
</div>
<div className="flex justify-between pt-2 border-t border-amber-200 font-bold">
<span className="text-gray-900">الإجمالي</span>
<span className="text-amber-600 text-lg">{formatCurrency(booking.totalAmount)}</span>
</div>
</div>
</div>
{booking.notes && (
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="font-bold text-gray-900 mb-2">ملاحظات</h3>
<p className="text-gray-600">{booking.notes}</p>
</div>
)}
</div>
</motion.div>
</motion.div>
);
};
export default function OwnerBookingsPage() {
const router = useRouter();
const [user, setUser] = useState(null);
const [bookings, setBookings] = useState([]);
const [filteredBookings, setFilteredBookings] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedBooking, setSelectedBooking] = useState(null);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
const [dateRange, setDateRange] = useState({ start: '', end: '' });
const [showCalendar, setShowCalendar] = useState(false);
useEffect(() => {
const authUser = AuthService.getUser();
if (authUser && AuthService.isOwner()) {
setUser({
name: authUser.name || authUser.email,
email: authUser.email,
role: 'owner',
});
loadBookings();
} else {
router.push('/auth/choose-role');
}
}, [router]);
const loadBookings = () => {
const storedBookings = localStorage.getItem('ownerBookings');
if (storedBookings) {
setBookings(JSON.parse(storedBookings));
setFilteredBookings(JSON.parse(storedBookings));
} else {
const mockBookings = [
{
id: 'BK001',
propertyId: 1,
propertyTitle: 'فيلا فاخرة في المزة',
location: 'دمشق، المزة',
propertyDetails: { bedrooms: 5, bathrooms: 4, area: 450 },
tenantName: 'أحمد محمد',
tenantEmail: 'ahmed@example.com',
tenantPhone: '0933111222',
startDate: '2024-03-10',
endDate: '2024-03-15',
days: 5,
dailyPrice: 500000,
totalAmount: 2500000,
securityDeposit: 500000,
status: 'confirmed',
createdAt: '2024-02-25',
notes: 'طلب الحجز من خلال الموقع'
},
{
id: 'BK002',
propertyId: 2,
propertyTitle: 'شقة حديثة في الشهباء',
location: 'حلب، الشهباء',
propertyDetails: { bedrooms: 3, bathrooms: 2, area: 180 },
tenantName: 'سارة أحمد',
tenantEmail: 'sara@example.com',
tenantPhone: '0945123789',
startDate: '2024-03-05',
endDate: '2024-03-08',
days: 3,
dailyPrice: 250000,
totalAmount: 750000,
securityDeposit: 250000,
status: 'pending',
createdAt: '2024-02-24',
notes: 'تحتاج إلى تأكيد'
},
{
id: 'BK003',
propertyId: 3,
propertyTitle: 'بيت عائلي في بابا عمرو',
location: 'حمص، بابا عمرو',
propertyDetails: { bedrooms: 4, bathrooms: 3, area: 300 },
tenantName: 'محمد الحلبي',
tenantEmail: 'mohammed@example.com',
tenantPhone: '0956123456',
startDate: '2024-02-20',
endDate: '2024-03-20',
days: 30,
dailyPrice: 350000,
totalAmount: 10500000,
securityDeposit: 500000,
status: 'completed',
createdAt: '2024-02-15',
notes: 'تم إنهاء الإيجار بنجاح'
}
];
setBookings(mockBookings);
setFilteredBookings(mockBookings);
localStorage.setItem('ownerBookings', JSON.stringify(mockBookings));
}
setIsLoading(false);
};
const handleViewDetails = (booking) => {
setSelectedBooking(booking);
};
const handleContact = (booking) => {
toast.success(`جاري فتح محادثة مع ${booking.tenantName}`, {
icon: '💬',
style: { background: '#dcfce7', color: '#166534' }
});
};
const handleStatusChange = (bookingId, newStatus) => {
const updatedBookings = bookings.map(b =>
b.id === bookingId ? { ...b, status: newStatus } : b
);
setBookings(updatedBookings);
setFilteredBookings(updatedBookings);
localStorage.setItem('ownerBookings', JSON.stringify(updatedBookings));
toast.success(`تم تحديث حالة الحجز بنجاح`);
};
const statusCounts = {
all: bookings.length,
pending: bookings.filter(b => b.status === 'pending').length,
confirmed: bookings.filter(b => b.status === 'confirmed').length,
completed: bookings.filter(b => b.status === 'completed').length,
cancelled: bookings.filter(b => b.status === 'cancelled').length
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-amber-500 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} />
<BookingDetailsModal
booking={selectedBooking}
isOpen={!!selectedBooking}
onClose={() => setSelectedBooking(null)}
/>
<div className="container mx-auto px-4">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">حجوزاتي</h1>
<p className="text-gray-600">مرحباً {user?.name}، لديك {bookings.length} حجز</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowCalendar(!showCalendar)}
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
>
<Calendar className="w-5 h-5" />
{showCalendar ? 'إخفاء التقويم' : 'عرض التقويم'}
</button>
{/* <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" />
تصدير التقرير
</button> */}
</div>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200 cursor-pointer hover:shadow-md transition-all"
onClick={() => setFilterStatus('all')}
>
<div className="text-2xl font-bold text-gray-900">{statusCounts.all}</div>
<div className="text-sm text-gray-600">جميع الحجوزات</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
filterStatus === 'pending' ? 'border-yellow-500 bg-yellow-50' : 'border-gray-200'
}`}
onClick={() => setFilterStatus('pending')}
>
<div className="text-2xl font-bold text-yellow-600">{statusCounts.pending}</div>
<div className="text-sm text-gray-600">قيد الانتظار</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
filterStatus === 'confirmed' ? 'border-green-500 bg-green-50' : 'border-gray-200'
}`}
onClick={() => setFilterStatus('confirmed')}
>
<div className="text-2xl font-bold text-green-600">{statusCounts.confirmed}</div>
<div className="text-sm text-gray-600">مؤكدة</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
filterStatus === 'completed' ? 'border-gray-500 bg-gray-50' : 'border-gray-200'
}`}
onClick={() => setFilterStatus('completed')}
>
<div className="text-2xl font-bold text-gray-600">{statusCounts.completed}</div>
<div className="text-sm text-gray-600">منتهية</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className={`bg-white rounded-xl shadow-sm p-4 text-center border cursor-pointer hover:shadow-md transition-all ${
filterStatus === 'cancelled' ? 'border-red-500 bg-red-50' : 'border-gray-200'
}`}
onClick={() => setFilterStatus('cancelled')}
>
<div className="text-2xl font-bold text-red-600">{statusCounts.cancelled}</div>
<div className="text-sm text-gray-600">ملغية</div>
</motion.div>
</div>
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -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>
<div className="flex gap-3">
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="من تاريخ"
/>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
className="px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-500"
placeholder="إلى تاريخ"
/>
{(dateRange.start || dateRange.end) && (
<button
onClick={() => setDateRange({ start: '', end: '' })}
className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
>
مسح
</button>
)}
</div>
</div>
{showCalendar && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<OwnerBookingCalendar
property={{ bookings }}
onDateSelect={(date) => console.log('Date selected:', date)}
/>
</motion.div>
)}
{filteredBookings.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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">
<Calendar 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 mb-4">
{filterStatus !== 'all' ? 'لا توجد حجوزات في هذه الفئة' : 'لم يتم استلام أي حجوزات بعد'}
</p>
{filterStatus !== 'all' && (
<button
onClick={() => setFilterStatus('all')}
className="inline-flex items-center gap-2 bg-amber-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-amber-600"
>
عرض جميع الحجوزات
</button>
)}
</motion.div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filteredBookings.map((booking) => (
<BookingCard
key={booking.id}
booking={booking}
onViewDetails={handleViewDetails}
onContact={handleContact}
/>
))}
</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>
);
}

738
app/owner/calendar/page.js Normal file
View File

@ -0,0 +1,738 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Calendar,
ChevronLeft,
ChevronRight,
Home,
Building,
MapPin,
Bed,
Bath,
Square,
DollarSign,
Eye,
ArrowLeft,
Loader2,
Filter,
Download,
Printer,
ChevronDown,
X,
CheckCircle,
XCircle,
Clock,
Users,
TrendingUp,
CalendarDays,
LayoutGrid,
List,
AlertCircle,
XCircle as XCircleIcon,
Calendar as CalendarIcon
} from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import AuthService from '../../services/AuthService';
const MonthlyCalendar = ({ properties, selectedPropertyId, onDateClick, onPropertySelect }) => {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
const [viewType, setViewType] = useState('grid');
const monthNames = [
'يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو',
'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'
];
const daysInMonth = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth() + 1,
0
).getDate();
const firstDayOfMonth = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
1
).getDay();
const isDateBookedForProperty = (date, property) => {
if (!property?.bookings) return false;
const dateStr = date.toISOString().split('T')[0];
return property.bookings.some(booking => {
const start = new Date(booking.startDate);
const end = new Date(booking.endDate);
const current = new Date(date);
return current >= start && current <= end;
});
};
const getDayStatus = (date) => {
if (selectedPropertyId === 'all') {
const totalProperties = properties.length;
const bookedCount = properties.filter(p => isDateBookedForProperty(date, p)).length;
if (bookedCount === 0) return { status: 'all_available', label: 'جميع العقارات متاحة', color: 'bg-green-100 text-green-800' };
if (bookedCount === totalProperties) return { status: 'all_booked', label: 'جميع العقارات محجوزة', color: 'bg-red-100 text-red-800' };
return { status: 'partial', label: `${bookedCount}/${totalProperties} محجوز`, color: 'bg-yellow-100 text-yellow-800' };
} else {
const property = properties.find(p => p.id === selectedPropertyId);
if (!property) return { status: 'no_property', label: 'غير متاح', color: 'bg-gray-100 text-gray-500' };
const isBooked = isDateBookedForProperty(date, property);
return {
status: isBooked ? 'booked' : 'available',
label: isBooked ? 'محجوز' : 'متاح',
color: isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
};
}
};
const handleDateClick = (date) => {
setSelectedDate(date);
onDateClick?.(date);
};
const changeMonth = (direction) => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + direction, 1));
};
const renderDays = () => {
const days = [];
const totalDays = daysInMonth + firstDayOfMonth;
for (let i = 0; i < totalDays; i++) {
if (i < firstDayOfMonth) {
days.push(<div key={`empty-${i}`} className="p-2 md:p-3" />);
} else {
const dayNumber = i - firstDayOfMonth + 1;
const date = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
dayNumber
);
const isToday = date.toDateString() === new Date().toDateString();
const status = getDayStatus(date);
const isSelected = selectedDate?.toDateString() === date.toDateString();
days.push(
<button
key={dayNumber}
onClick={() => handleDateClick(date)}
className={`
p-2 md:p-3 rounded-xl text-center transition-all relative group
${status.color}
${isToday ? 'ring-2 ring-amber-500 ring-offset-2' : ''}
${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}
hover:scale-105 hover:shadow-md
`}
>
<div className="text-sm md:text-base font-medium">{dayNumber}</div>
<div className="text-xs mt-1 hidden md:block">{status.label}</div>
{status.status === 'partial' && (
<div className="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
)}
</button>
);
}
}
return days;
};
return (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 md:p-6 border-b border-gray-200">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => changeMonth(-1)}
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
<h2 className="text-xl md:text-2xl font-bold text-gray-900">
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h2>
<button
onClick={() => changeMonth(1)}
className="p-2 hover:bg-gray-100 rounded-xl transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setCurrentMonth(new Date())}
className="px-4 py-2 bg-amber-500 text-white rounded-xl text-sm hover:bg-amber-600 transition-colors"
>
اليوم
</button>
<div className="flex border border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setViewType('grid')}
className={`p-2 transition-colors ${viewType === 'grid' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewType('list')}
className={`p-2 transition-colors ${viewType === 'list' ? 'bg-amber-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-100'}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-7 gap-1 p-4 bg-gray-50 border-b border-gray-200">
{['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'].map((day, index) => (
<div key={index} className="text-center text-sm font-medium text-gray-600 py-2">
{day}
</div>
))}
</div>
<div className="p-4">
<div className="grid grid-cols-7 gap-1 md:gap-2">
{renderDays()}
</div>
</div>
<div className="p-4 border-t border-gray-200 bg-gray-50">
<div className="flex flex-wrap gap-4 justify-center text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-100 rounded" />
<span className="text-gray-600">متاح</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-100 rounded" />
<span className="text-gray-600">محجوز</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-100 rounded" />
<span className="text-gray-600">محجوز جزئياً</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 ring-2 ring-amber-500 rounded" />
<span className="text-gray-600">اليوم</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 ring-2 ring-blue-500 rounded" />
<span className="text-gray-600">محدد</span>
</div>
</div>
</div>
</div>
);
};
const PropertyCalendarList = ({ properties, selectedDate, onPropertyClick }) => {
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
const isDateBooked = (property, date) => {
if (!property?.bookings || !date) return false;
const dateStr = date.toISOString().split('T')[0];
return property.bookings.some(booking => {
const start = new Date(booking.startDate);
const end = new Date(booking.endDate);
const current = new Date(date);
return current >= start && current <= end;
});
};
const getBookingForDate = (property, date) => {
if (!property?.bookings || !date) return null;
const dateStr = date.toISOString().split('T')[0];
return property.bookings.find(booking => {
const start = new Date(booking.startDate);
const end = new Date(booking.endDate);
const current = new Date(date);
return current >= start && current <= end;
});
};
if (!selectedDate) {
return (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-12 text-center">
<CalendarDays className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-gray-700 mb-2">اختر تاريخاً</h3>
<p className="text-gray-500">اضغط على أي يوم في التقويم لعرض حالة العقارات في ذلك التاريخ</p>
</div>
);
}
const formattedDate = selectedDate.toLocaleDateString('ar-SA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
return (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 md:p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-amber-100">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<CalendarDays className="w-5 h-5 text-amber-500" />
حالة العقارات في تاريخ: {formattedDate}
</h3>
</div>
<div className="divide-y divide-gray-200">
{properties.map((property) => {
const isBooked = isDateBooked(property, selectedDate);
const booking = getBookingForDate(property, selectedDate);
return (
<motion.div
key={property.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={`p-4 md:p-6 hover:bg-gray-50 transition-colors cursor-pointer ${isBooked ? 'bg-red-50/30' : 'bg-green-50/30'}`}
onClick={() => onPropertyClick(property)}
>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-bold text-gray-900 text-lg">{property.title}</h4>
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
isBooked ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}`}>
{isBooked ? 'محجوز' : 'متاح'}
</span>
</div>
<div className="flex items-center gap-1 text-gray-500 text-sm mb-2">
<MapPin className="w-4 h-4" />
{property.location}
</div>
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
<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>
<div className="text-right">
<div className="text-lg font-bold text-amber-600">{formatCurrency(property.price)}</div>
<div className="text-xs text-gray-500">/يوم</div>
{isBooked && booking && (
<div className="mt-2 text-xs text-gray-500">
<div>مستأجر: {booking.tenantName || 'غير معروف'}</div>
<div>من: {booking.startDate} إلى {booking.endDate}</div>
</div>
)}
</div>
</div>
</motion.div>
);
})}
</div>
</div>
);
};
const PropertyDetailsModal = ({ property, isOpen, onClose }) => {
if (!isOpen || !property) return null;
const formatCurrency = (amount) => {
return amount?.toLocaleString() + ' ل.س';
};
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">
<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">
<XCircleIcon className="w-6 h-6" />
</button>
</div>
</div>
<div className="p-6 space-y-6">
{property.images && property.images.length > 0 && (
<div>
<h3 className="font-bold text-gray-900 mb-3">صور العقار</h3>
<div className="grid grid-cols-2 gap-3">
{property.images.slice(0, 4).map((image, index) => (
<div key={index} className="relative h-32 rounded-lg overflow-hidden bg-gray-100">
<img src={image} alt={`${property.title} ${index + 1}`} className="w-full h-full object-cover" />
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Bed className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.bedrooms}</div>
<div className="text-xs text-gray-500">غرف نوم</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Bath className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.bathrooms}</div>
<div className="text-xs text-gray-500">حمامات</div>
</div>
<div className="bg-gray-50 p-3 rounded-xl text-center">
<Square className="w-5 h-5 text-amber-500 mx-auto mb-1" />
<div className="text-sm font-bold">{property.area}</div>
<div className="text-xs text-gray-500">م²</div>
</div>
<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-sm font-bold">{formatCurrency(property.price)}</div>
<div className="text-xs text-gray-500">/يوم</div>
</div>
</div>
{property.features && property.features.length > 0 && (
<div>
<h3 className="font-bold text-gray-900 mb-3">المميزات</h3>
<div className="flex flex-wrap gap-2">
{property.features.map((feature, index) => (
<span key={index} className="px-2 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs">
{feature}
</span>
))}
</div>
</div>
)}
{property.bookings && property.bookings.length > 0 && (
<div>
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-500" />
الحجوزات القادمة
</h3>
<div className="space-y-2">
{property.bookings.slice(0, 3).map((booking, index) => (
<div key={index} className="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{booking.startDate} - {booking.endDate}</p>
<p className="text-xs text-gray-500">مستأجر: {booking.tenantName || 'غير معروف'}</p>
</div>
<span className="text-sm font-bold text-amber-600">{formatCurrency(booking.totalAmount)}</span>
</div>
))}
</div>
</div>
)}
</div>
<div className="p-6 pt-0 flex gap-3">
<Link
href={`/owner/properties/edit?id=${property.id}`}
className="flex-1 bg-amber-500 text-white py-3 rounded-xl text-center font-medium hover:bg-amber-600 transition-colors"
>
تعديل العقار
</Link>
<button
onClick={() => window.location.href = `/owner/bookings?property=${property.id}`}
className="flex-1 bg-gray-100 text-gray-700 py-3 rounded-xl text-center font-medium hover:bg-gray-200 transition-colors"
>
عرض الحجوزات
</button>
</div>
</motion.div>
</motion.div>
);
};
export default function OwnerCalendarPage() {
const router = useRouter();
const [user, setUser] = useState(null);
const [properties, setProperties] = useState([]);
const [filteredProperties, setFilteredProperties] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedPropertyId, setSelectedPropertyId] = useState('all');
const [selectedDate, setSelectedDate] = useState(null);
const [selectedProperty, setSelectedProperty] = useState(null);
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
const authUser = AuthService.getUser();
if (authUser && AuthService.isOwner()) {
setUser({
name: authUser.name || authUser.email,
email: authUser.email,
role: 'owner',
});
loadCalendar();
} else {
router.push('/auth/choose-role');
}
}, [router]);
const loadProperties = () => {
const storedProperties = localStorage.getItem('ownerProperties');
if (storedProperties) {
const props = JSON.parse(storedProperties);
setProperties(props);
setFilteredProperties(props);
} else {
const mockProperties = [
{
id: 1,
title: 'فيلا فاخرة في المزة',
location: 'دمشق، المزة',
bedrooms: 5,
bathrooms: 4,
area: 450,
price: 500000,
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن 24/7'],
images: ['/villa1.jpg'],
status: 'available',
bookings: [
{ startDate: '2024-03-10', endDate: '2024-03-15', totalAmount: 2500000, tenantName: 'أحمد محمد' },
{ startDate: '2024-03-20', endDate: '2024-03-25', totalAmount: 2500000, tenantName: 'سارة أحمد' }
]
},
{
id: 2,
title: 'شقة حديثة في الشهباء',
location: 'حلب، الشهباء',
bedrooms: 3,
bathrooms: 2,
area: 180,
price: 250000,
features: ['مطبخ مجهز', 'بلكونة', 'موقف سيارات', 'مصعد'],
images: ['/apartment1.jpg'],
status: 'available',
bookings: [
{ startDate: '2024-03-05', endDate: '2024-03-08', totalAmount: 750000, tenantName: 'محمد علي' }
]
},
{
id: 3,
title: 'بيت عائلي في بابا عمرو',
location: 'حمص، بابا عمرو',
bedrooms: 4,
bathrooms: 3,
area: 300,
price: 350000,
features: ['حديقة كبيرة', 'موقف سيارات', 'مدفأة', 'كراج'],
images: ['/house1.jpg'],
status: 'booked',
bookings: []
}
];
setProperties(mockProperties);
setFilteredProperties(mockProperties);
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties));
}
setIsLoading(false);
};
const calendarStats = {
totalProperties: properties.length,
availableToday: properties.filter(p => {
const today = new Date();
const isBooked = p.bookings?.some(b => {
const start = new Date(b.startDate);
const end = new Date(b.endDate);
return today >= start && today <= end;
});
return !isBooked;
}).length,
bookedToday: properties.filter(p => {
const today = new Date();
return p.bookings?.some(b => {
const start = new Date(b.startDate);
const end = new Date(b.endDate);
return today >= start && today <= end;
});
}).length,
upcomingBookings: properties.reduce((sum, p) => sum + (p.bookings?.length || 0), 0)
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-amber-500 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} />
<PropertyDetailsModal
property={selectedProperty}
isOpen={!!selectedProperty}
onClose={() => setSelectedProperty(null)}
/>
<div className="container mx-auto px-4">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">تقويم العقارات</h1>
<p className="text-gray-600">مرحباً {user?.name}، تتبع حالة عقاراتك عبر التقويم</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-white border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-2"
>
<Filter className="w-5 h-5" />
فلترة العقارات
<ChevronDown className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{/* <button className="px-4 py-2 bg-green-600 text-white rounded-xl hover:bg-green-700 transition-colors flex items-center gap-2">
<Printer className="w-5 h-5" />
طباعة التقويم
</button> */}
</div>
</motion.div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
>
<Building className="w-6 h-6 text-amber-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-gray-900">{calendarStats.totalProperties}</div>
<div className="text-sm text-gray-600">إجمالي العقارات</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
>
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-green-600">{calendarStats.availableToday}</div>
<div className="text-sm text-gray-600">متاح اليوم</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
>
<XCircle className="w-6 h-6 text-red-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-red-600">{calendarStats.bookedToday}</div>
<div className="text-sm text-gray-600">محجوز اليوم</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-white rounded-xl shadow-sm p-4 text-center border border-gray-200"
>
<CalendarDays className="w-6 h-6 text-blue-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-blue-600">{calendarStats.upcomingBookings}</div>
<div className="text-sm text-gray-600">حجوزات قادمة</div>
</motion.div>
</div>
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-6 overflow-hidden"
>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4">
<div className="flex flex-wrap gap-3 items-center">
<label className="text-sm font-medium text-gray-700">اختر عقاراً:</label>
<select
value={selectedPropertyId}
onChange={(e) => setSelectedPropertyId(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="all">جميع العقارات</option>
{properties.map((property) => (
<option key={property.id} value={property.id}>{property.title}</option>
))}
</select>
<button
onClick={() => setSelectedPropertyId('all')}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
إعادة تعيين
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="mb-8">
<MonthlyCalendar
properties={filteredProperties}
selectedPropertyId={selectedPropertyId}
onDateClick={setSelectedDate}
onPropertySelect={setSelectedProperty}
/>
</div>
<div>
<PropertyCalendarList
properties={filteredProperties}
selectedDate={selectedDate}
onPropertyClick={setSelectedProperty}
/>
</div>
{selectedDate && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-6 text-center text-sm text-gray-500"
>
<AlertCircle className="w-4 h-4 inline ml-1" />
اضغط على أي عقار لعرض التفاصيل الكاملة
</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>
);
}

592
app/owner/profits/page.js Normal file
View File

@ -0,0 +1,592 @@
// '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';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { Download, Loader2 } from 'lucide-react';
import toast, { Toaster } from 'react-hot-toast';
import * as XLSX from 'xlsx';
import AuthService from '@/app/services/AuthService';
export default function OwnerProfitsPage() {
const router = useRouter();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [tableData, setTableData] = useState([]);
const sampleData = [
{
id: 1,
property: 'A000000001',
bookingNumber: 'XX-101',
fromDate: '2025-05-01',
toDate: '2025-05-07',
amountReceived: 500,
platformCommission: 0,
transferredToOwner: 0,
transferReceipt: '—',
},
{
id: 2,
property: 'A000000002',
bookingNumber: 'XX-202',
fromDate: '2025-05-10',
toDate: '2025-05-15',
amountReceived: 300,
platformCommission: 0,
transferredToOwner: 0,
transferReceipt: '—',
},
{
id: 3,
property: 'A000000003',
bookingNumber: 'XX-309',
fromDate: '2025-06-01',
toDate: '2025-06-05',
amountReceived: 800,
platformCommission: 150,
transferredToOwner: 0,
transferReceipt: 'قيد الانتظار',
},
];
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);
}, [router]);
const totals = tableData.reduce(
(acc, row) => {
acc.totalAmountReceived += row.amountReceived;
acc.totalCommission += row.platformCommission;
acc.totalPlatformProfit += row.platformProfit;
acc.totalOwnerDue += row.ownerDue;
acc.totalTransferred += row.transferredToOwner;
return acc;
},
{
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('حدث خطأ أثناء تصدير التقرير');
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 text-amber-500 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" dir="rtl">
<Toaster position="top-center" reverseOrder={false} />
<div className="container mx-auto px-4 max-w-7xl">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4"
>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">أرباح المالك</h1>
<p className="text-gray-600">
مرحباً {user?.name}
</p>
</div>
<button
onClick={handleExportReport}
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"
>
<Download className="w-5 h-5" />
تصدير التقرير
</button>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden"
>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-800 text-gray-100">
<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 className="bg-gray-50 px-6 py-3 text-xs text-gray-500 border-t border-gray-200">
<span className="inline-flex items-center gap-1"></span> ملاحظة:
<strong> ربح المنصة </strong> يُحتسب تلقائياً بنسبة <strong className="text-amber-600">5%</strong> من قيمة «العروض المستلم».
</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 ${ <input
formData.services[service.id] type="checkbox"
? 'border-amber-500 bg-amber-50' checked={isSelected}
: 'border-gray-200 hover:border-amber-200 hover:bg-amber-50/50' onChange={() => toggleService(service.id)}
}`} className="w-4 h-4 text-amber-500 rounded"
> />
<input <Icon className={`w-5 h-5 ${isSelected ? 'text-amber-600' : 'text-gray-400'}`} />
type="checkbox" <span className={`text-sm font-medium ${isSelected ? 'text-amber-700' : 'text-gray-600'}`}>
checked={formData.services[service.id]} {service.label}
onChange={() => toggleService(service.id)} </span>
className="hidden" </label>
/> {isSelected && (
<Icon className={`w-5 h-5 ${ <div className="px-3 pb-3">
formData.services[service.id] ? 'text-amber-600' : 'text-gray-400' <input
}`} /> type="text"
<span className={`text-sm ${ value={formData.serviceDetails[service.id] || ''}
formData.services[service.id] ? 'text-amber-700' : 'text-gray-600' 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"
{service.label} placeholder="تفاصيل الخدمة (مثال: في جميع الغرف)"
</span> />
</label> </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: 'فيلا فاخرة في المزة', setIsLoading(false);
propertyType: 'villa', return;
purpose: 'rent', }
rentType: 'both',
dailyPrice: 500000, try {
monthlyPrice: 15000000, console.log('[OwnerProperties] Fetching listings for user:', userId);
location: 'دمشق، المزة', const data = await getMyRentListings();
bedrooms: 5, const list = Array.isArray(data) ? data : (data ? [data] : []);
bathrooms: 4, console.log('[OwnerProperties] API returned:', list.length, 'properties');
area: 450,
livingRooms: 3, const mapped = list.map((item) => {
status: 'available', const info = item.propertyInformation || {};
images: ['/villa1.jpg'], const details = (() => {
createdAt: new Date().toISOString(), try { return JSON.parse(info.detailsJSON || '{}'); } catch { return {}; }
furnished: true, })();
description: 'فيلا فاخرة مع حديقة خاصة ومسبح',
address: 'شارع المزة - فيلات غربية', return {
city: 'دمشق', id: item.id,
district: 'المزة', title: info.address || `عقار #${item.id}`,
services: { propertyType: { 0: 'apartment', 1: 'villa', 2: 'house' }[info.buildingType] || 'apartment',
electricity: true, purpose: 'rent',
internet: true, rentType: { 0: 'daily', 1: 'weekly', 2: 'monthly' }[item.rentType] || 'daily',
heating: true, dailyPrice: item.dailyRent || 0,
water: true, monthlyPrice: item.monthlyRent || 0,
airConditioning: true, deposit: item.deposit || 0,
parking: true, location: info.address || '',
elevator: false bedrooms: info.numberOfBedRooms || 0,
}, bathrooms: info.numberOfBathRooms || 0,
terms: { area: info.space || 0,
noSmoking: true, livingRooms: details.livingRooms || 0,
noPets: false, status: { 0: 'available', 1: 'booked', 2: 'maintenance' }[info.status] || 'available',
noParties: true, images: (() => {
noAlcohol: false, const apiBase = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_API_URL || 'https://45.93.137.91.nip.io/api') : '';
suitableForChildren: true, const raw = Array.isArray(info.images) ? info.images : [];
suitableForElderly: true 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,
setProperties(mockProperties); description: info.description || '',
localStorage.setItem('ownerProperties', JSON.stringify(mockProperties)); 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);
} }
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,7 +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 { useTranslation } from 'react-i18next'; import { usePathname } from 'next/navigation';
import { import {
ShieldCheck, ShieldCheck,
Lock, Lock,
@ -25,14 +25,91 @@ import {
Heart, Heart,
MessageCircle MessageCircle
} from 'lucide-react'; } from 'lucide-react';
import './i18n/config';
import HeroSearch from './components/home/HeroSearch'; 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 { t } = useTranslation();
const mapSectionRef = useRef(null); const mapSectionRef = useRef(null);
const [searchFilters, setSearchFilters] = useState(null); const [searchFilters, setSearchFilters] = useState(null);
const [showMap, setShowMap] = useState(false); const [showMap, setShowMap] = useState(false);
@ -41,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(() => {
@ -60,184 +180,36 @@ 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;
} }
if (filters.propertyType && filters.propertyType !== 'all' && property.type !== filters.propertyType) { if (filters.propertyType && filters.propertyType !== 'all' && property.type !== filters.propertyType) {
return false; return false;
} }
if (filters.priceRange && filters.priceRange !== 'all') { if (filters.priceRange && filters.priceRange !== 'all') {
const priceUSD = property.priceUSD; const priceUSD = property.priceUSD;
switch(filters.priceRange) { switch (filters.priceRange) {
case '0-500': if (priceUSD > 50) return false; break; case '0-500': if (priceUSD > 50) return false; break;
case '500-1000': if (priceUSD < 51 || priceUSD > 100) return false; break; case '500-1000': if (priceUSD < 51 || priceUSD > 100) return false; break;
case '1000-2000': if (priceUSD < 101 || priceUSD > 200) return false; break; case '1000-2000': if (priceUSD < 101 || priceUSD > 200) return false; break;
@ -245,37 +217,51 @@ export default function HomePage() {
case '3000+': if (priceUSD < 301) return false; break; case '3000+': if (priceUSD < 301) return false; break;
} }
} }
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;
} }
} }
return true; return true;
}); });
setFilteredProperties(filtered); setFilteredProperties(filtered);
if (!showMap) { if (!showMap) {
setShowMap(true); setShowMap(true);
setTimeout(() => { setTimeout(() => {
if (mapSectionRef.current) { if (mapSectionRef.current) {
setIsScrolling(true); setIsScrolling(true);
mapSectionRef.current.scrollIntoView({ mapSectionRef.current.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'center' block: 'center'
}); });
setTimeout(() => setIsScrolling(false), 1000); setTimeout(() => setIsScrolling(false), 1000);
} }
}, 300); }, 300);
} else { } else {
if (mapSectionRef.current) { if (mapSectionRef.current) {
setIsScrolling(true); setIsScrolling(true);
mapSectionRef.current.scrollIntoView({ mapSectionRef.current.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'center' block: 'center'
}); });
setTimeout(() => setIsScrolling(false), 1000); setTimeout(() => setIsScrolling(false), 1000);
@ -306,7 +292,7 @@ export default function HomePage() {
<div className="min-h-screen"> <div className="min-h-screen">
<section className="relative min-h-screen flex items-center justify-center overflow-hidden"> <section className="relative min-h-screen flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<motion.div <motion.div
className="absolute inset-0 bg-cover bg-center bg-no-repeat" className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{ style={{
backgroundImage: 'url(/hero.jpg)', backgroundImage: 'url(/hero.jpg)',
@ -319,7 +305,7 @@ export default function HomePage() {
</div> </div>
<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8"> <div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<motion.div <motion.div
className="text-center mb-12" className="text-center mb-12"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
@ -331,15 +317,15 @@ export default function HomePage() {
} }
}} }}
> >
<motion.h1 <motion.h1
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight tracking-tight"
variants={{ variants={{
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 } visible: { opacity: 1, y: 0 }
}} }}
> >
{t("heroTitleLine1")}<br /> إيجاد منزلك الجديد<br />
<motion.span <motion.span
className="text-amber-400" className="text-amber-400"
animate={{ animate={{
y: [0, -10, 0], y: [0, -10, 0],
@ -350,22 +336,22 @@ export default function HomePage() {
ease: "easeInOut" ease: "easeInOut"
}} }}
> >
{t("heroTitleLine2")} أصبح سهلاً
</motion.span> </motion.span>
</motion.h1> </motion.h1>
<motion.p <motion.p
className="text-base sm:text-lg text-gray-200 max-w-2xl mx-auto leading-relaxed" className="text-base sm:text-lg text-gray-200 max-w-2xl mx-auto leading-relaxed"
variants={{ variants={{
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 } visible: { opacity: 1, y: 0 }
}} }}
> >
{t("heroSubtitle")} نوفر قوائم عقارات عالية الجودة لمساعدتك في إيجاد المنزل المثالي
</motion.p> </motion.p>
</motion.div> </motion.div>
{!isOwner && <HeroSearch onSearch={applyFilters} />} {!isOwner && <HeroSearch onSearch={applyFilters} isAuthenticated={!!user} />}
{isOwner && ( {isOwner && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -379,20 +365,20 @@ export default function HomePage() {
<p className="text-gray-200 mb-4"> <p className="text-gray-200 mb-4">
يمكنك إدارة عقاراتك من خلال لوحة التحكم الخاصة بك يمكنك إدارة عقاراتك من خلال لوحة التحكم الخاصة بك
</p> </p>
{/* <Link <Link
href="/owner/properties" href="/owner/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" 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"
> >
<Building className="w-5 h-5" /> <Building className="w-5 h-5" />
إدارة عقاراتي إدارة عقاراتي
</Link> */} </Link>
</motion.div> </motion.div>
)} )}
</div> </div>
</div> </div>
{!showMap && !isOwner && ( {!showMap && !isOwner && (
<motion.div <motion.div
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 cursor-pointer" className="absolute bottom-8 left-1/2 transform -translate-x-1/2 cursor-pointer"
animate={{ animate={{
y: [0, 10, 0], y: [0, 10, 0],
@ -417,12 +403,12 @@ export default function HomePage() {
{!isOwner && ( {!isOwner && (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{showMap && ( {showMap && (
<motion.section <motion.section
ref={mapSectionRef} ref={mapSectionRef}
initial={{ opacity: 0, y: 50 }} initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }} exit={{ opacity: 0, y: -50 }}
transition={{ transition={{
type: "spring", type: "spring",
damping: 20, damping: 20,
stiffness: 100, stiffness: 100,
@ -431,7 +417,7 @@ export default function HomePage() {
className="py-12 bg-gray-50 relative" className="py-12 bg-gray-50 relative"
> >
{isScrolling && ( {isScrolling && (
<motion.div <motion.div
className="absolute top-0 left-0 right-0 h-1 bg-amber-500 z-10" className="absolute top-0 left-0 right-0 h-1 bg-amber-500 z-10"
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
@ -473,15 +459,15 @@ export default function HomePage() {
</p> </p>
)} )}
</motion.div> </motion.div>
<motion.div <motion.div
className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200" className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200"
initial={{ scale: 0.95, opacity: 0 }} initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3, type: "spring" }} transition={{ delay: 0.3, type: "spring" }}
> >
{filteredProperties.length > 0 ? ( {filteredProperties.length > 0 ? (
<PropertyMap <PropertyMap
properties={filteredProperties} properties={filteredProperties}
userIdentity={searchFilters?.identityType || 'syrian'} userIdentity={searchFilters?.identityType || 'syrian'}
/> />
@ -497,7 +483,7 @@ export default function HomePage() {
</div> </div>
)} )}
</motion.div> </motion.div>
{filteredProperties.length > 0 && searchFilters && ( {filteredProperties.length > 0 && searchFilters && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -514,21 +500,40 @@ export default function HomePage() {
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm"> <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="text-gray-600">نوع العقار: </span>
<span className="font-bold text-gray-900"> <span className="font-bold text-gray-900">
{searchFilters.propertyType === 'all' ? 'الكل' : {searchFilters.propertyType === 'all' ? 'الكل' :
searchFilters.propertyType === 'apartment' ? 'شقة' : searchFilters.propertyType === 'apartment' ? 'شقة' :
searchFilters.propertyType === 'villa' ? 'فيلا' : 'بيت'} searchFilters.propertyType === 'villa' ? 'فيلا' : 'بيت'}
</span> </span>
</div> </div>
<div className="bg-white px-4 py-2 rounded-full shadow-sm border border-gray-200 text-sm"> <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="text-gray-600">نطاق السعر: </span>
<span className="font-bold text-gray-900"> <span className="font-bold text-gray-900">
{searchFilters.priceRange === 'all' ? 'جميع الأسعار' : {searchFilters.priceRange === 'all' ? 'جميع الأسعار' :
searchFilters.priceRange === '0-500' ? 'أقل من 50$' : searchFilters.priceRange === '0-500' ? 'أقل من 50$' :
searchFilters.priceRange === '500-1000' ? '50$ - 100$' : searchFilters.priceRange === '500-1000' ? '50$ - 100$' :
searchFilters.priceRange === '1000-2000' ? '100$ - 200$' : searchFilters.priceRange === '1000-2000' ? '100$ - 200$' :
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>
@ -539,26 +544,23 @@ export default function HomePage() {
<section className="py-20 bg-gradient-to-b from-white to-gray-50"> <section className="py-20 bg-gradient-to-b from-white to-gray-50">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<motion.div <motion.div
className="text-center mb-12" className="text-center mb-12"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
> >
<div className="inline-block px-4 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium mb-4">
لماذا نحن؟
</div>
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4 tracking-tight"> <h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4 tracking-tight">
{t("whyChooseUsTitle")} لماذا تختار سويت هوم؟
</h2> </h2>
<p className="text-gray-600 max-w-2xl mx-auto text-lg"> <p className="text-gray-600 max-w-2xl mx-auto text-lg">
{t("whyChooseUsSubtitle")} نجعل عملية إيجاد منزلك المثالي سهلة وسريعة
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<motion.div <motion.div
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100" className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@ -571,16 +573,16 @@ export default function HomePage() {
<ShieldCheck className="w-6 h-6 text-amber-600" /> <ShieldCheck className="w-6 h-6 text-amber-600" />
</div> </div>
<h3 className="text-lg font-bold text-gray-900"> <h3 className="text-lg font-bold text-gray-900">
{t("feature1Title")} قوائم موثوقة
</h3> </h3>
</div> </div>
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-gray-600 text-sm leading-relaxed">
{t("feature1Description")} كل عقار يتم التحقق منه بدقة لضمان الدقة والجودة.
</p> </p>
</motion.div> </motion.div>
<motion.div <motion.div
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100" className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@ -593,16 +595,16 @@ export default function HomePage() {
<Lock className="w-6 h-6 text-blue-600" /> <Lock className="w-6 h-6 text-blue-600" />
</div> </div>
<h3 className="text-lg font-bold text-gray-900"> <h3 className="text-lg font-bold text-gray-900">
{t("feature2Title")} عمليات آمنة
</h3> </h3>
</div> </div>
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-gray-600 text-sm leading-relaxed">
{t("feature2Description")} سلامتك هي أولويتنا. نوفر معاملات آمنة ونحمي معلوماتك الشخصية.
</p> </p>
</motion.div> </motion.div>
<motion.div <motion.div
className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100" className="group bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@ -615,12 +617,12 @@ export default function HomePage() {
<Zap className="w-6 h-6 text-green-600" /> <Zap className="w-6 h-6 text-green-600" />
</div> </div>
<h3 className="text-lg font-bold text-gray-900"> <h3 className="text-lg font-bold text-gray-900">
{t("feature3Title")} نتائج سريعة
</h3> </h3>
</div> </div>
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-gray-600 text-sm leading-relaxed">
{t("feature3Description")} اعثر على منزلك المثالي في دقائق باستخدام خوارزميات البحث والمطابقة المتقدمة لدينا.
</p> </p>
</motion.div> </motion.div>
</div> </div>
@ -628,4 +630,4 @@ export default function HomePage() {
</section> </section>
</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,37 +64,75 @@ 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);
const savedProfile = localStorage.getItem('userProfile');
let profileData; // Fetch full profile from API using user ID (SID from JWT)
async function fetchProfile() {
if (savedProfile) { try {
profileData = JSON.parse(savedProfile); const fetchFn = userData.role === 'owner' ? getOwnerByUserId : getCustomerByUserId;
} else { console.log('[Profile] Fetching profile via', userData.role === 'owner' ? 'Owner' : 'Customer', 'GetByUserId:', userData.id);
profileData = { const profile = await fetchFn(userData.id);
name: userData.name || '', console.log('[Profile] API profile:', profile);
email: userData.email || '',
phone: '', if (profile) {
whatsapp: '', const profileData = {
bio: '', name: profile.fullName || profile.name || `${profile.firstName || ''} ${profile.lastName || ''}`.trim() || userData.name || '',
location: '', email: profile.email || userData.email || '',
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' }) 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');
let profileData;
if (savedProfile) {
profileData = JSON.parse(savedProfile);
} else {
profileData = {
name: userData.name || '',
email: userData.email || '',
phone: '',
whatsapp: '',
bio: '',
location: '',
joinedDate: new Date().toLocaleDateString('ar-SA', { month: 'long', year: 'numeric' })
};
}
setFormData(profileData);
setTempValues(profileData);
setIsLoading(false);
} }
setFormData(profileData);
setTempValues(profileData);
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,18 +31,98 @@ 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() + ' ل.س';
}; };
const getPropertyTypeIcon = (type) => { const getPropertyTypeIcon = (type) => {
switch(type) { switch (type) {
case 'villa': return <Home className="w-4 h-4" />; case 'villa': return <Home className="w-4 h-4" />;
case 'apartment': return <Building2 className="w-4 h-4" />; case 'apartment': return <Building2 className="w-4 h-4" />;
case 'house': return <Home className="w-4 h-4" />; case 'house': return <Home className="w-4 h-4" />;
@ -53,7 +132,7 @@ const PropertyCard = ({ property, viewMode = 'grid' }) => {
}; };
const getPropertyTypeLabel = (type) => { const getPropertyTypeLabel = (type) => {
switch(type) { switch (type) {
case 'villa': return 'فيلا'; case 'villa': return 'فيلا';
case 'apartment': return 'شقة'; case 'apartment': return 'شقة';
case 'house': return 'بيت'; case 'house': return 'بيت';
@ -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">
@ -496,8 +489,11 @@ 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,93 +505,34 @@ 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: 'المزة' }, const rentList = Array.isArray(rentData) ? rentData : [];
bedrooms: 5, const saleList = Array.isArray(saleData) ? saleData : [];
bathrooms: 4,
area: 450, const mapped = [
features: ['مسبح', 'حديقة خاصة', 'موقف سيارات', 'أمن'], ...rentList.map((p, i) => mapApiProperty(p, i)),
images: ['/villa1.jpg'], ...saleList.map((p, i) => mapApiProperty(p, rentList.length + i)),
status: 'available', ];
rating: 4.8,
isNew: true if (mapped.length > 0) {
}, setProperties(mapped);
{ }
id: 2, } catch (err) {
title: 'شقة حديثة في الشهباء', console.error('[Properties] Failed to fetch properties:', err);
description: 'شقة عصرية في حي الشهباء الراقي بحلب.', } finally {
type: 'apartment', setLoading(false);
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
} }
]);
fetchProperties();
}, []);
const filteredProperties = properties const filteredProperties = properties
.filter(property => { .filter(property => {
@ -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,17 +559,14 @@ 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) => {
switch(sortBy) { switch (sortBy) {
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>
@ -688,12 +621,12 @@ export default function PropertiesPage() {
</div> </div>
</div> </div>
<div className={viewMode === 'grid' <div className={viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6' ? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: '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() { function mapProperty(item) {
const params = useParams(); const info = item.propertyInformation || item.PropertyInformation || {};
const [currentImage, setCurrentImage] = useState(0); let details = {};
const [showContact, setShowContact] = useState(false); try { details = JSON.parse(info.detailsJSON || info.DetailsJSON || '{}'); } catch {}
const [bookingDates, setBookingDates] = useState({ start: '', end: '' });
const [selectedDuration, setSelectedDuration] = useState(1);
const [property, setProperty] = useState(null);
const [loading, setLoading] = useState(true);
const propertiesData = { const price = item.monthlyRent || item.MonthlyRent || item.dailyRent || item.DailyRent || 0;
1: { const priceUnit = item.monthlyRent || item.MonthlyRent ? 'monthly' : 'daily';
id: 1, const buildingType = info.buildingType ?? info.BuildingType ?? 0;
title: 'فيلا فاخرة في المزة', const type = { 0: 'apartment', 1: 'villa', 2: 'house' }[buildingType] || 'apartment';
description: `تتميز هذه الفيلا الفاخرة بتصميمها العصري وموقعها المميز في أفضل أحياء دمشق. تم بناء الفيلا بأعلى المواصفات باستخدام أفضل المواد، مع مساحات واسعة وحديقة خاصة. const typeLabel = { 0: 'شقة', 1: 'فيلا', 2: 'بيت' }[buildingType] || 'عقار';
const address = info.address || info.Address || '';
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,
};
}
المساحات الداخلية: export async function generateMetadata({ params }) {
• الطابق الأرضي: صالة استقبال كبيرة (80 م²)، مجلس رجال (40 م²)، مجلس نساء (35 م²)، مطبخ (25 م²)، غرفة طعام (30 م²) const { id } = await params;
• الطابق الأول: 5 غرف نوم ماستر مع حمامات خاصة (كل غرفة 35-45 م²) const raw = await fetchPropertyForMeta(id);
• الطابق الثاني: غرفة معيشة عائلية (50 م²)، غرفة ترفيه (40 م²)، سطح مع إطلالة (100 م²)
الخدمات القريبة: if (!raw) {
• مدارس وجامعات على بعد 5 دقائق return {
• مستشفيات ومراكز طبية title: 'SweetHome - عقار',
• مولات تجارية ومطاعم description: 'اكتشف أفضل العقارات للإيجار',
• حدائق عامة ومسارات مشي`, };
type: 'villa', }
price: 500000,
priceUnit: 'daily', const p = mapProperty(raw);
location: { const priceStr = `${p.price.toLocaleString()} ل.س / ${p.priceUnit === 'daily' ? 'يوم' : 'شهر'}`;
city: 'دمشق', const propertyImage = p.image
district: 'المزة', ? (p.image.startsWith('http') ? p.image : `http://45.93.137.91${p.image}`)
address: 'شارع المزة - فيلات غربية', : '';
lat: 33.5, const logoUrl = `http://45.93.137.91/logo.png`;
lng: 36.3
}, // Use property image if available, otherwise logo
bedrooms: 5, const ogImages = propertyImage
bathrooms: 4, ? [{ url: propertyImage, width: 1200, height: 630 }, { url: logoUrl, width: 512, height: 512 }]
area: 450, : [{ url: logoUrl, width: 512, height: 512 }];
features: [
{ name: 'مسبح', available: true, description: 'مسبح خاص بمساحة 40 م²' }, return {
{ name: 'حديقة خاصة', available: true, description: 'حديقة بمساحة 200 م² مع نوافير' }, title: `${p.title} - ${priceStr}`,
{ name: 'موقف سيارات', available: true, description: 'موقف يتسع لـ 4 سيارات' }, description: p.description,
{ name: 'أمن 24/7', available: true, description: 'كاميرات مراقبة وحراسة' }, openGraph: {
{ name: 'تدفئة مركزية', available: true, description: 'تدفئة مركزية لجميع الغرف' }, title: `${p.title} - ${priceStr}`,
{ name: 'تكييف مركزي', available: true, description: 'تكييف مركزي في جميع الغرف' }, description: p.description,
{ name: 'مطبخ مجهز', available: true, description: 'مطبخ أمريكي مجهز بالكامل' }, images: ogImages,
{ name: 'غرفة خادمة', available: true, description: 'غرفة خادمة مع حمام خاص' }, url: `http://45.93.137.91/property/${id}`,
{ name: 'مصعد', available: false, description: 'قابل للتركيب' }, type: 'website',
{ name: 'واي فاي', available: true, description: 'ألياف بصرية' } siteName: 'SweetHome',
], },
images: [ twitter: {
'/villa1.jpg', card: 'summary_large_image',
'/villa2.jpg', title: `${p.title} - ${priceStr}`,
'/villa3.jpg', description: p.description,
'/villa4.jpg', images: ogImages.map(i => i.url),
'/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(() => { export default function PropertyPage({ params }) {
setLoading(true); return <PropertyDetail params={params} />;
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) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<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>
<p className="text-gray-600 mb-4">لم نتمكن من العثور على العقار المطلوب</p>
<Link href="/properties" className="bg-gray-800 text-white px-6 py-3 rounded-xl font-medium hover:bg-gray-900 transition-colors">
العودة إلى العقارات
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b sticky top-16 z-40 shadow-sm">
<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">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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">
<Image
src={property.images[currentImage] || '/property-placeholder.jpg'}
alt={property.title}
fill
className="object-cover"
/>
{property.images.length > 1 && (
<>
<button
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"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setCurrentImage(prev => Math.min(property.images.length - 1, prev + 1))}
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"
>
<ChevronRight className="w-5 h-5" />
</button>
</>
)}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
{property.images.map((_, idx) => (
<button
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'
}`}
/>
))}
</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) { if (!formData.email) newErrors.email = 'البريد الإلكتروني مطلوب';
newErrors.name = 'الاسم الكامل مطلوب'; else if (!validateEmail(formData.email)) newErrors.email = 'البريد الإلكتروني غير صالح';
} else if (formData.name.length < 3) {
newErrors.name = 'الاسم يجب أن يكون 3 أحرف على الأقل';
}
if (!formData.email) { if (!formData.phone) newErrors.phone = 'رقم الهاتف مطلوب';
newErrors.email = 'البريد الإلكتروني مطلوب'; else if (!validatePhone(formData.phone)) newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
} else if (!validateEmail(formData.email)) {
newErrors.email = 'البريد الإلكتروني غير صالح';
}
if (!formData.phone) { if (!formData.password) newErrors.password = 'كلمة المرور مطلوبة';
newErrors.phone = 'رقم الهاتف مطلوب'; else if (formData.password.length < 6) newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل';
} else if (!validatePhone(formData.phone)) {
newErrors.phone = 'رقم الهاتف غير صالح (يجب أن يبدأ 09 أو 05)';
}
if (!formData.password) { if (!formData.whatsapp) newErrors.whatsapp = 'رقم الواتساب مطلوب';
newErrors.password = 'كلمة المرور مطلوبة'; if (!formData.phone2 || formData.phone2.length !== 7) newErrors.phone2 = 'رقم الهاتف يجب أن يكون 7 أرقام';
} else if (formData.password.length < 6) { if (!formData.nationalNumber) newErrors.nationalNumber = 'الرقم الوطني مطلوب';
newErrors.password = 'كلمة المرور يجب أن تكون 6 أحرف على الأقل'; if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
}
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 = {
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phoneNumber: formData.phone,
whatsAppNumber: formData.whatsapp,
phone: formData.phone2,
nationalNumber: formData.nationalNumber,
password: formData.password,
customerType: formData.customerType,
};
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('/');
}
} 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); setIsLoading(false);
toast.success('تم إنشاء الحساب بنجاح!', { }
style: { background: '#dcfce7', color: '#166534' }, };
duration: 3000
});
localStorage.setItem('user', JSON.stringify({ // ─── OTP verification handler ───
name: formData.name, const handleVerifyOTP = async () => {
email: formData.email, if (!otpCode || otpCode.length < 4) {
role: 'tenant', toast.error('يرجى إدخال رمز التحقق');
avatar: formData.name.charAt(0).toUpperCase() return;
})); }
setTimeout(() => { setIsLoading(true);
router.push('/'); console.log('[CustomerRegister] Verifying OTP:', otpCode);
}, 1500);
}, 2000); 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> */}
<div className="absolute inset-0 overflow-hidden">
<motion.div {backgroundElements}
initial={{ opacity: 0, scale: 0.95 }} </div>
animate={{ opacity: 1, scale: 1 }} <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5 }}
transition={{ duration: 0.5 }} className="relative z-10 w-full max-w-md">
className="relative z-10 w-full max-w-md" {/* Back */}
> <motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} className="mb-8">
<motion.div <Link href="/auth/choose-role" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors group">
initial={{ opacity: 0, x: -20 }} <motion.div whileHover={{ x: -5 }}><ArrowLeft className="w-4 h-4" /></motion.div>
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}
className="space-y-6"
>
<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.name ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'
}`} />
</div>
<input
type="text"
value={formData.name}
onChange={(e) => {
setFormData({...formData, name: e.target.value});
setErrors({...errors, name: 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.name ? 'border-red-500' : 'border-gray-700'
}`}
placeholder="أدخل اسمك الكامل"
/>
</div>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</motion.div>
<motion.div variants={fadeInUp}> {/* ─── STEP 1: Form ─── */}
<label className="block text-sm font-medium text-gray-300 mb-2"> {step === 1 && (
البريد الإلكتروني <span className="text-red-500">*</span> <>
</label> <motion.div variants={fadeInUp} className="grid grid-cols-2 gap-3">
<div className="relative group"> <div>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <label className="block text-sm font-medium text-gray-300 mb-2">الاسم الأول <span className="text-red-500">*</span></label>
<Mail className={`w-5 h-5 ${ <div className="relative group">
errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500' <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
}`} /> <User className={`w-5 h-5 ${errors.firstName ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
</div> </div>
<input <input type="text" value={formData.firstName}
type="email" onChange={(e) => { setFormData({...formData, firstName: e.target.value}); setErrors({...errors, firstName: 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.firstName ? 'border-red-500' : 'border-gray-700'}`}
onChange={(e) => { placeholder="الاسم الأول" />
setFormData({...formData, email: e.target.value}); </div>
setErrors({...errors, email: 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.email ? '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'}`}
</div> placeholder="اسم العائلة" />
{errors.email && ( {errors.lastName && <p className="text-red-500 text-sm mt-1">{errors.lastName}</p>}
<p className="text-red-500 text-sm mt-1">{errors.email}</p> </div>
)} </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> <div className="relative group">
</label> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<div className="relative group"> <Mail className={`w-5 h-5 ${errors.email ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> </div>
<Phone className={`w-5 h-5 ${ <input type="email" value={formData.email}
errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500' onChange={(e) => { 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'}`}
</div> placeholder="أدخل بريدك الإلكتروني" />
<input </div>
type="tel" {errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
value={formData.phone} </motion.div>
onChange={(e) => {
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>
{errors.phone && (
<p className="text-red-500 text-sm mt-1">{errors.phone}</p>
)}
</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> <div className="relative group">
</label> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<div className="relative group"> <Phone className={`w-5 h-5 ${errors.phone ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> </div>
<Lock className={`w-5 h-5 ${ <input type="tel" value={formData.phone}
errors.password ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500' onChange={(e) => { 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'}`}
</div> placeholder="أدخل رقم هاتفك" />
<input </div>
type={showPassword ? "text" : "password"} {errors.phone && <p className="text-red-500 text-sm mt-1">{errors.phone}</p>}
value={formData.password} </motion.div>
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 hover:text-gray-300" />
) : (
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" />
)}
</button>
</div>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
</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> <div className="relative group">
</label> <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<div className="relative group"> <Phone className={`w-5 h-5 ${errors.whatsapp ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> </div>
<Lock className={`w-5 h-5 ${ <input type="tel" value={formData.whatsapp}
errors.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500' onChange={(e) => { setFormData({...formData, whatsapp: e.target.value}); setErrors({...errors, whatsapp: 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.whatsapp ? 'border-red-500' : 'border-gray-700'}`}
</div> placeholder="أدخل رقم الواتساب" />
<input </div>
type={showConfirmPassword ? "text" : "password"} {errors.whatsapp && <p className="text-red-500 text-sm mt-1">{errors.whatsapp}</p>}
value={formData.confirmPassword} </motion.div>
onChange={(e) => {
setFormData({...formData, confirmPassword: e.target.value}); <motion.div variants={fadeInUp}>
setErrors({...errors, confirmPassword: null}); <label className="block text-sm font-medium text-gray-300 mb-2">رقم الهاتف (7 أرقام) <span className="text-red-500">*</span></label>
}} <div className="relative group">
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 ${ <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
errors.confirmPassword ? 'border-red-500' : 'border-gray-700' <Phone className={`w-5 h-5 ${errors.phone2 ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
}`} </div>
placeholder="أعد إدخال كلمة المرور" <input type="tel" value={formData.phone2}
/> onChange={(e) => { setFormData({...formData, phone2: e.target.value}); setErrors({...errors, phone2: null}); }}
<button 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'}`}
type="button" placeholder="أدخل رقم الهاتف" maxLength={7} />
onClick={() => setShowConfirmPassword(!showConfirmPassword)} </div>
className="absolute inset-y-0 left-0 pl-3 flex items-center" {errors.phone2 && <p className="text-red-500 text-sm mt-1">{errors.phone2}</p>}
> </motion.div>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5 text-gray-400 hover:text-gray-300" /> <motion.div variants={fadeInUp}>
) : ( <label className="block text-sm font-medium text-gray-300 mb-2">الرقم الوطني <span className="text-red-500">*</span></label>
<Eye className="w-5 h-5 text-gray-400 hover:text-gray-300" /> <div className="relative group">
)} <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
</button> <User className={`w-5 h-5 ${errors.nationalNumber ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
{formData.confirmPassword && ( </div>
<div className="absolute inset-y-0 left-12 flex items-center"> <input type="text" value={formData.nationalNumber}
{formData.password === formData.confirmPassword ? ( onChange={(e) => { setFormData({...formData, nationalNumber: e.target.value}); setErrors({...errors, nationalNumber: null}); }}
<CheckCircle className="w-5 h-5 text-green-500" /> 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="أدخل الرقم الوطني" />
<XCircle className="w-5 h-5 text-red-500" /> </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>
</div>
{errors.password && <p className="text-red-500 text-sm mt-1">{errors.password}</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 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.confirmPassword ? 'text-red-500' : 'text-gray-400 group-focus-within:text-blue-500'}`} />
</div>
<input type={showConfirmPassword ? "text" : "password"} value={formData.confirmPassword}
onChange={(e) => { setFormData({...formData, confirmPassword: e.target.value}); setErrors({...errors, confirmPassword: 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.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" /> : <Eye className="w-5 h-5 text-gray-400" />}
</button>
{formData.confirmPassword && (
<div className="absolute inset-y-0 left-12 flex items-center">
{formData.password === formData.confirmPassword ? <CheckCircle className="w-5 h-5 text-green-500" /> : <XCircle className="w-5 h-5 text-red-500" />}
</div>
)} )}
</div> </div>
)} {errors.confirmPassword && <p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>}
</div> </motion.div>
{errors.confirmPassword && ( </>
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p> )}
)}
</motion.div>
<motion.div variants={fadeInUp} className="flex items-center gap-2"> {/* ─── STEP 2: ID Images ─── */}
<input {step === 2 && (
type="checkbox" <>
id="terms" <motion.div variants={fadeInUp}>
checked={formData.agreeTerms} <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الأمامي <span className="text-red-500">*</span></label>
onChange={(e) => setFormData({...formData, agreeTerms: e.target.checked})} <div onClick={() => fileInputFrontRef.current?.click()}
className="w-4 h-4 rounded border-gray-600 bg-white/5 text-blue-500 focus:ring-blue-500 focus:ring-offset-0" 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'}`}>
required <input ref={fileInputFrontRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('front', e.target.files?.[0])} className="hidden" />
/> {idImagePreviews.front ? (
<label htmlFor="terms" className="text-sm text-gray-300"> <div className="relative">
أوافق على{' '} <Image src={idImagePreviews.front} alt="Front ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
<Link href="/terms" className="text-blue-400 hover:text-blue-300"> <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">
</Link> <X className="w-4 h-4 text-white" />
{' '}و{' '} </button>
<Link href="/privacy" className="text-blue-400 hover:text-blue-300"> </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></>)}
</Link> </div>
</label> {errors.front && <p className="text-red-500 text-sm mt-1">{errors.front}</p>}
</motion.div> </motion.div>
<motion.button <motion.div variants={fadeInUp}>
variants={fadeInUp} <label className="block text-sm font-medium text-gray-300 mb-2">صورة الهوية - الوجه الخلفي <span className="text-red-500">*</span></label>
type="submit" <div onClick={() => fileInputBackRef.current?.click()}
disabled={isLoading || !formData.agreeTerms} 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'}`}>
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" <input ref={fileInputBackRef} type="file" accept="image/*" onChange={(e) => handleImageUpload('back', e.target.files?.[0])} className="hidden" />
> {idImagePreviews.back ? (
{isLoading ? ( <div className="relative">
<div className="flex items-center justify-center gap-2"> <Image src={idImagePreviews.back} alt="Back ID" width={200} height={120} className="mx-auto rounded-lg object-cover" />
<Loader2 className="w-5 h-5 animate-spin" /> <button onClick={(e) => { e.stopPropagation(); setIdImages(prev => ({...prev, back: null})); setIdImagePreviews(prev => ({...prev, back: ''})); }}
<span>جاري إنشاء الحساب...</span> className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center hover:bg-red-600">
</div> <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 variants={fadeInUp} className="flex items-center gap-2">
<input type="checkbox" id="terms" checked={formData.agreeTerms}
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" required />
<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>
</label>
</motion.div>
</>
)}
{/* ─── 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.button> </motion.div>
<motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4"> {step === 1 && (
لديك حساب بالفعل؟{' '} <motion.p variants={fadeInUp} className="text-center text-gray-400 mt-4">
<Link لديك حساب بالفعل؟{' '}
href="/login" <Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">تسجيل الدخول</Link>
className="text-blue-400 hover:text-blue-300 font-medium transition-colors" </motion.p>
> )}
تسجيل الدخول
</Link>
</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 = { export const DEFAULT_COMMISSION_RATE = 5;
DAMASCUS: 'damascus',
ALEPPO: 'aleppo',
HOMS: 'homs',
LATTAKIA: 'latakia',
DARAA: 'daraa'
};
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 };

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

@ -0,0 +1,193 @@
// 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;
}
}

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/",
}; };

1376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,14 @@
}, },
"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",
"html2canvas": "^1.4.1",
"i18next": "^25.8.0", "i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
@ -22,7 +25,8 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-intersection-observer": "^10.0.3", "react-intersection-observer": "^10.0.3",
"react-leaflet": "^4.2.1" "react-leaflet": "^4.2.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

Binary file not shown.

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.

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