Compare commits

..

2 Commits

Author SHA1 Message Date
7a18eb6588 added the cropping library 2026-01-25 21:25:23 +03:00
c1fc28087e added affiliation tab card with its information 2026-01-24 14:52:55 +03:00
25 changed files with 2380 additions and 239 deletions

120
INSTALLATION_SUMMARY.md Normal file
View File

@ -0,0 +1,120 @@
# Laravel Image Cropper Installation Summary
## Package Installed
- **Package**: `takeone/cropper` from https://git.innovator.bh/ghassan/laravel-image-cropper
- **Version**: dev-main
- **Installation Date**: January 26, 2026
## Changes Made
### 1. composer.json
- Added VCS repository for the package
- Installed `takeone/cropper:@dev`
### 2. resources/views/layouts/app.blade.php
- Added `@stack('modals')` before closing `</body>` tag
- This allows the cropper modal to be injected into the page
### 3. resources/views/family/profile-edit.blade.php
- Replaced the old `<x-image-upload-modal>` component with `<x-takeone-cropper>`
- Configured cropper with:
- **ID**: `profile_picture`
- **Width**: 300px
- **Height**: 400px (portrait rectangle - 3:4 ratio)
- **Shape**: `square` (rectangle viewport)
- **Folder**: `images/profiles`
- **Filename**: `profile_{user_id}`
- **Upload URL**: Custom route `profile.upload-picture`
- Added image display logic to show current profile picture or placeholder
### 4. app/Http/Controllers/FamilyController.php
- Updated `uploadProfilePicture()` method to handle base64 image data from cropper
- Method now:
- Accepts base64 image data instead of file upload
- Decodes and saves the cropped image
- Updates user's `profile_picture` field in database
- Returns JSON response with success status and image path
### 5. resources/views/vendor/takeone/components/widget.blade.php
- Published and customized the package's widget component
- Added support for custom `uploadUrl` parameter
- Added page reload after successful upload to display new image
- Improved error handling with detailed error messages
### 6. Storage
- Ran `php artisan storage:link` to link public storage
## How It Works
1. User clicks "Change Photo" button on profile edit page
2. Modal popup appears with file selector
3. User selects an image file
4. Cropme.js library loads the image in a cropping interface
5. User can:
- Zoom in/out using slider
- Rotate image using slider
- Pan/move image within the viewport
6. Cropping viewport is set to 300x400px (portrait rectangle)
7. User clicks "Crop & Save Image"
8. Image is cropped to base64 format
9. AJAX POST request sent to `/profile/upload-picture`
10. FamilyController processes the base64 image:
- Decodes base64 data
- Saves to `storage/app/public/images/profiles/profile_{user_id}.{ext}`
- Deletes old profile picture if exists
- Updates user's `profile_picture` field
11. Page reloads to display the new profile picture
## File Locations
- **Uploaded Images**: `storage/app/public/images/profiles/`
- **Public Access**: `public/storage/images/profiles/` (via symlink)
- **Database Field**: `users.profile_picture`
## Portrait Rectangle Configuration
The cropper is configured for portrait orientation:
- **Aspect Ratio**: 3:4 (300px width × 400px height)
- **Shape**: `square` (rectangle viewport, not circular)
- **Viewport**: Displays as a rectangle overlay on the image
## Testing Checklist
- [x] Package installed successfully
- [x] Storage linked
- [x] Modal stack added to layout
- [x] Cropper component integrated
- [x] Custom upload route configured
- [x] Controller method updated
- [ ] Test image upload
- [ ] Verify cropped image saves correctly
- [ ] Confirm image displays after upload
- [ ] Test portrait rectangle cropping
- [ ] Verify old images are deleted
## Next Steps
1. Navigate to http://localhost:8000/profile/edit
2. Click "Change Photo" button
3. Select an image
4. Crop in portrait rectangle shape
5. Save and verify the image appears correctly
## Troubleshooting
If the cropper doesn't appear:
- Check browser console for JavaScript errors
- Verify `@stack('modals')` is in layout
- Ensure jQuery is loaded before the cropper script
If upload fails:
- Check `storage/app/public/images/profiles/` directory exists
- Verify storage is linked: `php artisan storage:link`
- Check file permissions on storage directory
- Review Laravel logs in `storage/logs/`
## Package Documentation
For more details, see:
- Package README: `vendor/takeone/cropper/README.md`
- Package Example: `vendor/takeone/cropper/EXAMPLE.md`

251
TESTING_GUIDE.md Normal file
View File

@ -0,0 +1,251 @@
# Laravel Image Cropper - Testing Guide
## Installation Complete ✅
The Laravel Image Cropper package has been successfully installed and integrated into your project.
## What Was Installed
1. **Package**: `takeone/cropper:@dev`
2. **Cropper Library**: Cropme.js (lightweight image cropping)
3. **Portrait Rectangle Configuration**: 300px × 400px (3:4 ratio)
4. **Storage Directory**: `storage/app/public/images/profiles/`
## Files Modified
### 1. composer.json
- Added VCS repository
- Added package dependency
### 2. resources/views/layouts/app.blade.php
- Added `@stack('modals')` for modal injection
### 3. resources/views/family/profile-edit.blade.php
- Replaced old image upload modal with `<x-takeone-cropper>` component
- Configured for portrait rectangle cropping
- Added image display with fallback to default avatar
### 4. app/Http/Controllers/FamilyController.php
- Updated `uploadProfilePicture()` method to handle base64 images
- Saves cropped images to `storage/app/public/images/profiles/`
- Updates user's `profile_picture` field in database
### 5. resources/views/vendor/takeone/components/widget.blade.php
- Published and customized widget component
- Added custom upload URL support
- Added page reload after successful upload
- Improved error handling
## How to Test
### Step 1: Start the Development Server
```bash
php artisan serve
```
### Step 2: Navigate to Profile Edit Page
Open your browser and go to:
```
http://localhost:8000/profile/edit
```
### Step 3: Test the Cropper
1. **Click "Change Photo" button**
- A modal should popup with a file selector
2. **Select an Image**
- Click "Choose File" and select any image from your computer
- The image should load in the cropping interface
3. **Crop the Image**
- You'll see a **portrait rectangle** overlay (300×400px)
- Use the **Zoom Level** slider to zoom in/out
- Use the **Rotation** slider to rotate the image
- Drag the image to position it within the rectangle
4. **Save the Image**
- Click "Crop & Save Image" button
- Button should show "Uploading..." while processing
- You should see "Saved successfully!" alert
- Modal should close automatically
- Page should reload
- Your new profile picture should appear in the profile picture box
### Step 4: Verify the Upload
Check that the image was saved:
```bash
dir storage\app\public\images\profiles\
```
You should see a file named `profile_{user_id}.png`
### Step 5: Verify Database Update
The `users` table should have the `profile_picture` field updated with:
```
images/profiles/profile_{user_id}.png
```
## Expected Behavior
### ✅ Success Indicators
- Modal opens when clicking "Change Photo"
- Image loads in cropper after selection
- Portrait rectangle viewport is visible (taller than wide)
- Zoom and rotation sliders work smoothly
- Image can be dragged/positioned
- "Crop & Save Image" button uploads successfully
- Success alert appears
- Page reloads automatically
- New profile picture displays in the profile box
### ❌ Potential Issues
**Modal doesn't appear:**
- Check browser console (F12) for JavaScript errors
- Verify `@stack('modals')` is in `resources/views/layouts/app.blade.php`
- Ensure jQuery and Bootstrap are loaded
**Upload fails:**
- Check `storage/app/public/images/profiles/` directory exists
- Verify storage link: `php artisan storage:link`
- Check file permissions on storage directory
- Review Laravel logs: `storage/logs/laravel.log`
**Image doesn't display after upload:**
- Verify storage is linked
- Check the `profile_picture` field in database
- Ensure the file exists in `public/storage/images/profiles/`
- Clear browser cache
**Cropper shows square instead of rectangle:**
- Verify the component has `width="300"` and `height="400"`
- Check that `shape="square"` (not "circle")
## Package Bug Note
⚠️ **Known Issue**: The package's service provider has a namespace bug in the route registration. This doesn't affect functionality because we're using our own custom route (`profile.upload-picture`) instead of the package's default route.
The error you might see when running `php artisan route:list`:
```
Class "takeone\cropper\Http\Controllers\ImageController" does not exist
```
This can be safely ignored as we're not using that route.
## Customization Options
### Change Aspect Ratio
Edit `resources/views/family/profile-edit.blade.php`:
```html
<!-- Current: 3:4 portrait -->
<x-takeone-cropper
width="300"
height="400"
/>
<!-- Square: 1:1 -->
<x-takeone-cropper
width="300"
height="300"
/>
<!-- Wide rectangle: 16:9 -->
<x-takeone-cropper
width="400"
height="225"
/>
<!-- Tall portrait: 2:3 -->
<x-takeone-cropper
width="300"
height="450"
/>
```
### Change to Circular Crop
```html
<x-takeone-cropper
width="300"
height="300"
shape="circle"
/>
```
### Change Storage Folder
```html
<x-takeone-cropper
folder="avatars"
/>
```
### Custom Filename Pattern
```html
<x-takeone-cropper
filename="user_{{ auth()->id() }}_{{ time() }}"
/>
```
## File Structure
```
takeone/
├── storage/
│ └── app/
│ └── public/
│ └── images/
│ └── profiles/ ← Uploaded images here
│ └── profile_1.png
├── public/
│ └── storage/ ← Symlink to storage/app/public
│ └── images/
│ └── profiles/
│ └── profile_1.png ← Publicly accessible
├── resources/
│ └── views/
│ ├── family/
│ │ └── profile-edit.blade.php ← Profile edit page
│ ├── layouts/
│ │ └── app.blade.php ← Main layout with @stack('modals')
│ └── vendor/
│ └── takeone/
│ └── components/
│ └── widget.blade.php ← Customized cropper widget
└── app/
└── Http/
└── Controllers/
└── FamilyController.php ← Upload handler
```
## Support
If you encounter any issues:
1. Check the browser console (F12) for JavaScript errors
2. Review Laravel logs: `storage/logs/laravel.log`
3. Verify all files were modified correctly
4. Ensure storage permissions are correct
5. Clear all caches: `php artisan config:clear && php artisan route:clear && php artisan view:clear`
## Next Steps
1. Test the cropper functionality
2. Upload a test image
3. Verify it displays correctly
4. Customize the aspect ratio if needed
5. Add additional validation if required
6. Consider adding image optimization/compression
---
**Installation Date**: January 26, 2026
**Package Version**: dev-main
**Laravel Version**: 12.0
**Status**: ✅ Ready for Testing

28
TODO_affiliations.md Normal file
View File

@ -0,0 +1,28 @@
# TODO: Implement Affiliations Tab
## Backend Implementation
- [x] Create ClubAffiliation model and migration
- [x] Create SkillAcquisition model and migration
- [x] Create AffiliationMedia model and migration
- [x] Update User model with clubAffiliations relationship
- [x] Update FamilyController::profile() to fetch affiliations data and summary stats
- [x] Add club_affiliation_id to TournamentEvent model and migration
## Frontend Implementation
- [x] Update show.blade.php affiliations tab content
- [x] Implement horizontal timeline with clickable nodes
- [x] Add dynamic skills wheel using Chart.js Polar Area
- [x] Create affiliation details panel
- [x] Add summary stats above timeline
- [x] Ensure responsive design (desktop side-by-side, mobile stacked)
- [x] Add Alpine.js for timeline interactions
- [x] Implement keyboard navigation and accessibility
- [x] Add club affiliation column to tournament table
## Testing & Deployment
- [x] Run migrations to create database tables
- [x] Add sample data for demonstration
- [x] Add calculated duration display to timeline cards (as badges)
- [x] Test timeline navigation and skills wheel updates
- [x] Verify responsive design on different screen sizes
- [x] Add "Add Tournament Participation" button with modal functionality

