70 Commits

Author SHA1 Message Date
twotalesanimation
9653443c09 updated to add country membership, singles and honorary recognition 2025-12-20 00:32:29 +02:00
twotalesanimation
782d343243 updated to add country membership 2025-12-19 19:50:22 +02:00
twotalesanimation
c618fd4506 updated BASE4 page 2025-12-18 21:11:43 +02:00
twotalesanimation
d5feaacddf consolodated admin pages 2025-12-17 12:42:09 +02:00
twotalesanimation
927f9f3fe1 whatsapp button added 2025-12-16 23:00:30 +02:00
twotalesanimation
1b47cb0a69 Implementation of Notification System #2 2025-12-16 22:48:19 +02:00
twotalesanimation
7ebc2f64cf Implementation of Notification System 2025-12-16 22:40:24 +02:00
twotalesanimation
ebd7efe21c added auto course codes 2025-12-15 18:07:11 +02:00
twotalesanimation
6ff20c1ffc added admin course creation 2025-12-15 16:57:11 +02:00
twotalesanimation
35c177b11d htacces update 2025-12-15 15:52:25 +02:00
twotalesanimation
acd7f563b1 added transaction table, fixed signature auth. Monitor for bugs before rmoving bypass 2025-12-15 15:51:11 +02:00
twotalesanimation
5768d8a7af Merge branch 'ikhokha2' into main 2025-12-15 10:54:31 +02:00
twotalesanimation
0e6ecd127f post auditlog implementation for bookings and payments 2025-12-15 10:52:09 +02:00
twotalesanimation
702e04e9bf pre auditlog implementations 2025-12-15 10:44:56 +02:00
twotalesanimation
d2c99e86b4 mostly complete payment system 2025-12-15 10:18:25 +02:00
twotalesanimation
f4934e9c13 iKhokha integration completerer... 2025-12-15 01:24:56 +02:00
twotalesanimation
477c2f2e04 iKhokha integration complete 2025-12-15 00:36:34 +02:00
twotalesanimation
a66382661d Fixed some bugs 2025-12-13 19:25:47 +02:00
twotalesanimation
32e50ffc39 Commit since isp push 2025-12-13 14:33:23 +02:00
twotalesanimation
cce181e2d0 Add interactive Base 4 track map with Leaflet.js
- Created new track-map page with aerial image and SVG overlay
- Implemented custom rotated square markers with obstacle numbers
- Added admin edit mode for placing and repositioning markers
- Database migration for track_obstacles table
- Modal form for adding new obstacles (replaces browser alerts)
- Drag-to-reposition functionality with auto-save
- Color-coded markers (green/red/black/split) for difficulty levels
- Clickable popups showing obstacle details
- Added track-map to navigation menu and sitemap
- URL rewrite rule for clean /track-map URL
2025-12-12 12:00:20 +02:00
twotalesanimation
48ee7592b2 Reorganize event processors and update routing
- Move process_event.php from src/admin to src/processors
- Move toggle_event_published.php from src/admin to src/processors
- Move delete_event.php from src/admin to src/processors
- Update .htaccess rewrite rules to point event processors to correct location
- Keep admin_events.php and manage_events.php in admin (display pages only)
2025-12-11 08:55:24 +02:00
twotalesanimation
abb8eb23e5 Add updates modal to homepage with session-based display and Jan 1 2026 expiry 2025-12-08 11:47:01 +02:00
twotalesanimation
2acbeac7ca fixed gallery 2025-12-08 11:39:57 +02:00
twotalesanimation
5808788b9e Make blog cards clickable - wrap in anchor tags matching gallery pattern 2025-12-08 11:35:22 +02:00
twotalesanimation
bbc0aecbcb force update CSS2 2025-12-08 10:55:08 +02:00
twotalesanimation
752ea6e5e9 fix: correct CSS syntax error in .comments rule that was breaking footer and other component styles 2025-12-08 10:37:01 +02:00
twotalesanimation
0af0bd33f9 Blog system enhancements: fix publish/unpublish permissions, add action buttons to blog listings, update gallery to show only published blog images, improve blog card layout and description truncation 2025-12-08 10:20:12 +02:00
twotalesanimation
54bd98c5de chore: organize documentation files into docs directory 2025-12-05 11:49:46 +02:00
twotalesanimation
60e1716730 chore: reorganize migration files to docs/migrations directory 2025-12-05 11:48:21 +02:00
twotalesanimation
a038a7449e docs: add comprehensive testing and implementation guide for membership linking feature 2025-12-05 11:47:29 +02:00
twotalesanimation
646a3ecbc5 fix: correct pricing calculations for non-members in bush_mechanics and rescue_recovery 2025-12-05 11:46:24 +02:00
twotalesanimation
bad1532dcd docs: verified linked member access across all protected areas
Verified that linked members now have full access to:

Member Area Navigation:
- Header shows Members Area dropdown (Campsites, Photo Gallery)

Protected Pages:
- campsites.php - Uses getUserMemberStatus() check
- gallery/gallery.php - Uses getUserMemberStatus() check
- gallery/view_album.php - Uses getUserMemberStatus() check
- gallery/create_album.php - Uses getUserMemberStatus() check

Booking Pages (show member pricing):
- src/pages/bookings/driver_training.php - Checks \ from header
- src/pages/bookings/course_details.php - Static pricing info
- src/pages/bookings/trip-details.php - Checks \
- src/pages/other/bush_mechanics.php - Checks \
- src/pages/other/rescue_recovery.php - Checks \

Booking Processors:
- src/processors/process_trip_booking.php - Uses getUserMemberStatus()
- src/processors/process_course_booking.php - Uses getUserMemberStatus()
- src/processors/process_camp_booking.php - Uses getUserMemberStatus()

All these components now recognize linked members as active members through
the improved getUserMemberStatus() function.
2025-12-05 11:43:03 +02:00
twotalesanimation
e63bd806f0 feat: improve getUserMemberStatus to check linked memberships at all failure points
Previously, linked membership checks only occurred if there was no membership
application record. Now linked memberships are checked as fallback at every
stage of the direct membership validation:

- No membership application  check linked
- Indemnity not accepted  check linked
- No membership fees record  check linked
- Direct membership not active/expired  check linked

This ensures linked members see themselves as active across all member areas,
detail pages, and booking forms (trips, courses, campsites, driver training,
bush mechanics, rescue & recovery).
2025-12-05 11:40:38 +02:00
twotalesanimation
c5112e1ce9 fix: move linked accounts form outside of infoForm to prevent form submission conflicts
The linkUserForm was nested inside the infoForm, causing the 'Link Account' button
to trigger the parent form's update_application submission instead of the AJAX
membership linking request. Moved the entire Linked Accounts section and form to
come after the infoForm closes, making it a separate form.
2025-12-05 11:27:20 +02:00
twotalesanimation
924e5cdbc9 fix: improve CSRF token handling and add debugging to membership linking JavaScript
- Fixed CSRF token selector to be form-specific instead of page-global
- Added console.log statements for debugging AJAX requests
- Improved error handling with better error messages showing HTTP status
- Better error message when linking fails (shows actual error from server)
2025-12-05 11:23:55 +02:00
twotalesanimation
619ad0b320 debug: add comprehensive logging to membership linking feature
- Added detailed error logging to link_membership_user processor
- Added error handling for database operations in processor
- Added comprehensive logging to linkSecondaryUserToMembership function
- Logs now show: CSRF validation, database operations, link creation, permission grants
- Improved error messages for debugging
2025-12-05 11:22:38 +02:00
twotalesanimation
886bdc5db8 feat: Add JavaScript handlers for membership linking UI
- Add form submission handler for linkUserForm
  - Validates form input and sends email + relationship to /link_membership_user
  - Displays success message and reloads page on successful link
  - Shows error messages with proper styling

- Add unlink button click handlers
  - Confirms deletion before removing linked account
  - Sends link_id to /unlink_membership_user processor
  - Reloads page on successful removal

- Integrate CSRF token validation
  - Form includes CSRF token generation
  - JavaScript captures and includes token in AJAX requests

The membership linking UI is now fully functional. Secondary users can be linked
to primary memberships and removed as needed.
2025-12-05 10:55:35 +02:00
twotalesanimation
bd20fc0f9b feat: implement membership linking system for couples and family members
- Created membership_links table to associate secondary users with primary memberships
- Created membership_permissions table for granular permission control
- Added linkSecondaryUserToMembership() function to create links with validation
- Added getUserMembershipLink() to check access via secondary links
- Added getLinkedSecondaryUsers() to list all secondary users for a primary member
- Added unlinkSecondaryUser() to remove links
- Updated getUserMemberStatus() to check both direct and linked memberships
- Created link_membership_user processor to handle linking via API
- Created unlink_membership_user processor to handle unlinking via API
- Added .htaccess routes for linking endpoints
- Grants default permissions: access_member_areas, member_pricing, book_campsites, book_courses, book_trips
- Includes transaction safety with rollback on errors
- Includes comprehensive documentation with usage examples
- Validates primary user has active membership before allowing links
- Prevents duplicate links and self-linking
2025-12-05 10:44:52 +02:00
twotalesanimation
7dad2a4ce2 chore: add uploads directory to gitignore to prevent tracking user-uploaded files 2025-12-05 10:28:52 +02:00
twotalesanimation
325e2b4707 fix: improve text visibility on album header background
- Changed album title to white color
- Added text-shadow to album title for better contrast over images
- Changed album description to white color
- Added text-shadow to album description for readability
- Ensures text is visible regardless of cover image darkness
2025-12-05 10:22:13 +02:00
twotalesanimation
233305cac2 feat: use album cover image as album header background
- Fetch cover_image in album query
- Set album-header background-image with cover image
- Add dark overlay (50% opacity) over cover for text readability
- Increased padding for better header spacing with cover image
- Improved visual design using cover image as backdrop
- Fallback to overlay-only design if no cover image exists
- Enhanced header layout with proper z-index for content layering
2025-12-05 10:18:51 +02:00
twotalesanimation
5736757f19 feat: add cover image field to album creation and editing
- Added dedicated cover image upload field in create_album.php form
- Display current cover image preview when editing
- Drag-and-drop support for cover image with real-time preview
- Shows filename and file size after selection
- Updated save_album.php to handle cover image upload
- Updated update_album.php to handle cover image replacement
- Deletes old cover image when updating
- Cover image optional - first photo in album used as fallback
- Recommended cover dimensions: 500x500px or larger (square)
- File validation: max 5MB, supports JPG, PNG, GIF, WEBP
- All cover image changes included in transaction with rollback on error
2025-12-05 10:14:35 +02:00
twotalesanimation
ad460ef85a feat: redesign gallery page with grid layout and enhance ownership checks
- Changed gallery from carousel to responsive grid layout (similar to about page)
- Shows album cover images with titles, creator info, and photo count
- Improved visual design with hover effects and better spacing
- Edit buttons now only visible to album owners (uses current_user_id variable)
- Added proper ownership verification in all album edit/delete operations
- Enhanced styling for mobile/tablet/desktop responsiveness
- Simplified layout makes it easier to browse multiple albums at once
2025-12-05 10:12:08 +02:00
twotalesanimation
e6d298c506 fix: correct require paths and database connection in album processors
- Fix rootPath calculation in all album processors (was going up too many levels)
- Use global \ from connection.php instead of calling openDatabaseConnection()
- Fix cleanup code in save_album.php to use existing \
- Update all processors to use proper config file includes (env.php, session.php, connection.php, functions.php)
- Ensures validateCSRFToken() and other functions are properly available
2025-12-05 09:59:05 +02:00
twotalesanimation
98ef03c7af feat: complete photo gallery implementation with album management and lightbox viewer
- Added photo gallery carousel view (gallery.php) with all member albums
- Implemented album detail view with responsive photo grid and lightbox
- Created album creation/editing form with drag-and-drop photo uploads
- Added backend processors for album CRUD operations and photo management
- Implemented API endpoints for fetching and deleting photos
- Added database migration for photo_albums and photos tables
- Included comprehensive feature documentation with testing checklist
- Updated .htaccess with URL rewrite rules for gallery routes
- Added Gallery link to Members Area menu in header
- Created upload directory structure (/assets/uploads/gallery/)
- Implemented security: CSRF tokens, ownership verification, file validation
- Added transaction safety with rollback on errors and cleanup
- Features: Lightbox with keyboard navigation, drag-and-drop uploads, responsive design
2025-12-05 09:53:27 +02:00
twotalesanimation
05f74f1b86 feat: prevent duplicate membership applications and fees
- Add UNIQUE constraint on membership_application.user_id (one app per user)
- Add UNIQUE constraint on membership_fees.user_id (one fee record per user)
- Add validation checks in process_application.php before inserting
- Improve error messages for duplicate submission attempts
- Add migration script to clean up existing duplicates before constraints
- Update checkMembershipApplication to set session message on redirect
- Add comprehensive documentation of duplicate prevention architecture

Individual payments/EFTs are tracked separately in payments table
2025-12-05 09:42:42 +02:00
twotalesanimation
9133b7bbc6 feat: improve campsites and events management UX
- Add map-based location picker with centered pin for campsites (two-step process)
- Hide edit buttons for campsites not owned by current user
- Allow numbers in campsite names (fix validateName function)
- Prepopulate edit form with existing campsite data
- Preserve country/province selection when confirming location
- Add real-time filter functionality to campsites table
- Fix events publish button error handling (use output buffering cleanup)
- Improve AJAX response handling with complete callback

Changes:
- src/pages/bookings/campsites.php: Location mode UI, filter, edit form improvements
- src/config/functions.php: Allow numbers in validateName regex
- src/admin/toggle_event_published.php: Clean output buffers before JSON response
- src/admin/admin_events.php: Use complete callback instead of success/error handlers
2025-12-05 09:20:48 +02:00
twotalesanimation
b52c46b67c feat: add campsites link to members area menu with membership access control
- Replace 'Coming Soon!' with 'Campsites' link in Members Area dropdown
- Add membership verification check to campsites.php
- Redirect non-logged-in users to login page
- Redirect non-members to index page
- Only active members can access campsites feature
2025-12-04 23:01:28 +02:00
twotalesanimation
32651ed433 fix: publish toggle error alert and event visibility
- Add proper error handling to toggle_event_published.php with HTTP status codes
- Add try-catch block for database operations in toggle endpoint
- Update events.php query to only show published events (added published = 1 filter)
- Add updated_at timestamp update when toggling publish status
- Improve error messages for better debugging
2025-12-04 21:56:57 +02:00
twotalesanimation
f522b84fc1 refactor: align events admin pages with trips layout and add publish functionality
- Remove checkbox from manage_events.php form (publish via admin table instead)
- Redesign admin_events.php to match admin_trips.php layout exactly
- Add table-based actions with icon buttons (Edit, Publish/Unpublish, Delete)
- Change button styling to match trips (btn classes with colors)
- Add publish/unpublish toggle button with eye icon
- Create toggle_event_published.php endpoint for publish status switching
- Create delete_event.php endpoint for event deletion
- Add AJAX functionality for instant publish/delete without page reload
- Update .htaccess with new endpoint rewrite rules
- Badge styling updated to match trips (bg-success, bg-warning)
- Consistent sorting and filtering functionality
2025-12-04 21:40:11 +02:00
twotalesanimation
2b136c4b06 feat: add events admin navigation links and URL rewrite rules
- Add 'Manage Events' link to admin dropdown menu in header
- Add URL rewrite rules for admin_events and manage_events pages
- Add process_event endpoint rewrite rule
- Events admin pages now accessible via clean URLs
2025-12-04 20:32:49 +02:00
twotalesanimation
7f0964009a docs: add events admin system documentation 2025-12-04 20:26:17 +02:00
twotalesanimation
5be946f78f feat: create events management admin system
- Add manage_events.php form for creating/editing events
- Add process_event.php endpoint for CRUD operations with image uploads
- Add admin_events.php list view with sorting, filtering, and delete functionality
- Add database migration to add created_by, published, created_at, updated_at columns to events table
- Add event images directory structure
- All features follow same patterns as trip management system
2025-12-04 20:25:48 +02:00
twotalesanimation
cb588d20ee Feature: Campsite management system with map, form, and province/country filtering 2025-12-04 20:15:14 +02:00
twotalesanimation
fdeaf85bf0 Update: Add publish/unpublish button to admin trips table and improve table styling 2025-12-04 18:35:36 +02:00
twotalesanimation
d81d74a7c7 Fix: Add env.php include to delete_trip and toggle_trip_published processors 2025-12-04 17:31:27 +02:00
twotalesanimation
bfb3a0f8a9 Fix: Correct bind_param type strings for date fields in trip processor 2025-12-04 17:26:05 +02:00
twotalesanimation
5a2c48f343 Fix: Correct CSRF token validation in process_trip processor 2025-12-04 17:07:29 +02:00
twotalesanimation
1767337d99 Update: Allow superadmin role to manage trips alongside admin 2025-12-04 17:06:34 +02:00
twotalesanimation
674af23994 Feature: Add trip publisher system - create, edit, delete, and publish trips 2025-12-04 16:56:31 +02:00
twotalesanimation
ec563e0376 Update: Formatting and code cleanup in processor and config files 2025-12-04 16:41:10 +02:00
twotalesanimation
a3403bf503 Fix: Move POP notification email addresses to .env configuration
- Updated sendPOP() function to read recipient emails from POP_NOTIFICATION_EMAILS env variable
- Supports comma-separated email list for flexibility
- Allows dev and live servers to have different notification recipients
- Falls back to info@4wdcsa.co.za if no env variable configured
2025-12-04 16:14:16 +02:00
twotalesanimation
5f1a6bc441 Fix: Use EFT ID as filename for POP uploads instead of random filename
- Changed from random filename to eft_id.pdf format for proof of payment files
- Updated sendPOP() and auditLog() calls to use new filename variable
2025-12-04 16:11:37 +02:00
twotalesanimation
716de2f0e9 Fix: Clean output buffer in upload_profile_picture.php to prevent HTML in JSON response
- Move header() call to before any includes that might output
- Start output buffering at the beginning
- Clean output buffer before sending JSON response
2025-12-04 16:05:44 +02:00
twotalesanimation
79e292dc7c Fix: Profile picture upload AJAX response handling
- Add dataType: 'json' to AJAX call to properly parse JSON response
- Add Content-Type header to upload_profile_picture.php
- Add error callback with console logging for debugging
- Remove manual JSON parsing since jQuery handles it with dataType
2025-12-04 16:04:22 +02:00
twotalesanimation
59c1e37d5c Fix: Profile picture upload issues and improved error handling
- account_settings.php: Show success message before reloading page (with 1.5s delay)
- upload_profile_picture.php: Reorder require statements for proper initialization, add file error code to error message
2025-12-04 15:59:49 +02:00
twotalesanimation
0c068eeb69 Fix: Use absolute paths for all upload directories in processor files
- upload_profile_picture.php: Use absolute path for profile picture uploads, store relative path in DB
- submit_pop.php: Use absolute path for proof of payment uploads
- process_signature.php: Use absolute path for signature uploads, store relative path in DB
2025-12-04 15:34:15 +02:00
twotalesanimation
6fd3b8d082 Cleanup: Remove test and temporary page files 2025-12-04 15:28:17 +02:00
twotalesanimation
902291d8d1 Remove: Delete duplicate validate_login.php from src/processors - keep only root endpoint 2025-12-04 15:24:39 +02:00
twotalesanimation
ac460ef97f Restore: Recover src/processors folder accidentally deleted during merge 2025-12-04 15:19:52 +02:00
235 changed files with 29599 additions and 2497 deletions

5
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.env
/vendor/
.htaccess
/uploads/
/uploads/pop/
/assets/uploads/gallery/
/assets/uploads/

View File

@@ -30,6 +30,7 @@ RewriteRule ^membership$ src/pages/memberships/membership.php [L]
RewriteRule ^membership_details$ src/pages/memberships/membership_details.php [L]
RewriteRule ^membership_application$ src/pages/memberships/membership_application.php [L]
RewriteRule ^membership_payment$ src/pages/memberships/membership_payment.php [L]
RewriteRule ^renewal_payment$ src/pages/memberships/renewal_payment.php [L]
RewriteRule ^renew_membership$ src/pages/memberships/renew_membership.php [L]
RewriteRule ^member_info$ src/pages/memberships/member_info.php [L]
@@ -37,6 +38,7 @@ RewriteRule ^member_info$ src/pages/memberships/member_info.php [L]
RewriteRule ^bookings$ src/pages/bookings/bookings.php [L]
RewriteRule ^campsites$ src/pages/bookings/campsites.php [L]
RewriteRule ^campsite_booking$ src/pages/bookings/campsite_booking.php [L]
RewriteRule ^add_campsite$ src/pages/add_campsite.php [L]
RewriteRule ^trips$ src/pages/bookings/trips.php [L]
RewriteRule ^trip-details$ src/pages/bookings/trip-details.php [L]
RewriteRule ^course_details$ src/pages/bookings/course_details.php [L]
@@ -50,10 +52,16 @@ RewriteRule ^payment_confirmation$ src/pages/shop/payment_confirmation.php [L]
RewriteRule ^confirm$ src/pages/shop/confirm.php [L]
RewriteRule ^confirm2$ src/pages/shop/confirm2.php [L]
# === GALLERY PAGES ===
RewriteRule ^gallery$ src/pages/gallery/gallery.php [L]
RewriteRule ^create_album$ src/pages/gallery/create_album.php [L]
RewriteRule ^edit_album$ src/pages/gallery/create_album.php [L]
RewriteRule ^view_album$ src/pages/gallery/view_album.php [L]
# === EVENTS & BLOG PAGES ===
RewriteRule ^events$ src/pages/events/events.php [L]
RewriteRule ^blog$ src/pages/events/blog.php [L]
RewriteRule ^blog_details$ src/pages/events/blog_details.php [L]
RewriteRule ^blog$ src/pages/blog/blog.php [L]
RewriteRule ^blog_details$ src/pages/blog/blog_details.php [L]
RewriteRule ^best_of_the_eastern_cape_2024$ src/pages/events/best_of_the_eastern_cape_2024.php [L]
RewriteRule ^2025_agm_minutes$ src/pages/events/2025_agm_minutes.php [L]
RewriteRule ^agm_content$ src/pages/events/agm_content.php [L]
@@ -61,8 +69,10 @@ RewriteRule ^instapage$ src/pages/events/instapage.php [L]
# === OTHER PAGES ===
RewriteRule ^about$ src/pages/other/about.php [L]
RewriteRule ^base4$ src/pages/other/base4.php [L]
RewriteRule ^contact$ src/pages/other/contact.php [L]
RewriteRule ^privacy_policy$ src/pages/other/privacy_policy.php [L]
RewriteRule ^track-map$ src/pages/track-map.php [L]
RewriteRule ^404$ src/pages/other/404.php [L]
RewriteRule ^account_settings$ src/pages/other/account_settings.php [L]
RewriteRule ^rescue_recovery$ src/pages/other/rescue_recovery.php [L]
@@ -72,16 +82,29 @@ RewriteRule ^indemnity_waiver$ src/pages/other/indemnity_waiver.php [L]
RewriteRule ^basic_indemnity$ src/pages/other/basic_indemnity.php [L]
RewriteRule ^view_indemnity$ src/pages/other/view_indemnity.php [L]
# === PAYMENT RETURN PAGES ===
RewriteRule ^success$ src/pages/payment/success.php [L]
RewriteRule ^failure$ src/pages/payment/failure.php [L]
RewriteRule ^cancel$ src/pages/payment/cancel.php [L]
# === ADMIN PAGES ===
RewriteRule ^admin_trips_events_courses$ src/admin/admin_trips_events_courses.php [L]
RewriteRule ^admin_bookings$ src/admin/admin_bookings.php [L]
RewriteRule ^admin_members$ src/admin/admin_members.php [L]
RewriteRule ^admin_payments$ src/admin/admin_payments.php [L]
RewriteRule ^admin_web_users$ src/admin/admin_web_users.php [L]
RewriteRule ^admin_events$ src/admin/admin_events.php [L]
RewriteRule ^admin_course_bookings$ src/admin/admin_course_bookings.php [L]
RewriteRule ^admin_camp_bookings$ src/admin/admin_camp_bookings.php [L]
RewriteRule ^admin_trip_bookings$ src/admin/admin_trip_bookings.php [L]
RewriteRule ^admin_visitors$ src/admin/admin_visitors.php [L]
RewriteRule ^admin_efts$ src/admin/admin_efts.php [L]
RewriteRule ^add_campsite$ src/admin/add_campsite.php [L]
RewriteRule ^admin_transactions$ src/admin/admin_transactions.php [L]
RewriteRule ^admin_trips$ src/admin/admin_trips.php [L]
RewriteRule ^manage_events$ src/admin/manage_events.php [L]
RewriteRule ^manage_trips$ src/admin/manage_trips.php [L]
RewriteRule ^admin_courses$ /src/admin/admin_courses.php [L,QSA]
RewriteRule ^manage_courses$ /src/admin/manage_courses.php [L,QSA]
# === API/AJAX ENDPOINTS ===
RewriteRule ^fetch_users$ src/api/fetch_users.php [L]
@@ -92,6 +115,8 @@ RewriteRule ^get_tab_total$ src/api/get_tab_total.php [L]
RewriteRule ^google_validate_login$ src/api/google_validate_login.php [L]
# === PROCESSORS ===
RewriteRule ^process_course$ /src/processors/process_course.php [L,QSA]
RewriteRule ^delete_course$ /src/processors/delete_course.php [L,QSA]
RewriteRule ^validate_login$ src/processors/validate_login.php [L]
RewriteRule ^register_user$ src/processors/register_user.php [L]
RewriteRule ^process_application$ src/processors/process_application.php [L]
@@ -111,6 +136,32 @@ RewriteRule ^update_user$ src/processors/update_user.php [L]
RewriteRule ^upload_profile_picture$ src/processors/upload_profile_picture.php [L]
RewriteRule ^send_reset_link$ src/processors/send_reset_link.php [L]
RewriteRule ^logout$ src/processors/logout.php [L]
RewriteRule ^process_trip$ src/processors/process_trip.php [L]
RewriteRule ^process_event$ src/processors/process_event.php [L]
RewriteRule ^toggle_trip_published$ src/processors/toggle_trip_published.php [L]
RewriteRule ^toggle_event_published$ src/processors/toggle_event_published.php [L]
RewriteRule ^delete_trip$ src/processors/delete_trip.php [L]
RewriteRule ^delete_event$ src/processors/delete_event.php [L]
RewriteRule ^save_album$ src/processors/save_album.php [L]
RewriteRule ^update_album$ src/processors/update_album.php [L]
RewriteRule ^delete_album$ src/processors/delete_album.php [L]
RewriteRule ^delete_photo$ src/processors/delete_photo.php [L]
RewriteRule ^get_album_photos$ src/processors/get_album_photos.php [L]
RewriteRule ^link_membership_user$ src/processors/link_membership_user.php [L]
RewriteRule ^unlink_membership_user$ src/processors/unlink_membership_user.php [L]
# Blog routes
RewriteRule ^admin_blogs$ src/admin/admin_blogs.php [L]
RewriteRule ^user_blogs$ src/pages/blog/user_blogs.php [L]
RewriteRule ^blog_read$ src/pages/blog/blog_read.php [L]
RewriteRule ^blog_edit$ src/pages/blog/blog_edit.php [L]
RewriteRule ^blog_create$ src/processors/blog/blog_create.php [L]
RewriteRule ^blog_delete$ src/processors/blog/blog_delete.php [L]
RewriteRule ^publish_blog$ src/processors/blog/publish_blog.php [L]
RewriteRule ^blog_unpublish$ src/processors/blog/blog_unpublish.php [L]
RewriteRule ^submit_blog$ src/processors/blog/submit_blog.php [L]
RewriteRule ^upload_blog_image$ src/processors/blog/upload_blog_image.php [L]
RewriteRule ^autosave$ src/processors/blog/autosave.php [L]
</IfModule>

4
.user.ini Normal file
View File

@@ -0,0 +1,4 @@
; memory_limit = 512M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 120

View File

@@ -1,3 +0,0 @@
<?php
// Redirector file - loads the actual page from src/pages/other/
require_once __DIR__ . '/src/pages/other/about.php';

View File

@@ -0,0 +1,73 @@
.notif-avatar-container {
position: relative;
display: inline-block;
}
.notif-badge {
position: absolute;
top: -6px;
right: -6px;
background: #e74c3c;
color: #fff;
border-radius: 50%;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 12px;
display: none;
line-height: 20px;
text-align: center;
box-sizing: border-box;
font-weight: 600;
}
.notif-panel {
position: absolute;
right: 10px;
top: 44px;
width: 320px;
background: #fff;
border: 1px solid #ddd;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
z-index: 9999;
padding: 8px;
border-radius: 6px;
}
.notif-panel .notif-empty {
padding: 12px;
text-align: center;
color: #666;
}
.notif-item {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid #f1f1f1;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
margin-right: 8px;
}
.notif-item-body {
flex: 1;
}
.notif-item-title {
font-weight: 600;
font-size: 13px;
}
.notif-item-meta {
font-size: 11px;
color: #888;
}
.notif-close {
background: transparent;
border: 0;
color: #999;
font-weight: 700;
padding: 6px;
cursor: pointer;
}

View File

@@ -1,12 +1,6 @@
@charset "UTF-8";
/*----------------------------------------------------------------------
Template Name: Ravelo - Travel & Tour Booking HTML Template
Template URI: https://webtend.net/demo/html/ravelo/
Author: WebTend
Author URI: https://webtend.net/
Version: 1.0
Note: This is Main Style CSS File. */
4WDCSA.co.za CSS Stylesheet
/*----------------------------------------------------------------------
CSS INDEX
----------------------
@@ -7124,7 +7118,8 @@ blockquote {
/* Comments */
.comments {
border-radius: 10px;
border: 1px solid var(--border-color); }
/* border: 1px solid var(--border-color); */
}
.comment-body {
padding: 50px; }

Binary file not shown.

BIN
assets/images/base4/01.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

13126
assets/images/track-route2.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

66
assets/js/map.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* TRACK MAP WITH LEAFLET.JS
*
* Basic Leaflet map test
*/
console.log('Track map script loaded2');
// Check if Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet library not loaded!');
} else {
console.log('Leaflet library is available, version:', L.version);
}
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing map...');
const mapElement = document.getElementById('map');
console.log('Map element:', mapElement);
if (!mapElement) {
console.error('Map element not found!');
return;
}
console.log('Map element dimensions:', mapElement.offsetWidth, 'x', mapElement.offsetHeight);
try {
// Image dimensions: 2876 x 2035 pixels
const imageWidth = 2876;
const imageHeight = 2035;
// Create map with simple CRS (pixel coordinates)
// Note: Leaflet uses [y, x] format, so bounds are [[0, 0], [height, width]]
const bounds = [[0, 0], [imageHeight, imageWidth]];
const map = L.map('map', {
crs: L.CRS.Simple,
minZoom: -2,
maxZoom: 2,
center: [imageHeight / 2, imageWidth / 2],
zoom: -1
});
console.log('Map object created with CRS.Simple:', map);
// Add aerial image overlay
const imageUrl = '/assets/images/track-aerial.jpg';
L.imageOverlay(imageUrl, bounds).addTo(map);
console.log('Aerial image overlay added');
// Add SVG overlay
const svgUrl = '/assets/images/track-route.svg';
L.imageOverlay(svgUrl, bounds, {
opacity: 0.8,
interactive: false
}).addTo(map);
console.log('SVG route overlay added');
// Fit map to image bounds
map.fitBounds(bounds);
console.log('Map initialized successfully');
} catch (error) {
console.error('Error initializing map:', error);
}
});

View File

@@ -0,0 +1,103 @@
/* notifications.js - small admin notification panel
Requires jQuery. */
(function($){
function timeAgo(ts){
var seconds = Math.floor((Date.now() - (new Date(ts)).getTime())/1000);
if (seconds < 60) return seconds + 's ago';
var minutes = Math.floor(seconds/60);
if (minutes < 60) return minutes + 'm ago';
var hours = Math.floor(minutes/60);
if (hours < 24) return hours + 'h ago';
var days = Math.floor(hours/24);
return days + 'd ago';
}
function renderNotifications(list){
var $panel = $('#notif-panel');
$panel.empty();
if (!list || list.length === 0) {
$panel.append('<div class="notif-empty">No notifications</div>');
return;
}
list.forEach(function(n){
var actorAvatar = (n.data && n.data.actor_avatar) ? n.data.actor_avatar : 'assets/images/icons/user.png';
// Prefer the notification payload title (n.data.title). Do NOT fall back to the event string.
var title = (n.data && n.data.title) ? n.data.title : 'Notification';
var time = n.time_created || new Date().toISOString();
var read = (n.read_by && Array.isArray(n.read_by) && n.read_by.length>0);
var $item = $('<div class="notif-item" data-id="'+n.id+'">');
$item.append('<img class="notif-item-avatar" src="'+actorAvatar+'" alt="avatar">');
var $body = $('<div class="notif-item-body">');
$body.append('<div class="notif-item-title">'+escapeHtml(title)+'</div>');
$body.append('<div class="notif-item-meta">'+timeAgo(time)+'</div>');
$item.append($body);
$item.append('<button class="notif-close" title="Mark read">×</button>');
$panel.append($item);
});
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"'`]/g, function(s){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;",'`':'&#96;'}[s]; });
}
function fetchAndRender(adminId){
$.getJSON('/src/api/notifications.php', { action: 'fetch' }, function(resp){
if (resp && resp.success) {
renderNotifications(resp.notifications);
if (resp.unread_count && resp.unread_count > 0) {
$('#notif-badge').text(resp.unread_count).show();
} else {
$('#notif-badge').hide();
}
}
});
}
// Fetch only unread count (used on page load so badge shows without opening panel)
function fetchUnreadCount(){
$.getJSON('/src/api/notifications.php', { action: 'fetch' }, function(resp){
if (resp && resp.success) {
if (resp.unread_count && resp.unread_count > 0) {
$('#notif-badge').text(resp.unread_count).show();
} else {
$('#notif-badge').hide();
}
}
});
}
$(function(){
var $container = $('.notif-avatar-container');
if (!$container.length) return;
var adminId = $container.data('admin-id');
// ensure badge is populated on page load
fetchUnreadCount();
$container.on('click', function(e){
e.preventDefault();
$('#notif-panel').toggle();
if ($('#notif-panel').is(':visible')) fetchAndRender(adminId);
});
$(document).on('click', '.notif-close', function(e){
e.stopPropagation();
var $it = $(this).closest('.notif-item');
var id = $it.data('id');
if (!id) return;
$.post('/src/api/notifications.php', { action: 'mark_read', id: id }, function(resp){
if (resp && resp.success) {
$it.remove();
// refresh count
fetchAndRender(adminId);
}
}, 'json');
});
// click outside to close
$(document).on('click', function(e){
if (!$(e.target).closest('#notif-panel, .notif-avatar-container').length) {
$('#notif-panel').hide();
}
});
});
})(jQuery);

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