View File

@ -9,6 +9,7 @@ use App\Models\Invoice;
use App\Models\TournamentEvent; use App\Models\TournamentEvent;
use App\Models\Goal; use App\Models\Goal;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\ClubAffiliation;
use App\Services\FamilyService; use App\Services\FamilyService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -60,7 +61,7 @@ class FamilyController extends Controller
// Fetch tournament data // Fetch tournament data
$tournamentEvents = $user->tournamentEvents() $tournamentEvents = $user->tournamentEvents()
->with(['performanceResults', 'notesMedia']) ->with(['performanceResults', 'notesMedia', 'clubAffiliation'])
->orderBy('date', 'desc') ->orderBy('date', 'desc')
->get(); ->get();
@ -88,6 +89,24 @@ class FamilyController extends Controller
$totalSessions = $attendanceRecords->count(); $totalSessions = $attendanceRecords->count();
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0; $attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
// Fetch affiliations data
$clubAffiliations = $user->clubAffiliations()
->with(['skillAcquisitions', 'affiliationMedia'])
->orderBy('start_date', 'desc')
->get();
// Add icon_class to media items for JavaScript
$clubAffiliations->each(function($affiliation) {
$affiliation->affiliationMedia->each(function($media) {
$media->icon_class = $media->icon_class;
});
});
// Calculate summary stats
$totalAffiliations = $clubAffiliations->count();
$distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count();
$totalMembershipDuration = $clubAffiliations->sum('duration_in_months');
// Pass user directly and a flag to indicate it's the current user's profile // Pass user directly and a flag to indicate it's the current user's profile
return view('family.show', [ return view('family.show', [
'relationship' => (object)[ 'relationship' => (object)[
@ -111,6 +130,10 @@ class FamilyController extends Controller
'sessionsCompleted' => $sessionsCompleted, 'sessionsCompleted' => $sessionsCompleted,
'noShows' => $noShows, 'noShows' => $noShows,
'attendanceRate' => $attendanceRate, 'attendanceRate' => $attendanceRate,
'clubAffiliations' => $clubAffiliations,
'totalAffiliations' => $totalAffiliations,
'distinctSkills' => $distinctSkills,
'totalMembershipDuration' => $totalMembershipDuration,
]); ]);
} }
@ -135,39 +158,44 @@ class FamilyController extends Controller
public function uploadProfilePicture(Request $request) public function uploadProfilePicture(Request $request)
{ {
$request->validate([ $request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,gif|max:5120', // 5MB max 'image' => 'required',
'folder' => 'required|string',
'filename' => 'required|string',
]); ]);
$user = Auth::user(); try {
$user = Auth::user();
if ($request->hasFile('image')) { // Handle base64 image from cropper
$image = $request->file('image'); $imageData = $request->image;
$imageParts = explode(";base64,", $imageData);
$imageTypeAux = explode("image/", $imageParts[0]);
$extension = $imageTypeAux[1];
$imageBinary = base64_decode($imageParts[1]);
// Generate unique filename $folder = trim($request->folder, '/');
$filename = 'profile_' . $user->id . '_' . time() . '.' . $image->getClientOriginalExtension(); $fileName = $request->filename . '.' . $extension;
$fullPath = $folder . '/' . $fileName;
// Store in public/images/profiles
$path = $image->storeAs('images/profiles', $filename, 'public');
// Delete old profile picture if exists // Delete old profile picture if exists
if ($user->profile_picture && \Storage::disk('public')->exists($user->profile_picture)) { if ($user->profile_picture && \Storage::disk('public')->exists($user->profile_picture)) {
\Storage::disk('public')->delete($user->profile_picture); \Storage::disk('public')->delete($user->profile_picture);
} }
// Update user // Store in the public disk (storage/app/public)
$user->update(['profile_picture' => $path]); \Storage::disk('public')->put($fullPath, $imageBinary);
// Update user's profile_picture field
$user->update(['profile_picture' => $fullPath]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Profile picture uploaded successfully.', 'path' => $fullPath,
'path' => $path, 'url' => asset('storage/' . $fullPath)
]); ]);
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
} }
return response()->json([
'success' => false,
'message' => 'No image file provided.',
], 400);
} }
/** /**
@ -280,7 +308,7 @@ class FamilyController extends Controller
// Fetch tournament data for the dependent // Fetch tournament data for the dependent
$tournamentEvents = $relationship->dependent->tournamentEvents() $tournamentEvents = $relationship->dependent->tournamentEvents()
->with(['performanceResults', 'notesMedia']) ->with(['performanceResults', 'notesMedia', 'clubAffiliation'])
->orderBy('date', 'desc') ->orderBy('date', 'desc')
->get(); ->get();
@ -308,7 +336,46 @@ class FamilyController extends Controller
$totalSessions = $attendanceRecords->count(); $totalSessions = $attendanceRecords->count();
$attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0; $attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0;
return view('family.show', compact('relationship', 'latestHealthRecord', 'healthRecords', 'comparisonRecords', 'invoices', 'tournamentEvents', 'awardCounts', 'sports', 'goals', 'activeGoalsCount', 'completedGoalsCount', 'successRate', 'attendanceRecords', 'sessionsCompleted', 'noShows', 'attendanceRate')); // Fetch affiliations data for the dependent
$clubAffiliations = $relationship->dependent->clubAffiliations()
->with(['skillAcquisitions', 'affiliationMedia'])
->orderBy('start_date', 'desc')
->get();
// Add icon_class to media items for JavaScript
$clubAffiliations->each(function($affiliation) {
$affiliation->affiliationMedia->each(function($media) {
$media->icon_class = $media->icon_class;
});
});
// Calculate summary stats
$totalAffiliations = $clubAffiliations->count();
$distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count();
$totalMembershipDuration = $clubAffiliations->sum('duration_in_months');
return view('family.show', [
'relationship' => $relationship,
'latestHealthRecord' => $latestHealthRecord,
'healthRecords' => $healthRecords,
'comparisonRecords' => $comparisonRecords,
'invoices' => $invoices,
'tournamentEvents' => $tournamentEvents,
'awardCounts' => $awardCounts,
'sports' => $sports,
'goals' => $goals,
'activeGoalsCount' => $activeGoalsCount,
'completedGoalsCount' => $completedGoalsCount,
'successRate' => $successRate,
'attendanceRecords' => $attendanceRecords,
'sessionsCompleted' => $sessionsCompleted,
'noShows' => $noShows,
'attendanceRate' => $attendanceRate,
'clubAffiliations' => $clubAffiliations,
'totalAffiliations' => $totalAffiliations,
'distinctSkills' => $distinctSkills,
'totalMembershipDuration' => $totalMembershipDuration,
]);
} }
/** /**
@ -634,6 +701,81 @@ class FamilyController extends Controller
return response()->json(['success' => true, 'message' => 'Goal updated successfully']); return response()->json(['success' => true, 'message' => 'Goal updated successfully']);
} }
/**
* Store a new tournament participation record.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\JsonResponse
*/
public function storeTournament(Request $request, $id)
{
$user = Auth::user();
// Check if user is authorized to add tournament for this dependent
if ($user->id !== (int)$id) {
$relationship = UserRelationship::where('guardian_user_id', $user->id)
->where('dependent_user_id', $id)
->first();
if (!$relationship) {
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
}
}
// Validate the request
$validated = $request->validate([
'title' => 'required|string|max:255',
'type' => 'required|in:championship,tournament,competition,exhibition',
'sport' => 'required|string|max:100',
'date' => 'required|date',
'time' => 'nullable|date_format:H:i',
'location' => 'nullable|string|max:255',
'participants_count' => 'nullable|integer|min:1',
'club_affiliation_id' => 'nullable|exists:club_affiliations,id',
'performance_results' => 'nullable|array',
'performance_results.*.medal_type' => 'nullable|in:special,1st,2nd,3rd',
'performance_results.*.points' => 'nullable|numeric|min:0',
'performance_results.*.description' => 'nullable|string|max:500',
'notes_media' => 'nullable|array',
'notes_media.*.note_text' => 'nullable|string|max:1000',
'notes_media.*.media_link' => 'nullable|url',
]);
// Create the tournament event
$tournament = TournamentEvent::create([
'user_id' => $id,
'club_affiliation_id' => $validated['club_affiliation_id'] ?? null,
'title' => $validated['title'],
'type' => $validated['type'],
'sport' => $validated['sport'],
'date' => $validated['date'],
'time' => $validated['time'],
'location' => $validated['location'],
'participants_count' => $validated['participants_count'],
]);
// Create performance results
if (isset($validated['performance_results'])) {
foreach ($validated['performance_results'] as $resultData) {
if (!empty($resultData['medal_type'])) {
$tournament->performanceResults()->create($resultData);
}
}
}
// Create notes and media
if (isset($validated['notes_media'])) {
foreach ($validated['notes_media'] as $noteData) {
if (!empty($noteData['note_text']) || !empty($noteData['media_link'])) {
$tournament->notesMedia()->create($noteData);
}
}
}
return response()->json(['success' => true, 'message' => 'Tournament record added successfully']);
}
/** /**
* Remove the specified family member from storage. * Remove the specified family member from storage.
* *

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AffiliationMedia extends Model
{
protected $fillable = [
'club_affiliation_id',
'media_type',
'media_url',
'title',
'description',
];
/**
* Get the club affiliation that owns the media.
*/
public function clubAffiliation(): BelongsTo
{
return $this->belongsTo(ClubAffiliation::class);
}
/**
* Get the full URL for the media.
*/
public function getFullUrlAttribute(): string
{
if (filter_var($this->media_url, FILTER_VALIDATE_URL)) {
return $this->media_url;
}
return asset('storage/' . $this->media_url);
}
/**
* Get icon class for media type.
*/
public function getIconClassAttribute(): string
{
return match($this->media_type) {
'certificate' => 'bi-file-earmark-text',
'photo' => 'bi-image',
'video' => 'bi-play-circle',
'document' => 'bi-file-text',
default => 'bi-file',
};
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClubAffiliation extends Model
{
protected $fillable = [
'member_id',
'club_name',
'logo',
'start_date',
'end_date',
'location',
'coaches',
'description',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'coaches' => 'array',
];
/**
* Get the member that owns the affiliation.
*/
public function member(): BelongsTo
{
return $this->belongsTo(User::class, 'member_id');
}
/**
* Get the skills acquired during this affiliation.
*/
public function skillAcquisitions(): HasMany
{
return $this->hasMany(SkillAcquisition::class);
}
/**
* Get the media associated with this affiliation.
*/
public function affiliationMedia(): HasMany
{
return $this->hasMany(AffiliationMedia::class);
}
/**
* Get the duration of the affiliation in months.
*/
public function getDurationInMonthsAttribute(): int
{
$endDate = $this->end_date ?? now();
return $this->start_date->diffInMonths($endDate);
}
/**
* Get formatted date range.
*/
public function getDateRangeAttribute(): string
{
$start = $this->start_date->format('M Y');
$end = $this->end_date ? $this->end_date->format('M Y') : 'Present';
return $start . ' ' . $end;
}
/**
* Get detailed formatted duration (years, months, days).
*/
public function getFormattedDurationAttribute(): string
{
$endDate = $this->end_date ?? now();
$diff = $this->start_date->diff($endDate);
$parts = [];
if ($diff->y > 0) {
$parts[] = $diff->y . ' year' . ($diff->y > 1 ? 's' : '');
}
if ($diff->m > 0) {
$parts[] = $diff->m . ' month' . ($diff->m > 1 ? 's' : '');
}
if ($diff->d > 0) {
$parts[] = $diff->d . ' day' . ($diff->d > 1 ? 's' : '');
}
return implode(' ', $parts) ?: 'Same day';
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SkillAcquisition extends Model
{
protected $fillable = [
'club_affiliation_id',
'skill_name',
'icon',
'duration_months',
'proficiency_level',
];
protected $casts = [
'duration_months' => 'integer',
];
/**
* Get the club affiliation that owns the skill acquisition.
*/
public function clubAffiliation(): BelongsTo
{
return $this->belongsTo(ClubAffiliation::class);
}
/**
* Get formatted duration.
*/
public function getFormattedDurationAttribute(): string
{
$months = $this->duration_months;
if ($months < 12) {
return $months . ' month' . ($months > 1 ? 's' : '');
}
$years = floor($months / 12);
$remainingMonths = $months % 12;
$result = $years . ' year' . ($years > 1 ? 's' : '');
if ($remainingMonths > 0) {
$result .= ' ' . $remainingMonths . ' month' . ($remainingMonths > 1 ? 's' : '');
}
return $result;
}
/**
* Get proficiency level color for UI.
*/
public function getProficiencyColorAttribute(): string
{
return match($this->proficiency_level) {
'beginner' => 'text-blue-500',
'intermediate' => 'text-yellow-500',
'advanced' => 'text-orange-500',
'expert' => 'text-red-500',
default => 'text-gray-500',
};
}
}

View File

@ -10,6 +10,7 @@ class TournamentEvent extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'club_affiliation_id',
'title', 'title',
'type', 'type',
'sport', 'sport',
@ -29,6 +30,11 @@ class TournamentEvent extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function clubAffiliation(): BelongsTo
{
return $this->belongsTo(ClubAffiliation::class);
}
public function performanceResults(): HasMany public function performanceResults(): HasMany
{ {
return $this->hasMany(PerformanceResult::class); return $this->hasMany(PerformanceResult::class);

View File

@ -250,6 +250,14 @@ class User extends Authenticatable
return $this->hasMany(Attendance::class, 'member_id'); return $this->hasMany(Attendance::class, 'member_id');
} }
/**
* Get the club affiliations for the user.
*/
public function clubAffiliations(): HasMany
{
return $this->hasMany(ClubAffiliation::class, 'member_id');
}
/** /**
* Send the email verification notification. * Send the email verification notification.
* Override to prevent sending the default Laravel notification. * Override to prevent sending the default Laravel notification.

View File

@ -5,11 +5,18 @@
"description": "The skeleton application for the Laravel framework.", "description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"], "keywords": ["laravel", "framework"],
"license": "MIT", "license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper"
}
],
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1",
"takeone/cropper": "@dev"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

46
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "d3c16cb86c42230c6c023d9a5d9bcf42", "content-hash": "d81f57cbd65389eb9d9702b5825564c1",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -5854,6 +5854,44 @@
], ],
"time": "2025-12-18T07:04:31+00:00" "time": "2025-12-18T07:04:31+00:00"
}, },
{
"name": "takeone/cropper",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://git.innovator.bh/ghassan/laravel-image-cropper",
"reference": "155876bd2165271116f7d8ea3cf3afddd9511ec9"
},
"require": {
"laravel/framework": "^11.0|^12.0",
"php": "^8.2"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"Takeone\\Cropper\\CropperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Takeone\\Cropper\\": "src/"
}
},
"license": [
"MIT"
],
"authors": [
{
"name": "Ghassan",
"email": "ghassan.yousif.83@gmail.com"
}
],
"description": "A professional image cropping component for Laravel using Bootstrap and Cropme.",
"time": "2026-01-25T11:58:48+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",
@ -8434,12 +8472,14 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": {
"takeone/cropper": 20
},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"
} }

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('club_affiliations', function (Blueprint $table) {
$table->id();
$table->foreignId('member_id')->constrained('users')->onDelete('cascade');
$table->string('club_name');
$table->string('logo')->nullable();
$table->date('start_date');
$table->date('end_date')->nullable();
$table->string('location')->nullable();
$table->json('coaches')->nullable(); // Array of coach names
$table->text('description')->nullable();
$table->timestamps();
$table->index(['member_id', 'start_date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('club_affiliations');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('skill_acquisitions', function (Blueprint $table) {
$table->id();
$table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade');
$table->string('skill_name');
$table->string('icon')->nullable(); // FontAwesome or Heroicons class
$table->integer('duration_months');
$table->enum('proficiency_level', ['beginner', 'intermediate', 'advanced', 'expert'])->default('beginner');
$table->timestamps();
$table->index(['club_affiliation_id', 'skill_name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('skill_acquisitions');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('affiliation_media', function (Blueprint $table) {
$table->id();
$table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade');
$table->enum('media_type', ['certificate', 'photo', 'video', 'document']);
$table->string('media_url');
$table->string('title')->nullable();
$table->text('description')->nullable();
$table->timestamps();
$table->index(['club_affiliation_id', 'media_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('affiliation_media');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tournament_events', function (Blueprint $table) {
$table->foreignId('club_affiliation_id')->nullable()->constrained('club_affiliations')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tournament_events', function (Blueprint $table) {
//
});
}
};

View File

@ -0,0 +1,160 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Tenant;
use App\Models\ClubAffiliation;
use App\Models\SkillAcquisition;
use App\Models\AffiliationMedia;
use App\Models\TournamentEvent;
use Carbon\Carbon;
class AffiliationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Get the first user or create a sample one
$user = User::first();
if (!$user) {
return; // No users to seed affiliations for
}
// Create additional clubs if they don't exist
$clubs = [
[
'club_name' => 'Elite Boxing Club',
'slug' => 'elite-boxing-club',
'gps_lat' => 25.276987,
'gps_long' => 55.296249,
],
[
'club_name' => 'Zen Martial Arts Academy',
'slug' => 'zen-martial-arts-academy',
'gps_lat' => 25.286987,
'gps_long' => 55.306249,
],
[
'club_name' => 'Power Fitness Gym',
'slug' => 'power-fitness-gym',
'gps_lat' => 25.266987,
'gps_long' => 55.286249,
],
];
$createdClubs = [];
foreach ($clubs as $clubData) {
$club = Tenant::firstOrCreate(
['slug' => $clubData['slug']],
array_merge($clubData, ['owner_user_id' => $user->id])
);
$createdClubs[] = $club;
}
// Sample club affiliations
$affiliations = [
[
'club_name' => 'Elite Boxing Club',
'start_date' => Carbon::parse('2020-01-15'),
'end_date' => Carbon::parse('2021-12-31'),
'location' => 'Downtown Fitness Center',
'coaches' => ['Coach Mike Johnson', 'Coach Sarah Davis'],
'description' => 'Premier boxing training facility focusing on technique and conditioning.',
'logo' => 'https://via.placeholder.com/100x100/FF6B6B/FFFFFF?text=EBC',
'skills' => [
['skill_name' => 'Boxing', 'icon' => 'fas fa-fist-raised', 'duration_months' => 18, 'proficiency_level' => 'advanced'],
['skill_name' => 'Fitness Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 12, 'proficiency_level' => 'intermediate'],
['skill_name' => 'Footwork', 'icon' => 'fas fa-shoe-prints', 'duration_months' => 15, 'proficiency_level' => 'advanced'],
],
'media' => [
['media_type' => 'certificate', 'title' => 'Boxing Certification', 'media_url' => 'https://via.placeholder.com/300x200/4ECDC4/FFFFFF?text=Boxing+Cert', 'description' => 'Advanced Boxing Certificate'],
['media_type' => 'photo', 'title' => 'Championship Photo', 'media_url' => 'https://via.placeholder.com/300x200/45B7D1/FFFFFF?text=Championship', 'description' => 'Regional Championship 2021'],
]
],
[
'club_name' => 'Zen Martial Arts Academy',
'start_date' => Carbon::parse('2018-03-01'),
'end_date' => Carbon::parse('2020-01-10'),
'location' => 'East Side Dojo',
'coaches' => ['Master Chen Wei', 'Instructor Lisa Park'],
'description' => 'Traditional martial arts academy specializing in multiple disciplines.',
'logo' => 'https://via.placeholder.com/100x100/96CEB4/FFFFFF?text=ZMA',
'skills' => [
['skill_name' => 'Taekwondo', 'icon' => 'fas fa-hand-rock', 'duration_months' => 20, 'proficiency_level' => 'expert'],
['skill_name' => 'Karate', 'icon' => 'fas fa-fist-raised', 'duration_months' => 16, 'proficiency_level' => 'advanced'],
['skill_name' => 'Self Defense', 'icon' => 'fas fa-shield-alt', 'duration_months' => 18, 'proficiency_level' => 'advanced'],
],
'media' => [
['media_type' => 'certificate', 'title' => 'Black Belt Certificate', 'media_url' => 'https://via.placeholder.com/300x200/FECA57/FFFFFF?text=Black+Belt', 'description' => 'Taekwondo Black Belt Certification'],
]
],
[
'club_name' => 'Power Fitness Gym',
'start_date' => Carbon::parse('2022-06-01'),
'end_date' => null, // Current affiliation
'location' => 'West End Sports Complex',
'coaches' => ['Trainer Alex Rodriguez', 'Trainer Emma Wilson'],
'description' => 'Modern fitness center with comprehensive training programs.',
'logo' => 'https://via.placeholder.com/100x100/FFEAA7/000000?text=PFG',
'skills' => [
['skill_name' => 'Weight Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 8, 'proficiency_level' => 'intermediate'],
['skill_name' => 'Cardio Fitness', 'icon' => 'fas fa-heartbeat', 'duration_months' => 6, 'proficiency_level' => 'beginner'],
['skill_name' => 'Nutrition', 'icon' => 'fas fa-apple-alt', 'duration_months' => 4, 'proficiency_level' => 'beginner'],
],
'media' => [
['media_type' => 'photo', 'title' => 'Gym Progress Photo', 'media_url' => 'https://via.placeholder.com/300x200/DD5E89/FFFFFF?text=Progress', 'description' => 'Before and after transformation'],
]
],
];
$createdAffiliations = [];
foreach ($affiliations as $affiliationData) {
$skills = $affiliationData['skills'];
$media = $affiliationData['media'];
unset($affiliationData['skills'], $affiliationData['media']);
$affiliationData['member_id'] = $user->id;
$affiliation = ClubAffiliation::create($affiliationData);
$createdAffiliations[] = $affiliation;
// Create skills
foreach ($skills as $skillData) {
$affiliation->skillAcquisitions()->create($skillData);
}
// Create media
foreach ($media as $mediaData) {
$affiliation->affiliationMedia()->create($mediaData);
}
}
// Link some tournament events to affiliations
$tournamentEvents = TournamentEvent::where('user_id', $user->id)->get();
if ($tournamentEvents->count() > 0 && count($createdAffiliations) > 0) {
// Link first tournament to Elite Boxing Club affiliation
$boxingAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Elite Boxing Club');
if ($boxingAffiliation) {
$boxingEvents = $tournamentEvents->where('sport', 'Boxing');
foreach ($boxingEvents as $event) {
$event->update(['club_affiliation_id' => $boxingAffiliation->id]);
}
}
// Link martial arts events to Zen Martial Arts Academy
$martialArtsAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Zen Martial Arts Academy');
if ($martialArtsAffiliation) {
$martialArtsEvents = $tournamentEvents->whereIn('sport', ['Taekwondo', 'Karate', 'Martial Arts']);
foreach ($martialArtsEvents as $event) {
$event->update(['club_affiliation_id' => $martialArtsAffiliation->id]);
}
}
}
}
}

2
public/storage/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -285,15 +285,6 @@
} }
</style> </style>
<!-- Flag Icons CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@6.6.6/css/flag-icons.min.css">
<!-- Select2 CSS (for nationality dropdown) -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<!-- Flatpickr CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<div class="login-page"> <div class="login-page">
<div class="login-box"> <div class="login-box">
<div class="card"> <div class="card">
@ -311,11 +302,11 @@
<!-- Full Name --> <!-- Full Name -->
<div class="mb-3"> <div class="mb-3">
<label for="full_name" class="form-label">Full Name</label>
<input id="full_name" type="text" <input id="full_name" type="text"
class="form-control @error('full_name') is-invalid @enderror" class="form-control @error('full_name') is-invalid @enderror"
name="full_name" name="full_name"
value="{{ old('full_name') }}" value="{{ old('full_name') }}"
placeholder="Full Name"
required autocomplete="name"> required autocomplete="name">
@error('full_name') @error('full_name')
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback" role="alert">
@ -326,11 +317,11 @@
<!-- Email Address --> <!-- Email Address -->
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input id="email" type="email" <input id="email" type="email"
class="form-control @error('email') is-invalid @enderror" class="form-control @error('email') is-invalid @enderror"
name="email" name="email"
value="{{ old('email') }}" value="{{ old('email') }}"
placeholder="Email Address"
required autocomplete="email"> required autocomplete="email">
@error('email') @error('email')
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback" role="alert">
@ -341,10 +332,10 @@
<!-- Password --> <!-- Password -->
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" type="password" <input id="password" type="password"
class="form-control @error('password') is-invalid @enderror" class="form-control @error('password') is-invalid @enderror"
name="password" name="password"
placeholder="Password"
required autocomplete="new-password"> required autocomplete="new-password">
@error('password') @error('password')
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback" role="alert">
@ -355,15 +346,16 @@
<!-- Confirm Password --> <!-- Confirm Password -->
<div class="mb-3"> <div class="mb-3">
<label for="password-confirm" class="form-label">Confirm Password</label>
<input id="password-confirm" type="password" <input id="password-confirm" type="password"
class="form-control" class="form-control"
name="password_confirmation" name="password_confirmation"
placeholder="Confirm Password"
required autocomplete="new-password"> required autocomplete="new-password">
</div> </div>
<!-- Mobile Number with Country Code --> <!-- Mobile Number with Country Code -->
<div class="mb-3"> <div class="mb-3">
<label for="mobile_number" class="form-label">Mobile Number</label>
<x-country-code-dropdown <x-country-code-dropdown
name="country_code" name="country_code"
id="country_code" id="country_code"
@ -374,7 +366,6 @@
class="form-control @error('mobile_number') is-invalid @enderror" class="form-control @error('mobile_number') is-invalid @enderror"
name="mobile_number" name="mobile_number"
value="{{ old('mobile_number') }}" value="{{ old('mobile_number') }}"
placeholder="Mobile Number"
required autocomplete="tel"> required autocomplete="tel">
</x-country-code-dropdown> </x-country-code-dropdown>
@error('mobile_number') @error('mobile_number')
@ -386,11 +377,12 @@
<!-- Gender --> <!-- Gender -->
<div class="mb-3"> <div class="mb-3">
<label for="gender" class="form-label">Gender</label>
<select id="gender" class="form-select @error('gender') is-invalid @enderror" <select id="gender" class="form-select @error('gender') is-invalid @enderror"
name="gender" required> name="gender" required>
<option value="">Select Gender</option> <option value="">Select Gender</option>
<option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>Male</option> <option value="m" {{ old('gender') == 'm' ? 'selected' : '' }}>♂️ Male</option>
<option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>Female</option> <option value="f" {{ old('gender') == 'f' ? 'selected' : '' }}>♀️ Female</option>
</select> </select>
@error('gender') @error('gender')
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback" role="alert">
@ -401,15 +393,54 @@
<!-- Birthdate --> <!-- Birthdate -->
<div class="mb-3"> <div class="mb-3">
<input id="birthdate" type="text" <label for="birthdate" class="form-label">Birthdate</label>
class="flatpickr-input @error('birthdate') is-invalid @enderror" <div class="row g-2">
name="birthdate" <div class="col-4">
value="{{ old('birthdate') }}" <select id="birth_day" class="form-select @error('birthdate') is-invalid @enderror" required>
placeholder="Birthdate" <option value="">Day</option>
required @for($day = 1; $day <= 31; $day++)
readonly> <option value="{{ str_pad($day, 2, '0', STR_PAD_LEFT) }}" {{ old('birth_day') == str_pad($day, 2, '0', STR_PAD_LEFT) ? 'selected' : '' }}>
{{ $day }}
</option>
@endfor
</select>
</div>
<div class="col-4">
<select id="birth_month" class="form-select @error('birthdate') is-invalid @enderror" required>
<option value="">Month</option>
<option value="01" {{ old('birth_month') == '01' ? 'selected' : '' }}>January</option>
<option value="02" {{ old('birth_month') == '02' ? 'selected' : '' }}>February</option>
<option value="03" {{ old('birth_month') == '03' ? 'selected' : '' }}>March</option>
<option value="04" {{ old('birth_month') == '04' ? 'selected' : '' }}>April</option>
<option value="05" {{ old('birth_month') == '05' ? 'selected' : '' }}>May</option>
<option value="06" {{ old('birth_month') == '06' ? 'selected' : '' }}>June</option>
<option value="07" {{ old('birth_month') == '07' ? 'selected' : '' }}>July</option>
<option value="08" {{ old('birth_month') == '08' ? 'selected' : '' }}>August</option>
<option value="09" {{ old('birth_month') == '09' ? 'selected' : '' }}>September</option>
<option value="10" {{ old('birth_month') == '10' ? 'selected' : '' }}>October</option>
<option value="11" {{ old('birth_month') == '11' ? 'selected' : '' }}>November</option>
<option value="12" {{ old('birth_month') == '12' ? 'selected' : '' }}>December</option>
</select>
</div>
<div class="col-4">
<select id="birth_year" class="form-select @error('birthdate') is-invalid @enderror" required>
<option value="">Year</option>
@php
$currentYear = date('Y');
$startYear = $currentYear - 10; // Start from 10 years ago
$endYear = 1900;
@endphp
@for($year = $startYear; $year >= $endYear; $year--)
<option value="{{ $year }}" {{ old('birth_year') == $year ? 'selected' : '' }}>
{{ $year }}
</option>
@endfor
</select>
</div>
</div>
<input type="hidden" id="birthdate" name="birthdate" value="{{ old('birthdate') }}">
@error('birthdate') @error('birthdate')
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback d-block" role="alert">
<strong>{{ $message }}</strong> <strong>{{ $message }}</strong>
</span> </span>
@enderror @enderror
@ -417,7 +448,7 @@
<!-- Nationality --> <!-- Nationality -->
<div class="mb-3"> <div class="mb-3">
<x-country-dropdown <x-nationality-dropdown
name="nationality" name="nationality"
id="nationality" id="nationality"
:value="old('nationality')" :value="old('nationality')"
@ -434,38 +465,40 @@
<!-- /.login-box --> <!-- /.login-box -->
</div> </div>
<!-- jQuery (required for Select2) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Select2 JS -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize Flatpickr for birthdate // Combine birth date dropdowns into hidden field
flatpickr('#birthdate', { const birthDay = document.getElementById('birth_day');
dateFormat: 'Y-m-d', const birthMonth = document.getElementById('birth_month');
maxDate: 'today', const birthYear = document.getElementById('birth_year');
yearRange: [1900, new Date().getFullYear()], const birthdateHidden = document.getElementById('birthdate');
disableMobile: true,
showMonths: 1,
clickOpens: true,
onReady: function(selectedDates, dateStr, instance) {
const calendar = instance.calendarContainer;
calendar.style.fontSize = '14px';
}
});
// Form submission handler function updateBirthdate() {
$('#registrationForm').on('submit', function(e) { const day = birthDay.value;
console.log('Form submitting...'); const month = birthMonth.value;
console.log('Country code:', $('#country_code').val()); const year = birthYear.value;
console.log('Nationality:', $('#nationality').val());
console.log('Mobile number:', $('#mobile_number').val()); if (day && month && year) {
}); birthdateHidden.value = `${year}-${month}-${day}`;
} else {
birthdateHidden.value = '';
}
}
// Update hidden field when any dropdown changes
birthDay.addEventListener('change', updateBirthdate);
birthMonth.addEventListener('change', updateBirthdate);
birthYear.addEventListener('change', updateBirthdate);
// Initialize from old value if exists
if (birthdateHidden.value) {
const parts = birthdateHidden.value.split('-');
if (parts.length === 3) {
birthYear.value = parts[0];
birthMonth.value = parts[1];
birthDay.value = parts[2];
}
}
// Error handler // Error handler
window.onerror = function(message, source, lineno, colno, error) { window.onerror = function(message, source, lineno, colno, error) {
@ -475,8 +508,16 @@
console.error('Error:', error); console.error('Error:', error);
}; };
}); });
// Form submission handler
document.getElementById('registrationForm').addEventListener('submit', function(e) {
console.log('Form submitting...');
console.log('Country code:', document.getElementById('country_code').value);
console.log('Nationality:', document.getElementById('nationality').value);
console.log('Mobile number:', document.getElementById('mobile_number').value);
console.log('Birthdate:', document.getElementById('birthdate').value);
});
</script> </script>
@stack('styles') @stack('styles')
@stack('scripts')
@endsection @endsection

View File

@ -162,8 +162,8 @@
} }
} }
// Initialize all country code dropdowns on the page // Initialize only country code dropdowns (not nationality dropdowns)
document.querySelectorAll('[id$="List"]').forEach(function(listElement) { document.querySelectorAll('[id$="country_codeList"]').forEach(function(listElement) {
const componentId = listElement.id.replace('List', ''); const componentId = listElement.id.replace('List', '');
initializeCountryDropdown(componentId, countries); initializeCountryDropdown(componentId, countries);
}); });

View File

@ -1,114 +1,166 @@
@props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null]) @props(['name' => 'nationality', 'id' => 'nationality', 'value' => '', 'required' => false, 'error' => null, 'label' => 'Nationality'])
<select id="{{ $id }}" <label for="{{ $id }}" class="form-label">{{ $label }}</label>
class="form-select nationality-select @error($name) is-invalid @enderror" <div class="dropdown w-100" onclick="event.stopPropagation()">
name="{{ $name }}" <button class="form-select dropdown-toggle d-flex align-items-center justify-content-between @error($name) is-invalid @enderror"
{{ $required ? 'required' : '' }}> type="button"
<option value="">Select Nationality</option> id="{{ $id }}Dropdown"
</select> data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
style="text-align: left; background-color: rgba(255,255,255,0.8);">
<span class="d-flex align-items-center">
<span id="{{ $id }}SelectedFlag"></span>
<span class="country-label" id="{{ $id }}SelectedCountry">Select Nationality</span>
</span>
</button>
<div class="dropdown-menu p-2 w-100" aria-labelledby="{{ $id }}Dropdown" onclick="event.stopPropagation()">
<input type="text"
class="form-control form-control-sm mb-2"
placeholder="Search country..."
id="{{ $id }}Search"
onmousedown="event.stopPropagation()"
onfocus="event.stopPropagation()"
oninput="event.stopPropagation()"
onkeydown="event.stopPropagation()"
onkeyup="event.stopPropagation()">
<div class="country-list" id="{{ $id }}List" style="max-height: 300px; overflow-y: auto;">
<!-- Countries will be populated by JavaScript -->
</div>
</div>
<input type="hidden" id="{{ $id }}" name="{{ $name }}" value="{{ $value }}" {{ $required ? 'required' : '' }}>
</div>
@if($error) @if($error)
<span class="invalid-feedback" role="alert"> <span class="invalid-feedback d-block" role="alert">
<strong>{{ $error }}</strong> <strong>{{ $error }}</strong>
</span> </span>
@endif @endif
@once @once
@push('styles')
<style>
.country-dropdown-btn {
min-width: 150px;
display: flex;
align-items: center;
justify-content: space-between;
}
.country-list {
max-height: 300px;
overflow-y: auto;
}
.dropdown-item {
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
</style>
@endpush
@push('scripts') @push('scripts')
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Country data for nationality // Load countries from JSON file
const countries = [ fetch('/data/countries.json')
{ name: 'United States', flagCode: 'us' }, .then(response => response.json())
{ name: 'Canada', flagCode: 'ca' }, .then(countries => {
{ name: 'United Kingdom', flagCode: 'gb' }, // Initialize only nationality dropdowns (not country code dropdowns)
{ name: 'United Arab Emirates', flagCode: 'ae' }, document.querySelectorAll('[id$="nationalityList"]').forEach(function(listElement) {
{ name: 'Saudi Arabia', flagCode: 'sa' }, const componentId = listElement.id.replace('List', '');
{ name: 'Qatar', flagCode: 'qa' }, initializeNationalityDropdown(componentId, countries);
{ name: 'Kuwait', flagCode: 'kw' },
{ name: 'Bahrain', flagCode: 'bh' },
{ name: 'Oman', flagCode: 'om' },
{ name: 'Egypt', flagCode: 'eg' },
{ name: 'India', flagCode: 'in' },
{ name: 'Pakistan', flagCode: 'pk' },
{ name: 'Bangladesh', flagCode: 'bd' },
{ name: 'Malaysia', flagCode: 'my' },
{ name: 'Singapore', flagCode: 'sg' },
{ name: 'Japan', flagCode: 'jp' },
{ name: 'China', flagCode: 'cn' },
{ name: 'South Korea', flagCode: 'kr' },
{ name: 'Australia', flagCode: 'au' },
{ name: 'Germany', flagCode: 'de' },
{ name: 'France', flagCode: 'fr' },
{ name: 'Italy', flagCode: 'it' },
{ name: 'Spain', flagCode: 'es' },
{ name: 'Netherlands', flagCode: 'nl' },
{ name: 'Sweden', flagCode: 'se' },
{ name: 'Norway', flagCode: 'no' },
{ name: 'Denmark', flagCode: 'dk' },
{ name: 'Finland', flagCode: 'fi' },
{ name: 'Switzerland', flagCode: 'ch' },
{ name: 'Austria', flagCode: 'at' },
{ name: 'Poland', flagCode: 'pl' },
{ name: 'Czech Republic', flagCode: 'cz' },
{ name: 'Hungary', flagCode: 'hu' },
{ name: 'Romania', flagCode: 'ro' },
{ name: 'Greece', flagCode: 'gr' },
{ name: 'Turkey', flagCode: 'tr' },
{ name: 'Russia', flagCode: 'ru' },
{ name: 'Brazil', flagCode: 'br' },
{ name: 'Mexico', flagCode: 'mx' },
{ name: 'Argentina', flagCode: 'ar' },
{ name: 'Chile', flagCode: 'cl' },
{ name: 'Colombia', flagCode: 'co' },
{ name: 'South Africa', flagCode: 'za' },
{ name: 'Nigeria', flagCode: 'ng' },
{ name: 'Kenya', flagCode: 'ke' },
{ name: 'Sri Lanka', flagCode: 'lk' },
{ name: 'Vietnam', flagCode: 'vn' },
{ name: 'Thailand', flagCode: 'th' },
{ name: 'Indonesia', flagCode: 'id' },
{ name: 'Philippines', flagCode: 'ph' },
{ name: 'New Zealand', flagCode: 'nz' },
{ name: 'Portugal', flagCode: 'pt' },
{ name: 'Ireland', flagCode: 'ie' },
{ name: 'Israel', flagCode: 'il' },
{ name: 'Jordan', flagCode: 'jo' },
{ name: 'Lebanon', flagCode: 'lb' },
{ name: 'Iraq', flagCode: 'iq' },
];
// Initialize all nationality dropdowns on the page
document.querySelectorAll('.nationality-select').forEach(function(selectElement) {
if (typeof $ !== 'undefined' && $.fn.select2) {
const $select = $(selectElement);
$select.select2({
data: countries.map(country => ({
id: country.name,
text: country.name,
flagCode: country.flagCode
})),
templateResult: function(data) {
if (!data.id) return data.text;
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
},
templateSelection: function(data) {
if (!data.id) return data.text;
return $(`<span><span class="fi fi-${data.flagCode} me-2"></span> ${data.text}</span>`);
},
placeholder: 'Select Nationality',
allowClear: true,
width: '100%'
}); });
})
.catch(error => console.error('Error loading countries:', error));
// Restore value if provided function initializeNationalityDropdown(componentId, countries) {
const initialValue = selectElement.getAttribute('data-value') || '{{ $value }}'; const countryList = document.getElementById(componentId + 'List');
if (initialValue) { if (!countryList) return;
$select.val(initialValue).trigger('change');
// Clear existing items
countryList.innerHTML = '';
// Populate country dropdown
countries.forEach(country => {
const button = document.createElement('button');
button.className = 'dropdown-item d-flex align-items-center';
button.type = 'button';
button.setAttribute('data-country-name', country.name);
button.setAttribute('data-flag', country.flag);
button.setAttribute('data-search', country.name.toLowerCase());
// Convert flag code to emoji
const flagEmoji = country.iso2
.toUpperCase()
.split('')
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
button.innerHTML = `
<span class="me-2">${flagEmoji}</span>
<span>${country.name}</span>
`;
button.addEventListener('click', function() {
selectNationality(componentId, country.name, flagEmoji);
});
countryList.appendChild(button);
});
// Search functionality
const searchInput = document.getElementById(componentId + 'Search');
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const items = countryList.querySelectorAll('.dropdown-item');
items.forEach(item => {
const searchText = item.getAttribute('data-search') || '';
if (searchText.includes(searchTerm)) {
item.classList.remove('d-none');
} else {
item.classList.add('d-none');
}
});
});
}
// Set initial value if provided
const hiddenInput = document.getElementById(componentId);
if (hiddenInput && hiddenInput.value) {
const initialCountry = countries.find(c => c.name === hiddenInput.value);
if (initialCountry) {
const flagEmoji = initialCountry.iso2
.toUpperCase()
.split('')
.map(char => String.fromCodePoint(127397 + char.charCodeAt(0)))
.join('');
selectNationality(componentId, initialCountry.name, flagEmoji);
} }
} }
}); }
function selectNationality(componentId, name, flag) {
const flagElement = document.getElementById(componentId + 'SelectedFlag');
const countryElement = document.getElementById(componentId + 'SelectedCountry');
const hiddenInput = document.getElementById(componentId);
if (flagElement) flagElement.textContent = flag + ' ';
if (countryElement) countryElement.textContent = name;
if (hiddenInput) hiddenInput.value = name;
// Close the dropdown after selection
const dropdownButton = document.getElementById(componentId + 'Dropdown');
if (dropdownButton) {
const dropdown = bootstrap.Dropdown.getInstance(dropdownButton);
if (dropdown) dropdown.hide();
}
}
}); });
</script> </script>
@endpush @endpush

View File

@ -12,14 +12,40 @@
<!-- Profile Picture Section --> <!-- Profile Picture Section -->
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<div class="mb-3"> <div class="mb-3">
<img src="{{ $user->profile_picture ? asset('storage/' . $user->profile_picture) : asset('images/default-avatar.png') }}" @if($user->profile_picture && file_exists(public_path('storage/' . $user->profile_picture)))
alt="Profile Picture" <img src="{{ asset('storage/' . $user->profile_picture) }}"
class="rounded-circle" alt="Profile Picture"
style="width: 120px; height: 120px; object-fit: cover; border: 3px solid #dee2e6;"> style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.png')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.png') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpg')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpg') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@elseif(file_exists(public_path('storage/images/profiles/profile_' . $user->id . '.jpeg')))
<img src="{{ asset('storage/images/profiles/profile_' . $user->id . '.jpeg') }}"
alt="Profile Picture"
style="width: 300px; height: 400px; object-fit: cover; border: 3px solid #dee2e6; border-radius: 8px;">
@else
<div style="width: 300px; height: 400px; background-color: #f0f0f0; border: 3px solid #dee2e6; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin: 0 auto;">
<div class="text-center">
<i class="bi bi-person-circle" style="font-size: 100px; color: #dee2e6;"></i>
<p class="text-muted mt-2">No profile picture</p>
</div>
</div>
@endif
</div> </div>
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#profilePictureModal"> <x-takeone-cropper
<i class="fas fa-camera"></i> Change Profile Picture id="profile_picture"
</button> width="300"
height="400"
shape="square"
folder="images/profiles"
filename="profile_{{ $user->id }}"
uploadUrl="{{ route('profile.upload-picture') }}"
/>
</div> </div>
<form method="POST" action="{{ route('profile.update') }}"> <form method="POST" action="{{ route('profile.update') }}">
@ -196,14 +222,6 @@
</div> </div>
</div> </div>
<!-- Profile Picture Upload Modal -->
<x-image-upload-modal
id="profilePictureModal"
aspectRatio="1"
maxSizeMB="1"
title="Upload Profile Picture"
uploadUrl="{{ route('profile.upload-picture') }}"
/>
</div> </div>
@push('scripts') @push('scripts')

View File

@ -42,8 +42,8 @@
<div class="d-flex"> <div class="d-flex">
<!-- Profile Picture --> <!-- Profile Picture -->
<div style="width: 180px; min-height: 250px; border-radius: 0.375rem 0 0 0.375rem;"> <div style="width: 180px; min-height: 250px; border-radius: 0.375rem 0 0 0.375rem;">
@if($relationship->dependent->media_gallery[0] ?? false) @if($relationship->dependent->profile_picture)
<img src="{{ $relationship->dependent->media_gallery[0] }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;"> <img src="{{ asset('storage/' . $relationship->dependent->profile_picture) }}" alt="{{ $relationship->dependent->full_name }}" class="w-100 h-100" style="object-fit: cover; border-radius: 0.375rem 0 0 0.375rem;">
@else @else
<div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold" style="font-size: 3rem; background: linear-gradient(135deg, {{ $relationship->dependent->gender == 'm' ? '#0d6efd 0%, #0a58ca 100%' : '#d63384 0%, #a61e4d 100%' }}); border-radius: 0.375rem 0 0 0.375rem;"> <div class="w-100 h-100 d-flex align-items-center justify-content-center text-white fw-bold" style="font-size: 3rem; background: linear-gradient(135deg, {{ $relationship->dependent->gender == 'm' ? '#0d6efd 0%, #0a58ca 100%' : '#d63384 0%, #a61e4d 100%' }}); border-radius: 0.375rem 0 0 0.375rem;">
{{ strtoupper(substr($relationship->dependent->full_name, 0, 1)) }} {{ strtoupper(substr($relationship->dependent->full_name, 0, 1)) }}
@ -66,7 +66,7 @@
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-check me-2"></i>Add Attendance Record</a></li> <li><a class="dropdown-item" href="#"><i class="bi bi-calendar-check me-2"></i>Add Attendance Record</a></li>
<li><a class="dropdown-item" href="#"><i class="bi bi-calendar-event me-2"></i>Add Event Participation</a></li> <li><a class="dropdown-item" href="#"><i class="bi bi-calendar-event me-2"></i>Add Event Participation</a></li>
<li><a class="dropdown-item" href="#" data-bs-target="#healthUpdateModal"><i class="bi bi-heart-pulse me-2"></i>Add Health Update</a></li> <li><a class="dropdown-item" href="#" data-bs-target="#healthUpdateModal"><i class="bi bi-heart-pulse me-2"></i>Add Health Update</a></li>
<li><a class="dropdown-item" href="#"><i class="bi bi-award me-2"></i>Add Tournament Participation</a></li> <li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#tournamentParticipationModal"><i class="bi bi-award me-2"></i>Add Tournament Participation</a></li>
<li><a class="dropdown-item" href="@if($relationship->relationship_type == 'self'){{ route('profile.edit') }}@else{{ route('family.edit', $relationship->dependent->id) }}@endif"> <li><a class="dropdown-item" href="@if($relationship->relationship_type == 'self'){{ route('profile.edit') }}@else{{ route('family.edit', $relationship->dependent->id) }}@endif">
<i class="bi bi-pencil me-2"></i>Edit Info <i class="bi bi-pencil me-2"></i>Edit Info
</a></li> </a></li>
@ -86,12 +86,12 @@
<!-- Achievement Badges --> <!-- Achievement Badges -->
<div class="d-flex gap-2 mb-3 flex-wrap"> <div class="d-flex gap-2 mb-3 flex-wrap">
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🏆 <span class="fw-semibold text-dark">3</span></a> <a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="special" onclick="filterTournamentsByMedal('special')">🏆 <span class="fw-semibold text-dark">{{ $awardCounts['special'] }}</span></a>
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥇 <span class="fw-semibold text-dark">4</span></a> <a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="1st" onclick="filterTournamentsByMedal('1st')">🥇 <span class="fw-semibold text-dark">{{ $awardCounts['1st'] }}</span></a>
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥈 <span class="fw-semibold text-dark">6</span></a> <a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="2nd" onclick="filterTournamentsByMedal('2nd')">🥈 <span class="fw-semibold text-dark">{{ $awardCounts['2nd'] }}</span></a>
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;">🥉 <span class="fw-semibold text-dark">3</span></a> <a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none achievement-badge" style="font-size: 1rem;" data-medal-type="3rd" onclick="filterTournamentsByMedal('3rd')">🥉 <span class="fw-semibold text-dark">{{ $awardCounts['3rd'] }}</span></a>
<a href="#goals" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;" onclick="document.getElementById('goals-tab').click();">🎯 <span class="fw-semibold text-dark">8</span></a> <a href="#goals" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;" onclick="document.getElementById('goals-tab').click();">🎯 <span class="fw-semibold text-dark">{{ $activeGoalsCount + $completedGoalsCount }}</span></a>
<a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;"> <span class="fw-semibold text-dark">12</span></a> <a href="#" class="border bg-white rounded px-2 py-1 text-decoration-none" style="font-size: 1rem;"> <span class="fw-semibold text-dark">{{ $totalAffiliations }}</span></a>
</div> </div>
<!-- Status Badges --> <!-- Status Badges -->
@ -990,8 +990,120 @@
<div class="tab-pane fade" id="affiliations" role="tabpanel"> <div class="tab-pane fade" id="affiliations" role="tabpanel">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
<div class="card-body p-4"> <div class="card-body p-4">
<h5 class="fw-bold mb-3"><i class="bi bi-diagram-3 me-2"></i>Affiliations & Badges</h5> <h5 class="fw-bold mb-3"><i class="bi bi-diagram-3 me-2"></i>Affiliations & Skills</h5>
<p class="text-muted">Affiliation system coming soon...</p>
@if($clubAffiliations->count() > 0)
<!-- Summary Stats -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm bg-primary text-white">
<div class="card-body text-center">
<i class="bi bi-building display-4 mb-2"></i>
<h4 class="fw-bold mb-1">{{ $totalAffiliations }}</h4>
<small>Total Affiliations</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm bg-success text-white">
<div class="card-body text-center">
<i class="bi bi-star display-4 mb-2"></i>
<h4 class="fw-bold mb-1">{{ $distinctSkills }}</h4>
<small>Distinct Skills</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm bg-info text-white">
<div class="card-body text-center">
<i class="bi bi-calendar-check display-4 mb-2"></i>
<h4 class="fw-bold mb-1">{{ floor($totalMembershipDuration / 12) }}y {{ $totalMembershipDuration % 12 }}m</h4>
<small>Total Membership</small>
</div>
</div>
</div>
</div>
<!-- Timeline and Skills Wheel Container -->
<div class="row">
<!-- Timeline -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0"><i class="bi bi-timeline me-2"></i>Club Affiliations Timeline</h6>
</div>
<div class="card-body" style="max-height: 600px; overflow-y: auto;">
<div class="timeline">
@foreach($clubAffiliations as $index => $affiliation)
<div class="timeline-item mb-4 position-relative" data-affiliation-id="{{ $affiliation->id }}">
<div class="timeline-marker bg-primary"></div>
<div class="timeline-content card border {{ $index === 0 ? 'border-primary' : '' }} affiliation-card" style="cursor: pointer;" data-affiliation-id="{{ $affiliation->id }}">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
@if($affiliation->logo)
<img src="{{ asset('storage/' . $affiliation->logo) }}" alt="{{ $affiliation->club_name }}" class="me-3 rounded" style="width: 40px; height: 40px; object-fit: cover;">
@else
<div class="bg-primary text-white rounded d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-building"></i>
</div>
@endif
<div class="flex-grow-1">
<h6 class="mb-0 fw-bold">{{ $affiliation->club_name }}</h6>
<small class="text-muted">{{ $affiliation->date_range }}</small>
<br>
<span class="badge bg-info text-dark small">{{ $affiliation->formatted_duration }}</span>
</div>
</div>
@if($affiliation->location)
<div class="text-muted small mb-1">
<i class="bi bi-geo-alt me-1"></i>{{ $affiliation->location }}
</div>
@endif
<div class="text-muted small">
<i class="bi bi-star me-1"></i>{{ $affiliation->skillAcquisitions->count() }} skills acquired
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
<!-- Skills Wheel and Details -->
<div class="col-lg-6">
<!-- Skills Wheel -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0"><i class="bi bi-pie-chart me-2"></i>Skills Wheel</h6>
</div>
<div class="card-body">
<div class="text-center">
<canvas id="skillsChart" width="300" height="300"></canvas>
<p id="noSkillsMessage" class="text-muted mt-3 d-none">Select an affiliation to view skills</p>
</div>
</div>
</div>
<!-- Affiliation Details -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="card-title mb-0"><i class="bi bi-info-circle me-2"></i>Affiliation Details</h6>
</div>
<div class="card-body" id="affiliationDetails">
<p class="text-muted">Select an affiliation from the timeline to view details</p>
</div>
</div>
</div>
</div>
@else
<div class="text-center py-5">
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3 mb-2">No Affiliations Yet</h5>
<p class="text-muted mb-0">Club affiliations and skills will appear here once added</p>
</div>
@endif
</div> </div>
</div> </div>
</div> </div>
@ -1069,6 +1181,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th class="text-muted small fw-semibold">Tournament Details</th> <th class="text-muted small fw-semibold">Tournament Details</th>
<th class="text-muted small fw-semibold">Club Affiliation</th>
<th class="text-muted small fw-semibold">Performance & Result</th> <th class="text-muted small fw-semibold">Performance & Result</th>
<th class="text-muted small fw-semibold">Notes & Media</th> <th class="text-muted small fw-semibold">Notes & Media</th>
</tr> </tr>
@ -1095,6 +1208,16 @@
@endif @endif
</div> </div>
</td> </td>
<td>
@if($event->clubAffiliation)
<div>
<div class="small fw-semibold">{{ $event->clubAffiliation->club_name }}</div>
<div class="text-muted small">{{ $event->clubAffiliation->location }}</div>
</div>
@else
<span class="text-muted small">Individual</span>
@endif
</td>
<td> <td>
@if($event->performanceResults->count() > 0) @if($event->performanceResults->count() > 0)
@foreach($event->performanceResults as $result) @foreach($event->performanceResults as $result)
@ -1297,10 +1420,215 @@
</div> </div>
</div> </div>
<!-- Tournament Participation Modal -->
<div class="modal fade" id="tournamentParticipationModal" tabindex="-1" aria-labelledby="tournamentParticipationModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tournamentParticipationModalLabel">Add Tournament Participation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="tournamentParticipationForm" method="POST" action="{{ route('family.store-tournament', $relationship->dependent->id) }}">
@csrf
<div class="modal-body">
<div class="row g-3">
<!-- Tournament Details -->
<div class="col-md-6">
<label for="tournament_title" class="form-label">Tournament Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="tournament_title" name="title" required>
</div>
<div class="col-md-6">
<label for="tournament_type" class="form-label">Type <span class="text-danger">*</span></label>
<select class="form-select" id="tournament_type" name="type" required>
<option value="">Select Type</option>
<option value="championship">Championship</option>
<option value="tournament">Tournament</option>
<option value="competition">Competition</option>
<option value="exhibition">Exhibition</option>
</select>
</div>
<div class="col-md-6">
<label for="tournament_sport" class="form-label">Sport <span class="text-danger">*</span></label>
<select class="form-select" id="tournament_sport" name="sport" required>
<option value="">Select Sport</option>
<option value="Boxing">Boxing</option>
<option value="Taekwondo">Taekwondo</option>
<option value="Karate">Karate</option>
<option value="Martial Arts">Martial Arts</option>
<option value="Fitness">Fitness</option>
<option value="Weightlifting">Weightlifting</option>
<option value="Other">Other</option>
</select>
</div>
<div class="col-md-6">
<label for="tournament_date" class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="tournament_date" name="date" required>
</div>
<div class="col-md-6">
<label for="tournament_time" class="form-label">Time</label>
<input type="time" class="form-control" id="tournament_time" name="time">
</div>
<div class="col-md-6">
<label for="tournament_location" class="form-label">Location</label>
<input type="text" class="form-control" id="tournament_location" name="location" placeholder="Venue name or address">
</div>
<div class="col-md-6">
<label for="participants_count" class="form-label">Number of Participants</label>
<input type="number" class="form-control" id="participants_count" name="participants_count" min="1">
</div>
<div class="col-md-6">
<label for="club_affiliation_id" class="form-label">Club Affiliation</label>
<select class="form-select" id="club_affiliation_id" name="club_affiliation_id">
<option value="">Select Club (Optional)</option>
@foreach($clubAffiliations ?? [] as $affiliation)
<option value="{{ $affiliation->id }}">{{ $affiliation->club_name }}</option>
@endforeach
</select>
</div>
<!-- Performance Results Section -->
<div class="col-12">
<hr>
<h6 class="mb-3">Performance Results</h6>
<div id="performanceResultsContainer">
<div class="performance-result-item mb-3 p-3 border rounded">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Medal Type</label>
<select class="form-select medal-type" name="performance_results[0][medal_type]">
<option value="">Select Medal</option>
<option value="special">Special Award</option>
<option value="1st">1st Place</option>
<option value="2nd">2nd Place</option>
<option value="3rd">3rd Place</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Points</label>
<input type="number" class="form-control" name="performance_results[0][points]" min="0" step="0.1">
</div>
<div class="col-md-4">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="performance_results[0][description]" placeholder="Optional description">
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-result" style="display: none;">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="addPerformanceResult">
<i class="bi bi-plus me-1"></i>Add Another Result
</button>
</div>
<!-- Notes & Media Section -->
<div class="col-12">
<hr>
<h6 class="mb-3">Notes & Media</h6>
<div id="notesMediaContainer">
<div class="notes-media-item mb-3 p-3 border rounded">
<div class="row g-2">
<div class="col-md-6">
<label class="form-label">Note Text</label>
<textarea class="form-control" name="notes_media[0][note_text]" rows="2" placeholder="Optional notes about the tournament"></textarea>
</div>
<div class="col-md-5">
<label class="form-label">Media Link</label>
<input type="url" class="form-control" name="notes_media[0][media_link]" placeholder="https://example.com/photo.jpg">
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-note" style="display: none;">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="addNotesMedia">
<i class="bi bi-plus me-1"></i>Add Another Note/Media
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Tournament Record</button>
</div>
</form>
</div>
</div>
</div>
<style> <style>
.history-row:hover .edit-record-btn { .history-row:hover .edit-record-btn {
opacity: 1 !important; opacity: 1 !important;
} }
/* Timeline Styles */
.timeline {
position: relative;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 15px;
top: 0;
bottom: 0;
width: 2px;
background: #e9ecef;
}
.timeline-item {
position: relative;
margin-bottom: 20px;
}
.timeline-marker {
position: absolute;
left: -22px;
top: 20px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #6c757d;
border: 2px solid #fff;
z-index: 1;
}
.timeline-marker.bg-primary {
background: #0d6efd;
}
.timeline-content {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.timeline-content:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.affiliation-card {
transition: all 0.3s ease;
}
.affiliation-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
transform: translateY(-2px);
}
.affiliation-card.border-primary {
border-color: #0d6efd !important;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25) !important;
}
</style> </style>
<script> <script>
@ -1666,51 +1994,103 @@
const tournamentsTable = document.getElementById('tournamentsTable'); const tournamentsTable = document.getElementById('tournamentsTable');
const awardCards = document.getElementById('awardCards'); const awardCards = document.getElementById('awardCards');
if (sportFilter && tournamentsTable) { // Global variables for current filters
sportFilter.addEventListener('change', function() { let currentSportFilter = 'all';
const selectedSport = this.value; let currentMedalFilter = 'all';
const rows = tournamentsTable.querySelectorAll('tbody tr');
let visibleRows = 0; function applyTournamentFilters() {
let specialCount = 0, firstCount = 0, secondCount = 0, thirdCount = 0; const rows = tournamentsTable.querySelectorAll('tbody tr');
rows.forEach(row => { let visibleRows = 0;
const sport = row.getAttribute('data-sport'); let specialCount = 0, firstCount = 0, secondCount = 0, thirdCount = 0;
if (selectedSport === 'all' || sport === selectedSport) {
row.style.display = '';
visibleRows++;
// Count awards in visible rows rows.forEach(row => {
const performanceCell = row.querySelector('td:nth-child(2)'); const sport = row.getAttribute('data-sport');
if (performanceCell) { const performanceCell = row.querySelector('td:nth-child(3)');
const badges = performanceCell.querySelectorAll('.badge'); let hasMatchingMedal = false;
badges.forEach(badge => {
if (badge.textContent.includes('Special Award')) specialCount++; if (performanceCell) {
else if (badge.textContent.includes('1st Place')) firstCount++; const badges = performanceCell.querySelectorAll('.badge');
else if (badge.textContent.includes('2nd Place')) secondCount++; badges.forEach(badge => {
else if (badge.textContent.includes('3rd Place')) thirdCount++; if (currentMedalFilter === 'all') {
}); hasMatchingMedal = true;
} else if (currentMedalFilter === 'special' && badge.textContent.includes('Special Award')) {
hasMatchingMedal = true;
} else if (currentMedalFilter === '1st' && badge.textContent.includes('1st Place')) {
hasMatchingMedal = true;
} else if (currentMedalFilter === '2nd' && badge.textContent.includes('2nd Place')) {
hasMatchingMedal = true;
} else if (currentMedalFilter === '3rd' && badge.textContent.includes('3rd Place')) {
hasMatchingMedal = true;
} }
} else { });
row.style.display = 'none'; }
const sportMatch = currentSportFilter === 'all' || sport === currentSportFilter;
const medalMatch = currentMedalFilter === 'all' || hasMatchingMedal;
if (sportMatch && medalMatch) {
row.style.display = '';
visibleRows++;
// Count awards in visible rows
if (performanceCell) {
const badges = performanceCell.querySelectorAll('.badge');
badges.forEach(badge => {
if (badge.textContent.includes('Special Award')) specialCount++;
else if (badge.textContent.includes('1st Place')) firstCount++;
else if (badge.textContent.includes('2nd Place')) secondCount++;
else if (badge.textContent.includes('3rd Place')) thirdCount++;
});
} }
});
// Update award counts
document.getElementById('specialCount').textContent = specialCount;
document.getElementById('firstCount').textContent = firstCount;
document.getElementById('secondCount').textContent = secondCount;
document.getElementById('thirdCount').textContent = thirdCount;
// Show/hide award cards based on visible rows
if (visibleRows === 0) {
awardCards.style.display = 'none';
} else { } else {
awardCards.style.display = ''; row.style.display = 'none';
} }
}); });
// Update award counts
document.getElementById('specialCount').textContent = specialCount;
document.getElementById('firstCount').textContent = firstCount;
document.getElementById('secondCount').textContent = secondCount;
document.getElementById('thirdCount').textContent = thirdCount;
// Show/hide award cards based on visible rows
if (visibleRows === 0) {
awardCards.style.display = 'none';
} else {
awardCards.style.display = '';
}
} }
if (sportFilter && tournamentsTable) {
sportFilter.addEventListener('change', function() {
currentSportFilter = this.value;
applyTournamentFilters();
});
}
// Function to filter tournaments by medal type (called from achievement badges)
window.filterTournamentsByMedal = function(medalType) {
// Switch to tournaments tab
const tournamentsTab = document.getElementById('tournaments-tab');
if (tournamentsTab) {
const tab = new bootstrap.Tab(tournamentsTab);
tab.show();
}
// Set medal filter
currentMedalFilter = medalType;
currentSportFilter = 'all'; // Reset sport filter
// Reset sport filter dropdown
if (sportFilter) {
sportFilter.value = 'all';
}
// Apply filters
applyTournamentFilters();
};
// Goals filtering functionality // Goals filtering functionality
const goalFilterCards = document.querySelectorAll('.goal-filter-card'); const goalFilterCards = document.querySelectorAll('.goal-filter-card');
const goalsContainer = document.querySelector('.row.g-4'); // Container with goal cards const goalsContainer = document.querySelector('.row.g-4'); // Container with goal cards
@ -1870,4 +2250,389 @@
}); });
}); });
</script> </script>
<!-- Chart.js for Skills Wheel -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Affiliations Tab JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Affiliations data
const affiliationsData = @json($clubAffiliations);
let skillsChart = null;
let selectedAffiliationId = null;
// Initialize affiliations functionality
function initAffiliations() {
// Set up timeline click handlers
document.querySelectorAll('.affiliation-card').forEach(card => {
card.addEventListener('click', function() {
const affiliationId = this.getAttribute('data-affiliation-id');
selectAffiliation(affiliationId);
// Update visual selection
document.querySelectorAll('.affiliation-card').forEach(c => {
c.classList.remove('border-primary');
});
this.classList.add('border-primary');
});
});
// Select first affiliation by default if available
if (affiliationsData.length > 0) {
selectAffiliation(affiliationsData[0].id);
}
}
function selectAffiliation(affiliationId) {
selectedAffiliationId = affiliationId;
const affiliation = affiliationsData.find(a => a.id == affiliationId);
if (!affiliation) return;
// Update skills chart
updateSkillsChart(affiliation.skill_acquisitions || []);
// Update affiliation details
updateAffiliationDetails(affiliation);
}
function updateSkillsChart(skills) {
const ctx = document.getElementById('skillsChart').getContext('2d');
const noSkillsMessage = document.getElementById('noSkillsMessage');
if (skills.length === 0) {
if (skillsChart) {
skillsChart.destroy();
skillsChart = null;
}
document.getElementById('skillsChart').style.display = 'none';
noSkillsMessage.classList.remove('d-none');
return;
}
document.getElementById('skillsChart').style.display = 'block';
noSkillsMessage.classList.add('d-none');
// Prepare data for polar area chart
const labels = skills.map(skill => skill.skill_name);
const data = skills.map(skill => skill.duration_months);
const backgroundColors = [
'rgba(54, 162, 235, 0.8)',
'rgba(255, 99, 132, 0.8)',
'rgba(255, 205, 86, 0.8)',
'rgba(75, 192, 192, 0.8)',
'rgba(153, 102, 255, 0.8)',
'rgba(255, 159, 64, 0.8)',
'rgba(199, 199, 199, 0.8)',
'rgba(83, 102, 255, 0.8)',
'rgba(255, 99, 255, 0.8)',
'rgba(99, 255, 132, 0.8)'
];
if (skillsChart) {
skillsChart.destroy();
}
skillsChart = new Chart(ctx, {
type: 'polarArea',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: backgroundColors.slice(0, skills.length),
borderWidth: 2,
borderColor: backgroundColors.slice(0, skills.length).map(color => color.replace('0.8', '1')),
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function(context) {
const skill = skills[context.dataIndex];
const duration = skill.formatted_duration || `${skill.duration_months} months`;
return `${context.label}: ${duration}`;
}
}
}
},
scales: {
r: {
beginAtZero: true,
ticks: {
display: false
}
}
},
animation: {
animateScale: true,
animateRotate: true
}
}
});
}
function updateAffiliationDetails(affiliation) {
const detailsContainer = document.getElementById('affiliationDetails');
let html = `
<div class="d-flex align-items-center mb-3">
${affiliation.logo ?
`<img src="${affiliation.logo}" alt="${affiliation.club_name}" class="me-3 rounded" style="width: 50px; height: 50px; object-fit: cover;">` :
`<div class="bg-primary text-white rounded d-flex align-items-center justify-content-center me-3" style="width: 50px; height: 50px;">
<i class="bi bi-building"></i>
</div>`
}
<div>
<h5 class="mb-1">${affiliation.club_name}</h5>
<p class="text-muted mb-0">${affiliation.date_range}</p>
<span class="badge bg-info text-dark small">${affiliation.formatted_duration}</span>
</div>
</div>
`;
if (affiliation.location) {
html += `<p class="mb-2"><i class="bi bi-geo-alt me-2"></i><strong>Location:</strong> ${affiliation.location}</p>`;
}
if (affiliation.description) {
html += `<p class="mb-2"><strong>Description:</strong> ${affiliation.description}</p>`;
}
if (affiliation.coaches && affiliation.coaches.length > 0) {
html += `<p class="mb-2"><strong>Coaches:</strong> ${affiliation.coaches.join(', ')}</p>`;
}
if (affiliation.affiliation_media && affiliation.affiliation_media.length > 0) {
html += `<div class="mt-3"><strong>Media & Certificates:</strong></div>`;
html += `<div class="row g-2 mt-1">`;
affiliation.affiliation_media.forEach(media => {
const iconClass = media.icon_class || 'bi-file';
html += `
<div class="col-6">
<a href="${media.full_url}" target="_blank" class="btn btn-outline-secondary btn-sm w-100">
<i class="bi ${iconClass} me-1"></i>${media.title || media.media_type}
</a>
</div>
`;
});
html += `</div>`;
}
detailsContainer.innerHTML = html;
}
// Initialize when affiliations tab is shown
const affiliationsTab = document.getElementById('affiliations-tab');
if (affiliationsTab) {
affiliationsTab.addEventListener('shown.bs.tab', function() {
initAffiliations();
});
}
// Initialize immediately if affiliations tab is active
if (document.getElementById('affiliations').classList.contains('show')) {
initAffiliations();
}
});
// Tournament Participation Modal Functionality
document.addEventListener('DOMContentLoaded', function() {
let performanceResultIndex = 1;
let notesMediaIndex = 1;
// Add Performance Result
document.getElementById('addPerformanceResult').addEventListener('click', function() {
const container = document.getElementById('performanceResultsContainer');
const newItem = document.createElement('div');
newItem.className = 'performance-result-item mb-3 p-3 border rounded';
newItem.innerHTML = `
<div class="row g-2">
<div class="col-md-4">
<label class="form-label">Medal Type</label>
<select class="form-select medal-type" name="performance_results[${performanceResultIndex}][medal_type]">
<option value="">Select Medal</option>
<option value="special">Special Award</option>
<option value="1st">1st Place</option>
<option value="2nd">2nd Place</option>
<option value="3rd">3rd Place</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Points</label>
<input type="number" class="form-control" name="performance_results[${performanceResultIndex}][points]" min="0" step="0.1">
</div>
<div class="col-md-4">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="performance_results[${performanceResultIndex}][description]" placeholder="Optional description">
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-result">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`;
container.appendChild(newItem);
performanceResultIndex++;
// Show remove buttons if more than one result
updateRemoveButtons('performance-result-item', 'remove-result');
});
// Add Notes & Media
document.getElementById('addNotesMedia').addEventListener('click', function() {
const container = document.getElementById('notesMediaContainer');
const newItem = document.createElement('div');
newItem.className = 'notes-media-item mb-3 p-3 border rounded';
newItem.innerHTML = `
<div class="row g-2">
<div class="col-md-6">
<label class="form-label">Note Text</label>
<textarea class="form-control" name="notes_media[${notesMediaIndex}][note_text]" rows="2" placeholder="Optional notes about the tournament"></textarea>
</div>
<div class="col-md-5">
<label class="form-label">Media Link</label>
<input type="url" class="form-control" name="notes_media[${notesMediaIndex}][media_link]" placeholder="https://example.com/photo.jpg">
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-note">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
`;
container.appendChild(newItem);
notesMediaIndex++;
// Show remove buttons if more than one note
updateRemoveButtons('notes-media-item', 'remove-note');
});
// Remove Performance Result
document.addEventListener('click', function(e) {
if (e.target.closest('.remove-result')) {
e.target.closest('.performance-result-item').remove();
updateRemoveButtons('performance-result-item', 'remove-result');
}
});
// Remove Notes & Media
document.addEventListener('click', function(e) {
if (e.target.closest('.remove-note')) {
e.target.closest('.notes-media-item').remove();
updateRemoveButtons('notes-media-item', 'remove-note');
}
});
function updateRemoveButtons(itemClass, buttonClass) {
const items = document.querySelectorAll('.' + itemClass);
const buttons = document.querySelectorAll('.' + buttonClass);
if (items.length > 1) {
buttons.forEach(button => button.style.display = 'block');
} else {
buttons.forEach(button => button.style.display = 'none');
}
}
// Reset modal when opened
document.getElementById('tournamentParticipationModal').addEventListener('show.bs.modal', function() {
// Reset form
document.getElementById('tournamentParticipationForm').reset();
// Reset dynamic content
const performanceContainer = document.getElementById('performanceResultsContainer');
const notesContainer = document.getElementById('notesMediaContainer');
// Keep only the first item in each container
const performanceItems = performanceContainer.querySelectorAll('.performance-result-item');
const notesItems = notesContainer.querySelectorAll('.notes-media-item');
for (let i = 1; i < performanceItems.length; i++) {
performanceItems[i].remove();
}
for (let i = 1; i < notesItems.length; i++) {
notesItems[i].remove();
}
// Reset indices
performanceResultIndex = 1;
notesMediaIndex = 1;
// Hide remove buttons
document.querySelectorAll('.remove-result, .remove-note').forEach(button => {
button.style.display = 'none';
});
});
// Handle form submission
document.getElementById('tournamentParticipationForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('tournamentParticipationModal'));
modal.hide();
// Show success message
showAlert('Tournament record added successfully!', 'success');
// Reload page to show new data
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showAlert('Error adding tournament record: ' + (data.message || 'Unknown error'), 'danger');
}
})
.catch(error => {
console.error('Error:', error);
showAlert('Error adding tournament record. Please try again.', 'danger');
});
});
function showAlert(message, type) {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.body.appendChild(alertDiv);
// Auto remove after 5 seconds
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}
});
</script>
@endsection @endsection

View File

@ -447,5 +447,8 @@
@vite(['resources/js/app.js']) @vite(['resources/js/app.js'])
@stack('scripts') @stack('scripts')
<!-- Modals Stack (for cropper and other modals) -->
@stack('modals')
</body> </body>
</html> </html>

View File

@ -0,0 +1,156 @@
@php
$id = $attributes->get('id');
$width = $attributes->get('width', 300);
$height = $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle');
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'cropped_' . time());
$uploadUrl = $attributes->get('uploadUrl', route('profile.upload-picture'));
@endphp
@once
<link rel="stylesheet" href="https://unpkg.com/cropme@1.4.1/dist/cropme.min.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://unpkg.com/cropme@1.4.1/dist/cropme.min.js"></script>
<style>
.modal-content-clean { border: none; border-radius: 15px; overflow: hidden; }
.cropme-wrapper { overflow: hidden !important; border-radius: 8px; }
.cropme-slider { display: none !important; }
.takeone-canvas {
height: 400px;
background: #111;
border-radius: 8px;
position: relative;
border: 1px solid #222;
}
.custom-slider-label {
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
color: #6c757d;
letter-spacing: 0.5px;
}
.form-range::-webkit-slider-thumb { background: #198754; }
.form-range::-moz-range-thumb { background: #198754; }
</style>
@endonce
<button type="button" class="btn btn-success px-4 fw-bold shadow-sm" data-bs-toggle="modal" data-bs-target="#cropperModal_{{ $id }}">
Change Photo
</button>
@push('modals')
<div class="modal fade" id="cropperModal_{{ $id }}" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content modal-content-clean shadow-lg">
<div class="modal-body p-4 text-start">
<div class="mb-3 d-flex align-items-center">
<input type="file" id="input_{{ $id }}" class="form-control form-control-sm" accept="image/*">
<button type="button" class="btn-close ms-2" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="box_{{ $id }}" class="takeone-canvas"></div>
<div class="row mt-4">
<div class="col-md-6 mb-3">
<label class="custom-slider-label d-block mb-2">Zoom Level</label>
<input type="range" class="form-range" id="zoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="col-md-6 mb-3">
<label class="custom-slider-label d-block mb-2">Rotation</label>
<input type="range" class="form-range" id="rot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
<div class="d-grid gap-2 mt-2">
<button type="button" class="btn btn-success btn-lg fw-bold py-3" id="save_{{ $id }}">
Crop & Save Image
</button>
</div>
</div>
</div>
</div>
</div>
<script>
$(function() {
let cropper_{{ $id }} = null;
const el_{{ $id }} = document.getElementById("box_{{ $id }}");
const zoomMin_{{ $id }} = 0.01;
const zoomMax_{{ $id }} = 3;
function applyTransform_{{ $id }}(instance) {
if (!instance.properties.image) return;
const p = instance.properties;
const t = `translate3d(${p.x}px, ${p.y}px, 0) scale(${p.scale}) rotate(${p.deg}deg)`;
p.image.style.transform = t;
}
$('#input_{{ $id }}').on('change', function() {
if (this.files && this.files[0]) {
const reader = new FileReader();
reader.onload = function(event) {
if (cropper_{{ $id }}) cropper_{{ $id }}.destroy();
cropper_{{ $id }} = new Cropme(el_{{ $id }}, {
container: { width: '100%', height: 400 },
viewport: {
width: {{ $width }},
height: {{ $height }},
type: '{{ $shape }}',
border: { enable: true, width: 2, color: '#fff' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin_{{ $id }}, max: zoomMax_{{ $id }}, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropper_{{ $id }}.bind({ url: event.target.result }).then(() => {
$('#zoom_{{ $id }}').val(0);
$('#rot_{{ $id }}').val(0);
});
};
reader.readAsDataURL(this.files[0]);
}
});
$('#zoom_{{ $id }}').on('input', function() {
if (!cropper_{{ $id }} || !cropper_{{ $id }}.properties.image) return;
const p = parseFloat($(this).val());
const scale = zoomMin_{{ $id }} + (zoomMax_{{ $id }} - zoomMin_{{ $id }}) * (p / 100);
cropper_{{ $id }}.properties.scale = Math.min(Math.max(scale, zoomMin_{{ $id }}), zoomMax_{{ $id }});
applyTransform_{{ $id }}(cropper_{{ $id }});
});
$('#rot_{{ $id }}').on('input', function() {
if (cropper_{{ $id }}) {
cropper_{{ $id }}.rotate(parseInt($(this).val(), 10));
}
});
$('#save_{{ $id }}').on('click', function() {
if (!cropper_{{ $id }}) return;
const btn = $(this);
btn.prop('disabled', true).text('Uploading...');
cropper_{{ $id }}.crop({ type: 'base64' }).then(base64 => {
$.post("{{ $uploadUrl }}", {
_token: "{{ csrf_token() }}",
image: base64,
folder: '{{ $folder }}',
filename: '{{ $filename }}'
}).done((res) => {
alert('Saved successfully!');
$('#cropperModal_{{ $id }}').modal('hide');
// Reload page to show new image
location.reload();
}).fail((err) => {
alert('Upload failed: ' + (err.responseJSON?.message || 'Unknown error'));
}).always(() => {
btn.prop('disabled', false).text('Crop & Save Image');
});
});
});
});
</script>
@endpush

View File

@ -95,6 +95,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/family/{id}/health', [FamilyController::class, 'storeHealth'])->name('family.store-health'); Route::post('/family/{id}/health', [FamilyController::class, 'storeHealth'])->name('family.store-health');
Route::put('/family/{id}/health/{recordId}', [FamilyController::class, 'updateHealth'])->name('family.update-health'); Route::put('/family/{id}/health/{recordId}', [FamilyController::class, 'updateHealth'])->name('family.update-health');
Route::put('/family/goal/{goalId}', [FamilyController::class, 'updateGoal'])->name('family.update-goal'); Route::put('/family/goal/{goalId}', [FamilyController::class, 'updateGoal'])->name('family.update-goal');
Route::post('/family/{id}/tournament', [FamilyController::class, 'storeTournament'])->name('family.store-tournament');
Route::post('/family/{id}/upload-picture', [FamilyController::class, 'uploadFamilyMemberPicture'])->name('family.upload-picture'); Route::post('/family/{id}/upload-picture', [FamilyController::class, 'uploadFamilyMemberPicture'])->name('family.upload-picture');
Route::delete('/family/{id}', [FamilyController::class, 'destroy'])->name('family.destroy'); Route::delete('/family/{id}', [FamilyController::class, 'destroy'])->name('family.destroy');