120
classes/iKhokhaClient.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
class IkhokhaClient {
private string $appId;
private string $appSecret;
private string $apiUrl;
public function __construct() {
// Try getenv first, then fallback to $_ENV if available
$this->appId = getenv('IKHOKHA_APP_ID') ?: ($_ENV['IKHOKHA_APP_ID'] ?? '');
$this->appSecret = getenv('IKHOKHA_APP_SECRET') ?: ($_ENV['IKHOKHA_APP_SECRET'] ?? '');
$this->apiUrl = getenv('IKHOKHA_API_URL') ?: ($_ENV['IKHOKHA_API_URL'] ?? '');
}
/**
* Make a request to the iKhokha API. Signs the payload per API docs.
* $endpoint should be the path portion starting with '/public-api/...'
*/
private function request(string $endpoint, array $data, string $method = 'POST') {
// Validate apiUrl
if (empty($this->apiUrl)) {
return ['error' => true, 'errno' => 3, 'message' => 'IKHOKHA_API_URL is not configured in environment'];
}
// If the configured API URL already contains the endpoint path, use it as-is.
if ((function_exists('str_ends_with') && str_ends_with($this->apiUrl, $endpoint)) ||
(substr_compare($this->apiUrl, $endpoint, -strlen($endpoint)) === 0)) {
$url = $this->apiUrl;
} else {
$url = rtrim($this->apiUrl, '/') . $endpoint;
}
$body = json_encode($data);
// Build payload to sign: path + body and apply escape rules per iKhokha docs
$parsed = parse_url($url);
$path = $parsed['path'] ?? $endpoint;
$payloadToSign = $path . $body;
// Escape function from iKhokha example
$escapeString = function ($str) {
$escaped = preg_replace(['/[\\\"\'\"]/u', '/\x00/'], ['\\\\$0', '\\0'], (string)$str);
$cleaned = str_replace('\/', '/', $escaped);
return $cleaned;
};
$escapedPayload = $escapeString($payloadToSign);
$signature = hash_hmac('sha256', $escapedPayload, $this->appSecret);
$ch = curl_init($url);
$headers = [
'Content-Type: application/json',
"IK-APPID: {$this->appId}",
"IK-SIGN: {$signature}"
];
// Optional debug logging to logs/ikhokha.log when IKHOKHA_DEBUG_LOG is true
$debugLog = getenv('IKHOKHA_DEBUG_LOG') ?: ($_ENV['IKHOKHA_DEBUG_LOG'] ?? null);
if ($debugLog) {
$logPath = dirname(__DIR__) . '/logs/ikhokha.log';
$logEntry = [
'time' => date('c'),
'url' => $url,
'headers' => $headers,
'body' => $data,
'signature' => $signature
];
@file_put_contents($logPath, json_encode(['request' => $logEntry]) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if (strtoupper($method) === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} else {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
}
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Log response if debug enabled
if (!empty($debugLog)) {
$logPath = dirname(__DIR__) . '/logs/ikhokha.log';
$respEntry = [
'time' => date('c'),
'http_code' => $httpCode,
'errno' => $errno,
'error' => $error,
'response' => $response
];
@file_put_contents($logPath, json_encode(['response' => $respEntry]) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
if ($response === false) {
return ['error' => true, 'message' => $error, 'errno' => $errno];
}
return json_decode($response, true);
}
/**
* Create a payment link using the iKhokha create payment endpoint.
* $body must match iKhokha request schema (amount in smallest unit, urls, externalTransactionID, etc.)
*/
public function createPaymentLink(array $body) {
return $this->request('/public-api/v1/api/payment', $body, 'POST');
}
public function getPaymentStatus($paymentId) {
// Use the GET status endpoint
$endpoint = '/public-api/v1/api/getStatus/' . urlencode($paymentId);
return $this->request($endpoint, [], 'GET');
}
}

176
docs/EVENTS_ADMIN_SYSTEM.md Normal file
View File

@@ -0,0 +1,176 @@
# Events Management Admin System
## Overview
A complete admin system for managing events on the 4WDCSA website, following the same patterns as the trip management system.
## Files Created
### 1. `/src/admin/manage_events.php`
**Purpose**: Form for creating and editing events
**Features**:
- Create new events form
- Edit existing events form
- Fields:
- Event Name (required)
- Event Type (required) - e.g., Workshop, Training, Rally
- Location (required)
- Date (required)
- Time (required)
- Feature/Category (required) - e.g., Off-Road Training, Social Event
- Description (required) - Full text description
- Event Image (required for new, optional for updates)
- Promotional Image (optional) - Displayed when users click "View Promo"
- Published Status (checkbox) - Controls visibility on website
**Technical Details**:
- AJAX form submission to `process_event` endpoint
- Image upload with validation
- CSRF token protection
- Responsive Bootstrap grid layout (col-md-6 fields)
- Success/error message display with auto-redirect
### 2. `/src/admin/process_event.php`
**Purpose**: Backend endpoint for handling event CRUD operations
**Endpoints**:
- `POST /process_event` - Create/Update event
- `GET /process_event?action=delete&event_id={id}` - Delete event
**Features**:
- Create new events with image uploads
- Update existing events with optional image replacement
- Delete events and associated image files
- CSRF token validation
- Image type validation (JPEG, PNG, GIF, WebP)
- File organization in `/assets/images/events/`
- Automatic timestamp management (created_at, updated_at)
- User tracking (created_by stores admin user_id)
**Image Handling**:
- Main event image: Stored with unique ID prefix
- Promo image: Stored with `_promo_` prefix
- Both uploaded to `/assets/images/events/`
### 3. `/src/admin/admin_events.php`
**Purpose**: Admin dashboard for managing all events
**Features**:
- List all events with sortable columns
- Real-time search/filter across all columns
- Create new event button
- Edit event link for each row
- Delete event with confirmation dialog
- Status badges (Published/Draft)
- Responsive table with alternating row colors
- Rounded corners on even rows
**Sortable Columns**:
- Event Name
- Type
- Location
- Date
- Status
**Actions**:
- Edit - Redirects to manage_events.php with event_id
- Delete - Removes event and associated files
## Database Schema Changes
### Migration File: `/docs/migrations/001_add_events_tracking_columns.sql`
**Columns Added to events table**:
- `created_by` (int) - References user who created the event
- `published` (tinyint(1)) - Boolean flag for publication status (default 0/false)
- `created_at` (timestamp) - Automatic timestamp when event is created
- `updated_at` (timestamp) - Automatic timestamp updated on modification
**Indexes Added**:
- `idx_date` - For sorting and filtering by date
- `idx_published` - For filtering published/draft events
- `idx_created_by` - For tracking who created events
## Design Patterns
### Follows Trip Management System Architecture
- Same form layout and styling (`.comment-form.bgc-lighter`)
- Same table styling with sortable headers and filters
- Same image upload and validation patterns
- AJAX submission with success/error messaging
- Auto-redirect on successful operation
### Image Organization
```
/assets/images/events/
├── {unique_id}_{original_filename}.jpg (event images)
└── {unique_id}_promo_{original_filename}.jpg (promo images)
```
### Front-end Integration
The existing `/src/pages/events/events.php` displays published events:
- Shows event image, name, location, date, time
- Feature description and full description
- "View Promo" button displays promotional image in modal
## Usage Workflow
### Creating an Event
1. Navigate to `/src/admin/manage_events.php`
2. Fill in all required fields
3. Upload event image
4. Optionally upload promotional image
5. Check "Publish Event" if ready to display
6. Submit form via AJAX
7. Redirected to admin_events.php list view
### Editing an Event
1. Click "Edit" button on admin_events.php
2. Modify any fields
3. Image upload is optional - existing image retained if not changed
4. Update timestamps and user tracking automatic
5. Submit form
6. Redirected back to list view
### Deleting an Event
1. Click "Delete" button on admin_events.php
2. Confirm deletion in dialog
3. Event and associated image files removed from server
4. Page automatically refreshes
### Publishing/Unpublishing
- Toggle "Publish Event" checkbox before saving
- Only published events appear on `/src/pages/events/events.php`
- Draft events hidden from public view
## Security Features
1. **CSRF Token Protection**: All forms include CSRF token validation
2. **Admin-only Access**: `checkAdmin()` function validates user permissions
3. **File Validation**: Image type checking (JPEG, PNG, GIF, WebP)
4. **SQL Injection Prevention**: Prepared statements with parameter binding
5. **XSS Prevention**: `htmlspecialchars()` used for output escaping
## Styling Classes
**Form Container**: `.comment-form.bgc-lighter.z-1.rel.mb-30.rmb-55`
**Action Buttons**: `.btn-edit`, `.btn-delete`
**Status Badges**: `.badge.badge-published`, `.badge.badge-draft`
**Tables**: Uses sortable header styling with visual sort indicators
## Browser Compatibility
- Modern browsers with AJAX/Fetch API support
- JavaScript enabled required for filtering and sorting
- File input accepts image MIME types
## Future Enhancement Opportunities
1. Bulk event operations (bulk delete, publish multiple)
2. Event categories/tags system
3. Event capacity limits with registrations
4. Email notifications for published events
5. Event calendar view
6. Event image gallery (multiple images per event)
7. Recurring events support
8. Event attendee tracking

297
docs/FEATURE_STATUS.md Normal file
View File

@@ -0,0 +1,297 @@
# Membership Linking Feature - Implementation Complete ✅
## Executive Summary
The membership linking feature has been successfully implemented, tested, and verified. This feature allows multiple users (such as married couples or family members) to share a single membership account, with all users receiving member benefits including:
- Access to member-only areas (gallery, campsites)
- Member pricing on trips, courses, and other events
- Free campsite bookings
- Reduced pricing on courses and trainings
## Implementation Status
### ✅ Backend Implementation (Complete)
**Database Tables Created**:
- `membership_links` - Tracks primary/secondary user relationships
- `membership_permissions` - Granular permission control
**Core Functions Added** (in `src/config/functions.php`):
- `linkSecondaryUserToMembership()` - Creates links with validation
- `getUserMembershipLink()` - Checks linked membership status
- `getLinkedSecondaryUsers()` - Lists all secondary users for a primary
- `unlinkSecondaryUser()` - Removes links
**Functions Enhanced**:
- `getUserMemberStatus()` - Now checks linked memberships at ALL failure points:
* No direct application → check linked
* No indemnity acceptance → check linked
* No payment record → check linked
* Direct membership expired → check linked
### ✅ API Endpoints (Complete)
**POST /link_membership_user**
- Validates CSRF token
- Validates secondary user email exists
- Creates link in database
- Assigns default permissions
- Returns JSON response
**POST /unlink_membership_user**
- Validates CSRF token
- Verifies primary user authorization
- Removes link and permissions
- Returns JSON response
### ✅ User Interface (Complete)
**Membership Details Page** (`src/pages/memberships/membership_details.php`)
- "Linked Accounts" section displays list of connected users
- Form to add new linked users by email
- Unlink buttons for each linked account
- CRITICAL FIX: Form moved OUTSIDE infoForm to prevent form collision
- Real-time updates without page reload
**Header Navigation** (`src/pages/header.php`)
- "Members Area" dropdown shown for users with direct OR linked membership
- Uses `getUserMemberStatus()` to determine access
- Shows Campsites & Gallery links
### ✅ Booking Pages & Pricing (Complete)
**Pricing Fixes Applied**:
1. **driver_training.php** - FIXED ✅
- Correct: Members count themselves + additional members + additional non-members
- Correct: Non-members count themselves + additional participants only
- Updated UI labels for non-member clarity
2. **bush_mechanics.php** - FIXED ✅
- Same pricing logic as driver training
- Correctly excludes "members" field for non-member calculations
3. **rescue_recovery.php** - FIXED ✅
- Same pricing logic as driver training
- Correctly excludes "members" field for non-member calculations
4. **trip-details.php** - VERIFIED ✅
- Correct adults/children/pensioner calculations
- Different pricing model but correctly applied
- No issues found
5. **campsite_booking.php** - VERIFIED ✅
- Members stay FREE
- Non-members pay R200/night
- Correct implementation in JavaScript
**Open to All Users**:
- Trip details page
- Course details page
- Bush mechanics page
- Rescue & recovery page
- Campsite booking page
**Member-Only Areas** (Redirect non-members):
- Campsites gallery
- Photo gallery
- Create albums
### ✅ Processors Updated (Complete)
All booking processors verified to handle non-member bookings:
- `process_trip_booking.php` - Applies pricing correctly ✅
- `process_course_booking.php` - Applies pricing correctly ✅
- `process_camp_booking.php` - Applies pricing correctly ✅
### ✅ Documentation (Complete)
- `TEST_MEMBERSHIP_LINKING.md` - Comprehensive testing guide
- `docs/MEMBERSHIP_LINKING.md` - Feature documentation
- `docs/migrations/004_create_membership_linking_tables.sql` - Migration script
- Migration files reorganized to `docs/migrations/`
## Key Fixes Applied
### Fix 1: Form Submission Conflict (Commit: c5112e1c)
**Problem**: Link form nested inside info form - submit button triggered parent
**Solution**: Moved entire Linked Accounts section OUTSIDE infoForm
**Result**: Linking now works correctly ✅
### Fix 2: Linked Members Not Recognized (Commit: e63bd806)
**Problem**: `getUserMemberStatus()` only checked linked if no application existed
**Solution**: Added linked membership checks at ALL decision points in function
**Result**: Linked members recognized everywhere ✅
### Fix 3: JavaScript Pricing Calculations (Commit: 646a3ecb)
**Problem**: `calculateTotal()` incorrectly added "members" field for non-members
**Solution**: Fixed variable names and logic across 3 files (driver_training, bush_mechanics, rescue_recovery)
**Result**: Correct pricing for members AND non-members ✅
## Feature Branch Statistics
**Total Commits**: 10 commits
**Files Modified**: 12 code files + 2 documentation files
**Database Changes**: 2 new tables (membership_links, membership_permissions)
**API Endpoints**: 2 new AJAX endpoints
**Lines Added**: ~1500+ lines of code + documentation
## Branch Details
```
Branch: feature/membership-linking
Base: main
Status: Ready for merge
Latest Commit: 60e17167 (chore: reorganize migration files)
```
## Pre-Merge Verification Checklist
### Backend Verification ✅
- [x] Database tables created
- [x] Core linking functions implemented
- [x] getUserMemberStatus() checks linked memberships at all decision points
- [x] API endpoints created and secured with CSRF tokens
- [x] Input validation on all endpoints
- [x] Error handling and logging in place
### Frontend Verification ✅
- [x] Membership details page displays linked accounts
- [x] Link form properly styled and positioned
- [x] Unlink buttons functional
- [x] Header shows "Members Area" for linked users
- [x] Booking pages open to all users (members and non-members)
- [x] Protected member pages block non-members
### Pricing Verification ✅
- [x] driver_training.php - Correct for members and non-members
- [x] bush_mechanics.php - Correct for members and non-members
- [x] rescue_recovery.php - Correct for members and non-members
- [x] trip-details.php - Verified correct
- [x] campsite_booking.php - Verified correct
- [x] Course booking - Verified correct
### Access Control Verification ✅
- [x] Linked members can access campsites page
- [x] Linked members can access gallery
- [x] Non-members cannot access member-only areas
- [x] Linked members get member pricing
- [x] Non-members get non-member pricing
### Code Quality ✅
- [x] CSRF tokens validated on all endpoints
- [x] SQL injection prevention in place
- [x] Error logging implemented
- [x] Consistent naming conventions
- [x] Proper comments and documentation
## Database Migration
To deploy this feature, run:
```bash
php run_migrations.php
```
Or manually execute:
```sql
-- See docs/migrations/004_create_membership_linking_tables.sql
```
## Testing Recommendations
### Manual Testing Scenarios
1. **Linking test**: Create primary user → Link secondary user → Verify in UI
2. **Access test**: Secondary user should see "Members Area" in header
3. **Pricing test**: Secondary user should get member pricing on trip booking
4. **Unlink test**: Primary user unlinking should remove secondary access
5. **Non-member test**: Non-member should be able to book but at higher rates
### Database Verification
```sql
-- Check created links
SELECT * FROM membership_links;
-- Check permissions
SELECT * FROM membership_permissions;
-- Check user as secondary in link
SELECT * FROM membership_links WHERE secondary_user_id = [user_id];
-- Check user as primary with secondaries
SELECT * FROM membership_links WHERE primary_user_id = [user_id];
```
## Known Limitations & Future Enhancements
### Current Design
- One-way linking: Primary → Secondary
- Primary user controls all link management
- Secondary users cannot self-manage their link
- Fixed set of default permissions
### Potential Future Enhancements
1. Two-way linking (secondary users can decline/accept)
2. Granular permission management UI
3. Multiple primary accounts support
4. Batch linking for organizations
5. Time-limited links with expiration
6. Link management dashboard
7. Secondary user self-unlink option
## Rollback Plan
If issues are discovered after merge:
```bash
# Revert to previous state
git revert --no-commit <commit-hash>
git commit -m "revert: [reason]"
# Drop tables if needed
DROP TABLE IF EXISTS membership_permissions;
DROP TABLE IF EXISTS membership_links;
```
## Deployment Checklist
Before merging to main:
- [ ] Run database migration
- [ ] Test linking functionality with real users
- [ ] Verify non-member bookings work
- [ ] Verify linked member access
- [ ] Monitor error logs for issues
- [ ] Update user documentation
## Success Criteria - ALL MET ✅
✅ Multiple users can link to one membership
✅ Linked users see "Members Area" in header
✅ Linked users get member pricing
✅ Linked users can access member-only areas
✅ Non-members can book at higher rates
✅ No form submission conflicts
✅ All pricing calculations correct
✅ Comprehensive documentation provided
✅ Database migration ready
✅ Feature branch clean and ready to merge
## Summary
The membership linking feature is **complete, tested, and ready for production**. All major components are working correctly:
- Backend linking system functional
- User interface intuitive and responsive
- Pricing calculations accurate for all user types
- Access control properly enforced
- Documentation comprehensive
- Code quality maintained
**Recommendation**: Safe to merge to main branch.
---
**Branch**: feature/membership-linking
**Status**: ✅ READY FOR MERGE
**Last Updated**: 2025-01-15
**Commits in Branch**: 10
**Files Modified**: 14

View File

@@ -0,0 +1,86 @@
# Membership Application Duplicate Prevention
## Overview
Implemented comprehensive validation to prevent users from submitting multiple membership applications or creating multiple membership fee records. Each user can have exactly one application and one membership fee record. Individual payments are tracked separately in the payments/efts table.
## Architecture
```
User (1) ---> Membership Application (1) ---> Membership Fee (1) ---> Multiple Payments/EFTs
```
- **Membership Application**: Stores user details and application information (one per user)
- **Membership Fee**: Stores the total fee amount and dates (one per user, linked to application)
- **Payments/EFTs**: Tracks individual payment transactions for the membership fee (many per fee)
## Changes Made
### 1. Database Level Protection
**File:** `docs/migrations/002_add_unique_constraints_membership.sql`
- Added `UNIQUE` constraint on `membership_application.user_id` - ensures each user can only have one application
- Added `UNIQUE` constraint on `membership_fees.user_id` - ensures each user can only have one membership fee record
- Cleans up any duplicate records before adding constraints
### 2. Application Level Validation
**File:** `src/processors/process_application.php`
Added pre-submission checks:
- Check if user already has a membership application in the database
- Check if user already has a membership fee record
- Return clear error message if either check fails
- Catch database constraint violations and provide user-friendly message
**File:** `src/config/functions.php`
- Improved `checkMembershipApplication()` to set session message before redirecting
- Message displayed: "You have already submitted a membership application."
### 3. Error Handling
If a user somehow bypasses checks:
- Server validates before processing
- Returns HTTP 400 error with JSON response
- User sees clear message directing them to support or check email
- Database constraints prevent data corruption (duplicate key violation)
## User Flow
1. **First Visit to Application Page:**
- `checkMembershipApplication()` checks database
- If no application exists, shows form
- If application exists, redirects to `membership_details.php`
2. **Form Submission:**
- Server checks for existing application
- Server checks for existing membership fee
- If checks pass, inserts application and fee in transaction
- On success, redirects to indemnity page
- On error, returns JSON error response
3. **Payment Process:**
- Individual payment records are created in payments/efts table
- Multiple payments can be made against the single membership_fee record
- Payment status is tracked independently from application
## Testing Recommendations
1. Test creating a membership application - should succeed
2. Try applying again - should be redirected to membership_details
3. Try submitting the form multiple times rapidly - should fail on 2nd attempt
4. Verify payments can be made against the single membership fee record
5. Check database constraints: `SHOW INDEX FROM membership_application;` and `SHOW INDEX FROM membership_fees;`
## Database Constraints
```sql
-- One application per user
ALTER TABLE membership_application
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
-- One membership fee record per user
ALTER TABLE membership_fees
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);
```
## Backwards Compatibility
The migration script cleans up any existing duplicate records before adding constraints, ensuring no data loss.

306
docs/MEMBERSHIP_LINKING.md Normal file
View File

@@ -0,0 +1,306 @@
# Membership Linking Feature
## Overview
The Membership Linking feature allows users to link secondary accounts (spouses, family members, etc.) to a primary membership account. This enables multiple users to access member-only areas and receive member pricing under a single membership.
## Database Schema
### membership_links Table
```sql
- link_id (INT, PK, AUTO_INCREMENT)
- primary_user_id (INT, FK to users) - Main membership holder
- secondary_user_id (INT, FK to users) - Secondary user sharing the membership
- relationship (VARCHAR 50) - Type of relationship (spouse, family_member, etc)
- linked_at (TIMESTAMP)
- created_at (TIMESTAMP)
Constraints:
- UNIQUE(primary_user_id, secondary_user_id) - Prevent duplicate links
- Foreign keys on both user IDs with CASCADE DELETE
- Indexes on both user IDs for performance
```
### membership_permissions Table
```sql
- permission_id (INT, PK, AUTO_INCREMENT)
- link_id (INT, FK to membership_links) - Reference to the link
- permission_name (VARCHAR 100) - Permission type (access_member_areas, member_pricing, etc)
- granted_at (TIMESTAMP)
Constraints:
- UNIQUE(link_id, permission_name) - Prevent duplicate permissions
- Foreign key to membership_links with CASCADE DELETE
- Index on link_id for performance
Default Permissions Granted:
- access_member_areas
- member_pricing
- book_campsites
- book_courses
- book_trips
```
## Functions
### linkSecondaryUserToMembership()
**Purpose**: Link a secondary user to a primary user's active membership
**Parameters**:
- `int $primary_user_id` - The main membership holder
- `int $secondary_user_id` - The user to link
- `string $relationship` - Relationship type (default: 'spouse')
**Returns**: `array` with keys:
- `success` (bool) - Whether the link was created
- `message` (string) - Status message
- `link_id` (int) - ID of created link (on success)
**Validation**:
- Primary and secondary user IDs must be different
- Primary user must have active membership
- Secondary user must exist
- Link must not already exist
**Side Effects**:
- Creates membership_links record
- Creates default permission records
- Uses transaction (rolls back on failure)
### getUserMembershipLink()
**Purpose**: Check if a user has access through a secondary membership link
**Parameters**:
- `int $user_id` - User to check
**Returns**: `array` with keys:
- `has_access` (bool) - Whether user has access via link
- `primary_user_id` (int|null) - ID of primary account holder
- `relationship` (string|null) - Relationship type
**Validation**:
- Verifies the link exists
- Checks primary user has active membership
- Validates payment status and expiration date
- Confirms indemnity waiver accepted
### getLinkedSecondaryUsers()
**Purpose**: Get all secondary users linked to a primary user's membership
**Parameters**:
- `int $primary_user_id` - The primary membership holder
**Returns**: `array` of linked users with:
- `link_id` - Link ID
- `user_id` - Secondary user ID
- `first_name` - User's first name
- `last_name` - User's last name
- `email` - User's email
- `relationship` - Relationship type
- `linked_at` - When the link was created
### unlinkSecondaryUser()
**Purpose**: Remove a secondary user from a primary user's membership
**Parameters**:
- `int $link_id` - The membership link ID to remove
- `int $primary_user_id` - The primary user (for verification)
**Returns**: `array` with keys:
- `success` (bool) - Whether the unlink was successful
- `message` (string) - Status message
**Validation**:
- Verifies link exists
- Confirms primary user owns the link
- Uses transaction (rolls back on failure)
## API Endpoints
### POST /link_membership_user
**Purpose**: Link a new secondary user to the requester's membership
**Required Parameters**:
- `secondary_email` (string) - Email of user to link
- `relationship` (string, optional) - Relationship type (default: 'spouse')
- `csrf_token` (string) - CSRF token
**Response**:
```json
{
"success": true,
"message": "User successfully linked to membership",
"link_id": 123
}
```
**Error Responses**:
- 403: Forbidden (not authenticated or POST required)
- 400: Bad Request (invalid CSRF, missing email, user not found, or linking failed)
**Access Control**:
- Authenticated users only
- Can only link to own membership
### POST /unlink_membership_user
**Purpose**: Remove a secondary user from the requester's membership
**Required Parameters**:
- `link_id` (int) - ID of the link to remove
- `csrf_token` (string) - CSRF token
**Response**:
```json
{
"success": true,
"message": "User successfully unlinked from membership"
}
```
**Error Responses**:
- 403: Forbidden (not authenticated or POST required)
- 400: Bad Request (invalid CSRF, link not found, or unauthorized)
**Access Control**:
- Authenticated users only
- Can only remove links from own membership
## Integration Points
### Updated getUserMemberStatus()
The `getUserMemberStatus()` function now checks both:
1. Direct membership (user has membership_application and membership_fees)
2. Secondary membership (user is linked to another user's active membership)
When user doesn't have direct membership, it automatically checks if they're linked to someone else's active membership.
### Member Access Checks
All member-only pages should use `getUserMemberStatus()` which now automatically handles:
- Direct members
- Secondary members via links
- Expired memberships
- Indemnity waiver validation
## Use Cases
### Spouse/Partner Access
1. User A (primary) has active membership
2. User B (spouse) links to User A's membership
3. User B can now:
- Access member areas
- Receive member pricing
- Book campsites
- Book courses
- Book trips
### Renewal
- When primary membership renews, secondary users automatically maintain access
- No need to re-create links on renewal
### Membership Termination
- If primary membership expires, secondary users lose access
- Primary user can manually unlink secondary users anytime
## Usage Examples
### Linking a User
```php
$result = linkSecondaryUserToMembership(
$_SESSION['user_id'],
'spouse@example.com',
'spouse'
);
if ($result['success']) {
$_SESSION['message'] = 'Successfully linked ' . $partner_email . ' to your membership';
} else {
$_SESSION['error'] = $result['message'];
}
```
### Checking User Access
```php
if (getUserMemberStatus($user_id)) {
// User has direct or linked membership
echo "Welcome member!";
} else {
// Redirect to membership page
header('Location: membership');
}
```
### Getting Linked Users
```php
$linkedUsers = getLinkedSecondaryUsers($_SESSION['user_id']);
foreach ($linkedUsers as $user) {
echo "Linked: " . $user['first_name'] . ' (' . $user['relationship'] . ')';
}
```
### Removing a Link
```php
$result = unlinkSecondaryUser($link_id, $_SESSION['user_id']);
if ($result['success']) {
echo "User unlinked successfully";
} else {
echo "Error: " . $result['message'];
}
```
## Security Considerations
### Authorization
- Users can only link to their own membership
- Users can only manage their own links
- Secondary users cannot create or modify links (primary user only)
### Data Validation
- Email validation before linking
- User existence verification
- Duplicate link prevention
- CSRF token validation on all operations
### Relationships
- Foreign keys prevent orphaned links
- CASCADE DELETE ensures cleanup when users are deleted
- Transactions ensure consistency
## Testing Checklist
- [ ] Link new user to own membership
- [ ] Attempt to link non-existent user (error)
- [ ] Attempt to link same user twice (error)
- [ ] Secondary user can access member areas
- [ ] Secondary user receives member pricing
- [ ] Unlink secondary user
- [ ] Unlinked user cannot access member areas
- [ ] Primary user can see list of linked users
- [ ] Linked user appears in notifications (if applicable)
- [ ] Membership renewal maintains links
- [ ] Expired membership removes secondary access
- [ ] Deleting user removes their links
- [ ] Permission records created on link
- [ ] Cannot link without active primary membership
- [ ] Cannot link if different user attempts
- [ ] CSRF token validation works
## Future Enhancements
1. **Admin Management**: Allow admins to create/remove links for members
2. **Selective Permissions**: Allow customizing which permissions each secondary user has
3. **Invitations**: Send email invitations to secondary users to accept
4. **Multiple Links**: Allow primary users to link multiple users (families)
5. **UI Dashboard**: Create page for managing linked accounts
6. **Notifications**: Notify secondary users when linked
7. **Payment Tracking**: Track which user made payments for membership
8. **Audit Log**: Log all link/unlink operations for compliance
## Migration Instructions
1. Run migration 004 to create tables and permissions table
2. Update `src/config/functions.php` with new linking functions
3. Update `getUserMemberStatus()` to check links
4. Add routes to `.htaccess` for new endpoints
5. Deploy processors for link/unlink operations
6. Test with married couple accounts
7. Document for users in membership information

View File

@@ -0,0 +1,494 @@
# Photo Gallery Feature - Complete Implementation
## Overview
The Photo Gallery feature allows 4WDCSA members to create, manage, and view photo albums with a carousel interface for browsing and a lightbox viewer for detailed photo viewing.
## Database Schema
### photo_albums table
```sql
- album_id (INT, PK, AUTO_INCREMENT)
- user_id (INT, FK to users)
- title (VARCHAR 255, NOT NULL)
- description (TEXT, nullable)
- cover_image (VARCHAR 500, nullable - stores file path)
- created_at (TIMESTAMP)
- updated_at (TIMESTAMP)
- UNIQUE INDEX on user_id (one album per user for now, can be modified)
- INDEX on created_at for sorting
```
### photos table
```sql
- photo_id (INT, PK, AUTO_INCREMENT)
- album_id (INT, FK to photo_albums, CASCADE DELETE)
- file_path (VARCHAR 500, NOT NULL)
- caption (VARCHAR 500, nullable)
- display_order (INT, default 0)
- created_at (TIMESTAMP)
- INDEX on album_id for quick lookups
- INDEX on display_order for sorting
```
## File Structure
### Pages (Public-Facing)
- `src/pages/gallery/gallery.php` - Main carousel view of all albums
- `src/pages/gallery/view_album.php` - Detailed album view with photo grid and lightbox
- `src/pages/gallery/create_album.php` - Form to create new albums and upload initial photos
### Processors (Backend Logic)
- `src/processors/save_album.php` - Creates new album and handles initial photo uploads
- `src/processors/update_album.php` - Updates album metadata and handles additional photo uploads
- `src/processors/delete_album.php` - Deletes entire album with all photos and files
- `src/processors/delete_photo.php` - Deletes individual photos from album
- `src/processors/get_album_photos.php` - API endpoint returning album photos as JSON
### Styling
All styling is embedded in each PHP file using `<style>` tags for consistency with existing pattern.
## Features
### Gallery View (gallery.php)
**Purpose**: Display all photo albums in a carousel format
**Features**:
- Bootstrap carousel with Previous/Next buttons
- Album cards showing:
- Cover image
- Album title
- Description
- Creator avatar and name
- Photo count
- "View Album" button
- "Create Album" button (visible to all members)
- Empty state message for members with no albums
- Responsive design for mobile/tablet/desktop
**Access Control**:
- Members-only (redirects non-members to membership page)
- Verified membership required
### Album Detail View (view_album.php)
**Purpose**: Display all photos from a single album with lightbox viewer
**Features**:
- Album header with:
- Creator information (avatar, name)
- Album title and description
- Photo count
- "Edit Album" button (visible only to album owner)
- Responsive photo grid layout
- Click any photo to open lightbox viewer
- Lightbox features:
- Full-screen image display
- Previous/Next navigation buttons
- Caption display
- Keyboard navigation:
- Arrow Left: Previous photo
- Arrow Right: Next photo
- Escape: Close lightbox
- Close button (X)
- Empty state message with "Add Photos" link for album owner
- "Back to Gallery" button at bottom
**Access Control**:
- Public albums visible to all members
- Edit button visible only to album owner
### Create/Edit Album (create_album.php)
**Purpose**: Create new albums or edit existing albums with photo uploads
**Features**:
- Album title input (required, validates with validateName())
- Description textarea (optional, max 500 characters)
- Drag-and-drop file upload area
- File selection click-to-upload
- Selected files list showing filename and size
- Photo grid showing existing photos (edit mode only)
- Delete button on each existing photo
- Delete album button (edit mode only)
- Submit and Cancel buttons
**File Upload Validation**:
- Allowed formats: JPG, PNG, GIF, WEBP
- Max file size: 5MB per image
- Validates MIME type and file size
- Generates unique filenames with uniqid()
- Stores in `/assets/uploads/gallery/{album_id}/`
**Form Behavior**:
- Create mode: Only allows setting album metadata and initial photos
- Edit mode: Shows existing photos, allows adding new photos, allows editing metadata
- First uploaded photo becomes cover image (auto-selected)
- Photos can be deleted before submission (edit mode)
- Form prepopulation on edit (title, description, existing photos)
**Access Control**:
- Members-only
- Edit form checks album ownership before allowing edits
- Redirect to gallery if not owner of album being edited
## API Endpoints
### GET /get_album_photos
Returns JSON array of photos for an album
**Parameters**:
- `id`: Album ID (GET parameter)
**Response**:
```json
[
{
"photo_id": 1,
"file_path": "/assets/uploads/gallery/1/photo_abc123.jpg",
"caption": "Sample photo",
"display_order": 1
}
]
```
**Access Control**: Members-only, owner of album only
### POST /delete_photo
Deletes a photo and updates album cover if needed
**Parameters**:
- `photo_id`: Photo ID (POST)
- `csrf_token`: CSRF token (POST)
**Response**:
```json
{ "success": true }
```
**Side Effects**:
- Deletes photo file from disk
- Removes photo from database
- Updates album cover image if deleted photo was cover
- Sets cover to first remaining photo or NULL if no photos left
**Access Control**: Members-only, owner of album only
## Workflow Examples
### Creating an Album
1. User clicks "Create Album" button on gallery page
2. Navigates to create_album.php
3. Enters album title (required) and description (optional)
4. Drags and drops or selects multiple photo files
5. Clicks "Create Album" button
6. save_album.php:
- Creates album directory: `/assets/uploads/gallery/{album_id}/`
- Validates each photo (mime type, file size)
- Moves photos to album directory with unique names
- Sets first photo as cover_image
- Inserts album record and photo records in transaction
- Redirects to view_album page for newly created album
### Editing an Album
1. User clicks "Edit Album" button on album view page
2. Navigates to create_album.php?id={album_id}
3. Form prepopulates with current album data
4. Existing photos displayed in grid with delete buttons
5. Can add more photos by uploading new files
6. Clicks "Update Album" button
7. update_album.php:
- Verifies ownership
- Updates album metadata
- Validates and uploads any new photos
- Appends new photos to existing ones (doesn't overwrite)
- Redirects back to view_album
### Deleting a Photo
1. User clicks delete (X) button on photo in edit form
2. JavaScript shows confirmation dialog
3. Sends POST to delete_photo with photo_id and csrf_token
4. delete_photo.php:
- Verifies ownership through album
- Deletes file from disk
- Removes from database
- Updates cover_image if needed
- Returns JSON success response
5. Page reloads to show updated photo list
### Deleting an Album
1. User clicks "Delete Album" button in edit form
2. JavaScript shows confirmation dialog
3. Navigates to delete_album.php?id={album_id}
4. delete_album.php:
- Verifies ownership
- Deletes all photo files from disk
- Deletes all photo records from database
- Deletes album directory
- Deletes album record
- Redirects to gallery page
## URL Routing (.htaccess)
```
/gallery → src/pages/gallery/gallery.php
/create_album → src/pages/gallery/create_album.php
/edit_album → src/pages/gallery/create_album.php (id parameter determines mode)
/view_album → src/pages/gallery/view_album.php
/save_album → src/processors/save_album.php (POST only)
/update_album → src/processors/update_album.php (POST only)
/delete_album → src/processors/delete_album.php (GET with id parameter)
/delete_photo → src/processors/delete_photo.php (POST only)
/get_album_photos → src/processors/get_album_photos.php (GET with id parameter)
```
## Security Features
### Authentication
- All pages check for `$_SESSION['user_id']`
- Non-members redirected to membership page
- Non-authenticated users redirected to login
### Authorization
- Album ownership verified before allowing edits
- Album ownership verified before allowing deletes
- Only album owner can edit or delete photos
- Only album owner can see edit buttons
### Data Validation
- Album title validated with validateName() function
- Description length limited to 500 characters
- File uploads validated for:
- MIME type (only image formats allowed)
- File size (max 5MB)
- File extension check
- Filename sanitized with uniqid() to prevent conflicts
### CSRF Protection
- All forms include csrf_token
- Processors validate CSRF token before processing
- POST-only operations protected
### Transaction Safety
- Album creation uses transaction
- Creates directory
- Inserts album record
- Inserts photo records
- Commits all or rolls back all on error
- Handles cleanup on failure:
- Deletes partial uploads
- Removes album directory
- Removes album record from database
## Error Handling
### Validation Errors
- File too large: "File too large: {filename}"
- Invalid file type: "Invalid file type: {filename}"
- Missing album title: "Album title is required and must be valid"
- Description too long: "Description must be 500 characters or less"
### Permission Errors
- Not authenticated: Redirects to login
- Not a member: Redirects to membership page
- Not album owner: Returns 403 Forbidden with error message
- Album not found: Returns 404 with redirect to gallery
### Upload Errors
- Directory creation failure: "Failed to create album directory"
- File move failure: "Failed to upload: {filename}"
- Database insert failure: HTTP 400 with error message
### Recovery
- All upload errors trigger transaction rollback
- Partial files cleaned up on failure
- Album record deleted if transaction fails
- Directory removed if transaction fails
## Testing Checklist
### Album Creation
- [ ] Create album with title only
- [ ] Create album with title and description
- [ ] Upload single photo to new album
- [ ] Upload multiple photos to new album
- [ ] Verify first photo becomes cover
- [ ] Verify files stored in correct directory
- [ ] Verify album appears in carousel
### Album Editing
- [ ] Edit album title
- [ ] Edit album description
- [ ] Add photos to existing album
- [ ] Add many photos at once (10+)
- [ ] Delete photos from album
- [ ] Delete last photo (cover updates to NULL)
- [ ] Delete album cover, verify new cover assigned
- [ ] Verify edit unavailable for non-owner
### Album Viewing
- [ ] View album as owner
- [ ] View album as other member
- [ ] View album with many photos
- [ ] Photo grid responsive on mobile
- [ ] Photo grid responsive on tablet
- [ ] All photos display correct captions
### Lightbox
- [ ] Open lightbox from first photo
- [ ] Open lightbox from middle photo
- [ ] Open lightbox from last photo
- [ ] Next button navigates forward
- [ ] Previous button navigates backward
- [ ] Next button wraps to first photo from last
- [ ] Previous button wraps to last photo from first
- [ ] Arrow key navigation works
- [ ] Escape key closes lightbox
- [ ] Click X button closes lightbox
- [ ] Photo caption displays correctly
### Gallery Page
- [ ] Carousel displays all albums
- [ ] Previous/Next buttons work
- [ ] Album cards show cover image
- [ ] Album cards show correct photo count
- [ ] Create Album button visible
- [ ] Create Album button navigates correctly
- [ ] Edit button visible only to owner
- [ ] Empty gallery state shows correct message
- [ ] Empty gallery has Create Album link
### Access Control
- [ ] Non-members cannot access gallery
- [ ] Non-members cannot create albums
- [ ] Non-members cannot edit albums
- [ ] Users cannot edit others' albums
- [ ] Users cannot delete others' albums
- [ ] Users cannot delete others' photos
### File Uploads
- [ ] JPG files accepted
- [ ] PNG files accepted
- [ ] GIF files accepted
- [ ] WEBP files accepted
- [ ] BMP files rejected
- [ ] ZIP files rejected
- [ ] Files over 5MB rejected
- [ ] Files exactly 5MB accepted
- [ ] Drag and drop upload works
- [ ] Click-to-upload works
### Database
- [ ] Albums table has correct structure
- [ ] Photos table has correct structure
- [ ] Foreign keys work correctly
- [ ] Cascade delete removes photos when album deleted
- [ ] Unique constraint prevents duplicate user ownership
- [ ] Indexes created for performance
### Navigation
- [ ] Gallery link appears in Members Area menu
- [ ] Gallery link visible only for logged-in users
- [ ] Gallery link locked (with icon) for non-members
- [ ] "View Album" button navigates to album detail
- [ ] "Edit Album" button navigates to edit form
- [ ] "Back to Gallery" button returns to gallery
- [ ] "Add Photos" link in empty album goes to edit form
## Future Enhancements
### Possible Features
1. Multiple albums per user (modify UNIQUE constraint)
2. Album visibility settings (private/members-only/public)
3. Album categories/tags
4. Photo ordering/reordering in album
5. Photo batch operations (delete multiple, move between albums)
6. Album sharing/collaboration
7. Photo comments/ratings
8. Admin gallery management
9. Automatic image optimization/compression
10. Photo metadata preservation (EXIF)
11. Album archives/export
12. Photo search across all albums
### Schema Changes Required
- Remove UNIQUE constraint on user_id to allow multiple albums per user
- Add visibility enum field to photo_albums
- Add category_id FK to photo_albums
- Add user_id to photos for future permission model
- Add updated_at timestamps to photos for tracking
## Deployment Notes
### Pre-Deployment
1. Run migration 003 to create tables
2. Create `/assets/uploads/gallery/` directory with proper permissions
3. Ensure PHP can write to upload directory (755 or 777)
4. Test file upload with valid and invalid files
### Post-Deployment
1. Verify gallery link appears in header menu
2. Test creating first album in production
3. Test file uploads with various image formats
4. Monitor disk space usage for uploads
5. Set up automated cleanup for orphaned files (if needed)
### Permissions
```
/assets/uploads/gallery/
Permissions: 755 (rwxr-xr-x)
Owner: web server user
Individual album directories:
Permissions: 755 (rwxr-xr-x)
Created automatically by application
Photo files:
Permissions: 644 (rw-r--r--)
Created automatically by application
```
### Backups
- Include `/assets/uploads/gallery/` in backup routine
- Include `photo_albums` and `photos` tables in database backups
- Consider separate backup for large image files
## Known Limitations
1. **One album per user**: Current schema design with UNIQUE constraint on user_id allows only one album per user. Can be modified if multiple albums per user needed.
2. **No album visibility control**: All member albums are visible to all members. Could add privacy settings in future.
3. **No photo ordering UI**: Photos ordered by display_order but no UI to reorder them. Captions show filename by default.
4. **No album categories**: All albums mixed in one carousel. Could add filtering/categories.
5. **Image optimization**: No automatic compression/optimization. Large images stored as-is.
6. **No EXIF data**: Photo metadata stripped during upload. Could preserve orientation/metadata.
## Troubleshooting
### Photos not uploading
- Check `/assets/uploads/gallery/` exists and is writable
- Verify file sizes under 5MB
- Confirm image MIME types are jpeg, png, gif, or webp
- Check PHP error logs for upload errors
### Album cover not updating
- Verify cover_image field in database
- Check if photo file path stored correctly
- Confirm image file exists on disk
### Lightbox not opening
- Check browser console for JavaScript errors
- Verify image paths are accessible
- Confirm file URLs accessible directly
### Permission denied errors
- Check album owner verification logic
- Verify CSRF tokens being passed correctly
- Confirm user_id matches in session
### Memory issues with large uploads
- Reduce PHP memory_limit if needed
- Split large batches of photos into smaller uploads
- Consider image optimization/compression

View File

@@ -0,0 +1,249 @@
# Membership Linking Feature - Test & Verification Checklist
## Feature Overview
This document outlines the membership linking feature that allows multiple users (e.g., married couples, family members) to share a single membership account.
## Database Schema
### Tables Created
1. **membership_links** - Tracks relationships between primary and secondary users
- link_id (auto-increment)
- primary_user_id - User who owns/manages the membership
- secondary_user_id - User gaining access to membership
- status (ACTIVE/INACTIVE)
- created_date
- expires_date (optional)
2. **membership_permissions** - Granular permission control
- permission_id (auto-increment)
- link_id - Foreign key to membership_links
- permission_name (e.g., access_member_areas, member_pricing, etc.)
- granted_date
## Core Functions (in src/config/functions.php)
### New Functions Added
1. **linkSecondaryUserToMembership($primary_user_id, $secondary_user_id, $permissions = [])**
- Creates link and assigns default permissions
- Validates primary user has active membership
- Validates secondary user exists and doesn't already link
- Returns success/error response
2. **getUserMembershipLink($user_id)**
- Checks if user is linked as secondary to another membership
- Returns link details if active
- Returns false if no active link
3. **getLinkedSecondaryUsers($primary_user_id)**
- Returns array of all secondary users linked to primary
- Includes link creation date and status
- Used for UI display on membership_details page
4. **unlinkSecondaryUser($primary_user_id, $secondary_user_id)**
- Removes link and associated permissions
- Returns success/error response
### Modified Functions
1. **getUserMemberStatus($user_id)**
- NOW checks linked memberships at ALL failure points:
* If user has no application → check if linked to active membership
* If user hasn't accepted indemnity → check if linked
* If user has no payment record → check if linked
* If user's direct membership expired → check if linked
- Returns true for linked members even if direct membership check fails
## API Endpoints
### POST /src/processors/link_membership_user.php
- **Purpose**: AJAX endpoint for creating membership links
- **Parameters**:
- csrf_token (validated)
- secondary_user_email (validated)
- **Returns**: JSON response with success/error
- **Security**: CSRF token validation, database injection prevention
### POST /src/processors/unlink_membership_user.php
- **Purpose**: AJAX endpoint for removing membership links
- **Parameters**:
- csrf_token (validated)
- secondary_user_id (validated)
- **Returns**: JSON response with success/error
- **Security**: CSRF token validation, only primary user can unlink
## UI Implementation
### Membership Details Page (src/pages/membership_details.php)
- Added "Linked Accounts" section OUTSIDE main info form
- Displays list of currently linked secondary users
- Form to add new linked user by email
- Unlink buttons for each linked user
- IMPORTANT FIX: Form moved outside infoForm to prevent form submission conflicts
### Header Navigation (src/pages/header.php)
- "Members Area" dropdown shown for users with direct OR linked membership
- Uses getUserMemberStatus() to determine visibility
- Shows: Campsites & Gallery links
## Booking Pages & Pricing
### Protected Member Pages
- `src/pages/bookings/campsites.php` - Redirects non-members
- `src/pages/gallery/gallery.php` - Redirects non-members
- `src/pages/gallery/view_album.php` - Redirects non-members
- `src/pages/gallery/create_album.php` - Redirects non-members
### Open Booking Pages (All Users Welcome)
1. **Trip Details** (`src/pages/bookings/trip-details.php`)
- Shows member & non-member rates
- Linked members get member pricing
- Correct calculateTotal() logic with adults/children/pensioners
2. **Driver Training** (`src/pages/bookings/driver_training.php`)
- Pricing: Members vs Non-members
- Form fields adjusted for non-members
- FIXED: calculateTotal() now correctly:
* Members: (self + additional_members at member rate) + additional_nonmembers
* Non-members: (self + additional participants at non-member rate)
3. **Bush Mechanics** (`src/pages/other/bush_mechanics.php`)
- FIXED: calculateTotal() pricing logic corrected
- Members: (self at member rate) + additional members + additional non-members
- Non-members: (self + additional participants at non-member rate)
4. **Rescue & Recovery** (`src/pages/other/rescue_recovery.php`)
- FIXED: calculateTotal() pricing logic corrected
- Members: (self at member rate) + additional members + additional non-members
- Non-members: (self + additional participants at non-member rate)
5. **Course Details** (`src/pages/bookings/course_details.php`)
- Shows member & non-member rates
- Open to all users (members and non-members)
6. **Campsite Booking** (`src/pages/bookings/campsite_booking.php`)
- Pricing: Members stay FREE, Non-members R200/night
- Calculates based on getUserMemberStatus()
### Booking Processors
1. **process_trip_booking.php** - ✅ Allows non-members, applies pricing correctly
2. **process_course_booking.php** - ✅ Allows non-members, applies pricing correctly
3. **process_camp_booking.php** - ✅ Allows non-members, applies pricing correctly
## Testing Checklist
### Unit Tests
- [ ] Link secondary user to primary user membership
- [ ] Verify linked user appears in getLinkedSecondaryUsers()
- [ ] Verify linked user gets member pricing on bookings
- [ ] Verify linked user can access member-only areas
- [ ] Unlink secondary user from primary membership
- [ ] Verify unlinked user loses member benefits
- [ ] Test with invalid secondary user email
- [ ] Test with secondary user who already has direct membership
### Integration Tests
- [ ] Member books trip - should use member pricing
- [ ] Member books course - should use member pricing
- [ ] Member books campsite - should stay FREE
- [ ] Linked member books trip - should use member pricing
- [ ] Linked member books course - should use member pricing
- [ ] Linked member books campsite - should stay FREE
- [ ] Non-member books trip - should use non-member pricing
- [ ] Non-member books course - should use non-member pricing
- [ ] Non-member books campsite - should pay R200/night
- [ ] Linked member can view members gallery
- [ ] Non-member cannot access members gallery
- [ ] Linked member dropdown link shows in header
- [ ] Payment processing for non-member bookings
### UI/UX Tests
- [ ] Linking form displays properly on membership details
- [ ] Unlink buttons work correctly
- [ ] "You will be added at non-member rate" message shows for non-members
- [ ] Pricing calculations update correctly as form fields change
- [ ] Member/Non-member rate display is clear
## Known Issues & Fixes Applied
### Issue 1: Form Submission Conflicts
- **Problem**: linkUserForm nested inside infoForm - submit triggered parent
- **Fix**: Moved linkUserForm outside infoForm closes
- **Commit**: c5112e1c
### Issue 2: Linked Members Not Recognized
- **Problem**: getUserMemberStatus() only checked linked if no application existed
- **Fix**: Added linked checks at all failure points in function
- **Commit**: e63bd806
### Issue 3: JavaScript Pricing Calculations Wrong
- **Problem**: calculateTotal() in driver_training, bush_mechanics, rescue_recovery incorrectly calculated non-member totals
- **Fix**: Corrected variable names and logic to properly handle:
- Members: count themselves + additional members/non-members
- Non-members: count themselves only + additional participants
- **Commits**:
- driver_training: inline with member label UI improvement
- bush_mechanics & rescue_recovery: 646a3ecb
## Performance Considerations
### Database Queries
- getUserMembershipLink() - Single query with index on secondary_user_id
- getLinkedSecondaryUsers() - Single join query with index on primary_user_id
- getUserMemberStatus() - Multiple queries but cached in session after first call
### Recommended Indexes
- membership_links(secondary_user_id)
- membership_links(primary_user_id)
- membership_links(status)
## Security Considerations
### Access Control
- Only primary user can link/unlink accounts
- Secondary user cannot manage their own link (primary must unlink)
- CSRF tokens validated on all membership operations
### Input Validation
- User emails validated before linking
- User IDs validated as integers
- Links can only be created between valid users
### Audit Trail
- All linking operations logged via auditLog()
- Timestamps recorded for all changes
## Future Enhancements
1. **Secondary user control**
- Allow secondary users to decline/accept links
- Option for secondary user to self-unlink
2. **Permissions system**
- Granular control over which permissions secondary users receive
- Ability to revoke specific permissions without unlinking
3. **Multiple primary accounts**
- Allow one user to be secondary to multiple primaries
- Flexible family/group structure support
4. **Member linking UI**
- Search for existing members to link
- Batch link multiple users
- Link management dashboard
5. **Expiration dates**
- Time-limited links (e.g., seasonal guests)
- Auto-renewal options
- Expiration notifications
## Rollback Plan
If issues arise, revert to previous commit:
```bash
git revert <commit-hash>
```
Key commits to know:
- 646a3ecb - Latest fixes (pricing calculations)
- e63bd806 - Improved getUserMemberStatus
- c5112e1c - Fixed form nesting issue
- bd20fc0f - Initial feature implementation

View File

@@ -0,0 +1,14 @@
-- Events Table Migration
-- Add missing columns to events table for proper tracking and publishing control
-- Add columns if they don't exist (using ALTER IGNORE for compatibility)
ALTER TABLE `events`
ADD COLUMN `created_by` int DEFAULT NULL AFTER `promo`,
ADD COLUMN `published` tinyint(1) DEFAULT 0 AFTER `created_by`,
ADD COLUMN `created_at` timestamp DEFAULT CURRENT_TIMESTAMP AFTER `published`,
ADD COLUMN `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER `created_at`;
-- Add indexes for better query performance
ALTER TABLE `events` ADD INDEX `idx_date` (`date`);
ALTER TABLE `events` ADD INDEX `idx_published` (`published`);
ALTER TABLE `events` ADD INDEX `idx_created_by` (`created_by`);

View File

@@ -0,0 +1,37 @@
-- Migration: Add UNIQUE constraints to prevent duplicate membership applications and fees
-- Date: 2025-12-05
-- Purpose: Ensure each user can only have one application and one membership fee record
-- Note: Individual payments are tracked in the payments/efts table, not here
-- Add UNIQUE constraint to membership_application table
-- First, delete any duplicate applications keeping the most recent one
DELETE FROM membership_application
WHERE application_id NOT IN (
SELECT MAX(application_id)
FROM (
SELECT application_id
FROM membership_application
) tmp
GROUP BY user_id
);
-- Add UNIQUE constraint on user_id in membership_application
ALTER TABLE membership_application
ADD CONSTRAINT uk_membership_application_user_id UNIQUE (user_id);
-- Add UNIQUE constraint to membership_fees table
-- First, delete any duplicate fees keeping the most recent one
DELETE FROM membership_fees
WHERE fee_id NOT IN (
SELECT MAX(fee_id)
FROM (
SELECT fee_id
FROM membership_fees
) tmp
GROUP BY user_id
);
-- Add UNIQUE constraint on user_id in membership_fees
ALTER TABLE membership_fees
ADD CONSTRAINT uk_membership_fees_user_id UNIQUE (user_id);

View File

@@ -0,0 +1,30 @@
-- Migration: Create photo gallery tables for member photo albums
-- Date: 2025-12-05
-- Purpose: Allow members to create albums and upload photos to share with the community
-- Create photo_albums table
CREATE TABLE IF NOT EXISTS photo_albums (
album_id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
cover_image VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
INDEX idx_user_id (user_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Create photos table
CREATE TABLE IF NOT EXISTS photos (
photo_id INT PRIMARY KEY AUTO_INCREMENT,
album_id INT NOT NULL,
file_path VARCHAR(255) NOT NULL,
caption VARCHAR(255),
display_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES photo_albums(album_id) ON DELETE CASCADE,
INDEX idx_album_id (album_id),
INDEX idx_display_order (display_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

@@ -0,0 +1,63 @@
-- Migration 004: Create membership linking tables
-- Purpose: Allow multiple users to share a single membership (for couples, families, etc)
-- Create membership_links table to associate secondary users with primary membership accounts
CREATE TABLE IF NOT EXISTS `membership_links` (
`link_id` INT AUTO_INCREMENT PRIMARY KEY,
`primary_user_id` INT NOT NULL,
`secondary_user_id` INT NOT NULL,
`relationship` VARCHAR(50) NOT NULL DEFAULT 'spouse', -- spouse, family member, etc
`linked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Foreign keys
CONSTRAINT `fk_membership_links_primary` FOREIGN KEY (`primary_user_id`)
REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_membership_links_secondary` FOREIGN KEY (`secondary_user_id`)
REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE,
-- Indexes for performance
INDEX `idx_primary_user` (`primary_user_id`),
INDEX `idx_secondary_user` (`secondary_user_id`),
-- Prevent duplicate links (user cannot be linked twice)
UNIQUE KEY `unique_link` (`primary_user_id`, `secondary_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create membership_permissions table to define what secondary users can access
CREATE TABLE IF NOT EXISTS `membership_permissions` (
`permission_id` INT AUTO_INCREMENT PRIMARY KEY,
`link_id` INT NOT NULL,
`permission_name` VARCHAR(100) NOT NULL, -- 'access_member_areas', 'member_pricing', 'book_campsites', etc
`granted_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Foreign key
CONSTRAINT `fk_membership_permissions_link` FOREIGN KEY (`link_id`)
REFERENCES `membership_links`(`link_id`) ON DELETE CASCADE ON UPDATE CASCADE,
-- Indexes
INDEX `idx_link` (`link_id`),
-- Prevent duplicate permissions
UNIQUE KEY `unique_permission` (`link_id`, `permission_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add foreign key to membership_fees to support links (optional - for tracking which membership fee covers the linked users)
-- ALTER TABLE `membership_fees` ADD COLUMN `primary_user_id` INT AFTER `user_id`;
-- This allows you to see if a fee was paid by primary or secondary user while maintaining the relationship
-- Create a view to easily get all users linked to a membership
CREATE OR REPLACE VIEW `linked_membership_users` AS
SELECT
primary_user_id,
secondary_user_id,
relationship,
linked_at
FROM membership_links
UNION ALL
SELECT
primary_user_id,
primary_user_id as secondary_user_id,
'primary' as relationship,
linked_at
FROM membership_links;

View File

@@ -0,0 +1,25 @@
-- ============================================================================
-- MIGRATION: Create Track Obstacles Table
-- Date: 2025-12-12
-- Description: Create table to store 4x4 track obstacles with positioning and details
-- ============================================================================
CREATE TABLE IF NOT EXISTS track_obstacles (
obstacle_id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT 'Obstacle name (e.g., "Rock Crawl", "Water Crossing")',
x_position INT NOT NULL COMMENT 'X pixel position on the track map',
y_position INT NOT NULL COMMENT 'Y pixel position on the track map',
difficulty VARCHAR(20) NOT NULL COMMENT 'Difficulty level (easy, medium, hard, extreme)',
description TEXT COMMENT 'Detailed description of the obstacle',
image_path VARCHAR(255) COMMENT 'Path to obstacle image (e.g., assets/images/obstacles/obstacle1.jpg)',
marker_color VARCHAR(20) NOT NULL COMMENT 'Marker color: red, green, black, or split (red-green)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name),
INDEX idx_difficulty (difficulty)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================================
-- ROLLBACK INSTRUCTIONS (if needed)
-- ============================================================================
-- DROP TABLE IF EXISTS track_obstacles;

View File

@@ -0,0 +1,13 @@
-- Migration: create notifications table (corrected: `read_by` nullable)
CREATE TABLE IF NOT EXISTS `notifications` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` INT DEFAULT NULL,
`event` VARCHAR(100) NOT NULL,
`sub_feed` VARCHAR(100) DEFAULT NULL,
`data` TEXT DEFAULT NULL,
`target_url` VARCHAR(1024) DEFAULT NULL,
`read_by` TEXT DEFAULT NULL,
`time_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`user_id`),
INDEX (`sub_feed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,4 +1,5 @@
<?php
/**
* UNIFIED HEADER TEMPLATE
*
@@ -25,6 +26,7 @@ require_once($rootDir . "/src/config/env.php");
require_once($rootDir . "/src/config/session.php");
require_once($rootDir . "/src/config/connection.php");
require_once($rootDir . "/src/config/functions.php");
require_once($rootDir . "/src/helpers/notification_helper.php");
$is_logged_in = isset($_SESSION['user_id']);
if (isset($_SESSION['user_id'])) {
@@ -186,8 +188,7 @@ if ($headerStyle === 'light') {
background-color: #f8f8f8;
}
<?php if ($headerStyle === 'light'): ?>
.page-banner-area {
<?php if ($headerStyle === 'light'): ?>.page-banner-area {
position: relative;
background-size: cover;
background-position: center;
@@ -214,6 +215,7 @@ if ($headerStyle === 'light') {
position: relative;
z-index: 3;
}
<?php endif; ?>
</style>
@@ -258,6 +260,7 @@ if ($headerStyle === 'light') {
<ul class="navigation clearfix">
<li><a href="index">Home</a></li>
<li><a href="about">About</a></li>
<li><a href="base4">BASE 4</a></li>
<li><a href="trips">Trips</a>
<?php if ($headerStyle === 'dark'): ?>
<ul>
@@ -277,16 +280,15 @@ if ($headerStyle === 'light') {
</ul>
</li>
<li><a href="events">Events</a></li>
<li><a href="blog">Blog</a></li>
<?php if ($role === 'admin' || $role === 'superadmin') { ?>
<li class="dropdown"><a href="#">admin</a>
<ul>
<li><a href="admin_web_users">Website Users</a></li>
<li><a href="admin_members">4WDCSA Members</a></li>
<li><a href="admin_trip_bookings">Trip Bookings</a></li>
<li><a href="admin_course_bookings">Course Bookings</a></li>
<li><a href="admin_efts">EFT Payments</a></li>
<li><a href="process_payments">Process Payments</a></li>
<li><a href="admin_trips_events_courses">Trips, Events & Courses</a></li>
<li><a href="admin_bookings">Bookings</a></li>
<li><a href="admin_transactions">iKhokha Payment History</a></li>
<!-- <li><a href="process_payments">Process Payments</a></li> -->
<?php if ($role === 'superadmin') { ?>
<li><a href="admin_visitors">Visitor Log</a></li>
<?php } ?>
@@ -294,21 +296,37 @@ if ($headerStyle === 'light') {
</li>
<?php } ?>
<li><a href="contact">Contact</a></li>
<?php if ($is_member) : ?>
<?php if ($is_logged_in) : ?>
<li class="dropdown"><a href="#">Members Area</a>
<ul>
<li><a href="#">Coming Soon!</a></li>
<li><a href="blog">Blog</a></li>
<?php
if (getUserMemberStatus($_SESSION['user_id'])) {
echo "<li><a href=\"campsites\">Campsites Directory</a></li>";
echo "<li><a href=\"gallery\">Photo Gallery</a></li>";
} else {
echo "<li><a href=\"membership\">Campsites Directory</a><i class='fal fa-lock'></i></li>";
echo "<li><a href=\"membership\">Photo Gallery</a><i class='fal fa-lock'></i></li>";
}
?>
</ul>
</li>
<?php endif; ?>
<?php if ($is_logged_in) : ?>
<li class="dropdown"><a href="#">My Account</a>
<ul>
<li><a href="account_settings">Account Settings</a></li>
<li><a href="membership_details">Membership</a></li>
<li><a href="bookings">My Bookings</a></li>
<li><a href="submit_pop">Submit P.O.P</a></li>
<?php
if (getUserMemberStatus($_SESSION['user_id'])) {
echo "<li><a href=\"user_blogs\">My Blog Posts</a></li>";
} else {
echo "<li><a href=\"membership\">My Blog Posts</a><i class='fal fa-lock'></i></li>";
}
?>
<!-- <li><a href="submit_pop">Submit P.O.P</a></li> -->
<li><a href="logout">Log Out</a></li>
</ul>
</li>
@@ -328,11 +346,19 @@ if ($headerStyle === 'light') {
<div class="profile-menu">
<div class="profile-info">
<span style="color: <?php echo $textColor; ?>;">Welcome, <?php echo $_SESSION['first_name']; ?></span>
<div class="notif-avatar-container" data-admin-id="<?php echo intval($_SESSION['user_id'] ?? 0); ?>">
<a href="account_settings">
<img src="<?php echo $_SESSION['profile_pic']; ?>?v=<?php echo time(); ?>" alt="Profile Picture" class="profile-pic">
</a>
<span id="notif-badge" class="notif-badge"></span>
</div>
<div id="notif-panel" class="notif-panel" style="display:none;"></div>
</div>
</div>
<?php if ($role === 'admin' || $role === 'superadmin') { ?>
<link rel="stylesheet" href="assets/css/notifications.css">
<script src="assets/js/notifications_panel.js" defer></script>
<?php } ?>
<?php else : ?>
<a href="login" class="theme-btn style-two bgc-secondary">
<span data-hover="Log In">Log In</span>
@@ -347,11 +373,44 @@ if ($headerStyle === 'light') {
<!--End Header Upper-->
</header>
<a href="https://wa.me/27790652795?text=Hi,%20I%20would%20like%20to%20know%20more%20about%20your%20club!"
class="whatsapp-float"
target="_blank"
aria-label="Chat on WhatsApp">
<i class="fab fa-whatsapp"></i>
</a>
<style>
.whatsapp-float {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
background-color: #25D366;
color: white;
border-radius: 50%;
text-align: center;
font-size: 30px;
line-height: 60px;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.whatsapp-float:hover {
background-color: #1ebe5d;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const profileInfo = document.querySelector('.profile-info');
if (profileInfo) {
profileInfo.addEventListener('click', function(event) {
// Ignore clicks on the notifications avatar so the notif panel
// can handle its own toggle without also toggling the profile dropdown.
if (event.target.closest && event.target.closest('.notif-avatar-container')) return;
const dropdownMenu = document.querySelector('.dropdown-menu2');
if (dropdownMenu) {
dropdownMenu.style.display = dropdownMenu.style.display === 'block' ? 'none' : 'block';

392
index.php
View File

@@ -1,8 +1,66 @@
<?php
$rootPath = dirname(__FILE__);
$headerStyle = 'dark';
include_once('header.php');
include_once($rootPath . '/header.php');
$indemnityPending = false;
// Set session flag for updates modal - only show once per session and before Jan 1, 2026
if (!isset($_SESSION['updates_modal_shown'])) {
$currentDate = new DateTime();
$endDate = new DateTime('2026-01-01');
if ($currentDate < $endDate) {
$_SESSION['updates_modal_shown'] = true;
$showUpdatesModal = true;
} else {
$showUpdatesModal = false;
}
} else {
$showUpdatesModal = false;
}
// Show renew membership modal for logged-in users and where membership_fees payment_status is not PENDING RENEWAL. only show once per session
$showRenewModal = isset($_SESSION['user_id']) ? true : false;
if ($showRenewModal) {
if (!isset($_SESSION['renew_modal_shown'])) {
$_SESSION['renew_modal_shown'] = true;
} else {
$showRenewModal = false;
}
$user_id = $_SESSION['user_id'];
// Ensure we have a DB connection
if (!isset($conn) || $conn === null) {
$showRenewModal = false;
} else {
$stmt = $conn->prepare("SELECT payment_status FROM membership_fees WHERE user_id = ? LIMIT 1");
$stmt->bind_param("i", $user_id);
$stmt->execute();
// store_result so we can check num_rows
$stmt->store_result();
// If there's no membership_fees record for this user, don't show the renew modal
if ($stmt->num_rows === 0) {
$showRenewModal = false;
} else {
$stmt->bind_result($payment_status);
$stmt->fetch();
if ($payment_status === 'PENDING RENEWAL') {
$showRenewModal = false;
}
if (isMembershipExpiringSoon($user_id)) {
$showRenewModal = true;
} else {
$showRenewModal = false;
}
}
$stmt->close();
}
}
if (isset($_SESSION['user_id']) && isset($conn) && $conn !== null) {
$userId = $_SESSION['user_id'];
$stmt = $conn->prepare("SELECT user_id FROM membership_application WHERE user_id = ? AND accept_indemnity = 0 LIMIT 1");
@@ -143,8 +201,6 @@ if (countUpcomingTrips() > 0) { ?>
}
?>
<!-- About Us Area start -->
<section class="about-us-area py-100 rpb-90 rel z-1">
<div class="container">
@@ -268,7 +324,6 @@ if (countUpcomingTrips() > 0) { ?>
</section>
<!-- Features Area end -->
<!-- Hotel Area start -->
<section class="hotel-area bgc-black py-100 rel z-1">
<div class="container-fluid">
@@ -378,57 +433,77 @@ if (countUpcomingTrips() > 0) { ?>
</div>
<div class="row justify-content-center">
<?php
$sql = "SELECT blog_id, title, date, category, image, description, author, link, members_only FROM blogs WHERE status = 'published' ORDER BY date DESC LIMIT 3";
$result = $conn->query($sql);
$result = $conn->prepare("
SELECT
b.blog_id,
b.title,
b.description,
b.category,
b.status,
b.date,
b.image,
b.members_only,
CONCAT(u.first_name, ' ', u.last_name) AS author_name,
u.email AS author_email,
u.profile_pic
FROM blogs b
JOIN users u ON b.author = u.user_id
WHERE b.status = 'published'
ORDER BY b.date DESC
");
if ($result->num_rows > 0) {
$result->execute();
$posts = $result->get_result();
if ($posts->num_rows > 0) {
// Loop through each row
while ($row = $result->fetch_assoc()) {
$blog_id = $row['blog_id'];
$blog_title = $row['title'];
$blog_date = $row['date'];
$blog_category = $row['category'];
$blog_image = $row['image'];
$blog_description = $row['description'];
$blog_author = $row['author'];
$members_only = $row['members_only'];
while ($post = $posts->fetch_assoc()):
$blog_id = $post['blog_id'];
$blog_title = $post['title'];
$blog_date = $post['date'];
$blog_category = $post['category'];
$blog_image = $post['image'];
$blog_description = $post['description'];
$members_only = $post['members_only'];
if ($members_only) {
if (!isset($_SESSION['user_id'])) {
$blog_link = "login.php";
$blog_link = "login";
$button_hover = "Members Only";
$icon = "fa-lock";
} else {
if (getUserMemberStatus($_SESSION['user_id'])) {
$blog_link = $row['link'];
$blog_link = "blog_read?token=" . encryptData($blog_id, $salt);
$button_hover = "Read More";
$icon = "fa-arrow-right";
} else {
$blog_link = "membership.php";
$blog_link = "membership";
$button_hover = "Members Only";
$icon = "fa-lock";
}
}
} else {
$blog_link = $row['link'];
$blog_link = "blog_read?token=" . encryptData($blog_id, $salt);
$button_hover = "Read More";
$icon = "fa-arrow-right";
}
echo '
<div class="col-xl-4 col-md-6">
<div class="blog-item" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="content">
<a href="#" class="category">' . $blog_category . '</a>
<h5><a href="' . $blog_link . '">' . $blog_title . '</a></h5>
<ul class="blog-meta">
<li><i class="far fa-calendar-alt"></i> <a href="#">' . $blog_date . '</a></li>
<li><i class="far fa-user"></i>' . getFullName($blog_author) . '</li>
</ul>
<div class="content" style="width:100%;">
<div class="destination-header d-flex align-items-start gap-3">
<img src="' . $post["profile_pic"] . '" alt="Author" class="rounded-circle border" width="60" height="60">
<div>
<span class="badge bg-dark mb-1">' . strtoupper($post["category"]) . '</span>
<h5 class="mb-0">' . $post["title"] . '</h5>
<small class="text-muted">' . $post["author_name"] . '</small>
</div>
</div>
<p style="max-height: 60px; overflow: hidden;">' . $post["description"] . '</p>
</div>
<div class="image">
<img style="border-radius:20px;" src="assets/images/blog/' . $blog_id . '/' . $blog_image . '" alt="Blog List">
<img style="aspect-ratio: 4 / 3; object-fit: cover; object-position: center; border-radius:20px; width: 100%; display: block;" src="' . $blog_image . '" alt="Blog List">
</div>
<a style="width:100%;" href="' . $blog_link . '" class="theme-btn">
<span style="width:100%;" data-hover="' . $button_hover . '">Read More</span>
@@ -436,10 +511,12 @@ if (countUpcomingTrips() > 0) { ?>
</a>
</div>
</div>';
endwhile;
} else {
echo "<p>No blog posts available.</p>";
}
// Close connection
$conn->close();
} ?>
?>
</div>
</div>
</section>
@@ -613,8 +690,255 @@ if (countUpcomingTrips() > 0) { ?>
updateCountdown(); // initial call
setInterval(updateCountdown, 1000);
// Show updates modal on page load
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('updatesModal');
const closeBtn = document.querySelector('.updates-modal-close');
const showModal = <?php echo $showUpdatesModal ? 'true' : 'false'; ?>;
const showRenewModal = <?php echo $showRenewModal ? 'true' : 'false'; ?>;
if (showModal && modal) {
// Show updates modal after a short delay for better UX
setTimeout(function() {
modal.style.display = 'flex';
}, 500);
}
// Close updates modal when X is clicked
if (closeBtn) {
closeBtn.addEventListener('click', function() {
if (modal) modal.style.display = 'none';
});
}
// Close updates modal when clicking outside the modal content
if (modal) {
modal.addEventListener('click', function(event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
}
// Show renew membership Bootstrap modal for logged-in users
try {
const renewModalEl = document.getElementById('renewModal');
if (showRenewModal && renewModalEl && typeof bootstrap !== 'undefined') {
setTimeout(function() {
const renewModal = new bootstrap.Modal(renewModalEl);
renewModal.show();
}, 700);
}
} catch (e) {
console.warn('Renew modal show failed', e);
}
});
</script>
<!-- Updates Modal -->
<!-- Renew Membership Modal (shown to logged-in users) -->
<div class="modal fade" id="renewModal" tabindex="-1" aria-labelledby="renewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<!-- <div class="modal-header bg-secondary text-white">
<h5 class="modal-title" id="renewModalLabel">Membership Renewal Reminder</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> -->
<div class="modal-body">
Your membership will be expiring soon. Click below to renew now.
<a style="width:100%; display:block;" href="renewal_payment" class="theme-btn style-two style-three mt-3">Renew Now</a>
</div>
<div class="modal-footer">
<button type="button" style="width:100%; display:block;" class="theme-btn" data-bs-dismiss="modal">Remind Me Later</button>
</div>
</div>
</div>
</div>
<!-- Updates Modal -->
<div id="updatesModal" class="updates-modal">
<div class="updates-modal-content">
<span class="updates-modal-close">&times;</span>
<div class="updates-modal-header">
<h2>What's New on 4WDCSA.co.za</h2>
</div>
<div class="updates-modal-body">
<div class="update-item">
<h3><i class="fas fa-images" style="margin-right: 10px; color: #e90000;"></i>Track Map</h3>
<p>Interactive map of the BASE4 4x4 Training Track.</p>
</div>
<div class="update-item">
<h3><i class="fas fa-images" style="margin-right: 10px; color: #e90000;"></i>Photo Gallery</h3>
<p>Explore and share memories from club events and trips. Members can now upload and view photos from past adventures.</p>
</div>
<div class="update-item">
<h3><i class="fas fa-map-location-dot" style="margin-right: 10px; color: #e90000;"></i>Campsites Directory</h3>
<p>Discover recommended campsites and accommodation options for your next adventure. Browse detailed information and member reviews.</p>
</div>
<div class="update-item">
<h3><i class="fas fa-users" style="margin-right: 10px; color: #e90000;"></i>Linked Membership</h3>
<p>Link a second user to your profile so both can book trips and receive member benefits together.</p>
</div>
<div class="update-item">
<h3><i class="fas fa-pen-fancy" style="margin-right: 10px; color: #e90000;"></i>Blog Posts</h3>
<p>Members can now post blogs, reviews, and trip reports to share experiences with the community.</p>
</div>
</div>
<div class="updates-modal-footer">
<button class="theme-btn style-two updates-modal-btn" onclick="document.getElementById('updatesModal').style.display='none'">
<span>Got It</span>
</button>
</div>
</div>
</div>
<style>
.updates-modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.updates-modal-content {
background-color: white;
padding: 40px;
border-radius: 15px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
animation: slideDown 0.3s ease-out;
position: relative;
box-sizing: border-box;
/* Limit height so the modal never exceeds the viewport and allow internal scrolling */
max-height: calc(100vh - 80px);
overflow-y: auto;
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.updates-modal-close {
position: absolute;
right: 20px;
top: 20px;
font-size: 28px;
font-weight: bold;
color: #999;
cursor: pointer;
transition: color 0.2s;
}
.updates-modal-close:hover {
color: #e90000;
}
.updates-modal-header {
margin-bottom: 30px;
text-align: center;
}
.updates-modal-header h2 {
color: #1c231f;
font-size: 28px;
margin: 0;
}
.updates-modal-body {
margin-bottom: 30px;
}
.update-item {
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid #e0e0e0;
}
.update-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.update-item h3 {
color: #1c231f;
font-size: 18px;
margin: 0 0 10px 0;
display: flex;
align-items: center;
}
.update-item p {
color: #666;
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.updates-modal-footer {
text-align: center;
}
.updates-modal-btn {
padding: 10px 30px !important;
background-color: #e90000 !important;
color: white !important;
}
.updates-modal-btn:hover {
background-color: #c70000 !important;
}
@media (max-width: 600px) {
.updates-modal {
/* Align to top on small screens so content's top (and close button) is visible */
align-items: flex-start;
padding-top: 18px;
}
.updates-modal-content {
padding: 20px;
max-width: 92%;
width: 92%;
max-height: calc(100vh - 36px);
}
.updates-modal-header h2 {
font-size: 24px;
}
.update-item h3 {
font-size: 16px;
}
}
</style>
</body>
</html>

128
progress.log Normal file
View File

@@ -0,0 +1,128 @@
[2025-12-15 12:32:19] AJAX BLOCK ENTERED
[2025-12-15 12:32:19] startDate=2025-10-16
[2025-12-15 12:32:19] endDate=2025-12-15
[2025-12-15 12:32:19] APP ID present: YES
[2025-12-15 12:32:19] APP SECRET present: YES
[2025-12-15 12:32:19] PAYLOAD: https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:32:19] IK-SIGN: ced35ab201ad54e8f8b5935d2778c4ec7e75af0102d40d9c4515f8118ca8b5dd
[2025-12-15 12:32:19] CURL HTTP CODE: 422
[2025-12-15 12:32:19] CURL ERROR: none
[2025-12-15 12:32:19] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:33:31] AJAX BLOCK ENTERED
[2025-12-15 12:33:31] startDate=2025-10-16
[2025-12-15 12:33:31] endDate=2025-12-15
[2025-12-15 12:33:31] APP ID present: YES
[2025-12-15 12:33:31] APP SECRET present: YES
[2025-12-15 12:33:31] IKHOKHA PAYLOAD (FULL URL): https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:33:31] IKHOKHA IK-SIGN: ced35ab201ad54e8f8b5935d2778c4ec7e75af0102d40d9c4515f8118ca8b5dd
[2025-12-15 12:33:31] CURL HTTP CODE: 422
[2025-12-15 12:33:31] CURL ERROR: none
[2025-12-15 12:33:31] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:33:59] AJAX BLOCK ENTERED
[2025-12-15 12:33:59] startDate=2025-10-16
[2025-12-15 12:33:59] endDate=2025-12-15
[2025-12-15 12:33:59] APP ID present: YES
[2025-12-15 12:33:59] APP SECRET present: YES
[2025-12-15 12:33:59] IKHOKHA PAYLOAD (FULL URL): https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:33:59] IKHOKHA IK-SIGN: ced35ab201ad54e8f8b5935d2778c4ec7e75af0102d40d9c4515f8118ca8b5dd
[2025-12-15 12:34:00] CURL HTTP CODE: 422
[2025-12-15 12:34:00] CURL ERROR: none
[2025-12-15 12:34:00] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:37:06] AJAX BLOCK ENTERED
[2025-12-15 12:37:06] startDate=2025-10-16
[2025-12-15 12:37:06] endDate=2025-12-15
[2025-12-15 12:37:06] APP ID present: YES
[2025-12-15 12:37:06] APP SECRET present: YES
[2025-12-15 12:37:06] IKHOKHA ENDPOINT (REQUEST): https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:37:06] IKHOKHA PAYLOAD (SIGNED): https://api.ikhokha.com/public-api/v1/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:37:06] IKHOKHA IK-SIGN: 418e48921e566e5804b58f65e1ca4a28dba4d69de3611d1cf7f90f865490f42d
[2025-12-15 12:37:06] CURL HTTP CODE: 422
[2025-12-15 12:37:06] CURL ERROR: none
[2025-12-15 12:37:06] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:56:21] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:56:21] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:56:37] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:56:37] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:57:04] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:57:04] 13e6e02a7ccad937bc27b31038373d48d8ba2700a7ba8d9a7a2e4f9b07378692 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:57:30] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:57:30] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:57:32] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:57:32] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:57:34] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:57:34] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:58:00] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA Endpoint
[2025-12-15 12:58:00] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:58:00] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:58:04] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA Endpoint
[2025-12-15 12:58:04] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:58:04] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:58:17] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 12:58:17] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:58:17] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 12:58:48] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 12:58:48] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 12:58:48] 13e6e02a7ccad937bc27b31038373d48d8ba2700a7ba8d9a7a2e4f9b07378692 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:00:13] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:00:13] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:00:13] 13e6e02a7ccad937bc27b31038373d48d8ba2700a7ba8d9a7a2e4f9b07378692 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:00:29] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:00:29] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:00:29] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:03:10] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:03:10] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:03:10] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:03:19] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:03:19] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:03:19] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:05:51] bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557 | CONTEXT: IKHOKHA Signature from Webhook
[2025-12-15 13:05:51] "{\"paylinkID\":\"ys5225k4z56x0mm\",\"status\":\"SUCCESS\",\"externalTransactionID\":\"693efeaca71a9\",\"responseCode\":\"00\",\"text\":null}" | CONTEXT: IKHOKHA Stringified Body
[2025-12-15 13:06:29] bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557 | CONTEXT: IKHOKHA Signature from Webhook
[2025-12-15 13:06:29] "{\"paylinkID\":\"ys5225k4z56x0mm\",\"status\":\"SUCCESS\",\"externalTransactionID\":\"693efeaca71a9\",\"responseCode\":\"00\",\"text\":null}" | CONTEXT: IKHOKHA Stringified Body
[2025-12-15 13:06:29] /src/api/ikhokha_webhook.php\"{\\"paylinkID\\":\\"ys5225k4z56x0mm\\",\\"status\\":\\"SUCCESS\\",\\"externalTransactionID\\":\\"693efeaca71a9\\",\\"responseCode\\":\\"00\\",\\"text\\":null}\" | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:06:29] 43a6a56af31c276174953e115eb41402f12969fedab5b673dd34327cd7135a75 | CONTEXT: IKHOKHA Generated Signature
[2025-12-15 13:06:42] bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557 | CONTEXT: IKHOKHA Signature from Webhook
[2025-12-15 13:06:42] {"paylinkID":"ys5225k4z56x0mm","status":"SUCCESS","externalTransactionID":"693efeaca71a9","responseCode":"00","text":null} | CONTEXT: IKHOKHA Stringified Body
[2025-12-15 13:06:42] /src/api/ikhokha_webhook.php\"{\\"paylinkID\\":\\"ys5225k4z56x0mm\\",\\"status\\":\\"SUCCESS\\",\\"externalTransactionID\\":\\"693efeaca71a9\\",\\"responseCode\\":\\"00\\",\\"text\\":null}\" | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:06:42] 43a6a56af31c276174953e115eb41402f12969fedab5b673dd34327cd7135a75 | CONTEXT: IKHOKHA Generated Signature
[2025-12-15 13:07:09] bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557 | CONTEXT: IKHOKHA Signature from Webhook
[2025-12-15 13:07:09] {"paylinkID":"ys5225k4z56x0mm","status":"SUCCESS","externalTransactionID":"693efeaca71a9","responseCode":"00","text":null} | CONTEXT: IKHOKHA Stringified Body
[2025-12-15 13:07:09] /src/api/ikhokha_webhook.php\"{\\"paylinkID\\":\\"ys5225k4z56x0mm\\",\\"status\\":\\"SUCCESS\\",\\"externalTransactionID\\":\\"693efeaca71a9\\",\\"responseCode\\":\\"00\\",\\"text\\":null}\" | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:07:09] 43a6a56af31c276174953e115eb41402f12969fedab5b673dd34327cd7135a75 | CONTEXT: IKHOKHA Generated Signature
[2025-12-15 13:09:39] bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557 | CONTEXT: IKHOKHA Signature from Webhook
[2025-12-15 13:19:12] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:19:12] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:19:12] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:36:28] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:36:28] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:36:28] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 13:36:54] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 13:36:54] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 13:36:54] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:41:25] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:41:25] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:41:25] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:43:53] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:43:53] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:43:53] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:44:29] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:44:29] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:44:29] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:46:02] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:46:02] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:46:02] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:47:46] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:47:46] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:47:46] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:47:51] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:47:51] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:47:51] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-15 15:48:43] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-15 15:48:43] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-15 15:48:43] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-16 22:38:31] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-16 22:38:31] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-16 22:38:31] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature
[2025-12-17 12:35:13] IKFLESZTKFM4HWWS76131L8HK9BYF96P | CONTEXT: IKHOKHA App ID
[2025-12-17 12:35:13] /public-api/v1/api/payments/history | CONTEXT: IKHOKHA Payload to Sign
[2025-12-17 12:35:13] b3b592829090d2cfd0912ccbdec73db18742088d01a2d2bee9f0eacdf37a6b26 | CONTEXT: IKHOKHA Signature

View File

@@ -0,0 +1,61 @@
-- Migration: add iKhokha / provider metadata to payments table
-- Migration: add iKhokha / provider metadata to payments table
-- Compatible with MySQL versions that do not support `ADD COLUMN IF NOT EXISTS`.
-- Run on staging first. Make a DB backup before running on production.
DELIMITER $$
CREATE PROCEDURE add_payment_columns_if_missing()
BEGIN
-- provider
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `provider` VARCHAR(50) NULL AFTER `status`;
END IF;
-- provider_payment_id
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_payment_id') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `provider_payment_id` VARCHAR(128) NULL AFTER `provider`;
END IF;
-- payment_link
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'payment_link') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `payment_link` VARCHAR(512) NULL AFTER `provider_payment_id`;
END IF;
-- provider_status
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_status') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `provider_status` VARCHAR(50) NULL AFTER `payment_link`;
END IF;
-- provider_response (JSON)
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_response') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `provider_response` JSON NULL AFTER `provider_status`;
END IF;
-- booking_id
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'booking_id') = 0 THEN
ALTER TABLE `payments` ADD COLUMN `booking_id` INT NULL AFTER `user_id`;
END IF;
-- index idx_provider_payment_id
IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND INDEX_NAME = 'idx_provider_payment_id') = 0 THEN
ALTER TABLE `payments` ADD INDEX `idx_provider_payment_id` (`provider_payment_id`(128));
END IF;
END$$
DELIMITER ;
CALL add_payment_columns_if_missing();
DROP PROCEDURE IF EXISTS add_payment_columns_if_missing;
-- Notes:
-- 1) This script creates a short stored procedure which performs existence checks
-- against INFORMATION_SCHEMA before applying each ALTER TABLE. Run in the
-- MySQL client or via your migration tool. It avoids syntax not supported on
-- older MySQL versions.
-- 2) Test on staging and make a DB dump before running on production.

View File

@@ -0,0 +1,14 @@
-- Migration: copy existing efts records into payments for historical continuity
-- This inserts EFT records into payments table as payments with provider='eft'.
-- Run only after verifying step 001 has been applied and a backup exists.
START TRANSACTION;
INSERT IGNORE INTO `payments` (`payment_id`, `user_id`, `amount`, `status`, `date`, `description`, `provider`, `provider_payment_id`, `provider_status`)
SELECT `eft_id`, `user_id`, `amount`, `status`, `timestamp`, `description`, 'eft', `eft_id`, `status` FROM `efts`;
COMMIT;
-- Notes:
-- 1) `INSERT IGNORE` prevents duplicate primary-key errors (payments.payment_id is PK).
-- 2) After running, review migrated rows and ensure admin workflows still operate.

View File

@@ -0,0 +1,21 @@
# iKhokha Migration SQL
This folder contains SQL migration files to add iKhokha/provider metadata to the `payments` table and to migrate legacy `efts` records.
Order to run:
1. Backup your database (mysqldump or preferred tool).
2. Apply `001_add_payment_columns.sql` on staging and verify schema changes.
3. (Optional) Apply `002_migrate_efts_to_payments.sql` to copy legacy `efts` into `payments`.
Commands (example using MySQL client):
```bash
mysql -u dbuser -p databasename < scripts/ikhokha_migrations/001_add_payment_columns.sql
mysql -u dbuser -p databasename < scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql
```
Notes:
- Always test on staging before running in production.
- `provider_response` is a JSON column used to store raw provider responses for audit.
- If you prefer not to migrate `efts`, skip step 3 and keep legacy POP handling.

View File

@@ -1,53 +1,124 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<!-- Updated: 2025-12-13 -->
<!-- Homepage -->
<url>
<loc>https://4wdcsa.co.za/</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>1.00</priority>
<changefreq>weekly</changefreq>
</url>
<!-- Main Pages -->
<url>
<loc>https://4wdcsa.co.za/about</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.90</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/index.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<loc>https://4wdcsa.co.za/contact</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.90</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/track-map</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.85</priority>
<changefreq>weekly</changefreq>
</url>
<!-- Trips & Events -->
<url>
<loc>https://4wdcsa.co.za/trips</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.95</priority>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/events</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.95</priority>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/driver_training</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.90</priority>
<changefreq>monthly</changefreq>
</url>
<!-- Blog & Gallery -->
<url>
<loc>https://4wdcsa.co.za/blog</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.85</priority>
<changefreq>weekly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/gallery</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.80</priority>
<changefreq>weekly</changefreq>
</url>
<!-- Membership -->
<url>
<loc>https://4wdcsa.co.za/membership</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.95</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/about.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<loc>https://4wdcsa.co.za/membership_details</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.85</priority>
<changefreq>monthly</changefreq>
</url>
<!-- Campsites -->
<url>
<loc>https://4wdcsa.co.za/campsites</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.90</priority>
<changefreq>monthly</changefreq>
</url>
<!-- Special Pages -->
<url>
<loc>https://4wdcsa.co.za/rescue_recovery</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.80</priority>
<changefreq>monthly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/trips.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<loc>https://4wdcsa.co.za/bush_mechanics</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.80</priority>
<changefreq>monthly</changefreq>
</url>
<!-- Auth Pages (Lower Priority) -->
<url>
<loc>https://4wdcsa.co.za/login</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.60</priority>
<changefreq>yearly</changefreq>
</url>
<url>
<loc>https://4wdcsa.co.za/events.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.80</priority>
<loc>https://4wdcsa.co.za/register</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.60</priority>
<changefreq>yearly</changefreq>
</url>
<!-- Legal -->
<url>
<loc>https://4wdcsa.co.za/blog.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://4wdcsa.co.za/login.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://4wdcsa.co.za/membership.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.80</priority>
</url>
<url>
<loc>https://4wdcsa.co.za/register.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.64</priority>
</url>
<url>
<loc>https://4wdcsa.co.za/forgot_password.php</loc>
<lastmod>2025-04-10T11:24:41+00:00</lastmod>
<priority>0.64</priority>
<loc>https://4wdcsa.co.za/privacy_policy</loc>
<lastmod>2025-12-13T00:00:00+00:00</lastmod>
<priority>0.50</priority>
<changefreq>yearly</changefreq>
</url>
</urlset>

View File

@@ -0,0 +1,56 @@
[2025-12-15 12:28:42] FILE HIT
[2025-12-15 12:28:42] AJAX BLOCK ENTERED
[2025-12-15 12:28:42] startDate=2025-10-16
[2025-12-15 12:28:42] endDate=2025-12-15
[2025-12-15 12:28:42] APP ID present: YES
[2025-12-15 12:28:42] APP SECRET present: YES
[2025-12-15 12:28:42] PAYLOAD: /public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:28:42] IK-SIGN: 3d610c60c8306cd1d5c99b2639f0e810594f8ffb9306a98d703f691173dab47d
[2025-12-15 12:28:44] CURL HTTP CODE: 422
[2025-12-15 12:28:44] CURL ERROR: none
[2025-12-15 12:28:44] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:28:51] FILE HIT
[2025-12-15 12:28:51] AJAX BLOCK ENTERED
[2025-12-15 12:28:51] startDate=2025-10-16
[2025-12-15 12:28:51] endDate=2025-12-15
[2025-12-15 12:28:51] APP ID present: YES
[2025-12-15 12:28:51] APP SECRET present: YES
[2025-12-15 12:28:51] PAYLOAD: /public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:28:51] IK-SIGN: 3d610c60c8306cd1d5c99b2639f0e810594f8ffb9306a98d703f691173dab47d
[2025-12-15 12:28:51] CURL HTTP CODE: 422
[2025-12-15 12:28:51] CURL ERROR: none
[2025-12-15 12:28:51] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:30:54] FILE HIT
[2025-12-15 12:30:54] AJAX BLOCK ENTERED
[2025-12-15 12:30:54] startDate=2025-10-16
[2025-12-15 12:30:54] endDate=2025-12-15
[2025-12-15 12:30:54] APP ID present: YES
[2025-12-15 12:30:54] APP SECRET present: YES
[2025-12-15 12:30:54] PAYLOAD: https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:30:54] IK-SIGN: ced35ab201ad54e8f8b5935d2778c4ec7e75af0102d40d9c4515f8118ca8b5dd
[2025-12-15 12:30:55] CURL HTTP CODE: 422
[2025-12-15 12:30:55] CURL ERROR: none
[2025-12-15 12:30:55] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:31:13] FILE HIT
[2025-12-15 12:31:13] AJAX BLOCK ENTERED
[2025-12-15 12:31:13] startDate=2025-10-16
[2025-12-15 12:31:13] endDate=2025-12-15
[2025-12-15 12:31:13] APP ID present: YES
[2025-12-15 12:31:13] APP SECRET present: YES
[2025-12-15 12:31:13] PAYLOAD: https://api.ikhokha.com/public-api/v1/api/payments/history?startDate=2025-10-16&endDate=2025-12-15
[2025-12-15 12:31:13] IK-SIGN: ced35ab201ad54e8f8b5935d2778c4ec7e75af0102d40d9c4515f8118ca8b5dd
[2025-12-15 12:31:13] CURL HTTP CODE: 422
[2025-12-15 12:31:13] CURL ERROR: none
[2025-12-15 12:31:13] RAW RESPONSE: {"error":"Invalid Signature"}
[2025-12-15 12:31:21] FILE HIT
[2025-12-15 12:31:47] FILE HIT
[2025-12-15 12:31:47] FILE HIT
[2025-12-15 12:31:58] FILE HIT
[2025-12-15 12:32:18] FILE HIT
[2025-12-15 12:32:19] FILE HIT
[2025-12-15 12:33:30] FILE HIT
[2025-12-15 12:33:31] FILE HIT
[2025-12-15 12:33:59] FILE HIT
[2025-12-15 12:33:59] FILE HIT
[2025-12-15 12:37:05] FILE HIT
[2025-12-15 12:37:06] FILE HIT

View File

@@ -38,13 +38,8 @@ if (isset($_FILES['thumbnail']) && $_FILES['thumbnail']['error'] !== UPLOAD_ERR_
}
$uploadDir = "assets/uploads/campsites/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (!is_writable($uploadDir)) {
http_response_code(500);
die('Upload directory is not writable.');
if (!file_exists($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$randomFilename = $validationResult['filename'];

View File

@@ -0,0 +1,230 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
// Ensure $conn is available; show a friendly message if DB is down
$conn = $conn ?? null;
if (!$conn) {
// Render a simple error notice inside the page layout and stop further DB queries
echo '<section class="tour-list-page py-10 rel z-1">\n <div class="container">\n <div class="alert alert-danger">Database connection unavailable. Please check your configuration and logs.</div>\n </div>\n</section>';
include_once($rootPath . '/components/insta_footer.php');
// Stop execution to prevent subsequent fatal errors from using $conn
exit;
}
?>
<style>
/* Shared table and tab styles */
.admin-tabs { text-align: center; display: flex; margin-bottom: 20px; margin-top: 20px; }
.admin-tab { padding: 10px 30px; margin-right: 20px; cursor: pointer; border: 2px solid; border-radius: 50px; background: none; font-size: 18px; color: #484848; }
.admin-tab.active { color: white; background: #63ab45; font-weight: bold; }
.tab-content { display: none; }
.tab-content.active { display: block; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 10px 0; }
thead th { cursor: pointer; text-align: left; padding: 10px; font-weight: bold; position: relative; }
thead th::after { content: '\25B2'; font-size: 0.8em; position: absolute; right: 10px; opacity: 0; transition: opacity 0.2s; }
thead th.asc::after { content: '\25B2'; opacity: 1; }
thead th.desc::after { content: '\25BC'; opacity: 1; }
tbody tr:nth-child(odd) { background-color: transparent; }
tbody tr:nth-child(even) { background-color: rgb(255, 255, 255); border-radius: 10px; }
tbody td { padding: 5px; }
tbody tr:nth-child(even) td:first-child { border-top-left-radius: 10px; border-bottom-left-radius: 10px; }
tbody tr:nth-child(even) td:last-child { border-top-right-radius: 10px; border-bottom-right-radius: 10px; }
.filter-input { width: 100%; padding: 5px; font-size: 16px; background-color: rgb(255, 255, 255); border-radius: 25px; }
.trip-booking { color: #484848; background: #f9f9f7; border: 1px solid #d8d8d8; border-radius: 10px; margin-top: 15px; margin-bottom: 15px; }
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Tab switching
const tabs = document.querySelectorAll('.admin-tab');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach((tab, idx) => {
tab.addEventListener('click', function() {
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
contents[idx].classList.add('active');
});
});
// Table sorting/filtering (same as before)
document.querySelectorAll("table").forEach((table) => {
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = table.previousElementSibling;
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
if (rows.length === 0) {
filterInput.style.display = "none";
} else {
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
}
});
});
</script>
<?php
$pageTitle = 'Manage Bookings';
$breadcrumbs = [['Home' => 'index']];
require_once($rootPath . '/components/banner.php');
?>
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<div class="admin-tabs">
<button class="admin-tab active">Trip Bookings</button>
<button class="admin-tab">Course Bookings</button>
<button class="admin-tab">Camp Bookings</button>
</div>
<div class="tab-content active">
<?php // Trip Bookings Tab
$tripsSql = "SELECT trip_id, trip_name FROM trips";
$tripsResult = $conn->query($tripsSql);
if ($tripsResult && $tripsResult->num_rows > 0) {
while ($trip = $tripsResult->fetch_assoc()) {
$tripId = $trip['trip_id'];
$tripName = htmlspecialchars($trip['trip_name']);
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>{$tripName}</h4>";
$bookingsSql = "SELECT b.user_id, b.num_vehicles, b.num_adults, b.num_children, b.num_pensioners, b.radio, b.status, u.first_name, u.last_name, u.profile_pic, (b.total_amount - b.discount_amount) AS paid FROM bookings b INNER JOIN users u ON b.user_id = u.user_id WHERE b.trip_id = ?";
$stmt = $conn->prepare($bookingsSql);
$stmt->bind_param('i', $tripId);
$stmt->execute();
$bookingsResult = $stmt->get_result();
if ($bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table><thead><tr><th></th><th>Name</th><th>Vehicles</th><th>Adults</th><th>Children</th><th>Pensioners</th><th>Radio</th><th>Status</th><th>Amount</th></tr></thead><tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$numVehicles = htmlspecialchars($booking['num_vehicles']);
$numAdults = htmlspecialchars($booking['num_adults']);
$numPensioners = htmlspecialchars($booking['num_pensioners']);
$numChildren = htmlspecialchars($booking['num_children']);
$radio = $booking['radio'] == 1 ? "YES" : "NO";
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['paid'], 2);
echo "<tr><td><img src='".$booking['profile_pic']."' alt='Profile Picture' class='profile-pic'></td><td>{$userName}</td><td>{$numVehicles}</td><td>{$numAdults}</td><td>{$numChildren}</td><td>{$numPensioners}</td><td>{$radio}</td><td>{$status}</td><td>{$paid}</td></tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this trip.</p>';
}
echo "</div></div>";
}
} else {
echo '<p>No trips found.</p>';
}
?>
</div>
<div class="tab-content">
<?php // Course Bookings Tab
$courseSql = "SELECT date, course_id, course_type FROM courses";
$courseResult = $conn->query($courseSql);
if ($courseResult && $courseResult->num_rows > 0) {
while ($course = $courseResult->fetch_assoc()) {
$course_id = $course['course_id'];
$date = $course['date'];
$type = htmlspecialchars($course['course_type']);
if ($type === "driver_training") {
$course_name = "Basic 4X4 Driver Training Course ".$date;
} elseif ($type === "bush_mechanics") {
$course_name = "Bush Mechanics Course ".$date;
} elseif ($type === "rescue_recovery") {
$course_name = "Rescue & Recovery Training Course ".$date;
} else {
$course_name = "General Course ".$date;
}
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>{$course_name}</h4>";
$bookingsSql = "SELECT b.user_id, b.num_adults, b.total_amount, b.status, b.course_non_members, u.first_name, u.last_name, u.profile_pic FROM bookings b INNER JOIN users u ON b.user_id = u.user_id WHERE b.course_id = ?";
if ($stmt = $conn->prepare($bookingsSql)) {
$stmt->bind_param('i', $course_id);
$stmt->execute();
$bookingsResult = $stmt->get_result();
} else {
echo "Error in prepared statement: " . $conn->error;
}
if ($bookingsResult && $bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table><thead><tr><th></th><th>Name</th><th>Members</th><th>Non-Members</th><th>Status</th><th>Amount</th></tr></thead><tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$members = htmlspecialchars($booking['num_adults']);
$non_members = htmlspecialchars($booking['course_non_members']);
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['total_amount'], 2);
echo "<tr><td><img src='".$booking['profile_pic']."' alt='Profile Picture' class='profile-pic'></td><td>{$userName}</td><td>{$members}</td><td>{$non_members}</td><td>{$status}</td><td>{$paid}</td></tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this course.</p>';
}
echo "</div></div>";
}
} else {
echo '<p>No courses found.</p>';
}
?>
</div>
<div class="tab-content">
<?php // Camp Bookings Tab
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>BASE4 Camping</h4>";
$bookingsSql = "SELECT b.user_id, b.from_date, b.to_date, b.num_vehicles, b.num_adults, b.num_children, b.add_firewood, b.status, u.first_name, u.last_name, (b.total_amount - b.discount_amount) AS paid FROM bookings b INNER JOIN users u ON b.user_id = u.user_id WHERE b.booking_type = 'camping'";
$stmt = $conn->prepare($bookingsSql);
$stmt->execute();
$bookingsResult = $stmt->get_result();
if ($bookingsResult && $bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table><thead><tr><th>Name</th><th>From</th><th>To</th><th>Vehicles</th><th>Adults</th><th>Children</th><th>Add Firewood</th><th>Status</th><th>Amount</th></tr></thead><tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$numVehicles = htmlspecialchars($booking['num_vehicles']);
$from = htmlspecialchars($booking['from_date']);
$to = htmlspecialchars($booking['to_date']);
$numAdults = htmlspecialchars($booking['num_adults']);
$numChildren = htmlspecialchars($booking['num_children']);
$radio = $booking['add_firewood'] == 1 ? "YES" : "NO";
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['paid'], 2);
echo "<tr><td>{$userName}</td><td>{$from}</td><td>{$to}</td><td>{$numVehicles}</td><td>{$numAdults}</td><td>{$numChildren}</td><td>{$radio}</td><td>{$status}</td><td>{$paid}</td></tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this trip.</p>';
}
echo "</div></div>";
?>
</div>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -1,227 +0,0 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
?>
<style>
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
}
thead th {
cursor: pointer;
text-align: left;
padding: 10px;
font-weight: bold;
position: relative;
}
thead th::after {
content: '\25B2';
/* Up arrow */
font-size: 0.8em;
position: absolute;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
}
thead th.asc::after {
content: '\25B2';
/* Up arrow */
opacity: 1;
}
thead th.desc::after {
content: '\25BC';
/* Down arrow */
opacity: 1;
}
tbody tr:nth-child(odd) {
background-color: transparent;
}
tbody tr:nth-child(even) {
background-color: rgb(255, 255, 255);
border-radius: 10px;
}
tbody td {
padding: 5px;
}
tbody tr:nth-child(even) td:first-child {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
tbody tr:nth-child(even) td:last-child {
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.filter-input {
width: 100%;
padding: 5px;
/* margin-bottom: 20px; */
font-size: 16px;
background-color: rgb(255, 255, 255);
border-radius: 25px;
}
.trip-booking {
color: #484848;
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
margin-top: 15px;
margin-bottom: 15px;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const tables = document.querySelectorAll("table");
tables.forEach((table) => {
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = table.previousElementSibling;
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
if (rows.length === 0) {
filterInput.style.display = "none";
} else {
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
}
});
});
</script>
<?php
$bannerFolder = 'assets/images/banners/';
$bannerImages = glob($bannerFolder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
$randomBanner = 'assets/images/base4/camping.jpg'; // default fallback
if (!empty($bannerImages)) {
$randomBanner = $bannerImages[array_rand($bannerImages)];
}
?>
<section class="page-banner-area pt-50 pb-35 rel z-1 bgs-cover" style="background-image: url('<?php echo $randomBanner; ?>');">
<div class="banner-overlay"></div>
<div class="container">
<div class="banner-inner text-white mb-50">
<h2 class="page-title mb-10" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">4WDCSA Camping Bookings</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center mb-20" data-aos="fade-right" data-aos-delay="200" data-aos-duration="1500" data-aos-offset="50">
<li class="breadcrumb-item"><a href="index.php">Home</a></li>
<li class="breadcrumb-item active">Camping Bookings</li>
</ol>
</nav>
</div>
</div>
</section>
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<?php
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>BASE4 Camping</h4>";
// Fetch bookings for the current trip
$bookingsSql = "SELECT b.user_id, b.from_date, b.to_date, b.num_vehicles, b.num_adults, b.num_children, b.add_firewood, b.status,
u.first_name, u.last_name,
(b.total_amount - b.discount_amount) AS paid
FROM bookings b
INNER JOIN users u ON b.user_id = u.user_id
WHERE b.booking_type = 'camping'";
$stmt = $conn->prepare($bookingsSql);
$stmt->execute();
$bookingsResult = $stmt->get_result();
if ($bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table>
<thead>
<tr>
<th>Name</th>
<th>From</th>
<th>To</th>
<th>Vehicles</th>
<th>Adults</th>
<th>Children</th>
<th>Add Firewood</th>
<th>Status</th>
<th>Amount</th>
</tr>
</thead>
<tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$numVehicles = htmlspecialchars($booking['num_vehicles']);
$from = htmlspecialchars($booking['from_date']);
$to = htmlspecialchars($booking['to_date']);
$numAdults = htmlspecialchars($booking['num_adults']);
$numChildren = htmlspecialchars($booking['num_children']);
$radio = $booking['add_firewood'] == 1 ? "YES" : "NO";
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['paid'], 2);
echo "<tr>
<td>{$userName}</td>
<td>{$from}</td>
<td>{$to}</td>
<td>{$numVehicles}</td>
<td>{$numAdults}</td>
<td>{$numChildren}</td>
<td>{$radio}</td>
<td>{$status}</td>
<td>{$paid}</td>
</tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this trip.</p>';
}
echo "</div>";
echo "</div>";
?>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -1,247 +0,0 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
// Fetch all trips
$courseSql = "SELECT date, course_id, course_type FROM courses";
$courseResult = $conn->query($courseSql);
if (!$courseResult) {
echo "Error in SQL query: " . $conn->error;
}
?>
<style>
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
}
thead th {
cursor: pointer;
text-align: left;
padding: 10px;
font-weight: bold;
position: relative;
}
thead th::after {
content: '\25B2';
/* Up arrow */
font-size: 0.8em;
position: absolute;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
}
thead th.asc::after {
content: '\25B2';
/* Up arrow */
opacity: 1;
}
thead th.desc::after {
content: '\25BC';
/* Down arrow */
opacity: 1;
}
tbody tr:nth-child(odd) {
background-color: transparent;
}
tbody tr:nth-child(even) {
background-color: rgb(255, 255, 255);
border-radius: 10px;
}
tbody td {
padding: 5px;
}
tbody tr:nth-child(even) td:first-child {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
tbody tr:nth-child(even) td:last-child {
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.filter-input {
width: 100%;
padding: 5px;
/* margin-bottom: 20px; */
font-size: 16px;
background-color: rgb(255, 255, 255);
border-radius: 25px;
}
.trip-booking {
color: #484848;
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
margin-top: 15px;
margin-bottom: 15px;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const tables = document.querySelectorAll("table");
tables.forEach((table) => {
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = table.previousElementSibling;
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
if (rows.length === 0) {
filterInput.style.display = "none";
} else {
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
}
});
});
</script>
<?php
$bannerFolder = 'assets/images/banners/';
$bannerImages = glob($bannerFolder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
$randomBanner = 'assets/images/base4/camping.jpg'; // default fallback
if (!empty($bannerImages)) {
$randomBanner = $bannerImages[array_rand($bannerImages)];
}
?>
<section class="page-banner-area pt-50 pb-35 rel z-1 bgs-cover" style="background-image: url('<?php echo $randomBanner; ?>');">
<div class="banner-overlay"></div>
<div class="container">
<div class="banner-inner text-white mb-50">
<h2 class="page-title mb-10" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">4WDCSA Course Bookings</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center mb-20" data-aos="fade-right" data-aos-delay="200" data-aos-duration="1500" data-aos-offset="50">
<li class="breadcrumb-item"><a href="index.php">Home</a></li>
<li class="breadcrumb-item active">Course Bookings</li>
</ol>
</nav>
</div>
</div>
</section>
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<?php
if ($courseResult->num_rows > 0) {
while ($course = $courseResult->fetch_assoc()) {
$course_id = $course['course_id'];
$date = $course['date'];
$type = htmlspecialchars($course['course_type']);
if ($type === "driver_training") {
$course_name = "Basic 4X4 Driver Training Course ".$date;
} elseif ($type === "bush_mechanics") {
$course_name = "Bush Mechanics Course ".$date;
} elseif ($type === "rescue_recovery") {
$course_name = "Rescue & Recovery Training Course ".$date;
} else {
$course_name = "General Course ".$date; // Default fallback description
}
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>{$course_name}</h4>";
// Fetch bookings for the current trip
$bookingsSql = "SELECT b.user_id, b.num_adults, b.total_amount, b.status, b.course_non_members,
u.first_name, u.last_name, u.profile_pic
FROM bookings b
INNER JOIN users u ON b.user_id = u.user_id
WHERE b.course_id = ?";
if ($stmt = $conn->prepare($bookingsSql)) {
$stmt->bind_param('i', $course_id);
$stmt->execute();
$bookingsResult = $stmt->get_result();
} else {
echo "Error in prepared statement: " . $conn->error;
}
if ($bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Members</th>
<th>Non-Members</th>
<th>Status</th>
<th>Amount</th>
</tr>
</thead>
<tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$members = htmlspecialchars($booking['num_adults']);
$non_members = htmlspecialchars($booking['course_non_members']);
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['total_amount'], 2);
echo "<tr>
<td><img src=".$booking['profile_pic']." alt='Profile Picture' class='profile-pic'></td>
<td>{$userName}</td>
<td>{$members}</td>
<td>{$non_members}</td>
<td>{$status}</td>
<td>{$paid}</td>
</tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this trip.</p>';
}
echo "</div>";
echo "</div>";
}
} else {
echo '<p>No courses found.</p>';
}
?>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,248 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
?>
<style>
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
}
thead th {
cursor: pointer;
text-align: left;
padding: 10px;
font-weight: bold;
position: relative;
}
thead th::after {
content: '\25B2';
/* Up arrow */
font-size: 0.8em;
position: absolute;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
}
thead th.asc::after {
content: '\25B2';
/* Up arrow */
opacity: 1;
}
thead th.desc::after {
content: '\25BC';
/* Down arrow */
opacity: 1;
}
tbody tr:nth-child(odd) {
background-color: transparent;
}
tbody tr:nth-child(even) {
background-color: rgb(255, 255, 255);
border-radius: 10px;
}
tbody td {
padding: 5px;
}
tbody tr:nth-child(even) td:first-child {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
tbody tr:nth-child(even) td:last-child {
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.filter-input {
width: 100%;
padding: 5px;
/* margin-bottom: 20px; */
font-size: 16px;
background-color: rgb(255, 255, 255);
border-radius: 25px;
}
.infobox {
color: #484848;
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
margin-top: 15px;
margin-bottom: 15px;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const table = document.querySelector("table");
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = document.getElementById("filterInput");
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
});
</script>
<!-- Page Banner Start -->
<?php
$bannerFolder = 'assets/images/banners/';
$bannerImages = glob($bannerFolder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
$randomBanner = 'assets/images/base4/camping.jpg'; // default fallback
if (!empty($bannerImages)) {
$randomBanner = $bannerImages[array_rand($bannerImages)];
}
?>
<section class="page-banner-area pt-50 pb-35 rel z-1 bgs-cover" style="background-image: url('<?php echo $randomBanner; ?>');">
<div class="banner-overlay"></div>
<div class="container">
<div class="banner-inner text-white mb-50">
<h2 class="page-title mb-10" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">iKhokha Payments</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center mb-20" data-aos="fade-right" data-aos-delay="200" data-aos-duration="1500" data-aos-offset="50">
<li class="breadcrumb-item"><a href="index.php">Home</a></li>
<li class="breadcrumb-item active">iKhokha Payments</li>
</ol>
</nav>
</div>
</div>
</section>
<!-- Tour List Area start -->
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class='infobox' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>
<div style='padding:10px;'>
<?php
// Fetch transactions from iKhokha API instead of DB
$startDate = isset($_GET['start']) ? $_GET['start'] : date('Y-m-d', strtotime('-30 days'));
$endDate = isset($_GET['end']) ? $_GET['end'] : date('Y-m-d');
// getIkhokhaTransactionHistory should return JSON (string) or an array
$raw = getIkhokhaTransactionHistory($startDate, $endDate);
$transactions = [];
if (is_string($raw)) {
$transactions = json_decode($raw, true);
} elseif (is_array($raw)) {
$transactions = $raw;
}
if (!empty($transactions)) {
echo '<input id="filterInput" type="text" class="filter-input" placeholder="Filter results...">';
echo '<table>
<thead>
<tr>
<th>Date</th>
<th>ID</th>
<th>PaylinkID</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>';
$printed = false;
foreach ($transactions as $row) {
$createdAt = isset($row['createdAt']) ? htmlspecialchars($row['createdAt']) : '';
// prefer externalTransactionID when available, fallback to paylinkID
$txId = isset($row['externalTransactionID']) ? $row['externalTransactionID'] : (isset($row['paylinkID']) ? $row['paylinkID'] : '');
$ikhokhaTxId = isset($row['paylinkID']) ? $row['paylinkID'] : '';
$description = isset($row['description']) ? $row['description'] : '';
$amount = isset($row['amount']) ? $row['amount'] : '';
$status = isset($row['status']) ? $row['status'] : '';
// Skip unpaid transactions
if (strcasecmp($status, 'UNPAID') === 0) {
continue;
}
echo "<tr>
<td>" . htmlspecialchars($createdAt) . "</td>
<td>" . htmlspecialchars($txId) . "</td>
<td>" . htmlspecialchars($ikhokhaTxId) . "</td>
<td>" . htmlspecialchars($description) . "</td>
<td>R " . htmlspecialchars($amount/100) . ".00</td>
<td>" . htmlspecialchars($status) . "</td>
</tr>";
$printed = true;
}
if (!$printed) {
echo '<tr><td colspan="6">No records found</td></tr>';
}
} else {
echo '<input id="filterInput" type="text" class="filter-input" placeholder="Filter results...">';
echo '<table>
<thead>
<tr>
<th>Date</th>
<th>ID</th>
<th>Description</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>';
echo '<tr><td colspan="5">No records found</td></tr>';
} ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Tour List Area end -->
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -1,240 +0,0 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
// Fetch all trips
$tripsSql = "SELECT trip_id, trip_name FROM trips";
$tripsResult = $conn->query($tripsSql);
?>
<style>
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
}
thead th {
cursor: pointer;
text-align: left;
padding: 10px;
font-weight: bold;
position: relative;
}
thead th::after {
content: '\25B2';
/* Up arrow */
font-size: 0.8em;
position: absolute;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
}
thead th.asc::after {
content: '\25B2';
/* Up arrow */
opacity: 1;
}
thead th.desc::after {
content: '\25BC';
/* Down arrow */
opacity: 1;
}
tbody tr:nth-child(odd) {
background-color: transparent;
}
tbody tr:nth-child(even) {
background-color: rgb(255, 255, 255);
border-radius: 10px;
}
tbody td {
padding: 5px;
}
tbody tr:nth-child(even) td:first-child {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
tbody tr:nth-child(even) td:last-child {
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.filter-input {
width: 100%;
padding: 5px;
/* margin-bottom: 20px; */
font-size: 16px;
background-color: rgb(255, 255, 255);
border-radius: 25px;
}
.trip-booking {
color: #484848;
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
margin-top: 15px;
margin-bottom: 15px;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
const tables = document.querySelectorAll("table");
tables.forEach((table) => {
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = table.previousElementSibling;
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
if (rows.length === 0) {
filterInput.style.display = "none";
} else {
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
}
});
});
</script>
<?php
$bannerFolder = 'assets/images/banners/';
$bannerImages = glob($bannerFolder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
$randomBanner = 'assets/images/base4/camping.jpg'; // default fallback
if (!empty($bannerImages)) {
$randomBanner = $bannerImages[array_rand($bannerImages)];
}
?>
<section class="page-banner-area pt-50 pb-35 rel z-1 bgs-cover" style="background-image: url('<?php echo $randomBanner; ?>');">
<div class="banner-overlay"></div>
<div class="container">
<div class="banner-inner text-white mb-50">
<h2 class="page-title mb-10" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">4WDCSA Trip Bookings</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center mb-20" data-aos="fade-right" data-aos-delay="200" data-aos-duration="1500" data-aos-offset="50">
<li class="breadcrumb-item"><a href="index.php">Home</a></li>
<li class="breadcrumb-item active">Trip Bookings</li>
</ol>
</nav>
</div>
</div>
</section>
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<?php
if ($tripsResult->num_rows > 0) {
while ($trip = $tripsResult->fetch_assoc()) {
$tripId = $trip['trip_id'];
$tripName = htmlspecialchars($trip['trip_name']);
echo "<div class='trip-booking' data-aos='fade-up' data-aos-duration='1500' data-aos-offset='50'>";
echo "<div style='padding:10px;'>";
echo "<h4>{$tripName}</h4>";
// Fetch bookings for the current trip
$bookingsSql = "SELECT b.user_id, b.num_vehicles, b.num_adults, b.num_children, b.num_pensioners, b.radio, b.status,
u.first_name, u.last_name,
(b.total_amount - b.discount_amount) AS paid
FROM bookings b
INNER JOIN users u ON b.user_id = u.user_id
WHERE b.trip_id = ?";
$stmt = $conn->prepare($bookingsSql);
$stmt->bind_param('i', $tripId);
$stmt->execute();
$bookingsResult = $stmt->get_result();
if ($bookingsResult->num_rows > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter results...">';
echo '<table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Vehicles</th>
<th>Adults</th>
<th>Children</th>
<th>Pensioners</th>
<th>Radio</th>
<th>Status</th>
<th>Amount</th>
</tr>
</thead>
<tbody>';
while ($booking = $bookingsResult->fetch_assoc()) {
$userName = htmlspecialchars($booking['first_name'] . ' ' . $booking['last_name']);
$numVehicles = htmlspecialchars($booking['num_vehicles']);
$numAdults = htmlspecialchars($booking['num_adults']);
$numPensioners = htmlspecialchars($booking['num_pensioners']);
$numChildren = htmlspecialchars($booking['num_children']);
$radio = $booking['radio'] == 1 ? "YES" : "NO";
$status = htmlspecialchars($booking['status']);
$paid = "R " . number_format($booking['paid'], 2);
echo "<tr>
<td><img src=".$booking['profile_pic']." alt='Profile Picture' class='profile-pic'></td>
<td>{$userName}</td>
<td>{$numVehicles}</td>
<td>{$numAdults}</td>
<td>{$numChildren}</td>
<td>{$numPensioners}</td>
<td>{$radio}</td>
<td>{$status}</td>
<td>{$paid}</td>
</tr>";
}
echo '</tbody></table>';
} else {
echo '<p>No bookings found for this trip.</p>';
}
echo "</div>";
echo "</div>";
}
} else {
echo '<p>No trips found.</p>';
}
?>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,441 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
// Ensure $conn is available; show a friendly message if DB is down
$conn = $conn ?? null;
if (!$conn) {
// Render a simple error notice inside the page layout and stop further DB queries
echo '<section class="tour-list-page py-10 rel z-1">\n <div class="container">\n <div class="alert alert-danger">Database connection unavailable. Please check your configuration and logs.</div>\n </div>\n</section>';
include_once($rootPath . '/components/insta_footer.php');
// Stop execution to prevent subsequent fatal errors from using $conn
exit;
}
?>
<style>
/* Shared table and tab styles */
.admin-tabs { text-align: center; display: flex; margin-bottom: 20px; margin-top: 20px; }
.admin-tab { padding: 10px 30px; margin-right: 20px; cursor: pointer; border: 2px solid; border-radius: 50px; background: none; font-size: 18px; color: #484848; }
.admin-tab.active { color: white; background: #63ab45; font-weight: bold; }
.tab-content { display: none; }
.tab-content.active { display: block; }
table { width: 100%; border-collapse: separate; border-spacing: 0; margin: 10px 0; }
thead th { cursor: pointer; text-align: left; padding: 10px; font-weight: bold; position: relative; }
thead th::after { content: '\25B2'; font-size: 0.8em; position: absolute; right: 10px; opacity: 0; transition: opacity 0.2s; }
thead th.asc::after { content: '\25B2'; opacity: 1; }
thead th.desc::after { content: '\25BC'; opacity: 1; }
tbody tr:nth-child(odd) { background-color: transparent; }
tbody tr:nth-child(even) { background-color: rgb(255, 255, 255); border-radius: 10px; }
tbody td { padding: 5px; }
tbody tr:nth-child(even) td:first-child { border-top-left-radius: 10px; border-bottom-left-radius: 10px; }
tbody tr:nth-child(even) td:last-child { border-top-right-radius: 10px; border-bottom-right-radius: 10px; }
.filter-input { width: 100%; padding: 5px; font-size: 16px; background-color: rgb(255, 255, 255); border-radius: 25px; }
.trip-booking { color: #484848; background: #f9f9f7; border: 1px solid #d8d8d8; border-radius: 10px; margin-top: 15px; margin-bottom: 15px; }
</style>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Tab switching
const tabs = document.querySelectorAll('.admin-tab');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach((tab, idx) => {
tab.addEventListener('click', function() {
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
contents[idx].classList.add('active');
});
});
// Table sorting/filtering (same as before)
document.querySelectorAll("table").forEach((table) => {
const headers = table.querySelectorAll("thead th");
const rows = Array.from(table.querySelectorAll("tbody tr"));
const filterInput = table.previousElementSibling;
headers.forEach((header, index) => {
header.addEventListener("click", () => {
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[index].textContent.trim().toLowerCase();
const bText = b.cells[index].textContent.trim().toLowerCase();
if (aText < bText) return -1;
if (aText > bText) return 1;
return 0;
});
if (header.classList.contains("asc")) {
header.classList.remove("asc");
header.classList.add("desc");
sortedRows.reverse();
} else {
headers.forEach(h => h.classList.remove("asc", "desc"));
header.classList.add("asc");
}
const tbody = table.querySelector("tbody");
tbody.innerHTML = "";
sortedRows.forEach(row => tbody.appendChild(row));
});
});
if (rows.length === 0) {
filterInput.style.display = "none";
} else {
filterInput.addEventListener("input", function() {
const filterValue = filterInput.value.trim().toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.trim().toLowerCase();
row.style.display = rowText.includes(filterValue) ? "" : "none";
});
});
}
});
});
</script>
<?php
$pageTitle = 'Manage Trips, Courses & Events';
$breadcrumbs = [['Home' => 'index']];
require_once($rootPath . '/components/banner.php');
?>
<section class="tour-list-page py-10 rel z-1">
<div class="container">
<div class="admin-tabs">
<button class="admin-tab active">Manage Trips</button>
<button class="admin-tab">Manage Events</button>
<button class="admin-tab">Manage Courses</button>
<button class="admin-tab">Manage Blogs</button>
</div>
<div class="tab-content active">
<?php
// Trips management content (adapted from admin_trips.php)
// Fetch trips (already available if connection present)
$trips_query = "
SELECT
trip_id, trip_name, location, start_date, end_date,
vehicle_capacity, places_booked, cost_members, cost_nonmembers,
cost_pensioner_member, cost_pensioner, published
FROM trips
ORDER BY start_date DESC
";
$result = $conn->query($trips_query);
$trips = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$trips[] = $row;
}
}
?>
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h2 style="margin: 0;">Manage Trips</h2>
<a href="manage_trips" class="theme-btn create-album-btn">
<i class="far fa-plus"></i> New Trip
</a>
</div>
<?php if (isset($_SESSION['message'])): ?>
<div class="alert alert-warning message-box">
<?php echo $_SESSION['message']; ?>
<span class="close-btn" onclick="this.parentElement.style.display='none'">&times;</span>
</div>
<?php unset($_SESSION['message']);
endif;
if (count($trips) > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter trips...">';
echo '<div class="trips-container" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">';
foreach ($trips as $trip) {
$available = $trip['vehicle_capacity'] - $trip['places_booked'];
$publishStatusBadge = $trip['published'] == 1 ? 'PUBLISHED' : 'DRAFT';
$tripImagePath = '';
$tripImagesGlob = glob($rootPath . '/assets/images/trips/' . $trip['trip_id'] . '_*.jpg');
if (!empty($tripImagesGlob)) {
$tripImagePath = str_replace($rootPath, '', $tripImagesGlob[0]);
} else {
$tripImagePath = 'assets/images/placeholder.jpg';
}
echo '
<div class="destination-item style-three bgc-lighter booking" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image" style="width:300px;height:250px;">
<img src="' . htmlspecialchars($tripImagePath) . '" alt="' . htmlspecialchars($trip['trip_name']) . '">
</div>
<div class="content" style="width:100%;">
<div class="destination-header d-flex align-items-start gap-3">
<div>
<span class="badge bg-dark mb-1">' . strtoupper($publishStatusBadge) . '</span>
<h5 class="mb-0">' . htmlspecialchars($trip['trip_name']) . '</h5>
<small class="text-muted">📍 ' . htmlspecialchars($trip['location']) . '</small>
</div>
</div>
<p style="margin: 10px 0;">
<strong>Dates:</strong> ' . date('M d', strtotime($trip['start_date'])) . ' - ' . date('M d, Y', strtotime($trip['end_date'])) . '<br>
<strong>Capacity:</strong> ' . $trip['places_booked'] . ' / ' . $trip['vehicle_capacity'] . '<br>
<strong>Costs:</strong> Members: R ' . number_format($trip['cost_members'], 2) . ' | Non-Members: R ' . number_format($trip['cost_nonmembers'], 2) . ' | Pensioner Members: R ' . number_format($trip['cost_pensioner_member'], 2) . ' | Pensioners: R ' . number_format($trip['cost_pensioner'], 2) . '
</p>
<div class="destination-footer">
<div class="btn-group" style="display:flex; justify-content:flex-end; gap:10px;">
<a href="manage_trips?trip_id=' . $trip['trip_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit"><span class="material-icons">edit</span></a>
<button type="button" class="toggle-publish" data-trip-id="' . $trip['trip_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="' . ($trip['published'] == 1 ? 'Unpublish' : 'Publish') . '" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">' . ($trip['published'] == 1 ? 'cloud_off' : 'cloud_upload') . '</span></button>
<button type="button" class="delete-trip" data-trip-id="' . $trip['trip_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">delete</span></button>
</div>
</div>
</div>
</div>
';
}
echo '</div>';
} else {
echo "<div class=\"no-trips\"><p>No trips found. <a href=\"manage_trips\">Create one</a></p></div>";
}
?>
</div>
</div>
</div>
<div class="tab-content">
<?php
// Events management content (adapted from admin_events.php)
$events_query = "
SELECT
event_id, name, type, location, date, image, published
FROM events
ORDER BY date DESC
";
$result = $conn->query($events_query);
$events = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$events[] = $row;
}
}
?>
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h2 style="margin: 0;">Manage Events</h2>
<a href="manage_events" class="theme-btn create-album-btn">
<i class="far fa-plus"></i> New Event
</a>
</div>
<?php if (count($events) > 0) {
echo '<input type="text" class="filter-input" placeholder="Filter events...">';
echo '<div class="events-container" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">';
foreach ($events as $event) {
$eventImagePath = $event['image'] ? htmlspecialchars($event['image']) : 'assets/images/placeholder.jpg';
$publishStatusBadge = $event['published'] == 1 ? 'PUBLISHED' : 'DRAFT';
echo '
<div class="destination-item style-three bgc-lighter booking" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image" style="width:300px;height:250px;">
<img src="' . $eventImagePath . '" alt="' . htmlspecialchars($event['name']) . '">
</div>
<div class="content" style="width:100%;">
<div class="destination-header d-flex align-items-start gap-3">
<div>
<span class="badge bg-dark mb-1">' . strtoupper($publishStatusBadge) . '</span>
<h5 class="mb-0">' . htmlspecialchars($event['name']) . '</h5>
<small class="text-muted">📍 ' . htmlspecialchars($event['location']) . '</small>
</div>
</div>
<p style="margin: 10px 0;">
<strong>Type:</strong> ' . htmlspecialchars($event['type']) . '<br>
<strong>Date:</strong> ' . convertDate($event['date']) . '
</p>
<div class="destination-footer">
<div class="btn-group" style="display:flex; justify-content:flex-end; gap:10px;">
<a href="manage_events?event_id=' . $event['event_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit"><span class="material-icons">edit</span></a>
<button type="button" class="toggle-publish" data-event-id="' . $event['event_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="' . ($event['published'] == 1 ? 'Unpublish' : 'Publish') . '" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">' . ($event['published'] == 1 ? 'cloud_off' : 'cloud_upload') . '</span></button>
<button type="button" class="delete-event" data-event-id="' . $event['event_id'] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">delete</span></button>
</div>
</div>
</div>
</div>
';
}
echo '</div>';
} else {
echo '<div class="no-events"><p>No events found. <a href="manage_events">Create one</a></p></div>';
}
?>
</div>
</div>
</div>
<div class="tab-content">
<?php
// Courses management content (adapted from admin_courses.php)
$courses_query = "
SELECT
course_id, course_type, date, capacity, booked, cost_members, cost_nonmembers, instructor, instructor_email, code
FROM courses
ORDER BY date DESC
";
$result = $conn->query($courses_query);
$courses = [];
if ($result && $result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$courses[] = $row;
}
}
?>
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h2 style="margin: 0;">Manage Courses</h2>
<a href="manage_courses" class="theme-btn create-album-btn">
<i class="far fa-plus"></i> New Course
</a>
</div>
<?php if (isset($_SESSION['message'])): ?>
<div class="alert alert-warning message-box">
<?php echo $_SESSION['message']; ?>
<span class="close-btn" onclick="this.parentElement.style.display='none'">&times;</span>
</div>
<?php unset($_SESSION['message']);
endif; ?>
<?php if (count($courses) > 0): ?>
<input type="text" class="filter-input" placeholder="Filter courses...">
<div class="courses-container" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<?php foreach ($courses as $course):
$available = intval($course['capacity']) - intval($course['booked']);
$type_label = strtoupper($course['course_type']);
if ($course['course_type'] == 'driver_training') {
$type_label = 'Driver Training';
} elseif ($course['course_type'] == 'bush_mechanics') {
$type_label = 'Bush Mechanics';
} elseif ($course['course_type'] == 'rescue_recovery') {
$type_label = 'Rescue & Recovery';
} elseif ($course['course_type'] == 'ladies_driver_training') {
$type_label = 'Ladies Driver Training';
}
?>
<div class="destination-item style-three bgc-lighter booking" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="content" style="width:100%;">
<div class="destination-header d-flex align-items-start gap-3">
<div>
<h5 class="mb-0"><?php echo $type_label; ?></h5>
<small class="text-muted"><?php echo htmlspecialchars($course['course_type']); ?> — <?php echo date('M d, Y', strtotime($course['date'])); ?></small><br>
<small class="text-muted"><?php echo $course['code'] ? 'Code: ' . htmlspecialchars($course['code']) : ''; ?></small>
</div>
</div>
<p style="margin: 10px 0;">
<strong>Instructor:</strong> <?php echo htmlspecialchars($course['instructor']); ?> (<?php echo htmlspecialchars($course['instructor_email']); ?>)<br>
<strong>Capacity:</strong> <?php echo intval($course['booked']); ?> / <?php echo intval($course['capacity']); ?> &nbsp; <strong>Available:</strong> <?php echo $available; ?><br>
<strong>Costs:</strong> Members: R <?php echo number_format($course['cost_members'],2); ?> | Non-Members: R <?php echo number_format($course['cost_nonmembers'],2); ?>
</p>
<div class="destination-footer">
<div class="btn-group" style="display:flex; justify-content:flex-end; gap:10px;">
<a href="manage_courses?course_id=<?php echo $course['course_id']; ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit"><span class="material-icons">edit</span></a>
<button type="button" class="delete-course" data-course-id="<?php echo $course['course_id']; ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">delete</span></button>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="no-courses">
<p>No courses found. <a href="manage_courses">Create one</a></p>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="tab-content">
<?php
// Blogs management content (adapted from src/admin/admin_blogs.php)
$posts = [];
if ($conn) {
$stmt = $conn->prepare("SELECT b.blog_id, b.title, b.description, b.status, b.date, b.image, CONCAT(u.first_name, ' ', u.last_name) AS author_name, u.email AS author_email, u.profile_pic FROM blogs b JOIN users u ON b.author = u.user_id WHERE b.status = 'published' ORDER BY b.date DESC");
if ($stmt) {
$stmt->execute();
$res = $stmt->get_result();
if ($res && $res->num_rows > 0) {
while ($r = $res->fetch_assoc()) $posts[] = $r;
}
}
}
?>
<div class="row">
<div class="col-lg-12">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:30px;">
<h2 style="margin:0;">Manage Blogs</h2>
<a href="blog_create" class="theme-btn create-album-btn"><i class="far fa-plus"></i> New Blog</a>
</div>
<?php if (count($posts) > 0): ?>
<input type="text" class="filter-input" placeholder="Filter blogs...">
<div class="blogs-container" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<?php foreach ($posts as $post):
$coverImage = $post['image'] ? $post['image'] : 'assets/images/placeholder.jpg';
?>
<div class="destination-item style-three bgc-lighter booking" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image" style="width:200px;height:200px;">
<img src="<?php echo htmlspecialchars($coverImage); ?>" alt="<?php echo htmlspecialchars($post['title']); ?>">
</div>
<div class="content" style="width:100%;">
<div class="destination-header d-flex align-items-start gap-3">
<img src="<?php echo htmlspecialchars($post['profile_pic'] ?? 'assets/images/placeholder.jpg'); ?>" alt="Author" class="rounded-circle border" width="80" height="80">
<div>
<span class="badge bg-dark mb-1"><?php echo $post['status'] == 1 || $post['status'] === 'published' ? 'PUBLISHED' : 'DRAFT'; ?></span>
<h5 class="mb-0"><?php echo htmlspecialchars($post['title']); ?></h5>
<small class="text-muted"><?php echo htmlspecialchars($post['author_name']); ?></small>
</div>
</div>
<p><?php echo htmlspecialchars($post['description']); ?></p>
<div class="destination-footer">
<div class="btn-group" style="display:flex; justify-content:flex-end; gap:10px;">
<a href="blog_edit.php?token=<?php echo encryptData($post['blog_id'], $salt); ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit"><span class="material-icons">edit</span></a>
<a href="blog_read.php?token=<?php echo encryptData($post['blog_id'], $salt); ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Preview"><span class="material-icons">visibility</span></a>
<button type="button" class="publish-btn" data-blog-id="<?php echo $post['blog_id']; ?>" data-status="<?php echo htmlspecialchars($post['status']); ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Publish/Unpublish" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons"><?php echo ($post['status'] == 1 || $post['status'] === 'published') ? 'cloud_off' : 'cloud_upload'; ?></span></button>
<a href="blog_delete.php?token=<?php echo encryptData($post['blog_id'], $salt); ?>" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete"><span class="material-icons">delete</span></a>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="no-blogs"><p>No blogs found. <a href="manage_blogs">Create one</a></p></div>
<?php endif; ?>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function() {
const filterInput = document.querySelector('.filter-input');
const cards = document.querySelectorAll('.destination-item');
if (cards.length === 0 && filterInput) filterInput.style.display = "none";
else if (filterInput) {
filterInput.addEventListener('input', function() {
const filterValue = filterInput.value.trim().toLowerCase();
cards.forEach(card => {
const cardText = card.textContent.trim().toLowerCase();
card.style.display = cardText.includes(filterValue) ? "" : "none";
});
});
}
// Tooltips
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
// Publish/unpublish
document.querySelectorAll('.publish-btn').forEach(btn => {
btn.addEventListener('click', function() {
const blogId = this.dataset.blogId;
const status = this.dataset.status;
const endpoint = (status == 1 || status === 'published') ? 'blog_unpublish' : 'publish_blog';
const formData = new FormData(); formData.append('id', blogId);
fetch(endpoint, { method: 'POST', body: formData })
.then(r => r.json())
.then(data => { if (data.status === 'success') location.reload(); else alert('Action failed'); })
.catch(()=> alert('Network error'));
});
});
});
</script>
</div>
</div>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,221 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
$course_id = $_GET['course_id'] ?? null;
$course = null;
// If editing an existing course, fetch its data
if ($course_id) {
$stmt = $conn->prepare("SELECT * FROM courses WHERE course_id = ?");
$stmt->bind_param("i", $course_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$course = $result->fetch_assoc();
}
$stmt->close();
}
?>
<?php
$pageTitle = $course ? 'Edit Course' : 'Create New Course';
$breadcrumbs = [['Home' => 'index'], ['Admin' => 'admin_courses'], [$pageTitle => '']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Course Manager Area start -->
<section class="trip-manager-area py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="comment-form bgc-lighter z-1 rel mb-30 rmb-55">
<form id="courseForm" method="POST" action="process_course">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<?php if ($course): ?>
<input type="hidden" name="course_id" value="<?php echo $course['course_id']; ?>">
<?php endif; ?>
<div class="section-title py-20">
<h2><?php echo $course ? 'Edit Course: ' . htmlspecialchars($course['code'] ?: $course['course_type']) : 'Create New Course'; ?></h2>
<div id="responseMessage"></div>
</div>
<div class="row mt-35">
<div class="col-md-6">
<div class="form-group">
<label for="course_type">Course Type *</label>
<select id="course_type" name="course_type" class="form-control" required>
<?php
$types = ['driver_training' => 'Driver Training', 'bush_mechanics' => 'Bush Mechanics', 'rescue_recovery' => 'Rescue & Recovery', 'ladies_driver_training' => 'Ladies Driver Training'];
foreach ($types as $key => $label) {
$sel = ($course && $course['course_type'] === $key) ? 'selected' : '';
echo "<option value=\"$key\" $sel>$label</option>";
}
?>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="code">Course Code</label>
<input type="text" id="code" name="code" class="form-control" maxlength="12" value="<?php echo $course ? htmlspecialchars($course['code']) : ''; ?>" placeholder="Optional code e.g., CRSE001" data-manual="0">
<small class="form-text text-muted">Auto-generated from type + date (you can edit manually)</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="date">Date *</label>
<input type="date" id="date" name="date" class="form-control" value="<?php echo $course ? $course['date'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="capacity">Capacity *</label>
<input type="number" id="capacity" name="capacity" class="form-control" min="1" value="<?php echo $course ? intval($course['capacity']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cost_members">Member Cost (R) *</label>
<input type="number" id="cost_members" name="cost_members" class="form-control" step="0.01" min="0" value="<?php echo $course ? $course['cost_members'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cost_nonmembers">Non-Member Cost (R) *</label>
<input type="number" id="cost_nonmembers" name="cost_nonmembers" class="form-control" step="0.01" min="0" value="<?php echo $course ? $course['cost_nonmembers'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="instructor">Instructor *</label>
<input type="text" id="instructor" name="instructor" class="form-control" value="<?php echo $course ? htmlspecialchars($course['instructor']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="instructor_email">Instructor Email</label>
<input type="email" id="instructor_email" name="instructor_email" class="form-control" value="<?php echo $course ? htmlspecialchars($course['instructor_email']) : ''; ?>">
</div>
</div>
<div class="col-md-12 mt-20">
<div class="form-group mb-0">
<button type="submit" class="theme-btn style-two" style="width:100%;">
<?php echo $course ? 'Update Course' : 'Create Course'; ?>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
<!-- Course Manager Area end -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
$(document).ready(function() {
$('#courseForm').on('submit', function(event) {
event.preventDefault();
var formData = $(this).serialize();
$.ajax({
url: 'process_course',
type: 'POST',
data: formData,
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
$('#responseMessage').html('<div class="alert alert-success">' + response.message + '</div>');
setTimeout(function() {
window.location.href = 'admin_courses';
}, 1200);
} else {
$('#responseMessage').html('<div class="alert alert-danger">' + response.message + '</div>');
}
},
error: function(xhr, status, error) {
console.error('AJAX Error:', error);
$('#responseMessage').html('<div class="alert alert-danger">Error creating/updating course: ' + error + '</div>');
}
});
});
});
</script>
<script>
// Auto-generate course code from type and date: ABBREVIATION_MMDD
(function(){
var typeMap = {
'driver_training': 'DRVTRN',
'bush_mechanics': 'BUSHMEC',
'rescue_recovery': 'RESREC',
'ladies_driver_training': 'LADYTRN'
};
var $type = document.getElementById('course_type');
var $date = document.getElementById('date');
var $code = document.getElementById('code');
function getMMDDFromISO(isoDate) {
if (!isoDate) return '';
// expecting YYYY-MM-DD
var parts = isoDate.split('-');
if (parts.length !== 3) return '';
return parts[1] + parts[2];
}
function generateCode() {
try {
var manual = $code.getAttribute('data-manual') === '1';
if (manual) return; // user has manually edited
var t = $type.value;
var d = $date.value;
if (!t || !d) return;
var abbr = typeMap[t] || t.toUpperCase().replace(/[^A-Z0-9]/g,'').substring(0,7);
var mmdd = getMMDDFromISO(d);
if (!mmdd) return;
var newCode = abbr + '_' + mmdd;
$code.value = newCode;
} catch (e) {
console.error('generateCode error', e);
}
}
// mark manual when user types
$code.addEventListener('input', function(){
var val = $code.value.trim();
if (val.length === 0) {
$code.setAttribute('data-manual','0');
} else {
// if value matches auto pattern for currently selected type+date, keep as auto; otherwise mark manual
var expected = '';
try { expected = (typeMap[$type.value] || $type.value.toUpperCase().replace(/[^A-Z0-9]/g,'').substring(0,7)) + '_' + ( ($date.value) ? $date.value.split('-')[1] + $date.value.split('-')[2] : '' ); } catch(e){ expected=''; }
if (val === expected) {
$code.setAttribute('data-manual','0');
} else {
$code.setAttribute('data-manual','1');
}
}
});
$type.addEventListener('change', generateCode);
$date.addEventListener('change', generateCode);
// generate on load if empty
if ($code.value.trim().length === 0) {
generateCode();
}
})();
</script>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

173
src/admin/manage_events.php Normal file
View File

@@ -0,0 +1,173 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
$event_id = $_GET['event_id'] ?? null;
$event = null;
// If editing an existing event, fetch its data
if ($event_id) {
$stmt = $conn->prepare("SELECT * FROM events WHERE event_id = ?");
$stmt->bind_param("i", $event_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$event = $result->fetch_assoc();
}
$stmt->close();
}
?>
<?php
$pageTitle = $event ? 'Edit Event' : 'Create New Event';
$breadcrumbs = [['Home' => 'index'], ['Admin' => 'admin_events'], [$pageTitle => '']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Event Manager Area start -->
<section class="event-manager-area py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="comment-form bgc-lighter z-1 rel mb-30 rmb-55">
<form id="eventForm" enctype="multipart/form-data" method="POST" action="process_event">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<?php if ($event): ?>
<input type="hidden" name="event_id" value="<?php echo $event['event_id']; ?>">
<?php endif; ?>
<div class="section-title py-20">
<h2><?php echo $event ? 'Edit Event: ' . htmlspecialchars($event['name']) : 'Create New Event'; ?></h2>
<div id="responseMessage"></div>
</div>
<!-- Event Information -->
<div class="row mt-35">
<div class="col-md-6">
<div class="form-group">
<label for="name">Event Name *</label>
<input type="text" id="name" name="name" class="form-control" value="<?php echo $event ? htmlspecialchars($event['name']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="type">Event Type *</label>
<input type="text" id="type" name="type" class="form-control" value="<?php echo $event ? htmlspecialchars($event['type']) : ''; ?>" placeholder="e.g., Workshop, Training, Rally" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="location">Location *</label>
<input type="text" id="location" name="location" class="form-control" value="<?php echo $event ? htmlspecialchars($event['location']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="date">Date *</label>
<input type="date" id="date" name="date" class="form-control" value="<?php echo $event ? $event['date'] : ''; ?>" required>
</div>
</div>
<!-- Time -->
<div class="col-md-6">
<div class="form-group">
<label for="time">Time *</label>
<input type="time" id="time" name="time" class="form-control" value="<?php echo $event ? $event['time'] : ''; ?>" required>
</div>
</div>
<!-- Feature/Category -->
<div class="col-md-6">
<div class="form-group">
<label for="feature">Feature/Category *</label>
<input type="text" id="feature" name="feature" class="form-control" value="<?php echo $event ? htmlspecialchars($event['feature']) : ''; ?>" placeholder="e.g., Off-Road Training, Social Event" required>
</div>
</div>
<!-- Descriptions -->
<div class="col-md-12">
<div class="form-group">
<label for="description">Description *</label>
<textarea id="description" name="description" class="form-control" rows="6" required><?php echo $event ? htmlspecialchars($event['description']) : ''; ?></textarea>
</div>
</div>
<!-- Image Upload -->
<div class="col-md-12 mt-20">
<div class="form-group">
<label for="image">Event Image *</label>
<input type="file" id="image" name="image" class="form-control" accept="image/*" <?php echo !$event ? 'required' : ''; ?>>
<?php if ($event && $event['image']): ?>
<small class="text-info d-block mt-2">Current image: <img src="<?php echo $event['image']; ?>" alt="Event Image" style="max-width: 200px; margin-top: 10px;"></small>
<?php endif; ?>
</div>
</div>
<!-- Promo Image Upload -->
<div class="col-md-12 mt-20">
<div class="form-group">
<label for="promo">Promotional Image</label>
<input type="file" id="promo" name="promo" class="form-control" accept="image/*">
<small class="text-muted">This image will be displayed when users click "View Promo"</small>
<?php if ($event && $event['promo']): ?>
<small class="text-info d-block mt-2">Current promo: <img src="<?php echo $event['promo']; ?>" alt="Promo Image" style="max-width: 200px; margin-top: 10px;"></small>
<?php endif; ?>
</div>
</div>
<div class="col-md-12 mt-20">
<div class="form-group mb-0">
<button type="submit" class="theme-btn style-two" style="width:100%;">
<?php echo $event ? 'Update Event' : 'Create Event'; ?>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
<!-- Event Manager Area end -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
$(document).ready(function() {
$('#eventForm').on('submit', function(event) {
event.preventDefault();
var formData = new FormData(this);
$.ajax({
url: 'process_event',
type: 'POST',
data: formData,
contentType: false,
processData: false,
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
$('#responseMessage').html('<div class="alert alert-success">' + response.message + '</div>');
setTimeout(function() {
window.location.href = 'admin_events';
}, 2000);
} else {
$('#responseMessage').html('<div class="alert alert-danger">' + response.message + '</div>');
console.error('Server error:', response.message);
}
},
error: function(xhr, status, error) {
console.log('AJAX Error:', error);
console.log('Response:', xhr.responseText);
$('#responseMessage').html('<div class="alert alert-danger">Error creating/updating event: ' + error + '</div>');
}
});
});
});
</script>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

200
src/admin/manage_trips.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(__DIR__));
include_once($rootPath . '/header.php');
checkAdmin();
$trip_id = $_GET['trip_id'] ?? null;
$trip = null;
// If editing an existing trip, fetch its data
if ($trip_id) {
$stmt = $conn->prepare("SELECT * FROM trips WHERE trip_id = ?");
$stmt->bind_param("i", $trip_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$trip = $result->fetch_assoc();
}
$stmt->close();
}
?>
<?php
$pageTitle = $trip ? 'Edit Trip' : 'Create New Trip';
$breadcrumbs = [['Home' => 'index'], ['Admin' => 'admin_trips'], [$pageTitle => '']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Trip Manager Area start -->
<section class="trip-manager-area py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="comment-form bgc-lighter z-1 rel mb-30 rmb-55">
<form id="tripForm" enctype="multipart/form-data" method="POST" action="process_trip">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<?php if ($trip): ?>
<input type="hidden" name="trip_id" value="<?php echo $trip['trip_id']; ?>">
<?php endif; ?>
<div class="section-title py-20">
<h2><?php echo $trip ? 'Edit Trip: ' . htmlspecialchars($trip['trip_name']) : 'Create New Trip'; ?></h2>
<div id="responseMessage"></div>
</div>
<!-- Trip Information -->
<div class="row mt-35">
<div class="col-md-6">
<div class="form-group">
<label for="trip_name">Trip Name *</label>
<input type="text" id="trip_name" name="trip_name" class="form-control" value="<?php echo $trip ? htmlspecialchars($trip['trip_name']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="trip_code">Trip Code</label>
<input type="text" id="trip_code" name="trip_code" class="form-control" maxlength="12" value="<?php echo $trip ? htmlspecialchars($trip['trip_code']) : ''; ?>" placeholder="e.g., TRIP001">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="location">Location *</label>
<input type="text" id="location" name="location" class="form-control" value="<?php echo $trip ? htmlspecialchars($trip['location']) : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="vehicle_capacity">Vehicle Capacity *</label>
<input type="number" id="vehicle_capacity" name="vehicle_capacity" class="form-control" min="1" value="<?php echo $trip ? $trip['vehicle_capacity'] : ''; ?>" required>
</div>
</div>
<!-- Dates -->
<div class="col-md-6">
<div class="form-group">
<label for="start_date">Start Date *</label>
<input type="date" id="start_date" name="start_date" class="form-control" value="<?php echo $trip ? $trip['start_date'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="end_date">End Date *</label>
<input type="date" id="end_date" name="end_date" class="form-control" value="<?php echo $trip ? $trip['end_date'] : ''; ?>" required>
</div>
</div>
<!-- Descriptions -->
<div class="col-md-12">
<div class="form-group">
<label for="short_description">Short Description *</label>
<textarea id="short_description" name="short_description" class="form-control" rows="3" required><?php echo $trip ? htmlspecialchars($trip['short_description']) : ''; ?></textarea>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label for="long_description">Long Description *</label>
<textarea id="long_description" name="long_description" class="form-control" rows="6" required><?php echo $trip ? htmlspecialchars($trip['long_description']) : ''; ?></textarea>
</div>
</div>
<!-- Pricing -->
<div class="col-md-6">
<div class="form-group">
<label for="cost_members">Member Cost (R) *</label>
<input type="number" id="cost_members" name="cost_members" class="form-control" step="0.01" min="0" value="<?php echo $trip ? $trip['cost_members'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cost_nonmembers">Non-Member Cost (R) *</label>
<input type="number" id="cost_nonmembers" name="cost_nonmembers" class="form-control" step="0.01" min="0" value="<?php echo $trip ? $trip['cost_nonmembers'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cost_pensioner_member">Pensioner Member Cost (R) *</label>
<input type="number" id="cost_pensioner_member" name="cost_pensioner_member" class="form-control" step="0.01" min="0" value="<?php echo $trip ? $trip['cost_pensioner_member'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="cost_pensioner">Pensioner Cost (R) *</label>
<input type="number" id="cost_pensioner" name="cost_pensioner" class="form-control" step="0.01" min="0" value="<?php echo $trip ? $trip['cost_pensioner'] : ''; ?>" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="booking_fee">Booking Fee (R) *</label>
<input type="number" id="booking_fee" name="booking_fee" class="form-control" step="0.01" min="0" value="<?php echo $trip ? $trip['booking_fee'] : ''; ?>" required>
</div>
</div>
<!-- Images Upload -->
<div class="col-md-12 mt-20">
<div class="form-group">
<label>Trip Images</label>
<p class="text-muted">Upload images for this trip. Ideally 5 different images will be required</p>
<input type="file" name="trip_images[]" class="form-control" accept="image/*" multiple>
<?php if ($trip): ?>
<small class="text-info">Images will be saved to: assets/images/trips/<?php echo $trip_id; ?>_{number}.jpg</small>
<?php endif; ?>
</div>
</div>
<div class="col-md-12 mt-20">
<div class="form-group mb-0">
<button type="submit" class="theme-btn style-two" style="width:100%;">
<?php echo $trip ? 'Update Trip' : 'Create Trip'; ?>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
<!-- Trip Manager Area end -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
$(document).ready(function() {
$('#tripForm').on('submit', function(event) {
event.preventDefault();
var formData = new FormData(this);
$.ajax({
url: 'process_trip',
type: 'POST',
data: formData,
contentType: false,
processData: false,
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
$('#responseMessage').html('<div class="alert alert-success">' + response.message + '</div>');
setTimeout(function() {
window.location.href = 'admin_trips';
}, 2000);
} else {
$('#responseMessage').html('<div class="alert alert-danger">' + response.message + '</div>');
console.error('Server error:', response.message);
}
},
error: function(xhr, status, error) {
console.log('AJAX Error:', error);
console.log('Response:', xhr.responseText);
$('#responseMessage').html('<div class="alert alert-danger">Error creating/updating trip: ' + error + '</div>');
}
});
});
});
</script>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -6,7 +6,16 @@ include_once('../config/functions.php');
$conn = openDatabaseConnection();
$stmt = $conn->prepare("SELECT
c.*,
c.id,
c.name,
c.description,
c.website,
c.telephone,
c.latitude,
c.longitude,
c.thumbnail,
c.country,
c.province,
u.first_name,
u.last_name,
u.profile_pic
@@ -26,6 +35,8 @@ while ($row = $result->fetch_assoc()) {
'latitude' => $row['latitude'],
'longitude' => $row['longitude'],
'thumbnail' => $row['thumbnail'],
'country' => $row['country'],
'province' => $row['province'],
'user' => [
'first_name' => $row['first_name'],
'last_name' => $row['last_name'],

292
src/api/ikhokha_webhook.php Normal file
View File

@@ -0,0 +1,292 @@
<?php
$rootPath = dirname(dirname(__DIR__));
require_once($rootPath . "/src/config/env.php");
require_once($rootPath . "/src/config/connection.php");
require_once($rootPath . "/src/config/functions.php");
require_once($rootPath . "/src/helpers/notification_helper.php");
/**
* ==========================================================
* JS-equivalent escaping (matches iKhokha docs exactly)
* ==========================================================
*/
function jsStringEscape(string $str): string
{
$str = preg_replace('/([\\\\\"\'])/', '\\\\$1', $str);
$str = str_replace("\0", "\\0", $str);
return $str;
}
function createPayloadToSign(string $path, string $body): string
{
return jsStringEscape($path . $body);
}
/**
* ==========================================================
* Read raw request body (DO NOT MODIFY)
* ==========================================================
*/
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
http_response_code(400);
progress_log('iKhokha webhook: empty body');
exit('No body');
}
/**
* ==========================================================
* Read headers
* ==========================================================
*/
$headers = function_exists('getallheaders') ? getallheaders() : [];
$headers = array_change_key_case($headers, CASE_LOWER);
$ikSign = $headers['ik-sign'] ?? null;
$ikAppId = $headers['ik-appid'] ?? null;
if (!$ikSign || !$ikAppId) {
http_response_code(400);
progress_log('iKhokha webhook: missing headers');
exit('Missing headers');
}
/**
* ==========================================================
* Signature verification (JS-equivalent)
* ==========================================================
*/
$secret = $_ENV['IKHOKHA_APP_SECRET'] ?? null;
$callbackUrl = $_ENV['IKHOKHA_CALLBACK_URL'] ?? null;
$bypass = ($_ENV['IKHOKHA_BYPASS_SIGNATURE'] ?? 'false') === 'true';
if (!$secret || !$callbackUrl) {
http_response_code(500);
exit('Server misconfigured');
}
progress_log('--- iKhokha WEBHOOK DEBUG ---');
progress_log('RAW BODY: ' . $raw);
progress_log('IK-SIGN: ' . $ikSign);
// Decode body so we can remove `text`
$bodyArray = json_decode($raw, true);
if (!is_array($bodyArray)) {
http_response_code(400);
exit('Invalid JSON');
}
// iKhokha JS deletes `text`
unset($bodyArray['text']);
// JS-style JSON (no escaped slashes)
$jsonBody = json_encode($bodyArray, JSON_UNESCAPED_SLASHES);
// Now sign the SAME payload JS signs
$payloadToSign = createPayloadToSign($callbackUrl, $jsonBody);
$expected = generateSignature($payloadToSign, $secret);
progress_log('JS PAYLOAD: ' . $payloadToSign);
progress_log('EXPECTED SIGN: ' . $expected);
progress_log('RECEIVED SIGN: ' . $ikSign);
if (!$bypass) {
if (!hash_equals($expected, $ikSign)) {
http_response_code(403);
progress_log('iKhokha webhook: signature mismatch');
if (function_exists('auditLog')) {
auditLog(null, 'IKHOKHA_SIGNATURE_MISMATCH', 'webhook', null, [
'expected' => $expected,
'received' => $ikSign
]);
}
exit('Invalid signature');
}
} else {
progress_log('⚠️ IKHOKHA SIGNATURE CHECK BYPASSED');
}
/**
* ==========================================================
* Decode payload
* ==========================================================
*/
$payload = json_decode($raw, true);
$data = $payload['data'] ?? $payload;
/**
* ==========================================================
* Extract fields safely
* ==========================================================
*/
$externalTransactionID =
$data['externalTransactionID']
?? $data['externalTransactionId']
?? $data['externalTxId']
?? null;
$providerPaymentId =
$data['paylinkID']
?? $data['id']
?? null;
$providerStatus =
$data['status']
?? $payload['status']
?? null;
progress_log('Parsed externalTransactionID: ' . $externalTransactionID);
progress_log('Parsed providerPaymentId: ' . $providerPaymentId);
progress_log('Parsed providerStatus: ' . $providerStatus);
/**
* ==========================================================
* Locate payment
* ==========================================================
*/
$localPaymentId = null;
$booking_id = null;
$user_id = null;
$description = null;
if ($externalTransactionID) {
$stmt = $conn->prepare(
"SELECT payment_id, user_id, booking_id, description
FROM payments
WHERE payment_id = ?
LIMIT 1"
);
if ($stmt) {
$stmt->bind_param('s', $externalTransactionID);
$stmt->execute();
$res = $stmt->get_result();
if ($row = $res->fetch_assoc()) {
extract($row);
$localPaymentId = $row['payment_id'];
}
$stmt->close();
}
}
if (!$localPaymentId && $providerPaymentId) {
$stmt = $conn->prepare(
"SELECT payment_id, user_id, booking_id, description
FROM payments
WHERE provider_payment_id = ?
LIMIT 1"
);
if ($stmt) {
$stmt->bind_param('s', $providerPaymentId);
$stmt->execute();
$res = $stmt->get_result();
if ($row = $res->fetch_assoc()) {
extract($row);
$localPaymentId = $row['payment_id'];
}
$stmt->close();
}
}
if (!$localPaymentId) {
http_response_code(404);
exit('Payment not found');
}
/**
* ==========================================================
* Persist provider response
* ==========================================================
*/
$update = $conn->prepare(
"UPDATE payments
SET provider_payment_id = ?,
provider_status = ?,
provider_response = ?
WHERE payment_id = ?"
);
if ($update) {
$update->bind_param(
'ssss',
$providerPaymentId,
$providerStatus,
$raw,
$localPaymentId
);
$update->execute();
$update->close();
}
/**
* ==========================================================
* Business logic
* ==========================================================
*/
$normalized = strtoupper(trim((string)$providerStatus));
if (in_array($normalized, ['PAID', 'SUCCESS', 'COMPLETED', 'SETTLED'], true)) {
$conn->prepare(
"UPDATE payments SET status = 'PAID' WHERE payment_id = ?"
)->bind_param('s', $localPaymentId)->execute();
if ($booking_id) {
$conn->prepare(
"UPDATE bookings SET status = 'PAID' WHERE booking_id = ?"
)->bind_param('i', $booking_id)->execute();
} else {
$conn->prepare(
"UPDATE membership_fees SET payment_status = 'PAID' WHERE payment_id = ?"
)->bind_param('s', $localPaymentId)->execute();
}
sendPaymentConfirmation(
getEmail($user_id),
getFullName($user_id),
$description
);
//generate $message for admin payment confirmation with payment details
$message = "Payment Confirmation\n\n";
$message .= "Payment ID: " . $localPaymentId . "\n";
$message .= "Amount: " . getPaymentAmount($localPaymentId) . "\n";
$message .= "Status: PAID\n";
$message .= "Description: " . $description . "\n";
$message .= "Thank you.\n";
$subject = "4WDCSA.co.za Payment Confirmation for Payment ID: " . $localPaymentId;
progress_log('Payment confirmation sent for payment ID: ' . $localPaymentId);
sendEmail(
$_ENV['FINANCE_EMAIL'],
$subject,
nl2br($message)
);
sendEmail(
'chrispintoza@gmail.com',
$subject,
nl2br($message)
);
sendAdminNotification($subject, nl2br($message));
$event = 'new_payment_received';
$sub_feed = 'payments';
$data = [
'actor_id' => $_SESSION['user_id'] ?? null,
'actor_avatar' => $_SESSION['profile_pic'] ?? null, // used by UI to show avatar
'title' => "New Payment Received for Payment ID: {$localPaymentId}"
];
addNotification(null, $event, $sub_feed, $data, null);
}
/**
* ==========================================================
* Acknowledge webhook
* ==========================================================
*/
http_response_code(200);
echo 'OK';

41
src/api/notifications.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
header('Content-Type: application/json');
// Ensure environment is loaded before attempting DB connection
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/connection.php';
// helper filename uses singular in this repo: notification_helper.php
require_once __DIR__ . '/../helpers/notification_helper.php';
session_start();
$admin_id = $_SESSION['user_id'] ?? null;
$action = $_REQUEST['action'] ?? '';
if ($action === 'fetch') {
$subs = getAdminSubscriptions($admin_id);
$notes = fetchNotifications($admin_id, $subs, 50);
echo json_encode(['success' => true, 'notifications' => $notes, 'unread_count' => getUnreadCount($admin_id, $subs)]);
exit;
}
if ($action === 'mark_read') {
if (!$admin_id) { echo json_encode(['success' => false, 'error' => 'unauthenticated']); exit; }
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if (!$id) { echo json_encode(['success' => false, 'error' => 'missing_id']); exit; }
$ok = markNotificationRead($id, $admin_id);
echo json_encode(['success' => (bool)$ok]);
exit;
}
if ($action === 'add') {
// internal use: create a notification
$target = isset($_POST['user_id']) ? intval($_POST['user_id']) : null;
$event = $_POST['event'] ?? '';
$sub_feed = $_POST['sub_feed'] ?? null;
$data = isset($_POST['data']) ? json_decode($_POST['data'], true) : [];
$target_url = $_POST['target_url'] ?? null;
$id = addNotification($target, $event, $sub_feed, $data, $target_url);
echo json_encode(['success' => (bool)$id, 'id' => $id]);
exit;
}
echo json_encode(['success' => false, 'error' => 'invalid_action']);

10
src/api/test_log.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
$rootPath = dirname(dirname(__DIR__));
// require_once($rootPath . "/src/config/env.php");
// require_once($rootPath . "/src/config/connection.php");
require_once($rootPath . "/src/config/functions.php");
echo "Test log entry attempt...";
progress_log('Testing Log Entry at ' . date('Y-m-d H:i:s'));
echo "Test log entry created.";

View File

@@ -0,0 +1,46 @@
<?php
// /api/track/get_obstacle.php
header('Content-Type: text/html; charset=utf-8');
// Read JSON POST body
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
if (!$id) {
http_response_code(400);
echo "<h3>Error</h3><p>Invalid obstacle id.</p>";
exit;
}
// TODO: Replace this with DB lookup (mysqli) by id.
// For demo return stubbed content:
$fake = [
'obst-camp' => [
'title' => 'Base Camp',
'img' => '/assets/images/camp.jpg',
'difficulty' => 'easy',
'desc' => 'Flat campsite with shade and water point.'
],
'obst-water' => [
'title' => 'Water Crossing',
'img' => '/assets/images/water.jpg',
'difficulty' => 'hard',
'desc' => 'Deep crossing after heavy rain, check depth first.'
]
];
$data = $fake[$id] ?? null;
if (!$data) {
http_response_code(404);
echo "<h3>Not found</h3><p>No details for '{$id}'.</p>";
exit;
}
// render HTML snippet for Magnific
?>
<img src="<?= htmlspecialchars($data['img']) ?>" alt="<?= htmlspecialchars($data['title']) ?>" style="width:100%; height:220px; object-fit:cover; border-radius:6px; margin-bottom:12px;">
<h3><?= htmlspecialchars($data['title']) ?></h3>
<span class="difficulty-badge <?= htmlspecialchars($data['difficulty']) ?>"><?= htmlspecialchars(ucfirst($data['difficulty'])) ?></span>
<div class="description" style="margin-top:10px;"><?= htmlspecialchars($data['desc']) ?></div>
<?php

View File

@@ -3,11 +3,12 @@
// Disable mysqli exceptions so we can handle connection errors gracefully
mysqli_report(MYSQLI_REPORT_OFF);
$dbhost = $_ENV['DB_HOST'];
$dbuser = $_ENV['DB_USER'];
$dbpass = $_ENV['DB_PASS'];
$dbname = $_ENV['DB_NAME'];
$salt = $_ENV['SALT'];
// Read from environment or fallback to getenv; keep empty string if not set to avoid PHP warnings
$dbhost = $_ENV['DB_HOST'] ?? getenv('DB_HOST') ?? '';
$dbuser = $_ENV['DB_USER'] ?? getenv('DB_USER') ?? '';
$dbpass = $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?? '';
$dbname = $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?? '';
$salt = $_ENV['SALT'] ?? getenv('SALT') ?? '';
// echo "hello. ". $dbhost;

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
<?php
require 'env.php';
require 'connection.php';
$conn = openDatabaseConnection();
if (!$conn) {
die('Database connection failed');
}
$sql = file_get_contents('migrations/001_phase1_security_schema.sql');
if ($conn->multi_query($sql)) {
echo "✓ Migration executed successfully\n";
} else {
echo "✗ Migration error: " . $conn->error . "\n";
}
$conn->close();
?>

View File

@@ -0,0 +1,151 @@
<?php
// Notifications helper
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/connection.php';
require_once __DIR__ . '/../../classes/DatabaseService.php';
function addNotification($target_user_id, $event, $sub_feed = null, $data = [], $target_url = null) {
global $db, $conn;
if (!isset($db) && isset($conn)) {
$db = new DatabaseService($conn);
}
$ds = $db;
if (!$ds) {
// Try to initialize DatabaseService if connection exists
if (isset($conn) && $conn) {
$ds = new DatabaseService($conn);
} else {
// No DB available
return false;
}
}
$data_json = $data ? json_encode($data) : null;
$read_by_json = json_encode([]);
$query = "INSERT INTO notifications (user_id, event, sub_feed, data, target_url, read_by) VALUES (?,?,?,?,?,?)";
return $ds->insert($query, [$target_user_id, $event, $sub_feed, $data_json, $target_url, $read_by_json], "isssss");
}
function fetchNotifications($admin_user_id = null, $subscriptions = [], $limit = 50) {
global $db, $conn;
if (!isset($db) && isset($conn)) {
$db = new DatabaseService($conn);
}
$ds = $db;
if (!$ds) {
if (isset($conn) && $conn) {
$ds = new DatabaseService($conn);
} else {
// No DB available — return empty list to avoid fatal error
return [];
}
}
$params = [];
$types = "";
$sql = "SELECT * FROM notifications";
$where = [];
// Admin-only: fetch notifications targeted to admins (user_id IS NULL) or global, or specifically to an admin
if ($admin_user_id) {
$where[] = "(user_id IS NULL OR user_id = ?)";
$params[] = $admin_user_id;
$types .= "i";
}
if (!empty($subscriptions)) {
// build IN (...) list safely by placeholders
$placeholders = implode(',', array_fill(0, count($subscriptions), '?'));
$where[] = "(sub_feed IN ($placeholders))";
foreach ($subscriptions as $s) { $params[] = $s; $types .= "s"; }
}
if (!empty($where)) {
$sql .= " WHERE " . implode(' AND ', $where);
}
$sql .= " ORDER BY time_created DESC LIMIT ?";
$params[] = $limit; $types .= "i";
$results = $ds->select($sql, $params, $types);
if ($results === false) {
// Query error - return empty list so UI doesn't break
return [];
}
// decode data JSON and include read_by as array
$filtered = [];
foreach ($results as $r) {
$r['data'] = $r['data'] ? json_decode($r['data'], true) : null;
$r['read_by'] = $r['read_by'] ? json_decode($r['read_by'], true) : [];
if (!is_array($r['read_by'])) $r['read_by'] = [];
// If admin_user_id is provided, skip notifications this admin already read
if ($admin_user_id && in_array((int)$admin_user_id, $r['read_by'])) {
continue;
}
$filtered[] = $r;
}
return $filtered;
}
function markNotificationRead($id, $admin_user_id) {
global $conn;
if (!$id || !$admin_user_id) return false;
if (!isset($conn) || !$conn) return false;
$stmt = $conn->prepare("SELECT read_by FROM notifications WHERE id = ? LIMIT 1");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->bind_result($read_by_json);
$found = $stmt->fetch();
$stmt->close();
if (!$found) return false;
$read_by = $read_by_json ? json_decode($read_by_json, true) : [];
if (!is_array($read_by)) $read_by = [];
if (!in_array($admin_user_id, $read_by)) {
$read_by[] = (int)$admin_user_id;
$new_json = json_encode(array_values($read_by));
$u = $conn->prepare("UPDATE notifications SET read_by = ? WHERE id = ?");
$u->bind_param("si", $new_json, $id);
$res = $u->execute();
$u->close();
return $res;
}
return true;
}
function getUnreadCount($admin_user_id, $subscriptions = []) {
global $conn;
if (!isset($conn) || !$conn) return 0;
$sql = "SELECT id, read_by, sub_feed FROM notifications";
$where = [];
$params = [];
$types = "";
if ($admin_user_id) {
$where[] = "(user_id IS NULL OR user_id = ?)"; $params[] = $admin_user_id; $types .= "i";
}
if (!empty($subscriptions)) {
$placeholders = implode(',', array_fill(0, count($subscriptions), '?'));
$where[] = "(sub_feed IN ($placeholders))";
foreach ($subscriptions as $s) { $params[] = $s; $types .= "s"; }
}
if (!empty($where)) $sql .= " WHERE " . implode(' AND ', $where);
$sql .= " ORDER BY time_created DESC";
$stmt = $conn->prepare($sql);
if ($types) {
// bind params dynamically
$refs = [];
$refs[] = &$types;
foreach ($params as $k => $v) { $refs[] = &$params[$k]; }
call_user_func_array([$stmt, 'bind_param'], $refs);
}
$stmt->execute();
$res = $stmt->get_result();
$count = 0;
while ($row = $res->fetch_assoc()) {
$read_by = $row['read_by'] ? json_decode($row['read_by'], true) : [];
if (!is_array($read_by)) $read_by = [];
if (!in_array((int)$admin_user_id, $read_by)) $count++;
}
$stmt->close();
return $count;
}
function getAdminSubscriptions($admin_user_id) {
// Placeholder: by default return empty array (all sub_feeds). Implement subscription table later.
return [];
}

1
src/logs/db_errors.log Normal file

File diff suppressed because one or more lines are too long

118
src/pages/add_campsite.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
$rootPath = dirname(dirname(__DIR__));
require_once($rootPath . '/src/config/env.php');
include_once($rootPath . '/src/config/connection.php');
include_once($rootPath . '/src/config/functions.php');
session_start();
$user_id = $_SESSION['user_id'] ?? null;
// CSRF Token Validation
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
http_response_code(403);
die('Security token validation failed. Please try again.');
}
// campsites.php
$conn = openDatabaseConnection();
// Get text inputs
$name = validateName($_POST['name'] ?? '') ?: '';
$desc = isset($_POST['description']) ? htmlspecialchars($_POST['description'], ENT_QUOTES, 'UTF-8') : '';
$country = isset($_POST['country']) ? htmlspecialchars($_POST['country'], ENT_QUOTES, 'UTF-8') : '';
$province = isset($_POST['province']) ? htmlspecialchars($_POST['province'], ENT_QUOTES, 'UTF-8') : '';
$lat = isset($_POST['latitude']) ? floatval($_POST['latitude']) : 0.0;
$lng = isset($_POST['longitude']) ? floatval($_POST['longitude']) : 0.0;
$website = isset($_POST['website']) ? filter_var($_POST['website'], FILTER_VALIDATE_URL) : '';
$telephone = validatePhoneNumber($_POST['telephone'] ?? '') ?: '';
if (empty($name)) {
http_response_code(400);
die('Campsite name is required.');
}
// Handle file upload
$thumbnailPath = null;
if (isset($_FILES['thumbnail']) && $_FILES['thumbnail']['error'] !== UPLOAD_ERR_NO_FILE) {
// Validate file using hardened validation function
$validationResult = validateFileUpload($_FILES['thumbnail'], 'profile_picture');
if ($validationResult === false) {
http_response_code(400);
die('Invalid thumbnail image. Only JPG, JPEG, PNG, GIF, and WEBP images under 5MB are allowed.');
}
$uploadDir = $rootPath . "/assets/uploads/campsites/";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (!is_writable($uploadDir)) {
http_response_code(500);
die('Upload directory is not writable.');
}
$randomFilename = $validationResult['filename'];
$targetFile = $uploadDir . $randomFilename;
if (move_uploaded_file($_FILES["thumbnail"]["tmp_name"], $targetFile)) {
chmod($targetFile, 0644);
$thumbnailPath = "assets/uploads/campsites/" . $randomFilename;
} else {
http_response_code(500);
die('Failed to move uploaded file.');
}
}
$id = isset($_POST['id']) ? intval($_POST['id']) : 0;
if ($id > 0) {
// Verify ownership - check if the campsite belongs to the current user
$ownerCheckStmt = $conn->prepare("SELECT user_id FROM campsites WHERE id = ?");
$ownerCheckStmt->bind_param("i", $id);
$ownerCheckStmt->execute();
$ownerResult = $ownerCheckStmt->get_result();
if ($ownerResult->num_rows === 0) {
http_response_code(404);
die('Campsite not found.');
}
$ownerRow = $ownerResult->fetch_assoc();
if ($ownerRow['user_id'] != $user_id) {
http_response_code(403);
die('You do not have permission to edit this campsite. Only the owner can make changes.');
}
$ownerCheckStmt->close();
// UPDATE
if ($thumbnailPath) {
$stmt = $conn->prepare("UPDATE campsites SET name=?, description=?, country=?, province=?, latitude=?, longitude=?, website=?, telephone=?, thumbnail=? WHERE id=?");
$stmt->bind_param("ssssddsssi", $name, $desc, $country, $province, $lat, $lng, $website, $telephone, $thumbnailPath, $id);
} else {
$stmt = $conn->prepare("UPDATE campsites SET name=?, description=?, country=?, province=?, latitude=?, longitude=?, website=?, telephone=? WHERE id=?");
$stmt->bind_param("ssssddssi", $name, $desc, $country, $province, $lat, $lng, $website, $telephone, $id);
}
// Log the action
auditLog($user_id, 'CAMPSITE_UPDATE', 'campsites', $id, ['name' => $name]);
} else {
// INSERT
$stmt = $conn->prepare("INSERT INTO campsites (name, description, country, province, latitude, longitude, website, telephone, thumbnail, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->bind_param("ssssddsssi", $name, $desc, $country, $province, $lat, $lng, $website, $telephone, $thumbnailPath, $user_id);
// Log the action
auditLog($user_id, 'CAMPSITE_CREATE', 'campsites', 0, ['name' => $name]);
}
if (!$stmt->execute()) {
http_response_code(500);
die('Database error: ' . $stmt->error);
}
$stmt->close();
header("Location: campsites");
?>

243
src/pages/blog/blog.php Normal file
View File

@@ -0,0 +1,243 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
?>
<style>
.image {
width: 400px;
/* Set your desired width */
height: 350px;
/* Set your desired height */
overflow: hidden;
/* Hide any overflow */
display: block;
/* Ensure proper block behavior */
}
.image img {
width: 100%;
/* Image scales to fill the container */
height: 100%;
/* Image scales to fill the container */
object-fit: cover;
/* Fills the container while maintaining aspect ratio */
object-position: top;
/* Aligns the top of the image with the top of the container */
display: block;
/* Prevents inline whitespace issues */
}
</style><?php
$pageTitle = 'Blogs';
$breadcrumbs = [['Home' => 'index.php']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Blog List Area start -->
<section class="blog-list-page py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-8">
<?php
// Query to retrieve data from the trips table
$result = $conn->prepare("
SELECT
b.blog_id,
b.title,
b.description,
b.category,
b.status,
b.date,
b.image,
b.members_only,
CONCAT(u.first_name, ' ', u.last_name) AS author_name,
u.email AS author_email,
u.profile_pic
FROM blogs b
JOIN users u ON b.author = u.user_id
WHERE b.status = 'published'
ORDER BY b.date DESC
");
$result->execute();
$posts = $result->get_result();
if ($posts->num_rows > 0) {
// Loop through each row
while ($post = $posts->fetch_assoc()):
$blog_id = $post['blog_id'];
$blog_title = $post['title'];
$blog_date = $post['date'];
$blog_category = $post['category'];
$blog_image = $post['image'];
$blog_description = $post['description'];
$members_only = $post['members_only'];
if ($members_only) {
if (!isset($_SESSION['user_id'])) {
$blog_link = "login";
$button_hover = "Members Only";
$icon = "fa-lock";
} else {
if (getUserMemberStatus($_SESSION['user_id'])) {
$blog_link = "blog_read?token=" . encryptData($blog_id, $salt);
$button_hover = "Read More";
$icon = "fa-arrow-right";
} else {
$blog_link = "membership";
$button_hover = "Members Only";
$icon = "fa-lock";
}
}
} else {
$blog_link = "blog_read?token=" . encryptData($blog_id, $salt);
$button_hover = "Read More";
$icon = "fa-arrow-right";
}
// Output the HTML structure with dynamic data
echo '
<a href="' . $blog_link . '" style="text-decoration: none; color: inherit;">
<div class="blog-item style-three" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image" style="border-radius:20px; width:300px;height: 250px;margin-right:0px;">
<img src="' . htmlspecialchars($blog_image) . '" alt="' . htmlspecialchars($post["title"]) . '">
</div>
<div style="padding: 10px; height: 100%; width:100%;">
<div class="destination-header d-flex align-items-start gap-3" style="width:100%; align-items: flex-start;">
<img src="' . $post["profile_pic"] . '" alt="Author" class="rounded-circle border" width="60" height="60">
<div>
<span class="badge bg-dark mb-1">' . strtoupper($post["category"]) . '</span>
<h5 class="mb-0">' . $post["title"] . '</h5>
<small class="text-muted">' . $post["author_name"] . '</small>
</div>
</div>
<p>' . $post["description"] . '</p>
</div>
</div>
</a>
';
endwhile;
} else {
echo '<p>No blog posts found.</p>';
}
?>
<!-- <ul class="pagination pt-15 flex-wrap" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<li class="page-item disabled">
<span class="page-link"><i class="far fa-chevron-left"></i></span>
</li>
<li class="page-item active">
<span class="page-link">
1
<span class="sr-only">(current)</span>
</span>
</li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item"><a class="page-link" href="#">...</a></li>
<li class="page-item">
<a class="page-link" href="#"><i class="far fa-chevron-right"></i></a>
</li>
</ul> -->
</div>
<div class="col-lg-4 col-md-8 col-sm-10 rmt-75">
<div class="blog-sidebar">
<div class="widget widget-search" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<form action="#" class="default-search-form" onsubmit="return false;">
<input type="text" id="blog-search" placeholder="Search" required="">
<button type="submit" class="searchbutton far fa-search"></button>
</form>
</div>
<div class="widget widget-gallery" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5 class="widget-title">Gallery</h5>
<div class="gallery">
<?php
// Get IDs of published blogs
$published_blogs = $conn->query("SELECT blog_id FROM blogs WHERE status = 'published'");
$blog_ids = [];
while ($blog = $published_blogs->fetch_assoc()) {
$blog_ids[] = $blog['blog_id'];
}
// Display images from published blogs only
if (!empty($blog_ids)) {
foreach ($blog_ids as $bid) {
$folder = $rootPath . '/uploads/blogs/' . $bid . '/';
if (is_dir($folder)) {
$files = glob($folder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
if (!empty($files)) {
foreach ($files as $file) {
// Skip cover images
if (basename($file) !== 'cover.' . pathinfo($file, PATHINFO_EXTENSION)) {
$relativePath = '/uploads/blogs/' . $bid . '/' . basename($file);
echo '<a href="' . $relativePath . '" style="width: 110px; height: 110px; overflow: hidden; display: inline-block; margin: 2px;">';
echo '<img src="' . $relativePath . '" alt="Gallery" style="width: 100%; height: 100%; object-fit: cover; display: block;">';
echo '</a>';
}
}
}
}
}
}
?>
</div>
</div>
<!-- <div class="widget widget-cta" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="content text-white">
<span class="h6">Explore The World</span>
<h3>Become a Member</h3>
<a href="membership" class="theme-btn style-two bgc-secondary">
<span data-hover="Explore Now">Join Now</span>
<i class="fal fa-arrow-right"></i>
</a>
</div>
<div class="image">
<img src="assets/images/logos/weblogo.png" alt="CTA">
</div>
<div class="cta-shape"><img src="assets/images/widgets/cta-shape.png" alt="Shape"></div>
</div> -->
</div>
</div>
</div>
</div>
</section>
<!-- Blog List Area end -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('blog-search');
const blogItems = document.querySelectorAll('.blog-item');
if (searchInput) {
searchInput.addEventListener('keyup', function() {
const searchTerm = this.value.toLowerCase();
blogItems.forEach(function(item) {
const title = item.querySelector('h5').textContent.toLowerCase();
const category = item.querySelector('.category').textContent.toLowerCase();
const description = item.querySelector('p').textContent.toLowerCase();
const author = item.querySelector('.blog-meta li:nth-child(2)').textContent.toLowerCase();
if (title.includes(searchTerm) || category.includes(searchTerm) || description.includes(searchTerm) || author.includes(searchTerm)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
});
}
});
</script>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,533 @@
<?php
$headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
?>
<style>
.image {
width: 400px;
/* Set your desired width */
height: 350px;
/* Set your desired height */
overflow: hidden;
/* Hide any overflow */
display: block;
/* Ensure proper block behavior */
}
.image img {
width: 100%;
/* Image scales to fill the container */
height: 100%;
/* Image scales to fill the container */
object-fit: cover;
/* Fills the container while maintaining aspect ratio */
object-position: top;
/* Aligns the top of the image with the top of the container */
display: block;
/* Prevents inline whitespace issues */
}
</style>
<?php
$pageTitle = 'Blog Details';
$breadcrumbs = [['Home' => 'index.php'], ['Blogs' => 'blog.php']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Blog Detaisl Area start -->
<section class="blog-detaisl-page py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="blog-details-content" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<a href="blog.html" class="category">Travel</a>
<ul class="blog-meta mb-30">
<li><img src="assets/images/pp/default.png" alt="Admin"> <a href="#">John Runciman</a></li>
<li><i class="far fa-calendar-alt"></i> <a href="#">25 Feb 2024</a></li>
<li><i class="far fa-comments"></i> <a href="#">Comments (5)</a></li>
</ul>
<p>Every year, Noelene and I organise a trip through the Eastern Cape, with the highlight being traversing Baviaanskloof. Each trip has been slightly different to the previous one, with this trip, in my opinion, being the best one!</p>
<p>The idea was to meet up at the village at the mouth of the Bushmans River, Boesmansriviermond, near Kenton-on-Sea. Mike and Clara arrived a few days early and we enjoyed a ride up the Bushmans River in our little boat and walks on the beach.</p>
<p>The rest of the group—Roy and Naome, Doug and Santie, and Dave and Valery—arrived on the Friday, the day before the official departure. Doug and Dave booked a campsite at Cannon Rocks, 20 or so kilometres from Bushmans. We arranged a braai for that evening, and I admit that I was shocked to my little toes when I saw that Doug and Dave had brought a caravan and camping trailer along. This is definitely not a caravan or trailer-friendly route and I voiced my hesitation.</p>
<p>The long and the short was that Doug decided to continue despite my fears, and Dave decided to withdraw from the trip. This was not entirely due to my warnings but also to Valery not feeling up to scratch. We also heard that Roger would not be able to make it because of personal problems at home.</p>
<h5>Saturday: Bushmans to Ocean View</h5>
<p>On Saturday morning, the remaining four vehicles met at Bushmans River with our first destination set for Bathurst for breakfast. We drove via the "poor mans game drive" (the old main road from Port Elizabeth to Port Alfred, now incorporated into the Sibuya Game Reserve) and the winding road through the spectacular Cowie River Valley.</p>
<p>After brunch (the trip took longer than expected due to the bad roads), we wandered along to the Fish River Lighthouse, a place worth a visit. This historic building was erected in the late 19th century with the light first shining on 1 July 1898. The warning light has a strength of 5,000,000 candelas and is 85 metres above the high water mark with a shine range of 32 sea miles. Wish I had that on the front of my Hilux!</p>
<p>The most unique feature about the light is that it has no bearings for the 2-ton light to spin on, but rather it floats in a bed of mercury—ingenious!</p>
<p>From there, we drove back past the Bushmans River, towards Boknes, and onto the scenic gravel road going to Alexandria that services all the dairy farms in the area. We turned off the gravel onto a farm road and came out at a camping site, Ocean View, where we arranged to spend 2 nights amongst the dense Eastern Cape bush on the edge of the sand dunes. This made for a snug campsite sheltered from the wind.</p>
<p><strong>Interest:</strong> The location of this campsite is on the eastern edge of the area with the largest shifting dunes in the southern hemisphere—truly spectacular!</p>
<h5>Sunday: Beach Day</h5>
<p>The next day was spent exploring the beach—miles and miles of pristine beach where there is not another soul to be seen!</p>
<h5>Monday: To Brakkeduine</h5>
<p>Monday morning, bright and early, we set off towards Port Elizabeth where we planned to leave Max, our faithful hound, for the duration of the trip, then on to Humansdorp and finally to a resort called Brakkeduine. Doug and Santie, pulling their caravan, suffered a puncture and stopped in the little town of Alexandria to have the tyre repaired. We decided that the remainder would go on in convoy through Port Elizabeth and meet them there.</p>
<p>Once clear of Port Elizabeth, the three remaining vehicles followed the R102, down the old Van Stadens Pass, across the single lane bridge spanning the Gamtoos River, and past Jeffereys Bay. At Humansdorp, we hit the gravel roads and eventually reached Brakkeduine in the late afternoon. Doug and Santie were already there, with Doug trying his hand at fishing in the dam. The campsites are to die for—set along manicured grassy terraced ledges overlooking the dam, each site separated by neatly trimmed hedges.</p>
<h5>Tuesday: Dune Adventure</h5>
<p>The following morning we met Johan, our guide for the day. After airing down (0.6 bar!), we set off in convoy to attack the dunes. Before we reached the first dune, Doug pulled a tyre off the rim. We all got stuck in to repair the wheel and were on the road again fifteen minutes later. The airjack proved its usefulness!</p>
<p>We played in the sand for the next few hours, then Roy managed to pull one of his tyres off the rim—on a steep incline and in the boiling heat of the midday sun. This time the airjack did not do so well! We were eventually forced to use Mikes trusty hi-lift jack. Eventually, we changed wheels and headed for camp, then back to Humansdorp to get the wheel repaired.</p>
<p>Doug had also picked up a problem with his Prado, and he and Santie decided to head to the Toyota garage in Joubertina, further along the R62, with the plan that we would all meet up again in Kareedouw.</p>
<h5>Wednesday: Rus en Vrede Trail</h5>
<p>From Kareedouw, we headed off north into the mountains. The road was rocky and full of loose stones. I was concerned about the tyres on Dougs Prado and caravan, but we arrived at our camp as the sun was setting. Baviaans Lodge is situated in the Kouga Mountains at the start of the Rus en Vrede trail across the mountains to the Baviaanskloof. The campsite is cosy, set among the trees on the bank of a small stream. There is a hot water shower and toilets, all well maintained and clean.</p>
<p>We enjoyed an evening around the campfire, though I went to bed concerned about Doug pulling his caravan over the mountains.</p>
<p>On Wednesday morning, everyone was packed and ready to go by 08:00. The day was slow going but with no delays or problems. The only casualty was the awning from Mikes Cruiser, which was shaken free and rescued by Roy.</p>
<p>The Rus en Vrede trail, originally cut by woodcutters in the 1800s, now crosses three farms. It consists of gravel, loose rocks, eroded farm tracks, and mountain terrain. There are 13 gates that had to be opened and closed—thank you Noelene and Naome!</p>
<p>The views are breathtaking, covering seven different mountain ranges. We were lucky with the weather—clear skies, no wind, and cool temperatures. The proteas were in bloom and the centuries-old cycads stood tall over the peaks.</p>
<h5>Thursday: Into the Kloof</h5>
<p>The trail ends at Rus en Vrede farm, where you pay the farmer per vehicle and person. We entered the Baviaanskloof Nature Reserve, crossing Holgats Pass, Kombrinks Pass, and the Grootrivier Pass. The roads were rough and slow-going but scenic.</p>
<p>Our destination was Kudu Kaya, a working citrus farm. We camped on a hill overlooking the farm. Doug did some repairs to the caravan and Santie spent time cleaning up food shaken loose—custard and gunk everywhere!</p>
<h5>Friday: To Kaboega</h5>
<p>Thursday morning, we drove to Steytlerville via Antonies Pass—a rugged rock and gravel road. After lunch at the Royal Hotel in Steytlerville, we continued to Kaboega, a private farm near Addo Elephant Park. We camped at a big dam and were warmly welcomed by Ian Ritchie and his wife Sandy.</p>
<p>Friday morning, Ian and Sandy joined us for coffee. Sandy shared insights into Bushmans paintings and local history. Ian then led us around the 6,000-hectare farm, sharing his deep knowledge of biodiversity, plants, and terrain. Apart from a locked gate we had to cut open, the day was smooth. We ended with a swim in a mountain pool instead of visiting more rock art sites due to the time.</p>
<h5>Saturday: Mountain Zebra Park</h5>
<p>On Saturday, we took scenic gravel roads to the Mountain Zebra Park via Somerset East and Cradock. After breakfast in Somerset East, we passed through Swarthoek and Maraiskloof Passes to Cradock for fuel, then entered the Park and set up camp.</p>
<p>Though we originally planned to stay one night, everyone decided to stay an extra day for game drives. The reserve is home to a wide range of plains animals, especially the rare mountain zebra, and other wildlife found in the gorges and valleys.</p>
<div style="width:100%; object-fit: cover;" class="image mt-40 mb-30" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<img src="assets/images/blog/1/widecrab.jpg" alt="Blog Details">
</div>
<h5>Services Offered by a Tour and Travel Agency</h5>
<p>Agency plays a pivotal role in crafting memorable experiences for travelers by offering wide range services tailored to individual preferences. Whether it's a family vacation, an adventure trip, or luxury getaway well-established travel agency can handle everything from flight bookings and accommodation to guided tours .</p>
<ul class="list-style-two mt-30 mb-45" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<li>Assisting customers in booking domestic and international flights.</li>
<li>Organizing adventure activities such as trekking, diving, safaris, or extreme sports.</li>
<li>Tailoring travel plans to meet the specific needs and preferences of the customer.</li>
<li>Providing professional guides for city tours, cultural experiences, adventure activities, etc.</li>
<li>Arranging local transportation such as car rentals, airport transfers, or bus tours.</li>
<li>Helping customers navigate the visa application process for international travel.</li>
</ul>
<div class="row mb-10">
<div class="col-sm-6">
<div class="image mb-30" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<img src="assets/images/blog/blog-middle1.jpg" alt="Blog">
</div>
</div>
<div class="col-sm-6">
<div class="image mb-30" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50" data-aos-delay="50">
<img src="assets/images/blog/blog-middle2.jpg" alt="Blog">
</div>
</div>
</div>
<h5>How to Start a Tour and Travel Agency</h5>
<p>Agency plays a pivotal role in crafting memorable experiences for travelers by offering wide range services tailored to individual preferences. Whether it's a family vacation, an adventure trip, or luxury getaway well-established travel agency can handle everything from flight bookings and accommodation to guided tours .</p>
<blockquote class="mt-30 mb-35" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<i class="flaticon-quote"></i>
<div class="text">"In the world of tours and travel, every journey is an invitation to explore the unknown, connect with cultures, and create memories that last lifetime It's not just about the destination,extraordinary adventures."
</div>
<div class="blockquote-footer">
Kevin F. Glasscock
</div>
</blockquote>
<ul class="list-style-two mb-45" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<li>Understand the demand in your area, competition, and potential customers.</li>
<li>Register your business, obtain necessary licenses, and ensure compliance with local regulations.</li>
<li>Build relationships with hotels, airlines, transport companies, and other service providers.</li>
</ul>
</div>
<hr class="mb-45">
<div class="tag-share mb-50">
<div class="item" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">
<h6>Tags </h6>
<div class="tag-coulds">
<a href="blog.html">Travel</a>
<a href="blog.html">Hotel</a>
<a href="blog.html">Tour</a>
</div>
</div>
<div class="item" data-aos="fade-right" data-aos-duration="1500" data-aos-offset="50">
<h6>Share </h6>
<div class="social-style-one">
<a href="#"><i class="fab fa-facebook-f"></i></a>
<a href="#"><i class="fab fa-twitter"></i></a>
<a href="#"><i class="fab fa-linkedin-in"></i></a>
<a href="#"><i class="fab fa-instagram"></i></a>
</div>
</div>
</div>
<div class="admin-comment bgc-lighter" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="comment-body">
<div class="author-thumb">
<img src="assets/images/blog/admin-comment.jpg" alt="Author">
</div>
<div class="content">
<h4>Richard M. Fudge</h4>
<p>The world is a book, and those who do not travel read only one page. Every journey we undertake is a chapter filled with lessons, experiences, and stories.</p>
<div class="social-icons">
<a href="contact"><i class="fab fa-facebook-f"></i></a>
<a href="contact"><i class="fab fa-twitter"></i></a>
<a href="contact"><i class="fab fa-linkedin-in"></i></a>
<a href="contact"><i class="fab fa-instagram"></i></a>
</div>
</div>
</div>
</div>
<div class="next-prev-blog pt-70 pb-15">
<div class="item" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">
<div class="image">
<img src="assets/images/blog/prev-post.jpg" alt="News">
</div>
<div class="content">
<h6><a href="blog-details.html">Unique Destinations an tolded Stories ways</a></h6>
<span class="date"><i class="far fa-calendar-alt"></i> 25 Feb 2024</span>
</div>
</div>
<div class="item" data-aos="fade-right" data-aos-duration="1500" data-aos-offset="50">
<div class="image">
<img src="assets/images/blog/next-post.jpg" alt="News">
</div>
<div class="content">
<h6><a href="blog-details.html">Immersive Experiences from Around Globe</a></h6>
<span class="date"><i class="far fa-calendar-alt"></i> 25 Feb 2024</span>
</div>
</div>
</div>
<form id="comment-form" class="comment-form bgc-lighter z-1 rel mt-25" name="review-form" action="#" method="post" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5>Leave A Comments</h5>
<p>Your email address will not be published. Required fields are marked *</p>
<div class="row gap-20 mt-30">
<div class="col-md-6">
<div class="form-group">
<input type="text" id="full-name" name="full-name" class="form-control" placeholder="Name" value="" required="">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<input type="email" id="email-address" name="email" class="form-control" placeholder="Email" value="" required="">
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<textarea name="message" id="message" class="form-control" rows="5" placeholder="Message" required=""></textarea>
</div>
</div>
<div class="col-md-12">
<div class="form-group mb-0">
<ul class="radio-filter mb-25">
<li>
<input class="form-check-input" type="radio" name="terms-condition" id="terms-condition">
<label for="terms-condition">Save my name, email, and website in this browser for the next time I comment.</label>
</li>
</ul>
<button type="submit" class="theme-btn style-two">
<span data-hover="Send Comments">Send Comments</span>
<i class="fal fa-arrow-right"></i>
</button>
</div>
</div>
</div>
</form>
</div>
<!-- <div class="col-lg-4 col-md-8 col-sm-10 rmt-75">
<div class="blog-sidebar">
<div class="widget widget-search" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<form action="#" class="default-search-form">
<input type="text" placeholder="Search" required="">
<button type="submit" class="searchbutton far fa-search"></button>
</form>
</div>
<div class="widget widget-category" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5 class="widget-title">Category</h5>
<ul class="list-style-three">
<li><a href="blog.html">Adventure</a></li>
<li><a href="blog.html">Hiking & Trekking</a></li>
<li><a href="blog.html">Cycling Tours</a></li>
<li><a href="blog.html">Family Tours</a></li>
<li><a href="blog.html">Mountain Hiking</a></li>
<li><a href="blog.html">Rafting Excursion</a></li>
<li><a href="blog.html">Coastal Paragliding</a></li>
</ul>
</div>
<div class="widget widget-news" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5 class="widget-title">Recent News</h5>
<ul>
<li>
<div class="image">
<img src="assets/images/widgets/news1.jpg" alt="News">
</div>
<div class="content">
<h6><a href="blog-details.html">Unique Destinations an tolded Stories ways</a></h6>
<span class="date"><i class="far fa-calendar-alt"></i> 25 Feb 2024</span>
</div>
</li>
<li>
<div class="image">
<img src="assets/images/widgets/news2.jpg" alt="News">
</div>
<div class="content">
<h6><a href="blog-details.html">Immersive Experiences from Around Globe</a></h6>
<span class="date"><i class="far fa-calendar-alt"></i> 25 Feb 2024</span>
</div>
</li>
<li>
<div class="image">
<img src="assets/images/widgets/news3.jpg" alt="News">
</div>
<div class="content">
<h6><a href="blog-details.html">Journey to Inspire Your Next Adventure</a></h6>
<span class="date"><i class="far fa-calendar-alt"></i> 25 Feb 2024</span>
</div>
</li>
</ul>
</div>
<div class="widget widget-gallery" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5 class="widget-title">Gallery</h5>
<div class="gallery">
<a href="assets/images/widgets/gallery1.jpg">
<img src="assets/images/widgets/gallery1.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery2.jpg">
<img src="assets/images/widgets/gallery2.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery3.jpg">
<img src="assets/images/widgets/gallery3.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery4.jpg">
<img src="assets/images/widgets/gallery4.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery5.jpg">
<img src="assets/images/widgets/gallery5.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery6.jpg">
<img src="assets/images/widgets/gallery6.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery7.jpg">
<img src="assets/images/widgets/gallery7.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery8.jpg">
<img src="assets/images/widgets/gallery8.jpg" alt="Gallery">
</a>
<a href="assets/images/widgets/gallery9.jpg">
<img src="assets/images/widgets/gallery9.jpg" alt="Gallery">
</a>
</div>
</div>
<div class="widget widget-cta" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="content text-white">
<span class="h6">Explore The World</span>
<h3>Best Tourist Place</h3>
<a href="tour-grid.html" class="theme-btn style-two bgc-secondary">
<span data-hover="Explore Now">Explore Now</span>
<i class="fal fa-arrow-right"></i>
</a>
</div>
<div class="image">
<img src="assets/images/widgets/cta-widget.png" alt="CTA">
</div>
<div class="cta-shape"><img src="assets/images/widgets/cta-shape.png" alt="Shape"></div>
</div>
</div>
</div> -->
</div>
</div>
</section>
<!-- Blog Detaisl Area end -->
<!-- footer area start -->
<footer class="main-footer footer-two bgc-black rel z-15">
<div class="container">
<div class="footer-instagram pt-100">
<div class="row row-cols-xxl-6 row-cols-xl-5 row-cols-lg-4 row-cols-md-3 row-cols-2">
<div class="col" data-aos="zoom-in-up" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram1.jpg">
<img src="assets/images/instagram/instagram1.jpg" alt="Instagram">
</a>
</div>
<div class="col" data-aos="zoom-in-down" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram2.jpg">
<img src="assets/images/instagram/instagram2.jpg" alt="Instagram">
</a>
</div>
<div class="col" data-aos="zoom-in-up" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram3.jpg">
<img src="assets/images/instagram/instagram3.jpg" alt="Instagram">
</a>
</div>
<div class="col" data-aos="zoom-in-down" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram4.jpg">
<img src="assets/images/instagram/instagram4.jpg" alt="Instagram">
</a>
</div>
<div class="col" data-aos="zoom-in-up" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram5.jpg">
<img src="assets/images/instagram/instagram5.jpg" alt="Instagram">
</a>
</div>
<div class="col" data-aos="zoom-in-down" data-aos-duration="1500" data-aos-offset="50">
<a class="instagram-item" href="assets/images/instagram/instagram6.jpg">
<img src="assets/images/instagram/instagram6.jpg" alt="Instagram">
</a>
</div>
</div>
</div>
</div>
<div class="widget-area bgp-bottom pt-70 pb-130 rpb-50" style="background-image: url(assets/images/backgrounds/footer-two.png);">
<div class="container">
<div class="row row-cols-xxl-5 row-cols-xl-4 row-cols-md-3 row-cols-2">
<div class="col col-small" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="footer-widget footer-text">
<div class="footer-logo mb-40">
<a href="index.html"><img src="assets/images/logos/logo.png" alt="Logo"></a>
</div>
<div class="footer-map">
<iframe src="https://www.google.com/maps/embed?pb=!1m10!1m8!1m3!1d96777.16150026117!2d-74.00840582560909!3d40.71171357405996!3m2!1i1024!2i768!4f13.1!5e0!3m2!1sen!2sbd!4v1706508986625!5m2!1sen!2sbd" style="border:0; width: 100%;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>
</div>
<div class="col col-small" data-aos="fade-up" data-aos-delay="50" data-aos-duration="1500" data-aos-offset="50">
<div class="footer-widget footer-links ms-sm-5">
<div class="footer-title">
<h5>Services</h5>
</div>
<ul class="list-style-three">
<li><a href="destination-details.html">Best Tour Guide</a></li>
<li><a href="destination-details.html">Tour Booking</a></li>
<li><a href="destination-details.html">Hotel Booking</a></li>
<li><a href="destination-details.html">Ticket Booking</a></li>
</ul>
</div>
</div>
<div class="col col-small" data-aos="fade-up" data-aos-delay="100" data-aos-duration="1500" data-aos-offset="50">
<div class="footer-widget footer-links ms-md-4">
<div class="footer-title">
<h5>Company</h5>
</div>
<ul class="list-style-three">
<li><a href="about.html">About Company</a></li>
<li><a href="blog.html">Community Blog</a></li>
<li><a href="contact">Jobs and Careers</a></li>
<li><a href="blog.html">latest News Blog</a></li>
</ul>
</div>
</div>
<div class="col col-small" data-aos="fade-up" data-aos-delay="150" data-aos-duration="1500" data-aos-offset="50">
<div class="footer-widget footer-links ms-lg-4">
<div class="footer-title">
<h5>Destinations</h5>
</div>
<ul class="list-style-three">
<li><a href="destination-details.html">African Safaris</a></li>
<li><a href="destination-details.html">Alaska & Canada</a></li>
<li><a href="destination-details.html">South America</a></li>
<li><a href="destination-details.html">Middle East</a></li>
</ul>
</div>
</div>
<div class="col col-md-6 col-10 col-small" data-aos="fade-up" data-aos-delay="200" data-aos-duration="1500" data-aos-offset="50">
<div class="footer-widget footer-contact">
<div class="footer-title">
<h5>Get In Touch</h5>
</div>
<ul class="list-style-one">
<li><i class="fal fa-map-marked-alt"></i> 578 Level, D-block 45 Street Melbourne, Australia</li>
<li><i class="fal fa-envelope"></i> <a href="mailto:supportrevelo@gmail.com">supportrevelo @gmail.com</a></li>
<li><i class="fal fa-phone-volume"></i> <a href="callto:+88012334588">+880 (123) 345 88</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="footer-bottom pt-20 pb-5">
<div class="container">
<div class="row">
<div class="col-lg-5">
<div class="copyright-text text-center text-lg-start">
<p>@Copy 2024 <a href="index.html">Ravelo</a>, All rights reserved</p>
</div>
</div>
<div class="col-lg-7 text-center text-lg-end">
<ul class="footer-bottom-nav">
<li><a href="about.html">Terms</a></li>
<li><a href="about.html">Privacy Policy</a></li>
<li><a href="about.html">Legal notice</a></li>
<li><a href="about.html">Accessibility</a></li>
</ul>
</div>
</div>
<!-- Scroll Top Button -->
<button class="scroll-top scroll-to-target" data-target="html"><img src="assets/images/icons/scroll-up.png" alt="Scroll Up"></button>
</div>
</div>
</footer>
<!-- footer area end -->
</div>
<!--End pagewrapper-->
<!-- Jquery -->
<script src="assets/js/jquery-3.6.0.min.js"></script>
<!-- Bootstrap -->
<script src="assets/js/bootstrap.min.js"></script>
<!-- Appear Js -->
<script src="assets/js/appear.min.js"></script>
<!-- Slick -->
<script src="assets/js/slick.min.js"></script>
<!-- Magnific Popup -->
<script src="assets/js/jquery.magnific-popup.min.js"></script>
<!-- Nice Select -->
<script src="assets/js/jquery.nice-select.min.js"></script>
<!-- Image Loader -->
<script src="assets/js/imagesloaded.pkgd.min.js"></script>
<!-- Jquery UI -->
<script src="assets/js/jquery-ui.min.js"></script>
<!-- Isotope -->
<script src="assets/js/isotope.pkgd.min.js"></script>
<!-- AOS Animation -->
<script src="assets/js/aos.js"></script>
<!-- Custom script -->
<script src="assets/js/script.js"></script>
</body>
</html>

View File

@@ -0,0 +1,282 @@
<?php
$rootPath = dirname(dirname(dirname(__DIR__)));
require_once($rootPath . "/src/config/env.php");
require_once($rootPath . "/src/config/connection.php");
require_once($rootPath . "/src/config/functions.php");
require_once($rootPath . "/header.php");
// Ensure the user is logged in
if (!isset($_SESSION['user_id'])) {
die("User not logged in.");
}
$pageTitle = 'Edit Blog Post';
$breadcrumbs = [['Home' => 'index'], ['My Blog Posts' => 'user_blogs']];
require_once($rootPath . '/components/banner.php');
$token = $_GET['token'];
// Sanitize the trip_id to prevent SQL injection
$blog_id = intval(decryptData($token, $salt)); // Ensures $trip_id is treated as an integer
$user_id = $_SESSION['user_id'];
$role = getUserRole();
// Fetch article info
$stmt = $conn->prepare("SELECT * FROM blogs WHERE blog_id = ?");
$stmt->bind_param("i", $blog_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
die("Blog post not found.");
}
$article = $result->fetch_assoc();
$stmt->close();
?>
<script src="https://cdn.tiny.cloud/1/o6xuedbd9z22xk0p5zszinevn4bdbljxnfwn0tjjvv6r37pb/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
tinymce.init({
selector: '#content',
plugins: 'image code link',
toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright | code | image | link',
images_upload_url: 'upload_blog_image?blog_id=<?= $blog_id ?>',
image_class_list: [
{ title: 'None', value: '' },
{ title: 'Left Align', value: 'img-left' },
{ title: 'Right Align', value: 'img-right' },
{ title: 'Rounded', value: 'img-rounded' }
],
automatic_uploads: true,
images_upload_credentials: true, // include cookies if needed
content_style: "body { font-family:Helvetica,Arial,sans-serif; font-size:14px }",
setup: function (editor) {
editor.on('init', function () {
setTimeout(() => {
editor.setContent(`<?= str_replace("`", "\`", addslashes($article['content'])) ?>`);
}, 100);
});
}
});
</script>
<section class="account-settings-area py-70 rel z-1">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-12">
<div class="comment-form bgc-lighter z-1 rel mb-55">
<form action="submit_blog.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="article_id" value="<?= htmlspecialchars($blog_id) ?>">
<div class="section-title py-20">
<h2>Edit Blog</h2>
<div id="autosave-status" style="font-style: italic; font-size: 0.9em;"></div>
</div>
<div class="row mt-35">
<div class="col-md-6">
<div class="form-group">
<label for="title">Blog Title</label>
<input type="text" id="title" class="form-control" name="title" placeholder="Title" required value="<?= htmlspecialchars($article['title']) ?>">
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label for="subtitle">Description</label>
<input type="text" id="subtitle" class="form-control" name="subtitle" placeholder="Description" required value="<?= htmlspecialchars($article['description']) ?>">
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label for="cover_image">Cover Image</label>
<input type="file" class="form-control" name="cover_image" id="cover_image" accept="image/*">
</div>
</div>
<div class="col-md-12 mb-10">
<div class="form-group">
<label for="category">Blog Category</label>
<select name="category" class="form-control" id="category" required>
<option value="Trip Report" <?= $article['category'] == 'Trip Report' ? 'selected' : '' ?>>Trip Report</option>
<option value="Gear Review" <?= $article['category'] == 'Gear Review' ? 'selected' : '' ?>>Gear Review</option>
<option value="Talking Dirty" <?= $article['category'] == 'Talking Dirty' ? 'selected' : '' ?>>Talking Dirty</option>
<option value="Report" <?= $article['category'] == 'Report' ? 'selected' : '' ?>>Report</option>
</select>
</div>
</div>
<div class="col-md-12 mb-10">
<div class="form-group">
<?php if ($role === 'admin' || $role === 'superadmin'): ?>
<label for="author">Author:</label>
<select class="form-control" name="author" id="author">
<?php
$user_query = $conn->query("SELECT user_id, CONCAT(first_name, ' ', last_name) AS name FROM users ORDER BY first_name ASC");
while ($user = $user_query->fetch_assoc()):
?>
<option value="<?= $user['user_id'] ?>" <?= $user['user_id'] == $article['author'] ? 'selected' : '' ?>>
<?= htmlspecialchars($user['name']) ?>
</option>
<?php endwhile; ?>
</select>
<?php endif; ?>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<textarea id="content" name="content"></textarea>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<button type="button" class="theme-btn style-three" style="width:100%;" id="manualSaveBtn">Save Draft</button>
</div>
<div class="form-group">
<a href="blog_read.php?token=<?php echo encryptData($blog_id, $salt); ?>" class="theme-btn style-three" style="width:100%;">Preview</a>
</div>
</div>
<div class="col-md-12">
<?php
if ($article['status'] == 'draft'){
echo '<div class="form-group">
<button type="button" class="theme-btn style-two" style="width:100%;" id="manualPostBtn">Publish</button>
</div> ';
} else {
echo '<div class="form-group">
<button type="button" class="theme-btn style-two" style="width:100%;" id="manualDraftBtn">Un-Publish</button>
</div> ';
}?>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script>
function autosavePost() {
const title = document.querySelector('[name="title"]').value;
const content = tinymce.get("content").getContent();
const subtitle = document.querySelector('[name="subtitle"]').value;
const category = document.querySelector('[name="category"]').value;
const author = document.querySelector('[name="author"]').value;
const articleId = document.querySelector('[name="article_id"]').value;
const coverImageInput = document.querySelector('[name="cover_image"]');
console.log("Saving: ", { title, subtitle, content, category, articleId, author });
const formData = new FormData();
formData.append("id", articleId);
formData.append("title", title);
formData.append("content", content);
formData.append("subtitle", subtitle);
formData.append("category", category);
formData.append("author", author);
// Only append image if a new file is selected
if (coverImageInput.files.length > 0) {
formData.append("cover_image", coverImageInput.files[0]);
}
return fetch("autosave", {
method: "POST",
body: formData
}).then(response => {
if (response.ok) {
document.getElementById("autosave-status").innerText = "Draft autosaved at " + new Date().toLocaleTimeString();
return true;
} else {
return response.text().then(errorText => {
document.getElementById("autosave-status").innerText = "Autosave failed: " + errorText;
console.error("Autosave failed", response.status, errorText);
return false;
});
}
}).catch(err => {
console.error("Autosave error:", err);
document.getElementById("autosave-status").innerText = "Autosave error: " + err.message;
return false;
});
}
// Trigger autosave every 15s
setInterval(autosavePost, 15000);
// Manual autosave button
const manualSaveBtn = document.getElementById("manualSaveBtn");
if (manualSaveBtn) {
manualSaveBtn.addEventListener("click", autosavePost);
}
// Manual publish button
const manualPostBtn = document.getElementById("manualPostBtn");
if (manualPostBtn) {
manualPostBtn.addEventListener("click", function () {
autosavePost().then(success => {
if (!success) return;
const articleId = document.querySelector('[name="article_id"]').value;
const publishData = new FormData();
publishData.append("id", articleId);
fetch("publish_blog", {
method: "POST",
body: publishData
}).then(response => {
if (response.ok) {
alert("Post published successfully!");
// Optional: redirect to the live post
window.location.href = "blog_read.php?token=<?php echo encryptData($blog_id, $salt);?>";
} else {
alert("Publish failed.");
console.error("Publish error:", response.statusText);
}
}).catch(err => {
console.error("Publish error:", err);
alert("Publish failed due to network error.");
});
});
});
}
// Manual unpublish button
const manualDraftBtn = document.getElementById("manualDraftBtn");
if (manualDraftBtn) {
manualDraftBtn.addEventListener("click", function () {
autosavePost().then(success => {
if (!success) return;
const articleId = document.querySelector('[name="article_id"]').value;
const publishData = new FormData();
publishData.append("id", articleId);
fetch("blog_unpublish", {
method: "POST",
body: publishData
}).then(response => {
if (response.ok) {
alert("Post unpublished successfully!");
// Optional: redirect to the live post
window.location.href = "blog_read.php?token=<?php echo encryptData($blog_id, $salt);?>";
} else {
alert("unPublish failed.");
console.error("Publish error:", response.statusText);
}
}).catch(err => {
console.error("Publish error:", err);
alert("Publish failed due to network error.");
});
});
});
}
</script>
</script>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,181 @@
<?php
$rootPath = dirname(dirname(dirname(__DIR__)));
require_once($rootPath . "/src/config/env.php");
require_once($rootPath . "/src/config/connection.php");
require_once($rootPath . "/src/config/functions.php");
require_once($rootPath . "/header.php");
$token = $_GET['token'];
// Sanitize the trip_id to prevent SQL injection
$blog_id = intval(decryptData($token, $salt)); // Ensures $trip_id is treated as an integer
$pageTitle = 'Blog Post';
$breadcrumbs = [['Home' => 'index'], ['Blog' => 'blog']];
require_once($rootPath . '/components/banner.php');
$page_id = 'blog_'.$blog_id;
$stmt = $conn->prepare("
SELECT a.blog_id, a.title, a.category, a.description, a.content, a.date, a.author,
u.first_name, u.last_name, u.user_id
FROM blogs a
JOIN users u ON a.author = u.user_id
WHERE a.blog_id = ?
");
$stmt->bind_param("i", $blog_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
die("Article not found.");
}
$row = $result->fetch_assoc();
$author = htmlspecialchars($row['first_name'] . ' ' . $row['last_name']);
$author_id = $row['author'];
$is_author = (isset($_SESSION['user_id']) && $_SESSION['user_id'] == $author_id);
?>
<style>
.image {
width: 400px;
/* Set your desired width */
height: 350px;
/* Set your desired height */
overflow: hidden;
/* Hide any overflow */
display: block;
/* Ensure proper block behavior */
}
.image img {
width: 100%;
/* Image scales to fill the container */
height: 100%;
/* Image scales to fill the container */
object-fit: cover;
/* Fills the container while maintaining aspect ratio */
object-position: top;
/* Aligns the top of the image with the top of the container */
display: block;
/* Prevents inline whitespace issues */
}
</style>
<style>
body {
/* font-family: Arial, sans-serif; */
line-height: 1.6;
/* max-width: 800px; */
margin: auto;
/* padding: 20px; */
}
h1,
h2 {
color: #2c3e50;
}
h2 {
margin-top: 2em;
}
.content {
margin-bottom: 2em;
}
.img-left,
.img-right {
max-width: 30%;
margin: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 10px;
}
.img-left {
float: left;
}
.img-right {
float: right;
}
.clearfix {
clear: both;
}
</style>
<?php
// Dynamically set page title to blog title
if (isset($row) && !empty($row['title'])) {
$pageTitle = htmlspecialchars($row['title']);
} else {
$pageTitle = 'Blog Post';
}
?>
<!-- Blog Detaisl Area start -->
<section class="blog-detaisl-page py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-8">
<div class="blog-details-content" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<a href="blog.html" class="category"><?= htmlspecialchars($row['category']) ?></a>
<ul class="blog-meta mb-30">
<li><img src="assets/images/pp/default.png" alt="Admin"> <a href="#"><?= $author?></a></li>
<li><i class="far fa-calendar-alt"></i> <a href="#"><?= htmlspecialchars($row['date']) ?></a></li>
<li><i class="far fa-comments"></i> <a href="#">Comments (<?= getCommentCount($page_id);?>)</a></li>
<?php if ($is_author): ?>
<li><a href="blog_edit.php?token=<?php echo encryptData($blog_id, $salt); ?>">Edit Post</a></li>
<?php endif; ?>
</ul>
<?= $row['content'] ?>
</div>
</div>
<div class="col-lg-4 col-md-8 col-sm-10 rmt-75">
<div class="blog-sidebar">
<div class="widget widget-gallery" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<h5 class="widget-title">Gallery</h5>
<div class="gallery">
<?php
$folder = $rootPath . '/uploads/blogs/' . $blog_id . '/';
$files = glob($folder . '*.{jpg,jpeg,png,webp}', GLOB_BRACE);
if ($files && count($files) > 0) {
shuffle($files); // Randomize the order
foreach ($files as $file) {
$relativePath = '/uploads/blogs/' . $blog_id . '/' . basename($file);
echo '<a href="' . $relativePath . '" style="width: 110px; height: 110px; overflow: hidden; display: inline-block; margin: 2px;">';
echo '<img src="' . $relativePath . '" alt="Gallery" style="width: 100%; height: 100%; object-fit: cover; display: block;">';
echo '</a>';
}
} else {
echo '<p style="font-size: 0.9em; color: #999;">No images available</p>';
}
?>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<hr class="mb-45">
<div class="tag-share">
<div class="item" data-aos="fade-left" data-aos-duration="1500" data-aos-offset="50">
<h6>Tags </h6>
<div class="tag-coulds">
<a href="blog.php"><?= htmlspecialchars($row['category']) ?></a>
</div>
</div>
</div>
<?php include_once($rootPath . '/src/pages/other/comment_box.php'); ?>
</div>
</div>
</div>
</section>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -0,0 +1,154 @@
<?php
$rootPath = dirname(dirname(dirname(__DIR__)));
require_once($rootPath . "/src/config/env.php");
require_once($rootPath . "/src/config/connection.php");
require_once($rootPath . "/src/config/functions.php");
require_once($rootPath . "/header.php");
checkUserSession();
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
$_SESSION['message'] = "My Blog Posts is only available to active members. Please contact info@4wdcsa.co.za for more information.";
header('Location: membership_details');
exit;
}
$pageTitle = 'My Blog Posts';
$breadcrumbs = [['Home' => 'index'], ['Blog' => 'blog']];
require_once($rootPath . '/components/banner.php');
$result = $conn->prepare("SELECT blog_id, title, description, status, date, image FROM blogs WHERE author = ? AND status != 'deleted' ORDER BY date DESC");
$result->bind_param("i", $user_id);
$result->execute();
$posts = $result->get_result();
?>
<style>
.image {
width: 400px;
/* Set your desired width */
height: 350px;
/* Set your desired height */
overflow: hidden;
/* Hide any overflow */
display: block;
/* Ensure proper block behavior */
}
.image img {
width: 100%;
/* Image scales to fill the container */
height: 100%;
/* Image scales to fill the container */
object-fit: cover;
/* Fills the container while maintaining aspect ratio */
object-position: top;
/* Aligns the top of the image with the top of the container */
display: block;
/* Prevents inline whitespace issues */
}
</style>
<!-- Blog List Area start -->
<section class="blog-list-page py-100 rel z-1">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<h2 style="margin: 0;">My Blog Posts</h2>
<a href="blog_create" class="theme-btn create-album-btn">
<i class="far fa-plus"></i> Create New Post
</a>
</div>
<?php if (isset($_SESSION['message'])): ?>
<div class="alert alert-warning message-box">
<?php echo $_SESSION['message']; ?>
<span class="close-btn" onclick="this.parentElement.style.display='none'">&times;</span>
</div>
<?php unset($_SESSION['message']);
endif;
while ($post = $posts->fetch_assoc()):
// Determine cover image - use provided image or fallback placeholder
$coverImage = $post["image"] ? $post["image"] : 'assets/images/placeholder.jpg';
// Output the HTML structure with dynamic data
echo '
<div class="destination-item style-three bgc-lighter booking" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image" style="width:200px;height:200px;"><img src="' . htmlspecialchars($coverImage) . '" alt="' . htmlspecialchars($post["title"]) . '"></div>
<div class="content" style="width:100%;">
<div class="destination-header">
<span class="badge bg-dark"> ' . strtoupper($post["status"]) . '</span>
</div>
<h5>' . $post["title"] . '</a></h5>
<p>' . $post["description"] . '</p>
<div class="destination-footer">
<div class="btn-group" style="display:flex; justify-content:flex-end; gap:10px;">
<a href="blog_edit.php?token=' . encryptData($post["blog_id"], $salt) . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit"><span class="material-icons">edit</span></a>
<a href="blog_read.php?token=' . encryptData($post["blog_id"], $salt) . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Preview"><span class="material-icons">visibility</span></a>
<button type="button" class="publish-btn" data-blog-id="' . $post["blog_id"] . '" data-status="' . $post["status"] . '" data-bs-toggle="tooltip" data-bs-placement="top" title="' . ($post["status"] == "published" ? "Unpublish" : "Publish") . '" style="background:none; border:none; cursor:pointer; color:inherit;"><span class="material-icons">' . ($post["status"] == "published" ? "cloud_off" : "cloud_upload") . '</span></button>
<a href="blog_delete.php?token=' . encryptData($post["blog_id"], $salt) . '" data-bs-toggle="tooltip" data-bs-placement="top" title="Delete"><span class="material-icons">delete</span></a>
</div>
</div>
</div>
</div>';
endwhile; ?>
</div>
</div>
</div>
</section>
<!-- Blog List Area end -->
<script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
// Handle publish/unpublish button clicks
document.querySelectorAll('.publish-btn').forEach(btn => {
btn.addEventListener('click', function() {
const blogId = this.dataset.blogId;
const status = this.dataset.status;
const action = status === 'published' ? 'unpublish' : 'publish';
const endpoint = status === 'published' ? 'blog_unpublish' : 'publish_blog';
const formData = new FormData();
formData.append('id', blogId);
fetch(endpoint, {
method: 'POST',
body: formData
})
.then(response => {
if (response.ok) {
alert(action.charAt(0).toUpperCase() + action.slice(1) + ' successful!');
location.reload();
} else {
alert(action + ' failed.');
console.error('Error:', response.statusText);
}
})
.catch(err => {
console.error('Error:', err);
alert(action + ' failed due to network error.');
});
});
});
</script>
<?php include_once($rootPath . '/components/insta_footer.php'); ?>

View File

@@ -114,6 +114,7 @@ $user_id = $_SESSION['user_id'];
// Loop through each row
while ($row = $result->fetch_assoc()) {
$booking_id = $row['booking_id'];
$payment_id = $row['payment_id'];
$booking_type = $row['booking_type'];
$from_date = $row['from_date'];
$to_date = $row['to_date'];
@@ -267,8 +268,8 @@ $user_id = $_SESSION['user_id'];
<div class="destination-footer">
<span class="price"><span>Booking Total: R ' . number_format($amount, 2) . '</span></span>';
if ($status == "AWAITING PAYMENT") {
echo '<a href="' . url('payment_confirmation') . '?token=' . encryptData($booking_id, $salt) . '" class="theme-btn style-two style-three">
<span data-hover="PAYMENT INFO">' . $status . '</span>
echo '<a href="' . getPaymentLinkByPaymentId($payment_id) . '" class="theme-btn style-two style-three">
<span data-hover="PAY NOW">' . $status . '</span>
</a>';
} else {
echo '<a href="" class="theme-btn style-two style-three">

View File

@@ -3,6 +3,18 @@ $headerStyle = 'light';
$rootPath = dirname(dirname(dirname(__DIR__)));
include_once($rootPath . '/header.php');
// Check if user has active membership
if (!isset($_SESSION['user_id'])) {
header('Location: login');
exit;
}
$is_member = getUserMemberStatus($_SESSION['user_id']);
if (!$is_member) {
header('Location: index');
exit;
}
$conn = openDatabaseConnection();
$stmt = $conn->prepare("SELECT * FROM campsites");
$stmt->execute();
@@ -17,104 +29,526 @@ while ($row = $result->fetch_assoc()) {
#map {
height: 600px;
width: 100%;
position: relative;
}
.gm-style .info-box {
max-width: 250px;
/* Center pin overlay */
.map-center-pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
z-index: 10;
pointer-events: none;
font-size: 48px;
}
.info-box img {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
/* Location mode indicator */
.location-mode-indicator {
position: absolute;
top: 20px;
left: 20px;
background: #4CAF50;
color: white;
padding: 12px 20px;
border-radius: 6px;
z-index: 11;
font-weight: 500;
display: none;
}
/* Confirm location button */
.confirm-location-btn {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #4CAF50;
color: white;
padding: 12px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
z-index: 11;
display: none;
}
.confirm-location-btn:hover {
background: #45a049;
}
.cancel-location-btn {
position: absolute;
bottom: 20px;
left: 20px;
background: #f44336;
color: white;
padding: 12px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
z-index: 11;
display: none;
}
.cancel-location-btn:hover {
background: #da190b;
}
/* Form styling to match manage_trips */
.campsite-form-container {
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
padding: 30px;
margin: 20px 0;
display: none;
}
.campsite-form-container h5 {
color: #2c3e50;
font-weight: 600;
margin-bottom: 30px;
font-size: 1.5rem;
}
.campsite-form-container .form-group {
margin-bottom: 20px;
}
.campsite-form-container label {
font-weight: 500;
color: #34495e;
margin-bottom: 8px;
display: block;
}
.campsite-form-container .form-control {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.campsite-form-container .form-control:focus {
border-color: #4CAF50;
box-shadow: 0 0 0 0.2rem rgba(76, 175, 80, 0.25);
outline: none;
}
.campsite-form-container .form-control select {
cursor: pointer;
}
.campsite-form-container .btn {
border-radius: 6px;
font-weight: 500;
padding: 10px 20px;
}
/* Table styling to match admin trips */
.campsites-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
}
.campsites-table thead th {
cursor: pointer;
text-align: left;
padding: 10px;
font-weight: bold;
position: relative;
}
.campsites-table thead th::after {
content: '\25B2';
font-size: 0.8em;
position: absolute;
right: 10px;
opacity: 0;
transition: opacity 0.2s;
}
.campsites-table thead th.asc::after {
content: '\25B2';
opacity: 1;
}
.campsites-table thead th.desc::after {
content: '\25BC';
opacity: 1;
}
.campsites-table tbody tr:nth-child(odd) {
background-color: transparent;
}
.campsites-table tbody tr:nth-child(even) {
background-color: rgb(255, 255, 255);
border-radius: 10px;
}
.campsites-table tbody td {
padding: 10px;
}
.campsites-table tbody tr:nth-child(even) td:first-child {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
.campsites-table tbody tr:nth-child(even) td:last-child {
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
}
.filter-input {
width: 100%;
padding: 10px;
font-size: 16px;
background-color: rgb(255, 255, 255);
border-radius: 25px;
margin-bottom: 20px;
border: 1px solid #ddd;
}
.campsite-group {
color: #484848;
background: #f9f9f7;
border: 1px solid #d8d8d8;
border-radius: 10px;
margin-top: 15px;
margin-bottom: 15px;
padding: 10px;
}
</style>
<?php
$pageTitle = 'Campsites';
$pageTitle = 'Campsites Directory';
$breadcrumbs = [['Home' => 'index.php']];
require_once($rootPath . '/components/banner.php');
?>
<!-- Tour List Area start -->
<section class="tour-list-page py-100 rel z-1">
<section class="tour-list-page py-100 rel">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3>Campsites Map</h3>
<button class="theme-btn" id="toggleFormBtn" onclick="startLocationMode()">
<i class="far fa-plus"></i> Add Campsite
</button>
</div>
<p style="color: #666; margin-bottom: 15px;">Click on a marker to view details, or use the "Add Campsite" button to add a new location.</p>
<!-- Map with location mode UI -->
<div style="position: relative; margin-bottom: 20px;">
<div id="map" style="width: 100%; height: 500px;"></div>
<!-- Add Campsite Modal -->
<!-- Location Mode Indicator -->
<div class="location-mode-indicator">
📍 Position the map center pin over your campsite location
</div>
<!-- Confirm and Cancel Buttons -->
<button type="button" class="confirm-location-btn" onclick="confirmLocation()">
✓ Confirm Location
</button>
<button type="button" class="cancel-location-btn" onclick="cancelLocationMode()">
✕ Cancel
</button>
</div>
<!-- Collapsible Campsite Form -->
<div class="campsite-form-container" id="campsiteFormContainer">
<h5>Add New Campsite</h5>
<form id="addCampsiteForm" method="POST" action="add_campsite" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<input type="hidden" name="latitude" id="latitude">
<input type="hidden" name="longitude" id="longitude">
<div class="row mt-35">
<div class="col-md-6">
<div class="form-group">
<label for="campsite_name">Campsite Name *</label>
<input type="text" id="campsite_name" class="form-control" name="name" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="countrySelect">Country *</label>
<select id="countrySelect" class="form-control" name="country" required>
<option value="">-- Select Country --</option>
<option value="South Africa">South Africa</option>
<option value="Botswana">Botswana</option>
<option value="Eswatini">Eswatini</option>
<option value="Lesotho">Lesotho</option>
<option value="Namibia">Namibia</option>
<option value="Zimbabwe">Zimbabwe</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="provinceSelect">Province *</label>
<select id="provinceSelect" class="form-control" name="province" required>
<option value="">-- Select Province --</option>
<option value="Eastern Cape">Eastern Cape</option>
<option value="Free State">Free State</option>
<option value="Gauteng">Gauteng</option>
<option value="KwaZulu-Natal">KwaZulu-Natal</option>
<option value="Limpopo">Limpopo</option>
<option value="Mpumalanga">Mpumalanga</option>
<option value="Northern Cape">Northern Cape</option>
<option value="North West">North West</option>
<option value="Western Cape">Western Cape</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label for="campsite_description">Description</label>
<textarea id="campsite_description" class="form-control" name="description" rows="3"></textarea>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="campsite_website">Booking URL</label>
<input type="url" id="campsite_website" class="form-control" name="website">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="campsite_phone">Phone Number</label>
<input type="text" id="campsite_phone" class="form-control" name="telephone">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="latitude_display">Latitude</label>
<input type="text" id="latitude_display" class="form-control" readonly>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="longitude_display">Longitude</label>
<input type="text" id="longitude_display" class="form-control" readonly>
</div>
</div>
<div class="col-md-12">
<div class="form-group">
<label for="campsite_thumbnail">Thumbnail Image</label>
<input type="file" id="campsite_thumbnail" class="form-control" name="thumbnail" accept="image/*">
</div>
</div>
<div class="col-md-12">
<div class="form-group mb-0">
<button class="theme-btn style-two" type="submit" style="width: 100%; margin-right: 10px;">Save Campsite</button>
<button class="theme-btn" type="button" onclick="toggleCampsiteForm()" style="width: 100%; margin-top: 10px;">Cancel</button>
</div>
</div>
</div>
</form>
</div>
<!-- Campsites Table -->
<div style="margin-top: 40px;">
<h4 style="margin-bottom: 20px;">All Campsites</h4>
<input type="text" class="filter-input" id="campsitesFilter" placeholder="Filter results...">
<div class="table-responsive">
<table class="campsites-table">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Description</th>
<th>Booking Website</th>
<th>Phone</th>
<th>Added By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="campsitesTableBody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="modal fade" id="addCampsiteModal" tabindex="-1">
<div class="modal-dialog">
<form id="addCampsiteForm" method="POST" action="add_campsite" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Campsite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="latitude" id="latitude">
<input type="hidden" name="longitude" id="longitude">
<div class="mb-3">
<label class="form-label">Campsite Name</label>
<input type="text" class="form-control" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Booking URL</label>
<input type="url" class="form-control" name="website">
</div>
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="text" class="form-control" name="telephone">
</div>
<div class="mb-3">
<label class="form-label">Thumbnail Image</label>
<input type="file" class="form-control" name="thumbnail" accept="image/*">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit">Save Campsite</button>
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</form>
</div>
</div>
<script>
let map;
let centerPinMarker;
let isLocationMode = false;
const currentUserId = <?php echo $_SESSION['user_id']; ?>;
const campsites = <?php echo json_encode($campsites); ?>;
function startLocationMode() {
if (isLocationMode) return;
isLocationMode = true;
// Show location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "block";
document.querySelector(".confirm-location-btn").style.display = "block";
document.querySelector(".cancel-location-btn").style.display = "block";
document.getElementById("toggleFormBtn").disabled = true;
// Create invisible marker at map center
const mapCenter = map.getCenter();
centerPinMarker = new google.maps.Marker({
position: mapCenter,
map: map,
title: "Campsite Location",
draggable: true,
icon: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png'
});
// Update coordinates when marker is dragged
centerPinMarker.addListener('drag', function() {
const position = centerPinMarker.getPosition();
updateCoordinatesDisplay(position.lat(), position.lng());
});
// Set initial coordinates
updateCoordinatesDisplay(mapCenter.lat(), mapCenter.lng());
// Update coordinates when map is moved
const moveListener = map.addListener('center_changed', function() {
const mapCenter = map.getCenter();
centerPinMarker.setPosition(mapCenter);
updateCoordinatesDisplay(mapCenter.lat(), mapCenter.lng());
});
// Store listener for cleanup
window.mapMoveListener = moveListener;
}
function updateCoordinatesDisplay(lat, lng) {
document.getElementById("latitude").value = lat;
document.getElementById("longitude").value = lng;
document.getElementById("latitude_display").value = lat.toFixed(6);
document.getElementById("longitude_display").value = lng.toFixed(6);
}
function confirmLocation() {
if (!isLocationMode) return;
isLocationMode = false;
// Hide location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "none";
document.querySelector(".confirm-location-btn").style.display = "none";
document.querySelector(".cancel-location-btn").style.display = "none";
document.getElementById("toggleFormBtn").disabled = false;
// Remove map move listener
if (window.mapMoveListener) {
google.maps.event.removeListener(window.mapMoveListener);
}
// Remove the center marker
if (centerPinMarker) {
centerPinMarker.setMap(null);
centerPinMarker = null;
}
// Reset form fields and show form (for new campsite only)
resetFormForNewCampsite();
document.getElementById("campsiteFormContainer").style.display = "block";
document.getElementById("campsiteFormContainer").scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function cancelLocationMode() {
if (!isLocationMode) return;
isLocationMode = false;
// Hide location mode UI elements
document.querySelector(".location-mode-indicator").style.display = "none";
document.querySelector(".confirm-location-btn").style.display = "none";
document.querySelector(".cancel-location-btn").style.display = "none";
document.getElementById("toggleFormBtn").disabled = false;
// Remove map move listener
if (window.mapMoveListener) {
google.maps.event.removeListener(window.mapMoveListener);
}
// Remove the center marker
if (centerPinMarker) {
centerPinMarker.setMap(null);
centerPinMarker = null;
}
}
function toggleCampsiteForm() {
if (isLocationMode) return;
const container = document.getElementById("campsiteFormContainer");
container.style.display = container.style.display === "none" ? "block" : "none";
if (container.style.display === "block") {
container.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function resetFormForNewCampsite() {
// This is called when confirming location for a NEW campsite
// Only clears text fields and removes ID, but keeps country/province selections
document.querySelector("#addCampsiteForm input[name='name']").value = '';
document.querySelector("#addCampsiteForm textarea[name='description']").value = '';
document.querySelector("#addCampsiteForm input[name='website']").value = '';
document.querySelector("#addCampsiteForm input[name='telephone']").value = '';
// Remove the ID input if it exists
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (idInput) {
idInput.remove();
}
// Change form heading
document.querySelector("#campsiteFormContainer h5").textContent = "Add New Campsite";
}
function resetForm() {
// This is called when canceling the form - fully resets everything
document.querySelector("#campsiteFormContainer h5").textContent = "Add New Campsite";
// Clear the form completely
document.getElementById("addCampsiteForm").reset();
// Remove the ID input if it exists
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
if (idInput) {
idInput.remove();
}
// Clear coordinate displays
document.getElementById("latitude_display").value = '';
document.getElementById("longitude_display").value = '';
}
function initMap() {
map = new google.maps.Map(document.getElementById("map"), {
center: {
lat: -28.0,
lng: 24.0
}, // SA center
},
zoom: 6,
});
map.addListener("click", function(e) {
const lat = e.latLng.lat();
const lng = e.latLng.lng();
document.getElementById("latitude").value = lat;
document.getElementById("longitude").value = lng;
const addModal = new bootstrap.Modal(document.getElementById("addCampsiteModal"));
addModal.show();
});
// Load existing campsites from PHP
fetch("get_campsites.php")
fetch("get_campsites")
.then(response => response.json())
.then(data => {
data.forEach(site => {
@@ -133,7 +567,7 @@ while ($row = $result->fetch_assoc()) {
${site.description ? site.description + "<br>" : ""}
${site.website ? `<a href="${site.website}" target="_blank">Visit Website</a><br>` : ""}
${site.telephone ? `Phone: ${site.telephone}<br>` : ""}
${site.thumbnail ? `<img src="${site.thumbnail}" style="width: 100%; max-width: 200px; border-radius: 8px; margin-top: 5px;">` : ""}
${site.thumbnail ? `<img src="${site.thumbnail}" style="width: 100%; max-width: 200px; border-radius: 8px; margin-top: 5px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);">` : ""}
${site.user && site.user.first_name ? `
<div class="user-info mt-2 d-flex align-items-center">
<img src="${site.user.profile_pic}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 10px;">
@@ -156,21 +590,108 @@ while ($row = $result->fetch_assoc()) {
infowindow.open(map, marker);
});
});
// Populate the table
populateCampsitesTable(data);
})
.catch(err => console.error("Failed to load campsites:", err));
}
function populateCampsitesTable(campsites) {
const tableBody = document.getElementById("campsitesTableBody");
tableBody.innerHTML = ""; // Clear existing rows
if (campsites.length === 0) {
tableBody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted" style="padding: 30px;">
No campsites added yet. Click on the map to add one!
</td>
</tr>
`;
return;
}
// Group campsites by country and province
const groupedByCountryAndProvince = {};
campsites.forEach(site => {
const country = site.country || 'Unknown Country';
const province = site.province || 'Unknown Province';
if (!groupedByCountryAndProvince[country]) {
groupedByCountryAndProvince[country] = {};
}
if (!groupedByCountryAndProvince[country][province]) {
groupedByCountryAndProvince[country][province] = [];
}
groupedByCountryAndProvince[country][province].push(site);
});
// Sort countries alphabetically
const sortedCountries = Object.keys(groupedByCountryAndProvince).sort();
// Populate table with grouped data
sortedCountries.forEach(country => {
// Sort provinces alphabetically for this country
const sortedProvinces = Object.keys(groupedByCountryAndProvince[country]).sort();
sortedProvinces.forEach(province => {
// Add province group header
const groupRow = document.createElement("tr");
groupRow.innerHTML = `
<td colspan="6" style="font-weight: 600; padding: 10px 8px; background-color: #f0f0f0;">
<i class="fas fa-globe" style="color: #2196F3; margin-right: 8px;"></i>${country} - ${province}
</td>
`;
tableBody.appendChild(groupRow);
// Add campsite rows for this province
groupedByCountryAndProvince[country][province].forEach(site => {
const row = document.createElement("tr");
const userName = site.user && site.user.first_name
? `${site.user.first_name} ${site.user.last_name}`
: "Unknown";
// Only show edit button if current user is the owner
const editButtonHTML = site.user_id == currentUserId
? `<button class="btn btn-sm btn-warning" onclick='editCampsite(${JSON.stringify(site)})'>Edit</button>`
: '';
row.innerHTML = `
<td><strong>${site.name}</strong></td>
<td>${site.description ? site.description.substring(0, 50) + (site.description.length > 50 ? '...' : '') : '-'}</td>
<td>${site.website ? `<a href="${site.website}" target="_blank" class="link-primary">Visit</a>` : '-'}</td>
<td>${site.telephone || '-'}</td>
<td><small>${userName}</small></td>
<td>
${editButtonHTML}
<a href="https://www.google.com/maps/dir/?api=1&destination=${site.latitude},${site.longitude}" target="_blank" class="btn btn-sm btn-outline-primary">Directions</a>
</td>
`;
tableBody.appendChild(row);
});
});
});
}
function editCampsite(site) {
// Pre-fill form
// Change form heading to indicate editing
document.querySelector("#campsiteFormContainer h5").textContent = "Edit Campsite";
// Pre-fill form with a slight delay to ensure DOM is ready
setTimeout(() => {
document.querySelector("#addCampsiteForm input[name='name']").value = site.name;
document.querySelector("#addCampsiteForm textarea[name='description']").value = site.description || "";
document.querySelector("#addCampsiteForm input[name='website']").value = site.website || "";
document.querySelector("#addCampsiteForm input[name='telephone']").value = site.telephone || "";
document.querySelector("#addCampsiteForm input[name='latitude']").value = site.latitude;
document.querySelector("#addCampsiteForm input[name='longitude']").value = site.longitude;
document.getElementById("latitude_display").value = parseFloat(site.latitude).toFixed(6);
document.getElementById("longitude_display").value = parseFloat(site.longitude).toFixed(6);
// Set country and province LAST to ensure they stick
document.querySelector("#addCampsiteForm select[name='country']").value = site.country || '';
document.querySelector("#addCampsiteForm select[name='province']").value = site.province || '';
// Add hidden ID input
let idInput = document.querySelector("#addCampsiteForm input[name='id']");
@@ -181,15 +702,65 @@ while ($row = $result->fetch_assoc()) {
document.querySelector("#addCampsiteForm").appendChild(idInput);
}
idInput.value = site.id;
}, 0);
// Show the modal
const addModal = new bootstrap.Modal(document.getElementById("addCampsiteModal"));
addModal.show();
// Show the form container
document.getElementById("campsiteFormContainer").style.display = "block";
document.getElementById("campsiteFormContainer").scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function filterCampsites() {
const filterInput = document.getElementById("campsitesFilter");
const filterValue = filterInput.value.toLowerCase();
const tableBody = document.getElementById("campsitesTableBody");
const rows = tableBody.getElementsByTagName("tr");
let visibleRows = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const text = row.textContent.toLowerCase();
// Show rows that match the filter or are group headers
if (text.includes(filterValue) || row.innerHTML.includes('fas fa-globe')) {
row.style.display = "";
if (row.innerHTML.includes('fas fa-globe') === false) {
visibleRows++;
}
} else {
row.style.display = "none";
}
}
// Hide group headers if no campsites match in that group
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.innerHTML.includes('fas fa-globe')) {
// Check if next visible row is also a header
let hasVisibleChildren = false;
for (let j = i + 1; j < rows.length; j++) {
if (rows[j].style.display !== "none") {
if (!rows[j].innerHTML.includes('fas fa-globe')) {
hasVisibleChildren = true;
}
break;
}
}
row.style.display = hasVisibleChildren ? "" : "none";
}
}
}
// Add filter event listener when page loads
document.addEventListener("DOMContentLoaded", function() {
const filterInput = document.getElementById("campsitesFilter");
if (filterInput) {
filterInput.addEventListener("keyup", filterCampsites);
}
});
</script>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyC-JuvnbUYc8WGjQBFFVZtKiv5_bFJoWLU&callback=initMap" async defer></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php'); ?>

View File

@@ -115,8 +115,7 @@ $page_id = 'driver_training';
</select>
</li>
';
} ?>
echo '
<li>
Additional Non-Members <span class="price"></span>
<select name="non-members" id="non-members">
@@ -126,6 +125,23 @@ $page_id = 'driver_training';
<option value="3">03</option>
</select>
</li>
';
} else {
echo '
<li>
<small style="color: #666; display: block; margin-bottom: 5px;">You will be added at non-member rate</small>
Additional Participants <span class="price"></span>
<select name="non-members" id="non-members">
<option value="0" selected>00</option>
<option value="1">01</option>
<option value="2">02</option>
<option value="3">03</option>
</select>
</li>
';
}
?>
</ul>
<hr class="mb-25">
@@ -161,7 +177,7 @@ $page_id = 'driver_training';
</div>
<input type="hidden" name="csrf_token" value="<?php echo generateCSRFToken(); ?>">
<?php
$button_text = "Book Now";
$button_text = "PROCEED TO PAYMENT";
$button_disabled = "";
if (!$result || $result->num_rows == 0) {
$button_text = "No booking dates available";
@@ -173,8 +189,9 @@ $page_id = 'driver_training';
<i class="fal fa-arrow-right"></i>
</button>
<div class="text-center">
<a href="contact">Need some help?</a>
<a href="contact">You will be redirected to iKhokha's Secure payment gateway.</a>
</div>
<img src="assets/images/logos/ikhokha.png"alt="Secure Payment Badges" style="max-width: 200px; display: block; margin: 10px auto 0;">
</form>
</div>
@@ -350,8 +367,8 @@ $page_id = 'driver_training';
// Function to calculate booking total
function calculateTotal() {
// Get selected values from the form
var members = parseInt($('#members').val()) || 0; // Default to 1 vehicle if not selected
var nonmembers = parseInt($('#non-members').val()) || 0; // Default to 1 adult if not selected
var additional_members = parseInt($('#members').val()) || 0;
var additional_nonmembers = parseInt($('#non-members').val()) || 0;
// Fetch PHP variables
var isMember = <?php echo $is_member ? 'true' : 'false'; ?>;
@@ -362,12 +379,12 @@ $page_id = 'driver_training';
// Calculate the total cost based on membership
var total = 0;
// Calculate cost for members
// Calculate cost for members: (you at member rate) + additional members + additional non-members
if (isMember || pendingMember) {
total = (cost_members) + (members * cost_members) + (nonmembers * cost_nonmembers);
total = (cost_members) + (additional_members * cost_members) + (additional_nonmembers * cost_nonmembers);
} else {
// Calculate cost for non-members
total = (cost_nonmembers) + (members * cost_members) + (nonmembers * cost_nonmembers);
// Calculate cost for non-members: (you at non-member rate) + all additional people at non-member rate
total = (cost_nonmembers) + (additional_nonmembers * cost_nonmembers);
}
// Update total price in the DOM

View File

@@ -12,12 +12,22 @@ $token = $_GET['token'];
// Sanitize the trip_id to prevent SQL injection
$trip_id = intval(decryptData($token, $salt)); // Ensures $trip_id is treated as an integer
// Check if user is admin or superadmin to allow draft preview
// Check if user is admin/superadmin
$user_role = getUserRole();
$is_admin = in_array($user_role, ['admin', 'superadmin']);
// Prepare the SQL query
$sql = "SELECT trip_id, trip_name, location, short_description, long_description, start_date, end_date,
vehicle_capacity, cost_members, cost_nonmembers, places_booked, booking_fee, cost_pensioner, cost_pensioner_member
vehicle_capacity, cost_members, cost_nonmembers, places_booked, booking_fee, cost_pensioner, cost_pensioner_member, published
FROM trips
WHERE trip_id = ?";
// If not admin, only show published trips
if (!$is_admin) {
$sql .= " AND published = 1";
}
// Use prepared statements for added security
$stmt = $conn->prepare($sql);
@@ -194,12 +204,39 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');
</ol>
</nav>
</div>
<!-- Draft Notice for Admin -->
<?php if ($is_admin && isset($row['published']) && $row['published'] == 0): ?>
<div class="alert alert-warning mt-3" role="alert">
<strong><i class="fas fa-exclamation-triangle"></i> Draft Trip</strong><br>
This trip is currently in draft status and is not visible to regular users. Only admins and superadmins can preview it.
</div>
<?php endif; ?>
<!-- Publish/Unpublish Button -->
<?php
$user_role = getUserRole();
if (in_array($user_role, ['admin', 'superadmin'])):
// Use published status from the main query
$is_published = $row['published'] ?? 0;
?>
<div class="admin-actions mt-20">
<button type="button" class="theme-btn" style="width: 100%; id=" publishBtn" onclick="toggleTripPublished(<?php echo $trip_id; ?>)">
<?php if ($is_published): ?>
<i class="fas fa-eye-slash"></i> Unpublish Trip
<?php else: ?>
<i class="fas fa-eye"></i> Publish Trip
<?php endif; ?>
</button>
</div>
<?php endif; ?>
</div>
</section>
<!-- Tour Gallery start -->
<div class="tour-gallery">
<div class="container-fluid">
@@ -259,6 +296,8 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');
</div>
<span class="subtitle mb-15"><?php echo $badge_text; ?></span>
</div>
<!-- <div class="col-xl-4 col-lg-5 text-lg-end" data-aos="fade-right" data-aos-duration="1500" data-aos-offset="50">
<div class="tour-header-social mb-10">
@@ -555,13 +594,14 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');
</button>
<?php else: ?>
<button type="submit" class="theme-btn style-two w-100 mt-15 mb-5">
<span data-hover="Book Now">Book Now</span>
<span data-hover="PROCEED TO PAYMENT">PROCEED TO PAYMENT</span>
<i class="fal fa-arrow-right"></i>
</button>
<?php endif; ?>
<div class="text-center">
<a href="contact">Need some help?</a>
<a href="contact">You will be redirected to iKhokha's Secure payment gateway.</a>
</div>
<img src="assets/images/logos/ikhokha.png" alt="Secure Payment Badges" style="max-width: 200px; display: block; margin: 10px auto 0;">
</form>
</div>
@@ -673,4 +713,42 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');
});
</script>
<!-- Trip Publish/Unpublish Script -->
<script>
function toggleTripPublished(tripId) {
$.ajax({
url: 'toggle_trip_published',
type: 'POST',
data: {
trip_id: tripId
},
dataType: 'json',
success: function(response) {
if (response.status === 'success') {
// Update button and status badge
const publishBtn = $('#publishBtn');
const statusBadge = $('#publishStatus');
if (response.published === 1) {
publishBtn.html('<i class="fas fa-eye-slash"></i> Unpublish Trip');
statusBadge.html('<span class="badge bg-success">Published</span>');
} else {
publishBtn.html('<i class="fas fa-eye"></i> Publish Trip');
statusBadge.html('<span class="badge bg-warning">Draft</span>');
}
// Show success message
alert(response.message);
} else {
alert('Error: ' + response.message);
}
},
error: function(xhr, status, error) {
console.log('Error:', error);
alert('Error updating trip status');
}
});
}
</script>
<?php include_once(dirname(dirname(dirname(__DIR__))) . '/components/insta_footer.php') ?>

View File

@@ -7,14 +7,18 @@ include_once($rootPath . '/header.php');
<style>
.image {
width: 400px;
/* Set your desired width */
width: 100%;
height: 350px;
/* Set your desired height */
overflow: hidden;
/* Hide any overflow */
display: block;
/* Ensure proper block behavior */
}
.image img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
</style>
@@ -52,8 +56,17 @@ include_once($rootPath . '/header.php');
<?php
// Check if user is admin or superadmin to show draft trips
$user_role = getUserRole();
$is_admin = in_array($user_role, ['admin', 'superadmin']);
// Query to retrieve data from the trips table
$sql = "SELECT trip_id, trip_name, location, short_description, start_date, end_date, vehicle_capacity, cost_members, places_booked FROM trips WHERE published = 1 AND start_date > CURDATE()";
// Admins see all trips (published and draft), regular users only see published upcoming trips
if ($is_admin) {
$sql = "SELECT trip_id, trip_name, location, short_description, start_date, end_date, vehicle_capacity, cost_members, places_booked, published FROM trips ORDER BY start_date DESC";
} else {
$sql = "SELECT trip_id, trip_name, location, short_description, start_date, end_date, vehicle_capacity, cost_members, places_booked, published FROM trips WHERE published = 1 AND start_date > CURDATE() ORDER BY start_date ASC";
}
$result = $conn->query($sql);
if ($result->num_rows > 0) {
@@ -68,16 +81,18 @@ include_once($rootPath . '/header.php');
$capacity = $row['vehicle_capacity'];
$cost_members = $row['cost_members'];
$places_booked = $row['places_booked'];
$published = $row['published'] ?? 1;
$remaining_places = getAvailableSpaces($trip_id);
// Determine the badge text based on the status
$badge_text = ($remaining_places > 0) ? $remaining_places.' PLACES LEFT!!' : 'FULLY BOOKED';
$draft_badge = ($published == 0) ? '<span class="badge bg-warning ms-2">DRAFT</span>' : '';
// Output the HTML structure with dynamic data
echo '
<div class="destination-item style-three bgc-lighter" data-aos="fade-up" data-aos-duration="1500" data-aos-offset="50">
<div class="image">
<span class="badge bgc-pink">' . $badge_text . '</span>
<span class="badge bgc-pink">' . $badge_text . '</span>' . $draft_badge . '
<img src="assets/images/trips/' . $trip_id . '_01.jpg" alt="' . $trip_name . '">
</div>
<div class="content">
@@ -91,7 +106,7 @@ include_once($rootPath . '/header.php');
<i class="fas fa-star"></i>
</div>
</div>
<h5><a href="trip-details.php?token=' . encryptData($trip_id, $salt) . '">' . $trip_name . '</a></h5>
<h5><a href="trip-details?token=' . encryptData($trip_id, $salt) . '">' . $trip_name . '</a></h5>
<p>' . $short_description . '</p>
<ul class="blog-meta">
<li><i class="far fa-calendar"></i> ' . convertDate($start_date) . ' - ' . convertDate($end_date) . '</li>
@@ -100,7 +115,7 @@ include_once($rootPath . '/header.php');
</ul>
<div class="destination-footer">
<span class="price"><span>R ' . $cost_members . '</span>/person</span>
<a href="trip-details.php?token=' . encryptData($trip_id, $salt) . '" class="theme-btn style-two style-three">
<a href="trip-details?token=' . encryptData($trip_id, $salt) . '" class="theme-btn style-two style-three">
<span data-hover="Book Now">Book Now</span>
<i class="fal fa-arrow-right"></i>
</a>

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