Compare commits

..

No commits in common. "main" and "feature/6-livewire" have entirely different histories.

147 changed files with 846 additions and 6367 deletions

View file

@ -1,24 +0,0 @@
APP_NAME=DishPlanner
APP_ENV=testing
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
APP_DEBUG=true
APP_URL=http://dishplanner_app:8000
LOG_CHANNEL=single
# Test database
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=dishplanner_test
DB_USERNAME=dishplanner
DB_PASSWORD=dishplanner
BROADCAST_DRIVER=log
CACHE_DRIVER=array
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MAIL_MAILER=array

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
/composer.lock /composer.lock
/.phpunit.cache /.phpunit.cache
/coverage
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot

View file

@ -14,8 +14,7 @@ RUN install-php-extensions \
opcache \ opcache \
zip \ zip \
gd \ gd \
intl \ intl
bcmath
# Install Composer # Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
@ -101,7 +100,7 @@ set -e
# Wait for database to be ready # Wait for database to be ready
echo "Waiting for database..." echo "Waiting for database..."
for i in $(seq 1 30); do for i in $(seq 1 30); do
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then if php artisan db:monitor --database=mysql 2>/dev/null | grep -q "OK"; then
echo "Database is ready!" echo "Database is ready!"
break break
fi fi

View file

@ -18,7 +18,6 @@ RUN install-php-extensions \
zip \ zip \
gd \ gd \
intl \ intl \
bcmath \
xdebug xdebug
# Install Composer # Install Composer
@ -102,10 +101,6 @@ echo "Waiting for database..."
sleep 5 sleep 5
php artisan migrate --force || echo "Migration failed or not needed" php artisan migrate --force || echo "Migration failed or not needed"
# Run development seeder (only in dev environment)
echo "Running development seeder..."
php artisan db:seed --class=DevelopmentSeeder --force || echo "Seeding skipped or already done"
# Generate app key if not set # Generate app key if not set
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
echo "Generating application key..." echo "Generating application key..."

View file

@ -72,10 +72,6 @@ # Database
make seed # Seed database make seed # Seed database
make fresh # Fresh migrate with seeds make fresh # Fresh migrate with seeds
# Testing
make test # Run tests
composer test:coverage-html # Run tests with coverage report (generates coverage/index.html)
# Utilities # Utilities
make shell # Enter app container make shell # Enter app container
make db-shell # Enter database shell make db-shell # Enter database shell

View file

@ -1,84 +0,0 @@
<?php
namespace App\Actions\User;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class CreateUserAction
{
/**
* @throws Exception
*/
public function execute(array $data): User
{
try {
// Validate required fields first
if (!isset($data['name']) || empty($data['name'])) {
throw new InvalidArgumentException('Name is required');
}
if (!isset($data['planner_id']) || empty($data['planner_id'])) {
throw new InvalidArgumentException('Planner ID is required');
}
DB::beginTransaction();
Log::info('CreateUserAction: Starting user creation', [
'name' => $data['name'],
'planner_id' => $data['planner_id'],
]);
// Create the user
$user = User::create([
'name' => $data['name'],
'planner_id' => $data['planner_id'],
]);
if (!$user) {
throw new Exception('User creation returned null');
}
Log::info('CreateUserAction: User creation result', [
'user_id' => $user->id,
'name' => $user->name,
'planner_id' => $user->planner_id,
]);
// Verify the user was actually created
$createdUser = User::find($user->id);
if (!$createdUser) {
throw new Exception('User creation did not persist to database');
}
if ($createdUser->name !== $data['name']) {
throw new Exception('User creation data mismatch');
}
DB::commit();
Log::info('CreateUserAction: User successfully created', [
'user_id' => $user->id,
'name' => $user->name,
'planner_id' => $user->planner_id,
]);
return $user;
} catch (Exception $e) {
DB::rollBack();
Log::error('CreateUserAction: User creation failed', [
'name' => $data['name'] ?? 'N/A',
'planner_id' => $data['planner_id'] ?? 'N/A',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View file

@ -1,79 +0,0 @@
<?php
namespace App\Actions\User;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeleteUserAction
{
/**
* @throws Exception
*/
public function execute(User $user): bool
{
try {
DB::beginTransaction();
Log::info('DeleteUserAction: Starting user deletion', [
'user_id' => $user->id,
'user_name' => $user->name,
'planner_id' => $user->planner_id,
]);
// Check for related data
$userDishCount = $user->userDishes()->count();
$dishCount = $user->dishes()->count();
Log::info('DeleteUserAction: User relationship counts', [
'user_id' => $user->id,
'user_dishes_count' => $userDishCount,
'dishes_count' => $dishCount,
]);
// Store user info before deletion for verification
$userId = $user->id;
$userName = $user->name;
// Delete the user (cascading deletes should handle related records)
$result = $user->delete();
Log::info('DeleteUserAction: Delete result', [
'result' => $result,
'user_id' => $userId,
]);
if (! $result) {
throw new Exception('User deletion returned false');
}
// Verify the deletion actually happened
$stillExists = User::find($userId);
if ($stillExists) {
throw new Exception('User deletion did not persist to database');
}
DB::commit();
Log::info('DeleteUserAction: User successfully deleted', [
'user_id' => $userId,
'user_name' => $userName,
]);
return true;
} catch (Exception $e) {
DB::rollBack();
Log::error('DeleteUserAction: User deletion failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View file

@ -1,63 +0,0 @@
<?php
namespace App\Actions\User;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class EditUserAction
{
public function execute(User $user, array $data): bool
{
try {
DB::beginTransaction();
Log::info('EditUserAction: Starting user update', [
'user_id' => $user->id,
'old_name' => $user->name,
'new_name' => $data['name'],
'planner_id' => $user->planner_id,
]);
$result = $user->update([
'name' => $data['name'],
]);
Log::info('EditUserAction: Update result', [
'result' => $result,
'user_id' => $user->id,
]);
if (!$result) {
throw new \Exception('User update returned false');
}
// Verify the update actually happened
$user->refresh();
if ($user->name !== $data['name']) {
throw new \Exception('User update did not persist to database');
}
DB::commit();
Log::info('EditUserAction: User successfully updated', [
'user_id' => $user->id,
'updated_name' => $user->name,
]);
return true;
} catch (\Exception $e) {
DB::rollBack();
Log::error('EditUserAction: User update failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Enums;
enum AppModeEnum: string
{
case APP = 'app';
case SAAS = 'saas';
public static function current(): self
{
return self::from(config('app.mode', 'app'));
}
public function isApp(): bool
{
return $this === self::APP;
}
public function isSaas(): bool
{
return $this === self::SAAS;
}
}

View file

@ -1,44 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function showLoginForm()
{
return view('auth.login');
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
throw ValidationException::withMessages([
'email' => 'These credentials do not match our records.',
]);
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -1,37 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Planner;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
class RegisterController extends Controller
{
public function showRegistrationForm()
{
return view('auth.register');
}
public function register(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:planners'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = Planner::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
Auth::login($user);
return redirect(route('dashboard'));
}
}

View file

@ -1,112 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Cashier\Cashier;
use Symfony\Component\HttpFoundation\Response;
class SubscriptionController extends Controller
{
public function checkout(Request $request)
{
$planner = $request->user();
if ($planner->subscribed()) {
return redirect()->route('dashboard');
}
$plan = $request->input('plan', 'monthly');
$priceId = $plan === 'yearly'
? config('services.stripe.price_yearly')
: config('services.stripe.price_monthly');
return $planner->newSubscription('default', $priceId)
->checkout([
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('subscription.index'),
]);
}
public function success(Request $request): RedirectResponse
{
$sessionId = $request->query('session_id');
if ($sessionId) {
$planner = $request->user();
$session = Cashier::stripe()->checkout->sessions->retrieve($sessionId, [
'expand' => ['subscription'],
]);
if ($session->subscription && ! $planner->subscribed()) {
$subscription = $session->subscription;
$planner->subscriptions()->create([
'type' => 'default',
'stripe_id' => $subscription->id,
'stripe_status' => $subscription->status,
'stripe_price' => $subscription->items->data[0]->price->id ?? null,
'quantity' => $subscription->items->data[0]->quantity ?? 1,
'trial_ends_at' => $subscription->trial_end ? now()->setTimestamp($subscription->trial_end) : null,
'ends_at' => null,
]);
}
}
return redirect()->route('dashboard')->with('success', 'Subscription activated!');
}
public function billing(Request $request)
{
$planner = $request->user();
$subscription = $planner->subscription();
if (! $subscription) {
return redirect()->route('subscription.index');
}
$planType = match ($subscription->stripe_price) {
config('services.stripe.price_yearly') => 'Yearly',
config('services.stripe.price_monthly') => 'Monthly',
default => 'Unknown',
};
$nextBillingDate = null;
if ($subscription->stripe_status === 'active') {
try {
$stripeSubscription = Cashier::stripe()->subscriptions->retrieve($subscription->stripe_id);
$nextBillingDate = $stripeSubscription->current_period_end
? now()->setTimestamp($stripeSubscription->current_period_end)
: null;
} catch (\Exception $e) {
// Stripe API error - continue without next billing date
}
}
return view('billing.index', [
'subscription' => $subscription,
'planner' => $planner,
'planType' => $planType,
'nextBillingDate' => $nextBillingDate,
]);
}
public function cancel(Request $request): RedirectResponse
{
$planner = $request->user();
if (! $planner->subscribed()) {
return back()->with('error', 'No active subscription found.');
}
$planner->subscription()->cancel();
return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.');
}
public function billingPortal(Request $request)
{
return $request->user()->redirectToBillingPortal(route('billing'));
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireSaasMode
{
public function handle(Request $request, Closure $next): Response
{
if (! is_mode_saas()) {
abort(404);
}
return $next($request);
}
}

View file

@ -1,25 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireSubscription
{
public function handle(Request $request, Closure $next): Response
{
if (is_mode_app()) {
return $next($request);
}
$planner = $request->user();
if (! $planner?->subscribed()) {
return redirect()->route('subscription.index');
}
return $next($request);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Rule;
class Login extends Component
{
#[Rule('required|email')]
public $email = '';
#[Rule('required')]
public $password = '';
public $remember = false;
public function login()
{
$this->validate();
if (Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
session()->regenerate();
return redirect()->intended(route('dashboard'));
}
$this->addError('email', 'These credentials do not match our records.');
}
public function render()
{
return view('livewire.auth.login')
->layout('components.layouts.guest');
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Auth;
use App\Models\Planner;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Rule;
class Register extends Component
{
#[Rule('required|string|max:255')]
public $name = '';
#[Rule('required|email|unique:planners,email')]
public $email = '';
#[Rule('required|min:8|confirmed')]
public $password = '';
public $password_confirmation = '';
public function register()
{
$this->validate();
$planner = Planner::create([
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
]);
Auth::login($planner);
session()->regenerate();
return redirect()->route('dashboard');
}
public function render()
{
return view('livewire.auth.register')
->layout('components.layouts.guest');
}
}

View file

@ -33,7 +33,7 @@ public function render()
->orderBy('name') ->orderBy('name')
->paginate(10); ->paginate(10);
$users = User::where('planner_id', auth()->id()) $users = User::where('planner_id', auth()->user()->planner_id)
->orderBy('name') ->orderBy('name')
->get(); ->get();
@ -56,7 +56,7 @@ public function store()
$dish = Dish::create([ $dish = Dish::create([
'name' => $this->name, 'name' => $this->name,
'planner_id' => auth()->id(), 'planner_id' => auth()->user()->planner_id,
]); ]);
// Attach selected users // Attach selected users
@ -119,14 +119,4 @@ public function cancel()
$this->showDeleteModal = false; $this->showDeleteModal = false;
$this->reset(['name', 'selectedUsers', 'editingDish', 'deletingDish']); $this->reset(['name', 'selectedUsers', 'editingDish', 'deletingDish']);
} }
public function toggleAllUsers(): void
{
$users = User::where('planner_id', auth()->id())->get();
if (count($this->selectedUsers) === $users->count()) {
$this->selectedUsers = [];
} else {
$this->selectedUsers = $users->pluck('id')->map(fn($id) => (string) $id)->toArray();
}
}
} }

View file

@ -2,18 +2,8 @@
namespace App\Livewire\Schedule; namespace App\Livewire\Schedule;
use App\Models\Dish;
use App\Models\Schedule;
use App\Models\ScheduledUserDish; use App\Models\ScheduledUserDish;
use App\Models\User;
use App\Models\UserDish;
use Carbon\Carbon; use Carbon\Carbon;
use DishPlanner\Schedule\Services\ScheduleCalendarService;
use DishPlanner\ScheduledUserDish\Actions\DeleteScheduledUserDishForDateAction;
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
use Exception;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Livewire\Component; use Livewire\Component;
class ScheduleCalendar extends Component class ScheduleCalendar extends Component
@ -24,52 +14,65 @@ class ScheduleCalendar extends Component
public $showRegenerateModal = false; public $showRegenerateModal = false;
public $regenerateDate = null; public $regenerateDate = null;
public $regenerateUserId = null; public $regenerateUserId = null;
// Edit dish modal public function mount()
public $showEditDishModal = false;
public $editDate = null;
public $editUserId = null;
public $selectedDishId = null;
public $availableDishes = [];
// Add dish modal
public $showAddDishModal = false;
public $addDate = null;
public $addUserIds = [];
public $addSelectedDishId = null;
public $addAvailableUsers = [];
public $addAvailableDishes = [];
public function mount(): void
{ {
$this->currentMonth = now()->month; $this->currentMonth = now()->month;
$this->currentYear = now()->year; $this->currentYear = now()->year;
$this->loadCalendar(); $this->generateCalendar();
} }
protected $listeners = ['schedule-generated' => 'refreshCalendar']; protected $listeners = ['schedule-generated' => 'refreshCalendar'];
public function render(): View public function render()
{ {
return view('livewire.schedule.schedule-calendar'); return view('livewire.schedule.schedule-calendar');
} }
public function refreshCalendar(): void public function refreshCalendar()
{ {
$this->loadCalendar(); $this->generateCalendar();
} }
public function loadCalendar(): void public function generateCalendar()
{ {
$service = new ScheduleCalendarService(); $this->calendarDays = [];
$this->calendarDays = $service->getCalendarDays(
auth()->user(), // Get first day of the month and total days
$this->currentMonth, $firstDay = Carbon::createFromDate($this->currentYear, $this->currentMonth, 1);
$this->currentYear $daysInMonth = $firstDay->daysInMonth;
);
// Generate 31 days for consistency with React version
for ($day = 1; $day <= 31; $day++) {
if ($day <= $daysInMonth) {
$date = Carbon::createFromDate($this->currentYear, $this->currentMonth, $day);
// Get scheduled dishes for this date
$scheduledDishes = ScheduledUserDish::with(['user', 'dish'])
->whereDate('date', $date->format('Y-m-d'))
->get();
$this->calendarDays[] = [
'day' => $day,
'date' => $date,
'isToday' => $date->isToday(),
'scheduledDishes' => $scheduledDishes,
'isEmpty' => $scheduledDishes->isEmpty()
];
} else {
// Empty slot for days that don't exist in this month
$this->calendarDays[] = [
'day' => null,
'date' => null,
'isToday' => false,
'scheduledDishes' => collect(),
'isEmpty' => true
];
}
}
} }
public function previousMonth(): void public function previousMonth()
{ {
if ($this->currentMonth === 1) { if ($this->currentMonth === 1) {
$this->currentMonth = 12; $this->currentMonth = 12;
@ -77,10 +80,10 @@ public function previousMonth(): void
} else { } else {
$this->currentMonth--; $this->currentMonth--;
} }
$this->loadCalendar(); $this->generateCalendar();
} }
public function nextMonth(): void public function nextMonth()
{ {
if ($this->currentMonth === 12) { if ($this->currentMonth === 12) {
$this->currentMonth = 1; $this->currentMonth = 1;
@ -88,346 +91,62 @@ public function nextMonth(): void
} else { } else {
$this->currentMonth++; $this->currentMonth++;
} }
$this->loadCalendar(); $this->generateCalendar();
} }
public function regenerateForUserDate($date, $userId): void
{
if (!$this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.');
return;
}
public function regenerateForUserDate($date, $userId)
{
$this->regenerateDate = $date; $this->regenerateDate = $date;
$this->regenerateUserId = $userId; $this->regenerateUserId = $userId;
$this->showRegenerateModal = true; $this->showRegenerateModal = true;
} }
public function confirmRegenerate(): void public function confirmRegenerate()
{ {
try { try {
if (!$this->authorizeUser($this->regenerateUserId)) { // Delete existing scheduled dish for this user on this date
session()->flash('error', 'Unauthorized action.'); ScheduledUserDish::whereDate('date', $this->regenerateDate)
return; ->where('user_id', $this->regenerateUserId)
} ->delete();
$action = new DeleteScheduledUserDishForDateAction(); // You could call a specific regeneration method here
$action->execute( // For now, we'll just delete and let the user generate again
auth()->user(),
Carbon::parse($this->regenerateDate),
$this->regenerateUserId
);
$this->showRegenerateModal = false; $this->showRegenerateModal = false;
$this->loadCalendar(); $this->generateCalendar(); // Refresh calendar
session()->flash('success', 'Schedule regenerated for the selected date!'); session()->flash('success', 'Schedule regenerated for the selected date!');
} catch (Exception $e) { } catch (\Exception $e) {
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $this->regenerateDate]); session()->flash('error', 'Error regenerating schedule: ' . $e->getMessage());
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
} }
} }
public function skipDay($date, $userId): void public function skipDay($date, $userId)
{ {
try { try {
if (!$this->authorizeUser($userId)) { // Mark this day as skipped or delete the assignment
session()->flash('error', 'Unauthorized action.'); ScheduledUserDish::whereDate('date', $date)
return; ->where('user_id', $userId)
} ->delete();
$action = new SkipScheduledUserDishForDateAction(); $this->generateCalendar(); // Refresh calendar
$action->execute(
auth()->user(),
Carbon::parse($date),
$userId
);
$this->loadCalendar();
session()->flash('success', 'Day skipped successfully!'); session()->flash('success', 'Day skipped successfully!');
} catch (Exception $e) { } catch (\Exception $e) {
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]); session()->flash('error', 'Error skipping day: ' . $e->getMessage());
session()->flash('error', 'Unable to skip day. Please try again.');
} }
} }
private function authorizeUser(int $userId): bool public function cancel()
{
$user = User::find($userId);
return $user && $user->planner_id === auth()->id();
}
public function cancel(): void
{ {
$this->showRegenerateModal = false; $this->showRegenerateModal = false;
$this->regenerateDate = null; $this->regenerateDate = null;
$this->regenerateUserId = null; $this->regenerateUserId = null;
$this->showEditDishModal = false;
$this->editDate = null;
$this->editUserId = null;
$this->selectedDishId = null;
$this->availableDishes = [];
$this->showAddDishModal = false;
$this->addDate = null;
$this->addUserIds = [];
$this->addSelectedDishId = null;
$this->addAvailableUsers = [];
$this->addAvailableDishes = [];
} }
public function removeDish($date, $userId): void public function getMonthNameProperty()
{ {
try { return Carbon::createFromDate($this->currentYear, $this->currentMonth, 1)->format('F Y');
if (!$this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.');
return;
}
$schedule = Schedule::where('planner_id', auth()->id())
->where('date', $date)
->first();
if ($schedule) {
ScheduledUserDish::where('schedule_id', $schedule->id)
->where('user_id', $userId)
->delete();
}
$this->loadCalendar();
session()->flash('success', 'Dish removed successfully!');
} catch (Exception $e) {
Log::error('Remove dish failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
session()->flash('error', 'Unable to remove dish. Please try again.');
}
} }
}
public function openAddDishModal($date): void
{
$this->addDate = $date;
// Load all users for this planner
$this->addAvailableUsers = User::where('planner_id', auth()->id())
->orderBy('name')
->get();
$this->addAvailableDishes = [];
$this->addUserIds = [];
$this->addSelectedDishId = null;
$this->showAddDishModal = true;
}
public function toggleAllUsers(): void
{
if (count($this->addUserIds) === count($this->addAvailableUsers)) {
$this->addUserIds = [];
} else {
$this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn($id) => (string) $id)->toArray();
}
$this->updateAvailableDishes();
}
public function updatedAddUserIds(): void
{
$this->updateAvailableDishes();
}
private function updateAvailableDishes(): void
{
if (empty($this->addUserIds)) {
$this->addAvailableDishes = [];
} else {
// Load dishes that ALL selected users have in common
$selectedCount = count($this->addUserIds);
$this->addAvailableDishes = Dish::whereHas('users', function ($query) {
$query->whereIn('users.id', $this->addUserIds);
}, '=', $selectedCount)->orderBy('name')->get();
}
$this->addSelectedDishId = null;
}
public function saveAddDish(): void
{
try {
if (empty($this->addUserIds)) {
session()->flash('error', 'Please select at least one user.');
return;
}
if (!$this->addSelectedDishId) {
session()->flash('error', 'Please select a dish.');
return;
}
// Find or create the schedule for this date
$schedule = Schedule::firstOrCreate(
[
'planner_id' => auth()->id(),
'date' => $this->addDate,
],
['is_skipped' => false]
);
$addedCount = 0;
$skippedCount = 0;
foreach ($this->addUserIds as $userId) {
if (!$this->authorizeUser((int) $userId)) {
$skippedCount++;
continue;
}
// Check if user already has a dish scheduled for this date
$existing = ScheduledUserDish::where('schedule_id', $schedule->id)
->where('user_id', $userId)
->first();
if ($existing) {
$skippedCount++;
continue;
}
// Find the UserDish for this user and dish
$userDish = UserDish::where('user_id', $userId)
->where('dish_id', $this->addSelectedDishId)
->first();
if (!$userDish) {
$skippedCount++;
continue;
}
// Create the scheduled user dish
ScheduledUserDish::create([
'schedule_id' => $schedule->id,
'user_id' => $userId,
'user_dish_id' => $userDish->id,
'is_skipped' => false,
]);
$addedCount++;
}
$this->closeAddDishModal();
$this->loadCalendar();
if ($addedCount > 0 && $skippedCount > 0) {
session()->flash('success', "Dish added for {$addedCount} user(s). {$skippedCount} user(s) skipped (already scheduled).");
} elseif ($addedCount > 0) {
session()->flash('success', "Dish added for {$addedCount} user(s)!");
} else {
session()->flash('error', 'No users could be scheduled. They may already have dishes for this date.');
}
} catch (Exception $e) {
Log::error('Add dish failed', ['exception' => $e]);
session()->flash('error', 'Unable to add dish. Please try again.');
}
}
private function closeAddDishModal(): void
{
$this->showAddDishModal = false;
$this->addDate = null;
$this->addUserIds = [];
$this->addSelectedDishId = null;
$this->addAvailableUsers = [];
$this->addAvailableDishes = [];
}
public function editDish($date, $userId): void
{
if (!$this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.');
return;
}
$this->editDate = $date;
$this->editUserId = $userId;
// Load dishes available for this user (via UserDish pivot)
$this->availableDishes = Dish::whereHas('users', function ($query) use ($userId) {
$query->where('users.id', $userId);
})->orderBy('name')->get();
// Get currently selected dish for this date/user if exists
$schedule = Schedule::where('planner_id', auth()->id())
->where('date', $date)
->first();
if ($schedule) {
$scheduledUserDish = ScheduledUserDish::where('schedule_id', $schedule->id)
->where('user_id', $userId)
->first();
if ($scheduledUserDish && $scheduledUserDish->userDish) {
$this->selectedDishId = $scheduledUserDish->userDish->dish_id;
}
}
$this->showEditDishModal = true;
}
public function saveDish(): void
{
try {
if (!$this->authorizeUser($this->editUserId)) {
session()->flash('error', 'Unauthorized action.');
return;
}
if (!$this->selectedDishId) {
session()->flash('error', 'Please select a dish.');
return;
}
// Find or create the schedule for this date
$schedule = Schedule::firstOrCreate(
[
'planner_id' => auth()->id(),
'date' => $this->editDate,
],
['is_skipped' => false]
);
// Find the UserDish for this user and dish
$userDish = UserDish::where('user_id', $this->editUserId)
->where('dish_id', $this->selectedDishId)
->first();
if (!$userDish) {
session()->flash('error', 'This dish is not assigned to this user.');
return;
}
// Update or create the scheduled user dish
ScheduledUserDish::updateOrCreate(
[
'schedule_id' => $schedule->id,
'user_id' => $this->editUserId,
],
[
'user_dish_id' => $userDish->id,
'is_skipped' => false,
]
);
$this->showEditDishModal = false;
$this->editDate = null;
$this->editUserId = null;
$this->selectedDishId = null;
$this->availableDishes = [];
$this->loadCalendar();
session()->flash('success', 'Dish updated successfully!');
} catch (Exception $e) {
Log::error('Save dish failed', ['exception' => $e]);
session()->flash('error', 'Unable to save dish. Please try again.');
}
}
public function getMonthNameProperty(): string
{
$service = new ScheduleCalendarService();
return $service->getMonthName($this->currentMonth, $this->currentYear);
}
}

View file

@ -3,120 +3,184 @@
namespace App\Livewire\Schedule; namespace App\Livewire\Schedule;
use App\Models\User; use App\Models\User;
use App\Models\Dish;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use Carbon\Carbon; use Carbon\Carbon;
use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction;
use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction;
use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction;
use Illuminate\Support\Facades\Log;
use Livewire\Component; use Livewire\Component;
class ScheduleGenerator extends Component class ScheduleGenerator extends Component
{ {
private const YEARS_IN_PAST = 1;
private const YEARS_IN_FUTURE = 5;
public $selectedMonth; public $selectedMonth;
public $selectedYear; public $selectedYear;
public $selectedUsers = []; public $selectedUsers = [];
public $clearExisting = true; public $clearExisting = true;
public $showAdvancedOptions = false; public $showAdvancedOptions = false;
public $isGenerating = false; public $isGenerating = false;
public function mount(): void public function mount()
{ {
$this->selectedMonth = now()->month; $this->selectedMonth = now()->month;
$this->selectedYear = now()->year; $this->selectedYear = now()->year;
$this->selectedUsers = User::where('planner_id', auth()->id()) // Select all users by default
$this->selectedUsers = User::where('planner_id', auth()->user()->planner_id)
->pluck('id') ->pluck('id')
->toArray(); ->toArray();
} }
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View public function render()
{ {
$users = User::where('planner_id', auth()->id()) $users = User::where('planner_id', auth()->user()->planner_id)
->orderBy('name') ->orderBy('name')
->get(); ->get();
$years = range(now()->year - self::YEARS_IN_PAST, now()->year + self::YEARS_IN_FUTURE); $months = [
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
];
$years = range(now()->year - 1, now()->year + 2);
return view('livewire.schedule.schedule-generator', [ return view('livewire.schedule.schedule-generator', [
'users' => $users, 'users' => $users,
'months' => $this->getMonthNames(), 'months' => $months,
'years' => $years 'years' => $years
]); ]);
} }
public function generate(): void public function generate()
{ {
$this->validate([ $this->validate([
'selectedUsers' => 'required|array|min:1', 'selectedUsers' => 'required|array|min:1',
'selectedMonth' => 'required|integer|min:1|max:12', 'selectedMonth' => 'required|integer|min:1|max:12',
'selectedYear' => 'required|integer|min:' . (now()->year - self::YEARS_IN_PAST) . '|max:' . (now()->year + self::YEARS_IN_FUTURE), 'selectedYear' => 'required|integer|min:2020|max:2030',
]); ]);
$this->isGenerating = true; $this->isGenerating = true;
try { try {
$action = new GenerateScheduleForMonthAction(); $startDate = Carbon::createFromDate($this->selectedYear, $this->selectedMonth, 1);
$action->execute( $endDate = $startDate->copy()->endOfMonth();
auth()->user(),
$this->selectedMonth, // Clear existing schedule if requested
$this->selectedYear, if ($this->clearExisting) {
$this->selectedUsers, ScheduledUserDish::whereBetween('date', [$startDate, $endDate])
$this->clearExisting ->whereIn('user_id', $this->selectedUsers)
); ->delete();
}
// Get all dishes assigned to selected users
$userDishes = [];
foreach ($this->selectedUsers as $userId) {
$user = User::find($userId);
$dishes = $user->dishes()->get();
if ($dishes->isNotEmpty()) {
$userDishes[$userId] = $dishes->toArray();
}
}
// Generate schedule for each day
$currentDate = $startDate->copy();
while ($currentDate <= $endDate) {
foreach ($this->selectedUsers as $userId) {
// Skip if user already has a dish for this day
if (ScheduledUserDish::where('date', $currentDate->format('Y-m-d'))
->where('user_id', $userId)
->exists()) {
continue;
}
// Get available dishes for this user
if (!isset($userDishes[$userId]) || empty($userDishes[$userId])) {
continue;
}
$availableDishes = $userDishes[$userId];
// Simple random assignment (you can implement more complex logic here)
if (!empty($availableDishes)) {
$randomDish = $availableDishes[array_rand($availableDishes)];
ScheduledUserDish::create([
'user_id' => $userId,
'dish_id' => $randomDish['id'],
'date' => $currentDate->format('Y-m-d'),
'planner_id' => auth()->user()->planner_id,
]);
}
}
$currentDate->addDay();
}
$this->isGenerating = false; $this->isGenerating = false;
// Emit event to refresh calendar
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule generated successfully for ' . session()->flash('success', 'Schedule generated successfully for ' .
$this->getSelectedMonthName() . ' ' . $this->selectedYear); $this->getSelectedMonthName() . ' ' . $this->selectedYear);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->isGenerating = false; $this->isGenerating = false;
Log::error('Schedule generation failed', ['exception' => $e]); session()->flash('error', 'Error generating schedule: ' . $e->getMessage());
session()->flash('error', 'Unable to generate schedule. Please try again.');
} }
} }
public function regenerateForDate($date): void public function regenerateForDate($date)
{ {
try { try {
$action = new RegenerateScheduleForDateForUsersAction(); // Clear existing assignments for this date
$action->execute( ScheduledUserDish::whereDate('date', $date)
auth()->user(), ->whereIn('user_id', $this->selectedUsers)
Carbon::parse($date), ->delete();
$this->selectedUsers
); // Regenerate for this specific date
$currentDate = Carbon::parse($date);
foreach ($this->selectedUsers as $userId) {
$user = User::find($userId);
$dishes = $user->dishes()->get();
if ($dishes->isNotEmpty()) {
$randomDish = $dishes->random();
ScheduledUserDish::create([
'user_id' => $userId,
'dish_id' => $randomDish->id,
'date' => $currentDate->format('Y-m-d'),
'planner_id' => auth()->user()->planner_id,
]);
}
}
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule regenerated for ' . Carbon::parse($date)->format('M d, Y')); session()->flash('success', 'Schedule regenerated for ' . $currentDate->format('M d, Y'));
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]); session()->flash('error', 'Error regenerating schedule: ' . $e->getMessage());
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
} }
} }
public function clearMonth(): void public function clearMonth()
{ {
try { try {
$action = new ClearScheduleForMonthAction(); $startDate = Carbon::createFromDate($this->selectedYear, $this->selectedMonth, 1);
$action->execute( $endDate = $startDate->copy()->endOfMonth();
auth()->user(),
$this->selectedMonth, ScheduledUserDish::whereBetween('date', [$startDate, $endDate])
$this->selectedYear, ->whereIn('user_id', $this->selectedUsers)
$this->selectedUsers ->delete();
);
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule cleared for ' . session()->flash('success', 'Schedule cleared for ' .
$this->getSelectedMonthName() . ' ' . $this->selectedYear); $this->getSelectedMonthName() . ' ' . $this->selectedYear);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Clear month failed', ['exception' => $e]); session()->flash('error', 'Error clearing schedule: ' . $e->getMessage());
session()->flash('error', 'Unable to clear schedule. Please try again.');
} }
} }
@ -125,17 +189,14 @@ public function toggleAdvancedOptions()
$this->showAdvancedOptions = !$this->showAdvancedOptions; $this->showAdvancedOptions = !$this->showAdvancedOptions;
} }
private function getMonthNames(): array private function getSelectedMonthName()
{ {
return [ $months = [
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December' 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
]; ];
return $months[$this->selectedMonth];
} }
}
private function getSelectedMonthName(): string
{
return $this->getMonthNames()[$this->selectedMonth];
}
}

View file

@ -3,11 +3,6 @@
namespace App\Livewire\Users; namespace App\Livewire\Users;
use App\Models\User; use App\Models\User;
use App\Actions\User\CreateUserAction;
use App\Actions\User\DeleteUserAction;
use App\Actions\User\EditUserAction;
use Exception;
use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -15,112 +10,127 @@ class UsersList extends Component
{ {
use WithPagination; use WithPagination;
public bool $showCreateModal = false; public $showCreateModal = false;
public bool $showEditModal = false; public $showEditModal = false;
public bool $showDeleteModal = false; public $showDeleteModal = false;
public ?User $editingUser = null; public $editingUser = null;
public ?User $deletingUser = null; public $deletingUser = null;
// Form fields // Form fields
public string $name = ''; public $name = '';
public $email = '';
protected array $rules = [ public $password = '';
public $password_confirmation = '';
protected $rules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|confirmed',
]; ];
public function render(): View public function render()
{ {
$users = User::where('planner_id', auth()->id()) $users = User::where('planner_id', auth()->user()->planner_id)
->orderBy('name') ->orderBy('name')
->paginate(10); ->paginate(10);
return view('livewire.users.users-list', [ return view('livewire.users.users-list', [
'users' => $users 'users' => $users
]); ]);
} }
public function create(): void public function create()
{ {
$this->reset(['name']); $this->reset(['name', 'email', 'password', 'password_confirmation']);
$this->resetValidation(); $this->resetValidation();
$this->showCreateModal = true; $this->showCreateModal = true;
} }
public function store(): void public function store()
{ {
$this->validate(); $this->validate();
try { User::create([
(new CreateUserAction())->execute([ 'name' => $this->name,
'name' => $this->name, 'email' => $this->email,
'planner_id' => auth()->id(), 'password' => bcrypt($this->password),
]); 'planner_id' => auth()->user()->planner_id,
]);
$this->showCreateModal = false; $this->showCreateModal = false;
$this->reset(['name']); $this->reset(['name', 'email', 'password', 'password_confirmation']);
session()->flash('success', 'User created successfully.'); session()->flash('success', 'User created successfully.');
} catch (Exception $e) {
session()->flash('error', 'Failed to create user: ' . $e->getMessage());
}
} }
public function edit(User $user): void public function edit(User $user)
{ {
$this->editingUser = $user; $this->editingUser = $user;
$this->name = $user->name; $this->name = $user->name;
$this->email = $user->email;
$this->password = '';
$this->password_confirmation = '';
$this->resetValidation(); $this->resetValidation();
$this->showEditModal = true; $this->showEditModal = true;
} }
public function update(): void public function update()
{ {
$this->validate(); $rules = [
'name' => 'required|string|max:255',
try { 'email' => 'required|email|unique:users,email,' . $this->editingUser->id,
(new EditUserAction())->execute($this->editingUser, ['name' => $this->name]); ];
$this->showEditModal = false; if ($this->password) {
$this->reset(['name', 'editingUser']); $rules['password'] = 'min:8|confirmed';
session()->flash('success', 'User updated successfully.');
// Force component to re-render with fresh data
$this->resetPage();
} catch (Exception $e) {
session()->flash('error', 'Failed to update user: ' . $e->getMessage());
} }
$this->validate($rules);
$this->editingUser->update([
'name' => $this->name,
'email' => $this->email,
]);
if ($this->password) {
$this->editingUser->update([
'password' => bcrypt($this->password)
]);
}
$this->showEditModal = false;
$this->reset(['name', 'email', 'password', 'password_confirmation', 'editingUser']);
session()->flash('success', 'User updated successfully.');
} }
public function confirmDelete(User $user): void public function confirmDelete(User $user)
{ {
$this->deletingUser = $user; $this->deletingUser = $user;
$this->showDeleteModal = true; $this->showDeleteModal = true;
} }
public function delete(): void public function delete()
{ {
try { if ($this->deletingUser->id === auth()->id()) {
(new DeleteUserAction())->execute($this->deletingUser); session()->flash('error', 'You cannot delete your own account.');
$this->showDeleteModal = false; $this->showDeleteModal = false;
$this->deletingUser = null; return;
session()->flash('success', 'User deleted successfully.');
// Force component to re-render with fresh data
$this->resetPage();
} catch (Exception $e) {
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
} }
$this->deletingUser->delete();
$this->showDeleteModal = false;
$this->deletingUser = null;
session()->flash('success', 'User deleted successfully.');
} }
public function cancel(): void public function cancel()
{ {
$this->showCreateModal = false; $this->showCreateModal = false;
$this->showEditModal = false; $this->showEditModal = false;
$this->showDeleteModal = false; $this->showDeleteModal = false;
$this->reset(['name', 'editingUser', 'deletingUser']); $this->reset(['name', 'email', 'password', 'password_confirmation', 'editingUser', 'deletingUser']);
} }
} }

View file

@ -6,17 +6,15 @@
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
/** /**
* @property int $id * @property int $id
* @property static PlannerFactory factory($count = null, $state = []) * @property static PlannerFactory factory($count = null, $state = [])
* @method static first()
*/ */
class Planner extends Authenticatable class Planner extends Authenticatable
{ {
use Billable, HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [ protected $fillable = [
'name', 'email', 'password', 'name', 'email', 'password',
@ -26,10 +24,6 @@ class Planner extends Authenticatable
'password', 'remember_token', 'password', 'remember_token',
]; ];
protected $casts = [
'password' => 'hashed',
];
public function schedules(): HasMany public function schedules(): HasMany
{ {
return $this->hasMany(Schedule::class); return $this->hasMany(Schedule::class);

View file

@ -27,7 +27,6 @@
* @method static create(array $array) * @method static create(array $array)
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') * @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
* @method static ScheduleFactory factory($count = null, $state = []) * @method static ScheduleFactory factory($count = null, $state = [])
* @method static firstOrCreate(array $array, false[] $array1)
*/ */
class Schedule extends Model class Schedule extends Model
{ {
@ -57,6 +56,6 @@ public function scheduledUserDishes(): HasMany
public function hasAllUsersScheduled(): bool public function hasAllUsersScheduled(): bool
{ {
return $this->scheduledUserDishes->count() === User::where('planner_id', $this->planner_id)->count(); return $this->scheduledUserDishes->count() === User::all()->count();
} }
} }

View file

@ -12,25 +12,17 @@
* @property int $id * @property int $id
* @property int $schedule_id * @property int $schedule_id
* @property Schedule $schedule * @property Schedule $schedule
* @property int $user_id
* @property User $user
* @property int $user_dish_id * @property int $user_dish_id
* @property UserDish $userDish * @property UserDish $userDish
* @property bool $is_skipped * @property bool $is_skipped
* @method static create(array $array) * @method static create(array $array)
* @method static ScheduledUserDishFactory factory($count = null, $state = []) * @method static ScheduledUserDishFactory factory($count = null, $state = [])
* @method static firstOrCreate(array $array, array $array1)
*/ */
class ScheduledUserDish extends Model class ScheduledUserDish extends Model
{ {
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = ['schedule_id', 'user_id', 'user_dish_id', 'is_skipped'];
'schedule_id',
'user_id',
'user_dish_id',
'is_skipped'
];
protected $casts = [ protected $casts = [
'is_skipped' => 'boolean', 'is_skipped' => 'boolean',

View file

@ -9,7 +9,8 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/** /**
* @property int $id * @property int $id
@ -19,17 +20,22 @@
* @property Collection<UserDish> $userDishes * @property Collection<UserDish> $userDishes
* @method static User findOrFail(int $user_id) * @method static User findOrFail(int $user_id)
* @method static UserFactory factory($count = null, $state = []) * @method static UserFactory factory($count = null, $state = [])
* @method static create(array $array)
* @method static where(string $string, int|string|null $id)
*/ */
class User extends Model class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory; use HasFactory, Notifiable;
protected $fillable = [ protected $fillable = [
'planner_id', 'planner_id',
'name', 'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
]; ];
protected static function booted(): void protected static function booted(): void
@ -37,6 +43,19 @@ protected static function booted(): void
static::addGlobalScope(new BelongsToPlanner); static::addGlobalScope(new BelongsToPlanner);
} }
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function dishes(): BelongsToMany public function dishes(): BelongsToMany
{ {
return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id'); return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id');

View file

@ -4,12 +4,10 @@
use App\Exceptions\CustomException; use App\Exceptions\CustomException;
use App\Models\Dish; use App\Models\Dish;
use App\Models\Planner;
use App\Models\Schedule; use App\Models\Schedule;
use App\Models\ScheduledUserDish; use App\Models\ScheduledUserDish;
use App\Models\User; use App\Models\User;
use App\Models\UserDish; use App\Models\UserDish;
use Laravel\Cashier\Cashier;
use DishPlanner\Dish\Policies\DishPolicy; use DishPlanner\Dish\Policies\DishPolicy;
use DishPlanner\Schedule\Policies\SchedulePolicy; use DishPlanner\Schedule\Policies\SchedulePolicy;
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy; use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
@ -47,8 +45,6 @@ public function render($request, Throwable $e)
public function boot(): void public function boot(): void
{ {
Cashier::useCustomerModel(Planner::class);
Gate::policy(Dish::class, DishPolicy::class); Gate::policy(Dish::class, DishPolicy::class);
Gate::policy(Schedule::class, SchedulePolicy::class); Gate::policy(Schedule::class, SchedulePolicy::class);
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class); Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);

View file

@ -1,17 +0,0 @@
<?php
use App\Enums\AppModeEnum;
if (! function_exists('is_mode_app')) {
function is_mode_app(): bool
{
return AppModeEnum::current()->isApp();
}
}
if (! function_exists('is_mode_saas')) {
function is_mode_saas(): bool
{
return AppModeEnum::current()->isSaas();
}
}

View file

@ -1,15 +1,19 @@
<?php <?php
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\RequireSaasMode; use App\Http\Middleware\HandleResourceNotFound;
use App\Http\Middleware\RequireSubscription;
use App\Services\OutputService; use App\Services\OutputService;
use DishPlanner\Dish\Controllers\DishController;
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Session\Middleware\StartSession;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -19,24 +23,12 @@
api: __DIR__.'/../routes/api.php', api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function () {
Route::middleware('web')
->group(base_path('routes/web/subscription.php'));
},
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// Apply ForceJsonResponse only to API routes // Apply ForceJsonResponse only to API routes
$middleware->api(ForceJsonResponse::class); $middleware->api(ForceJsonResponse::class);
$middleware->append(StartSession::class);
$middleware->alias([ $middleware->append(HandleCors::class);
'subscription' => RequireSubscription::class,
'saas' => RequireSaasMode::class,
]);
// Exclude Stripe webhook from CSRF verification
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
@ -50,31 +42,21 @@
/** @var OutputService $outputService */ /** @var OutputService $outputService */
$outputService = resolve(OutputService::class); $outputService = resolve(OutputService::class);
$exceptions->render(function (ValidationException $e, Request $request) use ($outputService) { $exceptions->render(fn (ValidationException $e, Request $request) => $outputService
if ($request->is('api/*') || $request->expectsJson()) { ->response(false, null, [$e->getMessage()], 404)
return response()->json( );
$outputService->response(false, null, [$e->getMessage()]),
404
);
}
});
$exceptions->render(function (NotFoundHttpException $e, Request $request) use ($outputService) { $exceptions->render(fn (NotFoundHttpException $e, Request $request) => response()->json(
if ($request->is('api/*') || $request->expectsJson()) { $outputService->response(false, null, ['MODEL_NOT_FOUND']),
return response()->json( 404
$outputService->response(false, null, ['MODEL_NOT_FOUND']), ));
404
);
}
});
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) use ($outputService) { $exceptions->render(fn (AccessDeniedHttpException $e, Request $request) => response()->json(
if ($request->is('api/*') || $request->expectsJson()) { $outputService->response(false, null, [$e->getMessage()]),
return response()->json( 403
$outputService->response(false, null, [$e->getMessage()]), ));
403
);
}
});
}) })
->withCommands([
GenerateScheduleCommand::class,
])
->create(); ->create();

View file

@ -10,7 +10,6 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/cashier": "^16.1",
"laravel/framework": "^12.9.2", "laravel/framework": "^12.9.2",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
@ -18,7 +17,6 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/dusk": "^8.3",
"laravel/pail": "^1.1", "laravel/pail": "^1.1",
"laravel/pint": "^1.13", "laravel/pint": "^1.13",
"laravel/sail": "^1.26", "laravel/sail": "^1.26",
@ -27,9 +25,6 @@
"phpunit/phpunit": "^11.0.1" "phpunit/phpunit": "^11.0.1"
}, },
"autoload": { "autoload": {
"files": [
"app/helpers.php"
],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
"DishPlanner\\": "src/DishPlanner/", "DishPlanner\\": "src/DishPlanner/",
@ -61,17 +56,6 @@
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
],
"test": [
"@php artisan test"
],
"test:coverage": [
"Composer\\Config::disableProcessTimeout",
"@php -d xdebug.mode=coverage artisan test --coverage"
],
"test:coverage-html": [
"Composer\\Config::disableProcessTimeout",
"@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html coverage --coverage-text"
] ]
}, },
"extra": { "extra": {

View file

@ -13,7 +13,7 @@
| |
*/ */
'name' => env('APP_NAME', 'Dish Planner'), 'name' => env('APP_NAME', 'Laravel'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -28,18 +28,6 @@
'env' => env('APP_ENV', 'production'), 'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Mode
|--------------------------------------------------------------------------
|
| Determines the application deployment mode: 'app' for self-hosted,
| 'saas' for multi-tenant SaaS, 'demo' for demonstration instances.
|
*/
'mode' => env('APP_MODE', 'app'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Debug Mode | Application Debug Mode

View file

@ -35,14 +35,4 @@
], ],
], ],
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook' => [
'secret' => env('STRIPE_WEBHOOK_SECRET'),
],
'price_monthly' => env('STRIPE_PRICE_MONTHLY'),
'price_yearly' => env('STRIPE_PRICE_YEARLY'),
],
]; ];

View file

@ -29,7 +29,7 @@ public function up(): void
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('user_dish_id')->references('id')->on('user_dishes')->onDelete('cascade'); $table->foreign('user_dish_id')->references('id')->on('user_dishes')->onDelete('cascade');
$table->unique(['schedule_id', 'user_id']); $table->unique(['schedule_id', 'user_dish_id']);
$table->index('user_dish_id'); $table->index('user_dish_id');
}); });
} }

View file

@ -1,34 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('planners', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
public function down(): void
{
Schema::table('planners', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View file

@ -1,37 +0,0 @@
<?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('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('planner_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['planner_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View file

@ -1,34 +0,0 @@
<?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('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View file

@ -1,28 +0,0 @@
<?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('subscription_items', function (Blueprint $table) {
$table->string('meter_id')->nullable()->after('stripe_price');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_id');
});
}
};

View file

@ -1,28 +0,0 @@
<?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('subscription_items', function (Blueprint $table) {
$table->string('meter_event_name')->nullable()->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_event_name');
});
}
};

View file

@ -1,76 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DevelopmentSeeder extends Seeder
{
public function run(): void
{
// Only run in development environment, not during testing
if (app()->environment('testing')) {
return;
}
// Create main planner
$planner = Planner::factory()->create([
'name' => 'Development Planner',
'email' => 'myrmidex@myrmidex.net',
'password' => Hash::make('Password'),
]);
// Create a few users (users don't have email/password - only planners do)
$users = collect([
User::factory()->create([
'planner_id' => $planner->id,
'name' => 'Alice Johnson',
]),
User::factory()->create([
'planner_id' => $planner->id,
'name' => 'Bob Smith',
]),
User::factory()->create([
'planner_id' => $planner->id,
'name' => 'Charlie Brown',
]),
]);
// Create various dishes
$dishNames = [
'Spaghetti Bolognese',
'Chicken Curry',
'Caesar Salad',
'Beef Stir Fry',
'Vegetable Lasagne',
'Fish Tacos',
'Mushroom Risotto',
'BBQ Ribs',
'Greek Salad',
'Pad Thai',
'Margherita Pizza',
'Beef Burger',
'Chicken Fajitas',
'Vegetable Soup',
'Salmon Teriyaki',
];
foreach ($dishNames as $dishName) {
$dish = Dish::factory()->create([
'planner_id' => $planner->id,
'name' => $dishName,
]);
// Randomly assign dish to 1-3 users
$assignedUsers = $users->random(rand(1, 3));
$dish->users()->attach($assignedUsers->pluck('id'));
}
$this->command->info('Development data seeded successfully!');
$this->command->info('Login credentials: myrmidex@myrmidex.net / Password');
}
}

View file

@ -11,34 +11,23 @@ class DishesSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
/** @var Planner $planner */ $users = User::all();
$planner = Planner::first() ?? Planner::factory()->create(); $userOptions = collect([
[$users->first()],
[$users->last()],
[$users->first(), $users->last()],
]);
// Get users belonging to this planner $planner = Planner::all()->first() ?? Planner::factory()->create();
$users = User::where('planner_id', $planner->id)->get();
if ($users->isEmpty()) {
$this->command->warn('No users found for planner. Skipping dishes seeder.');
return;
}
$userIds = $users->pluck('id')->toArray();
// Build possible user combinations (individual users + all users together)
$userOptions = collect($userIds)->map(fn ($id) => [$id])->toArray();
$userOptions[] = $userIds; // all users
collect([ collect([
'Lasagne', 'Pizza', 'Burger', 'Fries', 'Salad', 'Sushi', 'Pancakes', 'Ice Cream', 'Spaghetti', 'Mac and Cheese', 'lasagne', 'pizza', 'burger', 'fries', 'salad', 'sushi', 'pancakes', 'ice cream', 'spaghetti', 'mac and cheese',
'Steak', 'Chicken', 'Beef', 'Pork', 'Fish', 'Chips', 'Cake', 'steak', 'chicken', 'beef', 'pork', 'fish', 'chips', 'cake',
])->each(function (string $name) use ($planner, $userOptions) { ])->map(fn (string $name) => Dish::factory()
$dish = Dish::factory()->create([ ->create([
'planner_id' => $planner->id, 'planner_id' => $planner->id,
'name' => $name, 'name' => $name,
]); ])
)->each(fn (Dish $dish) => $dish->users()->attach($userOptions->random()));
$dish->users()->attach($userOptions[array_rand($userOptions)]);
});
} }
} }

View file

@ -63,8 +63,8 @@ services:
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}" MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
# Initialize with SQL scripts # Optional: Initialize with SQL dump
- ./docker/mysql-init:/docker-entrypoint-initdb.d # - ./database/dumps:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s interval: 10s
@ -84,21 +84,6 @@ services:
networks: networks:
- dishplanner - dishplanner
# Selenium for E2E testing with Dusk
selenium:
image: selenium/standalone-chrome:latest
container_name: dishplanner_selenium
restart: unless-stopped
ports:
- "4444:4444" # Selenium server
- "7900:7900" # VNC server for debugging
volumes:
- /dev/shm:/dev/shm
networks:
- dishplanner
environment:
- SE_VNC_PASSWORD=secret
# Optional: Redis for caching/sessions # Optional: Redis for caching/sessions
# redis: # redis:
# image: redis:alpine # image: redis:alpine

View file

@ -1,8 +0,0 @@
-- Create test database for Dusk E2E tests
CREATE DATABASE IF NOT EXISTS dishplanner_test;
-- Grant all privileges on test database to the dishplanner user
GRANT ALL PRIVILEGES ON dishplanner_test.* TO 'dishplanner'@'%' IDENTIFIED BY 'dishplanner';
GRANT ALL PRIVILEGES ON dishplanner_test.* TO 'dishplanner'@'localhost' IDENTIFIED BY 'dishplanner';
FLUSH PRIVILEGES;

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false">
<testsuites>
<testsuite name="Browser Test Suite">
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
</phpunit>

View file

@ -15,21 +15,8 @@
<source> <source>
<include> <include>
<directory>app</directory> <directory>app</directory>
<directory>src</directory>
</include> </include>
<exclude>
<directory>app/Console</directory>
<directory>app/Exceptions</directory>
<directory>app/Providers</directory>
</exclude>
</source> </source>
<coverage>
<report>
<html outputDirectory="coverage"/>
<text outputFile="coverage/coverage.txt" showOnlySummary="true"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

View file

@ -94,38 +94,3 @@ .button-accent-outline {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
/* Checkbox Styles */
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
background-color: var(--color-gray-600);
border: 1px solid var(--color-secondary);
border-radius: 0.25rem;
cursor: pointer;
position: relative;
}
input[type="checkbox"]:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
input[type="checkbox"]:focus {
outline: none;
box-shadow: 0 0 0 2px var(--color-accent-blue);
}

View file

@ -1 +1,5 @@
import './bootstrap'; import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View file

@ -1,37 +0,0 @@
@extends('components.layouts.guest')
@section('content')
<h2 class="text-xl text-center text-accent-blue mb-4">Sign in to your account</h2>
<form method="POST" action="{{ route('login') }}">
@csrf
<x-input
type="email"
name="email"
label="Email"
placeholder="you@example.com"
required
autofocus
/>
<x-input
type="password"
name="password"
label="Password"
placeholder="Enter your password"
required
/>
<x-checkbox name="remember" label="Remember me" />
<x-button type="submit" class="w-full">
Sign In
</x-button>
<div class="text-center mt-4">
<span class="text-sm text-gray-400">Don't have an account?</span>
<a href="{{ route('register') }}" class="text-accent-blue hover:underline text-sm ml-1">Register</a>
</div>
</form>
@endsection

View file

@ -1,51 +0,0 @@
@extends('components.layouts.guest')
@section('content')
<h2 class="text-xl text-center text-accent-blue mb-4">Create an account</h2>
<form method="POST" action="{{ route('register') }}">
@csrf
<x-input
type="text"
name="name"
label="Name"
placeholder="Enter your name"
required
autofocus
/>
<x-input
type="email"
name="email"
label="Email"
placeholder="Enter your email"
required
/>
<x-input
type="password"
name="password"
label="Password"
placeholder="Enter your password"
required
/>
<x-input
type="password"
name="password_confirmation"
label="Confirm Password"
placeholder="Confirm your password"
required
/>
<x-button type="submit" class="w-full">
Register
</x-button>
<div class="text-center mt-4">
<span class="text-sm text-gray-400">Already have an account?</span>
<a href="{{ route('login') }}" class="text-accent-blue hover:underline text-sm ml-1">Login</a>
</div>
</form>
@endsection

View file

@ -1,106 +0,0 @@
<x-layouts.app>
<div class="px-4 sm:px-6 lg:px-8" x-data="{ showCancelModal: false }">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">BILLING</h1>
@if (session('success'))
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
<p class="text-success">{{ session('success') }}</p>
</div>
@endif
@if (session('error'))
<div class="mb-6 border-2 border-danger rounded-lg p-4 bg-danger/10">
<p class="text-danger">{{ session('error') }}</p>
</div>
@endif
<div class="border-2 border-secondary rounded-lg p-6">
<h3 class="text-xl font-bold text-primary mb-6">Subscription Details</h3>
<div class="space-y-4">
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
<span class="text-gray-300">Plan</span>
<span class="text-white font-bold">{{ $planType }}</span>
</div>
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
<span class="text-gray-300">Status</span>
<span class="font-bold {{ $subscription->stripe_status === 'active' ? 'text-success' : 'text-warning' }}">
{{ ucfirst($subscription->stripe_status) }}
</span>
</div>
@if($nextBillingDate)
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
<span class="text-gray-300">Next billing date</span>
<span class="text-white">{{ $nextBillingDate->format('F j, Y') }}</span>
</div>
@endif
@if($subscription->ends_at)
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
<span class="text-gray-300">Access until</span>
<span class="text-warning">{{ $subscription->ends_at->format('F j, Y') }}</span>
</div>
@endif
@if($planner->pm_last_four)
<div class="flex justify-between items-center pb-4">
<span class="text-gray-300">Payment method</span>
<span class="text-white">
<span class="text-gray-400">{{ ucfirst($planner->pm_type ?? 'Card') }}</span>
•••• {{ $planner->pm_last_four }}
<a href="{{ route('billing.portal') }}" class="ml-2 text-accent-blue hover:underline text-sm">Update</a>
</span>
</div>
@endif
</div>
@if(!$subscription->ends_at)
<div class="mt-6 pt-6 border-t border-gray-600">
<button @click="showCancelModal = true" class="text-danger hover:text-red-400 text-sm transition-colors">
Cancel subscription
</button>
</div>
@endif
</div>
</div>
<!-- Cancel Confirmation Modal -->
<div x-show="showCancelModal"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div @click.away="showCancelModal = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="bg-gray-700 border-2 border-secondary rounded-lg p-6 max-w-md mx-4">
<h3 class="text-xl font-bold text-white mb-4">Cancel Subscription?</h3>
<p class="text-gray-300 mb-6">
Are you sure you want to cancel your subscription? You will retain access until the end of your current billing period.
</p>
<div class="flex justify-end space-x-4">
<button @click="showCancelModal = false" class="px-4 py-2 text-gray-300 hover:text-white transition-colors">
Keep subscription
</button>
<form action="{{ route('subscription.cancel') }}" method="POST">
@csrf
<button type="submit" class="px-4 py-2 bg-danger hover:bg-red-600 text-white rounded transition-colors">
Cancel subscription
</button>
</form>
</div>
</div>
</div>
</div>
</x-layouts.app>

View file

@ -1,20 +0,0 @@
@props([
'variant' => 'primary',
'type' => 'button',
])
@php
$baseClasses = 'px-4 py-2 rounded transition-colors duration-200 disabled:opacity-50';
$variantClasses = match($variant) {
'primary' => 'bg-primary text-white hover:bg-secondary',
'outline' => 'border-2 border-secondary text-gray-100 hover:bg-gray-700',
'danger' => 'bg-danger text-white hover:bg-red-700',
'danger-outline' => 'border-2 border-danger text-danger hover:bg-danger hover:text-white',
default => 'bg-secondary text-white hover:bg-secondary',
};
@endphp
<button type="{{ $type }}" {{ $attributes->merge(['class' => "$baseClasses $variantClasses"]) }}>
{{ $slot }}
</button>

View file

@ -1,7 +0,0 @@
@props([
'padding' => true,
])
<div {{ $attributes->merge(['class' => 'border-2 border-secondary rounded-lg bg-gray-650' . ($padding ? ' p-6' : '')]) }}>
{{ $slot }}
</div>

View file

@ -1,17 +0,0 @@
@props([
'name',
'label' => null,
'checked' => false,
])
<div class="mb-4">
<label class="inline-flex items-center">
<input type="checkbox"
name="{{ $name }}"
{{ $checked ? 'checked' : '' }}
{{ $attributes }}>
@if($label)
<span class="ml-2 text-sm">{{ $label }}</span>
@endif
</label>
</div>

View file

@ -1,28 +0,0 @@
@props([
'type' => 'text',
'name',
'label' => null,
'placeholder' => '',
'value' => null,
'required' => false,
'autofocus' => false,
])
<div class="mb-4">
@if($label)
<label for="{{ $name }}" class="block text-sm font-medium mb-1">{{ $label }}</label>
@endif
<input type="{{ $type }}"
id="{{ $name }}"
name="{{ $name }}"
value="{{ old($name, $value) }}"
placeholder="{{ $placeholder }}"
{{ $required ? 'required' : '' }}
{{ $autofocus ? 'autofocus' : '' }}
{{ $attributes->merge(['class' => 'w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue' . ($errors->has($name) ? ' border-red-500' : '')]) }}>
@error($name)
<span class="text-red-500 text-xs mt-1 block">{{ $message }}</span>
@enderror
</div>

View file

@ -11,7 +11,7 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="font-sans antialiased bg-gray-600 text-gray-100" x-data="{ mobileMenuOpen: false }"> <body class="font-sans antialiased bg-gray-600 text-gray-100">
<div class="min-h-screen"> <div class="min-h-screen">
<!-- Navigation --> <!-- Navigation -->
<nav class="border-b-2 border-secondary shadow-sm z-50 mb-8 bg-gray-700"> <nav class="border-b-2 border-secondary shadow-sm z-50 mb-8 bg-gray-700">
@ -20,31 +20,30 @@
<div class="flex items-center"> <div class="flex items-center">
<!-- Logo --> <!-- Logo -->
<div class="flex-shrink-0 flex items-center"> <div class="flex-shrink-0 flex items-center">
<a href="{{ route('dashboard') }}" class="flex items-center"> <a href="{{ route('dashboard') }}" class="text-2xl font-syncopate text-primary">
<img src="{{ asset('images/logo-without-text.png') }}" alt="Dish Planner" class="h-8 mr-2"> DISH PLANNER
<span class="text-2xl font-syncopate text-primary relative top-[3px]">DISH PLANNER</span>
</a> </a>
</div> </div>
<!-- Navigation Links --> <!-- Navigation Links -->
@auth @auth
<div class="hidden space-x-8 sm:ml-10 sm:flex"> <div class="hidden space-x-8 sm:ml-10 sm:flex">
<a href="{{ route('dashboard') }}" <a href="{{ route('dashboard') }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dashboard') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition"> class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dashboard') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
Dashboard Dashboard
</a> </a>
<a href="{{ route('users.index') }}" <a href="{{ route('dishes.index') }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
Users
</a>
<a href="{{ route('dishes.index') }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dishes.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition"> class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dishes.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
Dishes Dishes
</a> </a>
<a href="{{ route('schedule.index') }}" <a href="{{ route('schedule.index') }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('schedule.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition"> class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('schedule.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
Schedule Schedule
</a> </a>
<a href="{{ route('users.index') }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
Users
</a>
</div> </div>
@endauth @endauth
</div> </div>
@ -59,17 +58,12 @@ class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->rout
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</button> </button>
<div x-show="open" <div x-show="open"
@click.away="open = false" @click.away="open = false"
x-cloak x-cloak
x-transition x-transition
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary"> class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary">
<div class="py-1"> <div class="py-1">
@if(is_mode_saas())
<a href="{{ route('billing') }}" class="block px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
Billing
</a>
@endif
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue"> <button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
@ -88,86 +82,18 @@ class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-
</div> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
<div class="-mr-2 flex items-center sm:hidden"> <div class="-mr-2 flex items-center sm:hidden" x-data="{ open: false }">
<button @click="mobileMenuOpen = !mobileMenuOpen" class="inline-flex items-center justify-center p-2 rounded-md text-gray-100 hover:text-accent-blue hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-accent-blue transition"> <button @click="open = !open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-100 hover:text-accent-blue hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-accent-blue transition">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24"> <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': mobileMenuOpen, 'inline-flex': !mobileMenuOpen }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path :class="{'hidden': open, 'inline-flex': !open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': !mobileMenuOpen, 'inline-flex': mobileMenuOpen }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path :class="{'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<!-- Mobile menu (full screen overlay) -->
<div x-show="mobileMenuOpen"
x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="sm:hidden fixed inset-0 z-50 bg-gray-700">
<!-- Close button -->
<div class="flex justify-end p-4">
<button @click="mobileMenuOpen = false" class="p-2 text-gray-100 hover:text-accent-blue">
<svg class="h-8 w-8" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Menu content -->
<div class="flex flex-col items-center justify-center h-full -mt-16">
@auth
<div class="space-y-6 text-center">
<a href="{{ route('dashboard') }}"
class="block text-2xl font-medium {{ request()->routeIs('dashboard') ? 'text-accent-blue' : 'text-gray-100' }}">
Dashboard
</a>
<a href="{{ route('users.index') }}"
class="block text-2xl font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100' }}">
Users
</a>
<a href="{{ route('dishes.index') }}"
class="block text-2xl font-medium {{ request()->routeIs('dishes.*') ? 'text-accent-blue' : 'text-gray-100' }}">
Dishes
</a>
<a href="{{ route('schedule.index') }}"
class="block text-2xl font-medium {{ request()->routeIs('schedule.*') ? 'text-accent-blue' : 'text-gray-100' }}">
Schedule
</a>
</div>
<div class="mt-12 pt-6 border-t border-secondary text-center">
<div class="text-gray-300 mb-4">{{ Auth::user()->name }}</div>
@if(is_mode_saas())
<a href="{{ route('billing') }}" class="block text-xl text-accent-blue mb-4">
Billing
</a>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-xl text-danger">
Logout
</button>
</form>
</div>
@else
<div class="space-y-6 text-center">
<a href="{{ route('login') }}" class="block text-2xl font-medium text-accent-blue">Login</a>
@if (Route::has('register'))
<a href="{{ route('register') }}" class="block text-2xl font-medium text-accent-blue">Register</a>
@endif
</div>
@endauth
</div>
</div>
<!-- Page Content --> <!-- Page Content -->
<main> <main>
{{ $slot }} {{ $slot }}
@ -175,60 +101,8 @@ class="block text-2xl font-medium {{ request()->routeIs('schedule.*') ? 'text-ac
</div> </div>
@livewireScripts @livewireScripts
{{-- CSRF Token Auto-Refresh for Livewire --}}
<script>
// Handle CSRF token expiration gracefully
Livewire.hook("request", ({ fail }) => {
fail(async ({ status, preventDefault, retry }) => {
if (status === 419) {
// Prevent the default error handling
preventDefault();
try {
// Fetch a new CSRF token
const response = await fetch("/refresh-csrf", {
method: "GET",
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
});
if (response.ok) {
const data = await response.json();
const newToken = data.token;
// Update the CSRF token in the meta tag
const csrfMeta = document.querySelector("meta[name='csrf-token']");
if (csrfMeta) {
csrfMeta.setAttribute("content", newToken);
}
// Update Livewire's CSRF token
if (window.Livewire && Livewire.csrfToken) {
Livewire.csrfToken = newToken;
}
// Retry the original request with the new token
retry();
} else {
// If we can't refresh the token, redirect to login
window.location.href = '/login';
}
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
// Fallback: redirect to login
window.location.href = '/login';
}
}
});
});
</script>
<style> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
</body> </body>
</html> </html>

View file

@ -12,71 +12,18 @@
@livewireStyles @livewireStyles
</head> </head>
<body class="font-sans antialiased bg-gray-600 text-gray-100"> <body class="font-sans antialiased bg-gray-600 text-gray-100">
<div class="min-h-screen flex flex-col items-center px-4 py-8"> <div class="min-h-screen flex flex-col items-center justify-center">
<!-- Logo --> <div class="lg:w-1/3 lg:mx-auto w-full px-4" style="margin-top: 15vh;">
<div class="w-full max-w-xs mb-8"> <div class="text-center mb-8">
<img src="{{ asset('images/logo-with-text.png') }}" alt="Dish Planner" class="w-full"> <h1 class="text-2xl font-syncopate text-primary">DISH PLANNER</h1>
</div> </div>
<!-- Login box --> <div class="border-2 border-secondary rounded-lg px-5 pt-5 pb-3 lg:pt-10 lg:pb-7 bg-gray-600">
<div class="w-full max-w-sm"> {{ $slot }}
<x-card> </div>
@yield('content')
</x-card>
</div> </div>
</div> </div>
@livewireScripts @livewireScripts
{{-- CSRF Token Auto-Refresh for Livewire --}}
<script>
// Handle CSRF token expiration gracefully
Livewire.hook("request", ({ fail }) => {
fail(async ({ status, preventDefault, retry }) => {
if (status === 419) {
// Prevent the default error handling
preventDefault();
try {
// Fetch a new CSRF token
const response = await fetch("/refresh-csrf", {
method: "GET",
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
credentials: "same-origin",
});
if (response.ok) {
const data = await response.json();
const newToken = data.token;
// Update the CSRF token in the meta tag
const csrfMeta = document.querySelector("meta[name='csrf-token']");
if (csrfMeta) {
csrfMeta.setAttribute("content", newToken);
}
// Update Livewire's CSRF token
if (window.Livewire && Livewire.csrfToken) {
Livewire.csrfToken = newToken;
}
// Retry the original request with the new token
retry();
} else {
console.error('Failed to refresh CSRF token');
// For guest layout, just retry once more or show error
window.location.reload();
}
} catch (error) {
console.error('Failed to refresh CSRF token:', error);
window.location.reload();
}
}
});
});
</script>
</body> </body>
</html> </html>

View file

@ -1,26 +0,0 @@
@props([
'name',
'label' => null,
'options' => [],
'selected' => null,
'required' => false,
])
<div class="mb-4">
@if($label)
<label for="{{ $name }}" class="block text-sm font-medium mb-2">{{ $label }}</label>
@endif
<select id="{{ $name }}"
name="{{ $name }}"
{{ $required ? 'required' : '' }}
{{ $attributes->merge(['class' => 'w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue']) }}>
@foreach($options as $value => $label)
<option value="{{ $value }}" {{ $selected == $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
@error($name)
<span class="text-danger text-xs mt-1 block">{{ $message }}</span>
@enderror
</div>

View file

@ -1,36 +0,0 @@
@props([
'users',
'selectedIds' => [],
'wireModel' => null,
'toggleAllMethod' => null,
'label' => 'Users',
])
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{ $label }}</label>
<div class="space-y-2 max-h-40 overflow-y-auto p-2 bg-gray-700 rounded border border-secondary">
@if($toggleAllMethod)
<!-- Select All -->
<label class="flex items-center cursor-pointer hover:bg-gray-600 p-1 rounded">
<input type="checkbox"
wire:click="{{ $toggleAllMethod }}"
{{ count($selectedIds) === count($users) && count($users) > 0 ? 'checked' : '' }}
class="mr-2">
<span class="text-accent-blue font-medium">Select All</span>
</label>
<hr class="border-secondary">
@endif
<!-- Individual users -->
@forelse($users as $user)
<label class="flex items-center cursor-pointer hover:bg-gray-600 p-1 rounded">
<input type="checkbox"
@if($wireModel) wire:model.live="{{ $wireModel }}" @endif
value="{{ $user->id }}"
class="mr-2">
<span>{{ $user->name }}</span>
</label>
@empty
<p class="text-gray-400 text-sm italic p-1">No users available.</p>
@endforelse
</div>
</div>

View file

@ -1,30 +1,23 @@
<x-layouts.app> <x-layouts.app>
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
@if (session('success')) <h1 class="text-2xl font-syncopate text-accent-blue mb-8">Welcome {{ auth()->user()->name }}!</h1>
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
<p class="text-success font-bold">Welcome to Dish Planner!</p>
<p class="text-gray-100 text-sm">Your subscription is now active. Start planning your dishes!</p>
</div>
@endif
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3>
<p class="text-gray-100">Add and manage planner users</p>
</a>
<a href="{{ route('dishes.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200"> <a href="{{ route('dishes.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-accent-blue mb-2">Manage Dishes</h3> <h3 class="text-xl font-bold text-primary mb-2">Manage Dishes</h3>
<p class="text-gray-100">Create and manage your dish collection</p> <p class="text-gray-100">Create and manage your dish collection</p>
</a> </a>
<a href="{{ route('schedule.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200"> <a href="{{ route('schedule.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-success mb-2">View Schedule</h3> <h3 class="text-xl font-bold text-accent-blue mb-2">View Schedule</h3>
<p class="text-gray-100">See your monthly dish schedule</p> <p class="text-gray-100">See your monthly dish schedule</p>
</a> </a>
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-success mb-2">Manage Users</h3>
<p class="text-gray-100">Add and manage planner users</p>
</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,45 @@
<div>
<h2 class="text-2xl text-center text-accent-blue mb-6">Login</h2>
<form wire:submit="login">
<div>
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<input wire:model="email"
type="email"
id="email"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter your email"
autofocus>
@error('email') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<input wire:model="password"
type="password"
id="password"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter your password">
@error('password') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
</div>
<div class="mb-4">
<label class="inline-flex items-center">
<input wire:model="remember"
type="checkbox"
class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue">
<span class="ml-2 text-sm">Remember me</span>
</label>
</div>
<button type="submit" class="w-full py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transition-colors duration-200 mb-4">
Login
</button>
<div class="text-center">
<a href="{{ route('register') }}" class="text-accent-blue hover:underline text-sm">
Don't have an account? Register here
</a>
</div>
</form>
</div>

View file

@ -0,0 +1,56 @@
<div>
<h2 class="text-2xl text-center text-accent-blue mb-6">Register</h2>
<form wire:submit="register">
<div>
<label for="name" class="block text-sm font-medium mb-2">Name</label>
<input wire:model="name"
type="text"
id="name"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter your name"
autofocus>
@error('name') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
</div>
<div>
<label for="email" class="block text-sm font-medium mb-2">Email</label>
<input wire:model="email"
type="email"
id="email"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter your email">
@error('email') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<input wire:model="password"
type="password"
id="password"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter your password">
@error('password') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium mb-2">Confirm Password</label>
<input wire:model="password_confirmation"
type="password"
id="password_confirmation"
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Confirm your password">
</div>
<button type="submit" class="w-full py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transition-colors duration-200 mb-4">
Register
</button>
<div class="text-center">
<a href="{{ route('login') }}" class="text-accent-blue hover:underline text-sm">
Already have an account? Login here
</a>
</div>
</form>
</div>

View file

@ -83,28 +83,34 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror @error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div> </div>
@if($users->count() > 0) <div class="mb-6">
<x-user-multi-select <label class="block text-sm font-medium mb-2">Assign to Users</label>
:users="$users" <div class="space-y-2 max-h-40 overflow-y-auto border border-secondary rounded p-3 bg-gray-700">
:selectedIds="$selectedUsers" @foreach($users as $user)
wireModel="selectedUsers" <label class="flex items-center">
toggleAllMethod="toggleAllUsers" <input type="checkbox"
label="Assign to Users" wire:model="selectedUsers"
/> value="{{ $user->id }}"
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue mr-2">
@else <div class="flex items-center">
<div class="mb-6"> <div class="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold mr-2">
<p class="text-gray-400 text-sm italic">No users available to assign. <a href="{{ route('users.index') }}" class="text-accent-blue hover:underline">Add users</a> to assign them to dishes.</p> {{ strtoupper(substr($user->name, 0, 1)) }}
</div>
{{ $user->name }}
</div>
</label>
@endforeach
</div> </div>
@endif @error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" <button type="button"
wire:click="cancel" wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200"> class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel Cancel
</button> </button>
<button type="submit" <button type="submit"
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200"> class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
Create Dish Create Dish
</button> </button>
@ -129,28 +135,34 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror @error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div> </div>
@if($users->count() > 0) <div class="mb-6">
<x-user-multi-select <label class="block text-sm font-medium mb-2">Assign to Users</label>
:users="$users" <div class="space-y-2 max-h-40 overflow-y-auto border border-secondary rounded p-3 bg-gray-700">
:selectedIds="$selectedUsers" @foreach($users as $user)
wireModel="selectedUsers" <label class="flex items-center">
toggleAllMethod="toggleAllUsers" <input type="checkbox"
label="Assign to Users" wire:model="selectedUsers"
/> value="{{ $user->id }}"
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue mr-2">
@else <div class="flex items-center">
<div class="mb-6"> <div class="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold mr-2">
<p class="text-gray-400 text-sm italic">No users available to assign. <a href="{{ route('users.index') }}" class="text-accent-blue hover:underline">Add users</a> to assign them to dishes.</p> {{ strtoupper(substr($user->name, 0, 1)) }}
</div>
{{ $user->name }}
</div>
</label>
@endforeach
</div> </div>
@endif @error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" <button type="button"
wire:click="cancel" wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200"> class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel Cancel
</button> </button>
<button type="submit" <button type="submit"
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200"> class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
Update Dish Update Dish
</button> </button>

View file

@ -32,159 +32,67 @@ class="px-4 py-2 bg-gray-700 text-accent-blue rounded hover:bg-gray-600 transiti
</button> </button>
</div> </div>
<!-- Calendar Grid - Desktop --> <!-- Calendar Grid -->
<div class="hidden md:block"> <div class="grid grid-cols-7 gap-2 mb-4">
<div class="grid grid-cols-7 gap-2 mb-4"> <!-- Days of week headers -->
<!-- Days of week headers --> @foreach(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day)
@foreach(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day) <div class="text-center text-accent-blue font-bold p-2">{{ $day }}</div>
<div class="text-center text-accent-blue font-bold p-2">{{ $day }}</div> @endforeach
@endforeach
</div>
<div class="grid grid-cols-7 gap-2">
@foreach($calendarDays as $dayData)
<div class="min-h-[120px] border rounded-lg p-2
{{ $dayData['day'] ? 'bg-gray-500' : 'bg-gray-800' }}
{{ $dayData['isToday'] ? 'border-2 border-accent-blue' : 'border-gray-600' }}">
@if($dayData['day'])
<!-- Day number and add button -->
<div class="flex justify-between items-center mb-2">
<span class="font-bold {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}">
{{ $dayData['day'] }}
</span>
<button wire:click="openAddDishModal('{{ $dayData['date']->format('Y-m-d') }}')"
class="w-5 h-5 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white rounded flex items-center justify-center text-sm transition-colors duration-200">
+
</button>
</div>
<!-- Scheduled dishes -->
@if($dayData['scheduledDishes']->isNotEmpty())
<div class="space-y-1">
@foreach($dayData['scheduledDishes'] as $scheduled)
<div class="bg-primary rounded p-1 text-xs text-white flex items-center justify-between">
<div class="flex items-center">
<div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1">
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
</div>
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</span>
</div>
<!-- Action buttons -->
<div class="flex space-x-1" x-data="{ showActions: false }">
<button @click="showActions = !showActions"
class="text-white hover:text-gray-300">
</button>
<div x-show="showActions"
@click.away="showActions = false"
x-cloak
class="absolute bg-gray-700 border border-secondary rounded mt-4 ml-4 shadow-lg z-10">
<button wire:click="editDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-white">
Edit
</button>
<button wire:click="regenerateForUserDate('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-accent-blue">
Regenerate
</button>
<button wire:click="skipDay('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-warning">
Skip
</button>
<button wire:click="removeDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-danger">
Remove
</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-gray-400 text-xs">No dishes scheduled</div>
@endif
@endif
</div>
@endforeach
</div>
</div> </div>
<!-- Calendar List - Mobile --> <div class="grid grid-cols-7 gap-2">
<div class="md:hidden space-y-2">
@foreach($calendarDays as $dayData) @foreach($calendarDays as $dayData)
@if($dayData['day']) <div class="min-h-[120px] border rounded-lg p-2
<div class="border rounded-lg p-3 {{ $dayData['day'] ? 'bg-gray-500' : 'bg-gray-800' }}
{{ $dayData['isToday'] ? 'border-2 border-accent-blue bg-gray-600' : 'border-gray-600 bg-gray-500' }}"> {{ $dayData['isToday'] ? 'border-2 border-accent-blue' : 'border-gray-600' }}">
<!-- Day header --> @if($dayData['day'])
<div class="flex items-center justify-between mb-2"> <!-- Day number -->
<div class="font-bold {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}"> <div class="font-bold mb-2 {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}">
{{ $dayData['date']->format('D, M j') }} {{ $dayData['day'] }}
@if($dayData['isToday'])
<span class="text-xs ml-2">(Today)</span>
@endif
</div>
<button wire:click="openAddDishModal('{{ $dayData['date']->format('Y-m-d') }}')"
class="w-7 h-7 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white rounded flex items-center justify-center text-lg transition-colors duration-200">
+
</button>
</div> </div>
<!-- Scheduled dishes --> <!-- Scheduled dishes -->
@if($dayData['scheduledDishes']->isNotEmpty()) @if($dayData['scheduledDishes']->isNotEmpty())
<div class="space-y-2"> <div class="space-y-1">
@foreach($dayData['scheduledDishes'] as $scheduled) @foreach($dayData['scheduledDishes'] as $scheduled)
<div class="bg-primary rounded p-2 text-sm text-white flex items-center justify-between"> <div class="bg-primary rounded p-1 text-xs text-white flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-6 h-6 bg-white text-primary rounded-full flex items-center justify-center text-sm font-bold mr-2"> <div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1">
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }} {{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
</div> </div>
<div> <span class="truncate">{{ $scheduled->dish->name }}</span>
<div class="font-medium">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</div>
<div class="text-xs opacity-75">{{ $scheduled->user->name }}</div>
</div>
</div> </div>
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex space-x-2" x-data="{ showActions: false }"> <div class="flex space-x-1" x-data="{ showActions: false }">
<button @click="showActions = !showActions" <button @click="showActions = !showActions"
class="text-white hover:text-gray-300 p-1"> class="text-white hover:text-gray-300">
</button> </button>
<div x-show="showActions" <div x-show="showActions"
@click.away="showActions = false" @click.away="showActions = false"
x-cloak x-cloak
class="absolute right-4 bg-gray-700 border border-secondary rounded shadow-lg z-10"> class="absolute bg-gray-700 border border-secondary rounded mt-4 ml-4 shadow-lg z-10">
<button wire:click="editDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-white">
Edit
</button>
<button wire:click="regenerateForUserDate('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})" <button wire:click="regenerateForUserDate('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-accent-blue"> class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-accent-blue">
Regenerate Regenerate
</button> </button>
<button wire:click="skipDay('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})" <button wire:click="skipDay('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-warning"> class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-danger">
Skip Skip
</button> </button>
<button wire:click="removeDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-danger">
Remove
</button>
</div> </div>
</div> </div>
</div> </div>
@endforeach @endforeach
</div> </div>
@else @else
<div class="text-gray-400 text-sm">No dishes scheduled</div> <div class="text-gray-400 text-xs">No dishes scheduled</div>
@endif @endif
</div> @endif
@endif </div>
@endforeach @endforeach
</div> </div>
@ -197,13 +105,13 @@ class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-danger">
<p class="text-gray-100 mb-6"> <p class="text-gray-100 mb-6">
This will clear the selected day and allow for regeneration. Continue? This will clear the selected day and allow for regeneration. Continue?
</p> </p>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button wire:click="cancel" <button wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200"> class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel Cancel
</button> </button>
<button wire:click="confirmRegenerate" <button wire:click="confirmRegenerate"
class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-colors duration-200"> class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-colors duration-200">
Regenerate Regenerate
</button> </button>
@ -212,103 +120,6 @@ class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-co
</div> </div>
@endif @endif
<!-- Edit Dish Modal -->
@if($showEditDishModal)
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-600 border-2 border-secondary rounded-lg p-6 w-full max-w-md mx-4">
<h2 class="text-xl text-accent-blue mb-4">Edit Dish</h2>
<p class="text-gray-300 text-sm mb-4">
Choose a dish for <strong>{{ \App\Models\User::find($editUserId)?->name }}</strong> on {{ \Carbon\Carbon::parse($editDate)->format('M j, Y') }}
</p>
@if(count($availableDishes) > 0)
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Dish</label>
<select wire:model="selectedDishId"
class="w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
<option value="">Select a dish...</option>
@foreach($availableDishes as $dish)
<option value="{{ $dish->id }}">{{ $dish->name }}</option>
@endforeach
</select>
</div>
@else
<div class="mb-6">
<p class="text-gray-400 text-sm italic">
No dishes available for this user.
<a href="{{ route('dishes.index') }}" class="text-accent-blue hover:underline">Add dishes</a> first.
</p>
</div>
@endif
<div class="flex justify-end space-x-3">
<button wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel
</button>
@if(count($availableDishes) > 0)
<button wire:click="saveDish"
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
Save
</button>
@endif
</div>
</div>
</div>
@endif
<!-- Add Dish Modal -->
@if($showAddDishModal)
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-600 border-2 border-secondary rounded-lg p-6 w-full max-w-md mx-4">
<h2 class="text-xl text-accent-blue mb-4">Add Dish</h2>
<p class="text-gray-300 text-sm mb-4">
Add a dish for {{ \Carbon\Carbon::parse($addDate)->format('M j, Y') }}
</p>
<x-user-multi-select
:users="$addAvailableUsers"
:selectedIds="$addUserIds"
wireModel="addUserIds"
toggleAllMethod="toggleAllUsers"
/>
@if(count($addUserIds) > 0)
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Dish</label>
@if(count($addAvailableDishes) > 0)
<select wire:model="addSelectedDishId"
class="w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
<option value="">Select a dish...</option>
@foreach($addAvailableDishes as $dish)
<option value="{{ $dish->id }}">{{ $dish->name }}</option>
@endforeach
</select>
@else
<p class="text-gray-400 text-sm italic">
No dishes in common for selected users.
<a href="{{ route('dishes.index') }}" class="text-accent-blue hover:underline">Add dishes</a> first.
</p>
@endif
</div>
@endif
<div class="flex justify-end space-x-3">
<button wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel
</button>
@if(count($addAvailableUsers) > 0 && count($addAvailableDishes) > 0)
<button wire:click="saveAddDish"
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
Add
</button>
@endif
</div>
</div>
</div>
@endif
<style> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>

View file

@ -1,10 +1,6 @@
<div class="bg-gray-700 border-2 border-secondary rounded-lg p-6 mb-6" x-data="{ open: window.innerWidth >= 768 }"> <div class="bg-gray-700 border-2 border-secondary rounded-lg p-6 mb-6">
<button @click="open = !open" class="w-full flex justify-between items-center md:cursor-default"> <h3 class="text-lg font-bold text-accent-blue mb-4">Generate Schedule</h3>
<h3 class="text-lg font-bold text-accent-blue">Generate Schedule</h3>
<span class="md:hidden text-accent-blue" x-text="open ? '' : '+'"></span>
</button>
<div x-show="open" x-collapse class="mt-4 md:!block" x-cloak>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<!-- Month Selection --> <!-- Month Selection -->
<div> <div>
@ -90,8 +86,8 @@ class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-col
<span wire:loading wire:target="generate">Generating...</span> <span wire:loading wire:target="generate">Generating...</span>
</button> </button>
<button wire:click="clearMonth" <button wire:click="clearMonth"
class="px-4 py-2 border-2 border-danger text-danger rounded hover:bg-danger hover:text-white transition-colors duration-200"> class="px-4 py-2 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200">
Clear Month Clear Month
</button> </button>
</div> </div>
@ -106,5 +102,4 @@ class="px-4 py-2 border-2 border-danger text-danger rounded hover:bg-danger hove
Generating schedule... Generating schedule...
</div> </div>
</div> </div>
</div>
</div> </div>

View file

@ -30,20 +30,21 @@ class="py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transi
</div> </div>
<div> <div>
<h3 class="text-lg font-bold text-gray-100">{{ $user->name }}</h3> <h3 class="text-lg font-bold text-gray-100">{{ $user->name }}</h3>
<p class="text-gray-300">{{ $user->email }}</p>
</div> </div>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button wire:click="edit({{ $user->id }})" <button wire:click="edit({{ $user->id }})"
data-testid="user-edit-{{ $user->id }}"
class="px-3 py-1 bg-accent-blue text-gray-900 rounded hover:bg-secondary transition-colors duration-200"> class="px-3 py-1 bg-accent-blue text-gray-900 rounded hover:bg-secondary transition-colors duration-200">
Edit Edit
</button> </button>
<button wire:click="confirmDelete({{ $user->id }})" @if($user->id !== auth()->id())
data-testid="user-delete-{{ $user->id }}" <button wire:click="confirmDelete({{ $user->id }})"
class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200"> class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200">
Delete Delete
</button> </button>
@endif
</div> </div>
</div> </div>
@empty @empty
@ -65,7 +66,7 @@ class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors
<h2 class="text-xl text-accent-blue mb-4">Add New User</h2> <h2 class="text-xl text-accent-blue mb-4">Add New User</h2>
<form wire:submit="store"> <form wire:submit="store">
<div class="mb-6"> <div class="mb-4">
<label class="block text-sm font-medium mb-2">Name</label> <label class="block text-sm font-medium mb-2">Name</label>
<input wire:model="name" <input wire:model="name"
type="text" type="text"
@ -74,6 +75,32 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror @error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div> </div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Email</label>
<input wire:model="email"
type="email"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter email">
@error('email') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Password</label>
<input wire:model="password"
type="password"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Enter password">
@error('password') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Confirm Password</label>
<input wire:model="password_confirmation"
type="password"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Confirm password">
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" <button type="button"
wire:click="cancel" wire:click="cancel"
@ -97,15 +124,39 @@ class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-col
<h2 class="text-xl text-accent-blue mb-4">Edit User</h2> <h2 class="text-xl text-accent-blue mb-4">Edit User</h2>
<form wire:submit="update"> <form wire:submit="update">
<div class="mb-6"> <div class="mb-4">
<label class="block text-sm font-medium mb-2">Name</label> <label class="block text-sm font-medium mb-2">Name</label>
<input wire:model="name" <input wire:model="name"
type="text" type="text"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue" class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
placeholder="Enter name">
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror @error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div> </div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Email</label>
<input wire:model="email"
type="email"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
@error('email') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">New Password (leave blank to keep current)</label>
<input wire:model="password"
type="password"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="New password (optional)">
@error('password') <span class="text-danger text-xs">{{ $message }}</span> @enderror
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Confirm New Password</label>
<input wire:model="password_confirmation"
type="password"
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
placeholder="Confirm new password">
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button type="button" <button type="button"
wire:click="cancel" wire:click="cancel"

View file

@ -1,5 +1,5 @@
<x-layouts.app> <x-layouts.app>
<div class="px-4 sm:px-6 lg:px-8 pb-12"> <div class="px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<livewire:schedule.schedule-calendar /> <livewire:schedule.schedule-calendar />
</div> </div>

View file

@ -1,51 +0,0 @@
<x-layouts.app>
<div class="px-4 sm:px-6 lg:px-8">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">SUBSCRIPTION</h1>
@if(auth()->user()->subscribed())
<div class="border-2 border-success rounded-lg p-6 mb-6">
<h3 class="text-xl font-bold text-success mb-2">Active Subscription</h3>
<p class="text-gray-100 mb-4">You have an active subscription.</p>
<form action="{{ route('subscription.cancel') }}" method="POST">
@csrf
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded transition-colors duration-200">
Cancel Subscription
</button>
</form>
</div>
@else
<div class="border-2 border-secondary rounded-lg p-6">
<h3 class="text-xl font-bold text-primary mb-4">Subscribe to Dish Planner</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form action="{{ route('subscription.checkout') }}" method="POST">
@csrf
<input type="hidden" name="plan" value="monthly">
<div class="border border-gray-600 rounded-lg p-4 hover:border-accent-blue transition-colors">
<h4 class="text-lg font-bold text-white mb-2">Monthly</h4>
<p class="text-gray-300 mb-4">Billed monthly</p>
<button type="submit" class="w-full bg-accent-blue hover:bg-blue-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
Subscribe Monthly
</button>
</div>
</form>
<form action="{{ route('subscription.checkout') }}" method="POST">
@csrf
<input type="hidden" name="plan" value="yearly">
<div class="border border-gray-600 rounded-lg p-4 hover:border-success transition-colors">
<h4 class="text-lg font-bold text-white mb-2">Yearly</h4>
<p class="text-gray-300 mb-4">Billed annually</p>
<button type="submit" class="w-full bg-success hover:bg-green-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
Subscribe Yearly
</button>
</div>
</form>
</div>
</div>
@endif
</div>
</div>
</x-layouts.app>

View file

@ -1,9 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Auth\LoginController; use App\Livewire\Auth\Login;
use App\Http\Controllers\Auth\RegisterController; use App\Livewire\Auth\Register;
use App\Http\Controllers\SubscriptionController;
Route::get('/', function () { Route::get('/', function () {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
@ -11,40 +10,33 @@
// Guest routes // Guest routes
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login'); Route::get('/login', Login::class)->name('login');
Route::post('/login', [LoginController::class, 'login']); Route::get('/register', Register::class)->name('register');
Route::get('/register', [RegisterController::class, 'showRegistrationForm'])->name('register');
Route::post('/register', [RegisterController::class, 'register']);
}); });
// CSRF refresh route (available to both guest and authenticated users)
Route::get('/refresh-csrf', function () {
return response()->json(['token' => csrf_token()]);
})->name('refresh-csrf');
// Authenticated routes // Authenticated routes
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::get('/dashboard', function () {
return view('dashboard');
// Routes requiring active subscription in SaaS mode })->name('dashboard');
Route::middleware('subscription')->group(function () {
Route::get('/dashboard', function () { Route::post('/logout', function () {
return view('dashboard'); auth()->logout();
})->name('dashboard'); request()->session()->invalidate();
request()->session()->regenerateToken();
Route::get('/dishes', function () { return redirect('/');
return view('dishes.index'); })->name('logout');
})->name('dishes.index');
// Placeholder routes for future Livewire components
Route::get('/schedule', function () { Route::get('/dishes', function () {
return view('schedule.index'); return view('dishes.index');
})->name('schedule.index'); })->name('dishes.index');
Route::get('/users', function () { Route::get('/schedule', function () {
return view('users.index'); return view('schedule.index');
})->name('users.index'); })->name('schedule.index');
Route::get('/billing', [SubscriptionController::class, 'billing'])->name('billing')->middleware('saas'); Route::get('/users', function () {
Route::get('/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('billing.portal')->middleware('saas'); return view('users.index');
}); })->name('users.index');
}); });

View file

@ -1,18 +0,0 @@
<?php
use App\Http\Controllers\SubscriptionController;
use Illuminate\Support\Facades\Route;
use Laravel\Cashier\Http\Controllers\WebhookController;
// Stripe webhook (no auth, CSRF excluded in bootstrap/app.php)
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook'])->name('cashier.webhook');
Route::middleware('auth')->group(function () {
Route::get('/subscription', function () {
return view('subscription.index');
})->name('subscription.index');
Route::post('/subscription/checkout', [SubscriptionController::class, 'checkout'])->name('subscription.checkout');
Route::get('/subscription/success', [SubscriptionController::class, 'success'])->name('subscription.success');
Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel');
});

View file

@ -14,7 +14,7 @@ pkgs.mkShell {
podman-compose podman-compose
# Database client (optional, for direct DB access) # Database client (optional, for direct DB access)
mariadb.client mariadb-client
# Utilities # Utilities
git git
@ -88,7 +88,7 @@ pkgs.mkShell {
local REGISTRY="codeberg.org" local REGISTRY="codeberg.org"
local NAMESPACE="lvl0" local NAMESPACE="lvl0"
local IMAGE_NAME="dish-planner" local IMAGE_NAME="dish-planner"
echo "🔨 Building production image..." echo "🔨 Building production image..."
podman build -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} . podman build -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} .
@ -112,19 +112,6 @@ pkgs.mkShell {
fi fi
} }
prod-build-nc() {
local TAG="''${1:-latest}"
local REGISTRY="codeberg.org"
local NAMESPACE="lvl0"
local IMAGE_NAME="dish-planner"
echo "🔨 Building production image (no cache)..."
podman build --no-cache -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} .
echo " Build complete: ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG}"
echo "Run 'prod-push' to push to Codeberg"
}
prod-build-push() { prod-build-push() {
local TAG="''${1:-latest}" local TAG="''${1:-latest}"
prod-build "$TAG" && prod-push "$TAG" prod-build "$TAG" && prod-push "$TAG"

View file

@ -1,26 +0,0 @@
<?php
namespace DishPlanner\Schedule\Actions;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use Carbon\Carbon;
class ClearScheduleForMonthAction
{
public function execute(Planner $planner, int $month, int $year, array $userIds): void
{
$startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
$endDate = $startDate->copy()->endOfMonth()->endOfDay();
$scheduleIds = Schedule::withoutGlobalScopes()
->where('planner_id', $planner->id)
->whereBetween('date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
->pluck('id');
ScheduledUserDish::whereIn('schedule_id', $scheduleIds)
->whereIn('user_id', $userIds)
->delete();
}
}

View file

@ -1,102 +0,0 @@
<?php
namespace DishPlanner\Schedule\Actions;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class GenerateScheduleForMonthAction
{
public function execute(
Planner $planner,
int $month,
int $year,
array $userIds,
bool $clearExisting = true
): void {
DB::transaction(function () use ($planner, $month, $year, $userIds, $clearExisting) {
$startDate = Carbon::createFromDate($year, $month, 1);
$endDate = $startDate->copy()->endOfMonth();
if ($clearExisting) {
$this->clearExistingSchedules($planner, $startDate, $endDate, $userIds);
}
$userDishesMap = $this->loadUserDishes($planner, $userIds);
$this->generateSchedulesForPeriod($planner, $startDate, $endDate, $userIds, $userDishesMap);
});
}
private function clearExistingSchedules(
Planner $planner,
Carbon $startDate,
Carbon $endDate,
array $userIds
): void {
$scheduleIds = Schedule::withoutGlobalScopes()
->where('planner_id', $planner->id)
->whereBetween('date', [$startDate, $endDate])
->pluck('id');
ScheduledUserDish::whereIn('schedule_id', $scheduleIds)
->whereIn('user_id', $userIds)
->delete();
}
private function loadUserDishes(Planner $planner, array $userIds): array
{
$users = User::query()
->with('userDishes.dish')
->whereIn('id', $userIds)
->where('planner_id', $planner->id)
->get()
->keyBy('id');
$userDishesMap = [];
foreach ($users as $userId => $user) {
if ($user->userDishes->isNotEmpty()) {
$userDishesMap[$userId] = $user->userDishes;
}
}
return $userDishesMap;
}
private function generateSchedulesForPeriod(
Planner $planner,
Carbon $startDate,
Carbon $endDate,
array $userIds,
array $userDishesMap
): void {
$currentDate = $startDate->copy();
while ($currentDate <= $endDate) {
$schedule = Schedule::firstOrCreate(
['planner_id' => $planner->id, 'date' => $currentDate->format('Y-m-d')],
['is_skipped' => false]
);
foreach ($userIds as $userId) {
if (!isset($userDishesMap[$userId]) || $userDishesMap[$userId]->isEmpty()) {
continue;
}
$randomUserDish = $userDishesMap[$userId]->random();
ScheduledUserDish::firstOrCreate(
['schedule_id' => $schedule->id, 'user_id' => $userId],
['user_dish_id' => $randomUserDish->id, 'is_skipped' => false]
);
}
$currentDate->addDay();
}
}
}

View file

@ -1,45 +0,0 @@
<?php
namespace DishPlanner\Schedule\Actions;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class RegenerateScheduleForDateForUsersAction
{
public function execute(Planner $planner, Carbon $date, array $userIds): void
{
DB::transaction(function () use ($planner, $date, $userIds) {
$schedule = Schedule::firstOrCreate(
['planner_id' => $planner->id, 'date' => $date->format('Y-m-d')],
['is_skipped' => false]
);
ScheduledUserDish::where('schedule_id', $schedule->id)
->whereIn('user_id', $userIds)
->delete();
$users = User::with('userDishes.dish')
->whereIn('id', $userIds)
->where('planner_id', $planner->id)
->get();
foreach ($users as $user) {
if ($user->userDishes->isNotEmpty()) {
$randomUserDish = $user->userDishes->random();
ScheduledUserDish::create([
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'user_dish_id' => $randomUserDish->id,
'is_skipped' => false,
]);
}
}
});
}
}

View file

@ -1,66 +0,0 @@
<?php
namespace DishPlanner\Schedule\Services;
use App\Models\Planner;
use App\Models\Schedule;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class ScheduleCalendarService
{
public function getCalendarDays(Planner $planner, int $month, int $year): array
{
$firstDay = Carbon::createFromDate($year, $month, 1);
$lastDay = $firstDay->copy()->endOfMonth();
$daysInMonth = $firstDay->daysInMonth;
$schedules = $this->loadSchedulesForMonth($planner, $firstDay, $lastDay);
return $this->buildCalendarDays($year, $month, $daysInMonth, $schedules);
}
private function loadSchedulesForMonth(Planner $planner, Carbon $startDate, Carbon $endDate): Collection
{
return Schedule::with(['scheduledUserDishes.user', 'scheduledUserDishes.userDish.dish'])
->where('planner_id', $planner->id)
->whereBetween('date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
->get()
->keyBy(fn ($schedule) => $schedule->date->day);
}
private function buildCalendarDays(int $year, int $month, int $daysInMonth, Collection $schedules): array
{
$calendarDays = [];
for ($day = 1; $day <= 31; $day++) {
if ($day <= $daysInMonth) {
$date = Carbon::createFromDate($year, $month, $day);
$scheduledDishes = $schedules->get($day)?->scheduledUserDishes ?? collect();
$calendarDays[] = [
'day' => $day,
'date' => $date,
'isToday' => $date->isToday(),
'scheduledDishes' => $scheduledDishes,
'isEmpty' => $scheduledDishes->isEmpty()
];
} else {
$calendarDays[] = [
'day' => null,
'date' => null,
'isToday' => false,
'scheduledDishes' => collect(),
'isEmpty' => true
];
}
}
return $calendarDays;
}
public function getMonthName(int $month, int $year): string
{
return Carbon::createFromDate($year, $month, 1)->format('F Y');
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace DishPlanner\ScheduledUserDish\Actions;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use Carbon\Carbon;
class DeleteScheduledUserDishForDateAction
{
public function execute(Planner $planner, Carbon $date, int $userId): bool
{
$schedule = Schedule::query()
->where('planner_id', $planner->id)
->whereDate('date', $date)
->first();
if (! $schedule) {
return false;
}
return ScheduledUserDish::query()
->where('schedule_id', $schedule->id)
->where('user_id', $userId)
->delete() > 0;
}
}

View file

@ -1,39 +0,0 @@
<?php
namespace DishPlanner\ScheduledUserDish\Actions;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use Carbon\Carbon;
class SkipScheduledUserDishForDateAction
{
public function execute(Planner $planner, Carbon $date, int $userId): bool
{
$schedule = Schedule::query()
->where('planner_id', $planner->id)
->whereDate('date', $date)
->first();
if (! $schedule) {
return false;
}
$scheduledUserDish = ScheduledUserDish::query()
->where('schedule_id', $schedule->id)
->where('user_id', $userId)
->first();
if (! $scheduledUserDish) {
return false;
}
$scheduledUserDish->update([
'is_skipped' => true,
'user_dish_id' => null,
]);
return true;
}
}

View file

@ -30,7 +30,6 @@ export default {
400: '#444760', 400: '#444760',
500: '#2B2C41', 500: '#2B2C41',
600: '#24263C', 600: '#24263C',
650: '#202239',
700: '#1D1E36', 700: '#1D1E36',
800: '#131427', 800: '#131427',
900: '#0A0B1C', 900: '#0A0B1C',

View file

@ -1,89 +0,0 @@
<?php
namespace Tests\Browser\Auth;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use App\Models\Planner;
use Illuminate\Support\Facades\Hash;
class LoginTest extends DuskTestCase
{
protected static $testPlanner = null;
protected static $testEmail = null;
protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void
{
if (self::$testPlanner === null) {
// Generate unique email for this test run
self::$testEmail = fake()->unique()->safeEmail();
self::$testPlanner = Planner::factory()->create([
'email' => self::$testEmail,
'password' => Hash::make(self::$testPassword),
]);
}
}
public function testSuccessfulLogin(): void
{
$this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword)
->press('Login')
->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM)
->assertPathIs('/dashboard');
});
}
public function testLoginWithWrongCredentials(): void
{
$this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', 'wrongpassword')
->press('Login')
->pause(self::PAUSE_MEDIUM)
->assertPathIs('/login')
->assertSee('These credentials do not match our records');
});
}
public function testLoginFormRequiredFields(): void
{
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
// Check that both fields have the required attribute
$browser->assertAttribute('input[id="email"]', 'required', 'true');
$browser->assertAttribute('input[id="password"]', 'required', 'true');
// Verify email field is type email
$browser->assertAttribute('input[id="email"]', 'type', 'email');
// Verify password field is type password
$browser->assertAttribute('input[id="password"]', 'type', 'password');
// Test that we stay on login page if we try to submit with empty fields
$browser->press('Login')
->pause(self::PAUSE_SHORT)
->assertPathIs('/login');
});
}
}

View file

@ -1,105 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class DishModal extends BaseComponent
{
protected string $mode; // 'create' or 'edit'
public function __construct(string $mode = 'create')
{
$this->mode = $mode;
}
/**
* Get the root selector for the component.
*/
public function selector(): string
{
// Livewire modals typically have a specific structure
return '[role="dialog"], .fixed.inset-0';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
if ($this->mode === 'create') {
$browser->assertSee('Add New Dish');
} else {
$browser->assertSee('Edit Dish');
}
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@name-input' => 'input[wire\\:model="name"]',
'@description-input' => 'textarea[wire\\:model="description"]',
'@users-section' => 'div:contains("Assign to Users")',
'@submit-button' => $this->mode === 'create' ? 'button:contains("Create Dish")' : 'button:contains("Update Dish")',
'@cancel-button' => 'button:contains("Cancel")',
'@validation-error' => '.text-red-500',
];
}
/**
* Fill the dish form.
*/
public function fillForm(Browser $browser, string $name, ?string $description = null): void
{
$browser->waitFor('@name-input')
->clear('@name-input')
->type('@name-input', $name);
if ($description !== null && $browser->element('@description-input')) {
$browser->clear('@description-input')
->type('@description-input', $description);
}
}
/**
* Select users to assign the dish to.
*/
public function selectUsers(Browser $browser, array $userIds): void
{
foreach ($userIds as $userId) {
$browser->check("input[type='checkbox'][value='{$userId}']");
}
}
/**
* Submit the form.
*/
public function submit(Browser $browser): void
{
$browser->press($this->mode === 'create' ? 'Create Dish' : 'Update Dish');
}
/**
* Cancel the modal.
*/
public function cancel(Browser $browser): void
{
$browser->press('Cancel');
}
/**
* Assert validation error is shown.
*/
public function assertValidationError(Browser $browser, string $message = 'required'): void
{
$browser->assertSee($message);
}
}

View file

@ -1,89 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class LoginForm extends BaseComponent
{
/**
* Get the root selector for the component.
*/
public function selector(): string
{
return 'form[method="POST"][action*="login"]';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector())
->assertVisible('@email')
->assertVisible('@password')
->assertVisible('@submit');
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@email' => 'input[id="email"]',
'@password' => 'input[id="password"]',
'@submit' => 'button[type="submit"]',
'@remember' => 'input[name="remember"]',
'@error' => '.text-red-500',
];
}
/**
* Fill in the login form.
*/
public function fillForm(Browser $browser, string $email, string $password): void
{
$browser->type('@email', $email)
->type('@password', $password);
}
/**
* Submit the login form.
*/
public function submit(Browser $browser): void
{
$browser->press('@submit');
}
/**
* Login with the given credentials.
*/
public function loginWith(Browser $browser, string $email, string $password): void
{
$this->fillForm($browser, $email, $password);
$this->submit($browser);
}
/**
* Assert that the form fields are required.
*/
public function assertFieldsRequired(Browser $browser): void
{
$browser->assertAttribute('@email', 'required', 'true')
->assertAttribute('@password', 'required', 'true')
->assertAttribute('@email', 'type', 'email')
->assertAttribute('@password', 'type', 'password');
}
/**
* Assert that the form has validation errors.
*/
public function assertHasErrors(Browser $browser): void
{
$browser->assertPresent('@error');
}
}

View file

@ -1,49 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers;
class CreateDishFormValidationTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishFormValidationTestPlanner = null;
protected static $createDishFormValidationTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishFormValidationTestPlanner;
self::$testEmail = self::$createDishFormValidationTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishFormValidationTestPlanner = self::$testPlanner;
self::$createDishFormValidationTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCreateDishFormValidation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) {
$browser->fillForm('', null)
->submit()
->pause(2000)
->assertValidationError('required');
});
});
}
}

View file

@ -1,52 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers;
class CreateDishSuccessTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishSuccessTestPlanner = null;
protected static $createDishSuccessTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishSuccessTestPlanner;
self::$testEmail = self::$createDishSuccessTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishSuccessTestPlanner = self::$testPlanner;
self::$createDishSuccessTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanCreateDishSuccessfully(): void
{
$this->browse(function (Browser $browser) {
$dishName = 'Test Dish ' . uniqid();
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) use ($dishName) {
$browser->fillForm($dishName)
->submit();
})
->pause(3000)
->assertDishVisible($dishName)
->assertSee('Dish created successfully');
});
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers;
class CreateDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishTestPlanner = null;
protected static $createDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishTestPlanner;
self::$testEmail = self::$createDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishTestPlanner = self::$testPlanner;
self::$createDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanAccessDishesPage(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
// TODO: Moved to separate single-method test files to avoid static planner issues
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
}

View file

@ -1,76 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers;
use App\Models\Planner;
class DeleteDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $deleteDishTestPlanner = null;
protected static $deleteDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$deleteDishTestPlanner;
self::$testEmail = self::$deleteDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$deleteDishTestPlanner = self::$testPlanner;
self::$deleteDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanAccessDeleteFeature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
// Verify that delete functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Delete', $pageSource);
});
}
// TODO: Fix static planner issue causing login failures in suite runs
// These tests pass in isolation but fail when run in full suite
/*
public function testDeleteModalComponents(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
public function testDeletionSafetyFeatures(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
*/
}

View file

@ -1,49 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers;
class DishDeletionSafetyTest extends DuskTestCase
{
use LoginHelpers;
protected static $dishDeletionSafetyTestPlanner = null;
protected static $dishDeletionSafetyTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$dishDeletionSafetyTestPlanner;
self::$testEmail = self::$dishDeletionSafetyTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$dishDeletionSafetyTestPlanner = self::$testPlanner;
self::$dishDeletionSafetyTestEmail = self::$testEmail;
parent::tearDown();
}
public function testDeletionSafetyFeatures(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}

View file

@ -1,73 +0,0 @@
<?php
namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers;
use App\Models\Planner;
class EditDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $editDishTestPlanner = null;
protected static $editDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$editDishTestPlanner;
self::$testEmail = self::$editDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$editDishTestPlanner = self::$testPlanner;
self::$editDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanAccessEditFeature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
// Verify that edit functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Edit', $pageSource);
});
}
public function testEditModalComponents(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
public function testDishesPageStructure(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
// Check that the dishes CRUD structure is present
$pageSource = $browser->driver->getPageSource();
// Either we have dishes with Edit/Delete buttons OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Edit', $pageSource);
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}

View file

@ -1,62 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
trait LoginHelpers
{
protected static $testPlanner = null;
protected static $testEmail = null;
protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void
{
// Always create a fresh planner for each test class to avoid session conflicts
if (self::$testPlanner === null || !self::$testPlanner->exists) {
// Generate unique email for this test run
self::$testEmail = fake()->unique()->safeEmail();
self::$testPlanner = \App\Models\Planner::factory()->create([
'email' => self::$testEmail,
'password' => \Illuminate\Support\Facades\Hash::make(self::$testPassword),
]);
}
}
protected function loginAndNavigate(Browser $browser, string $page = '/dashboard'): Browser
{
$this->ensureTestPlannerExists();
// Clear browser session and cookies to start fresh
$browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword)
->press('Sign In')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM) // Wait for successful login redirect
->pause(DuskTestCase::PAUSE_SHORT) // Brief pause for any initialization
->visit('http://dishplanner_app:8000' . $page)
->pause(DuskTestCase::PAUSE_MEDIUM); // Let Livewire components initialize
}
protected function loginAndGoToDishes(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/dishes');
}
protected function loginAndGoToUsers(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/users');
}
protected function loginAndGoToSchedule(Browser $browser): Browser
{
return $this->loginAndNavigate($browser, '/schedule');
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class DishesPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/dishes';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE DISHES');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@dishes-list' => '[wire\\:id]', // Livewire component
'@search' => 'input[type="search"]',
'@no-dishes' => '*[text*="No dishes found"]',
];
}
/**
* Open the create dish modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click edit button for a dish.
*/
public function clickEditForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-accent-blue');
});
}
/**
* Click delete button for a dish.
*/
public function clickDeleteForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-red-500');
});
}
/**
* Assert a dish is visible in the list.
*/
public function assertDishVisible(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
/**
* Assert no dishes message is shown.
*/
public function assertNoDishes(Browser $browser): void
{
$browser->assertSee('No dishes found');
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Tests\Browser\Components\LoginForm;
class LoginPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/login';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('Login')
->assertPresent((new LoginForm)->selector());
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@register-link' => 'a[href*="register"]',
];
}
/**
* Navigate to the registration page.
*/
public function goToRegistration(Browser $browser): void
{
$browser->click('@register-link');
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page as BasePage;
abstract class Page extends BasePage
{
/**
* Get the global element shortcuts for the site.
*
* @return array<string, string>
*/
public static function siteElements(): array
{
return [
'@nav' => 'nav',
'@alert' => '[role="alert"]',
];
}
}

View file

@ -1,110 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class SchedulePage extends Page
{
public function url(): string
{
return '/schedule';
}
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('SCHEDULE');
}
public function elements(): array
{
return [
'@generate-button' => 'button[wire\\:click="generate"]',
'@clear-month-button' => 'button[wire\\:click="clearMonth"]',
'@previous-month' => 'button[wire\\:click="previousMonth"]',
'@next-month' => 'button[wire\\:click="nextMonth"]',
'@month-select' => 'select[wire\\:model="selectedMonth"]',
'@year-select' => 'select[wire\\:model="selectedYear"]',
'@clear-existing-checkbox' => 'input[wire\\:model="clearExisting"]',
'@calendar-grid' => '.grid.grid-cols-7',
];
}
public function clickGenerate(Browser $browser): void
{
$browser->waitFor('@generate-button')
->click('@generate-button')
->pause(2000); // Wait for generation
}
public function clickClearMonth(Browser $browser): void
{
$browser->waitFor('@clear-month-button')
->click('@clear-month-button')
->pause(1000);
}
public function goToPreviousMonth(Browser $browser): void
{
$browser->waitFor('@previous-month')
->click('@previous-month')
->pause(500);
}
public function goToNextMonth(Browser $browser): void
{
$browser->waitFor('@next-month')
->click('@next-month')
->pause(500);
}
public function selectMonth(Browser $browser, int $month): void
{
$browser->waitFor('@month-select')
->select('@month-select', $month)
->pause(500);
}
public function selectYear(Browser $browser, int $year): void
{
$browser->waitFor('@year-select')
->select('@year-select', $year)
->pause(500);
}
public function toggleClearExisting(Browser $browser): void
{
$browser->waitFor('@clear-existing-checkbox')
->click('@clear-existing-checkbox');
}
public function selectUser(Browser $browser, string $userName): void
{
$browser->check("input[type='checkbox'][value]", $userName);
}
public function assertSuccessMessage(Browser $browser, string $message = null): void
{
if ($message) {
$browser->assertSee($message);
} else {
$browser->assertPresent('.border-success');
}
}
public function assertDishScheduled(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
public function assertNoDishesScheduled(Browser $browser): void
{
$browser->assertSee('No dishes scheduled');
}
public function assertMonthDisplayed(Browser $browser, string $monthYear): void
{
$browser->assertSee($monthYear);
}
}

View file

@ -1,93 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class UsersPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/users';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE USERS');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@users-list' => '[wire\\:id]', // Livewire component
'@no-users' => '*[text*="No users found"]',
];
}
/**
* Open the create user modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click delete button for a user.
*/
public function clickDeleteForUser(Browser $browser, string $userName): void
{
$browser->within("tr:contains('{$userName}')", function ($row) {
$row->click('button.bg-danger');
});
}
/**
* Click the first available delete button.
*/
public function clickFirstDeleteButton(Browser $browser): void
{
$browser->waitFor('button.bg-danger', 5)
->click('button.bg-danger')
->pause(1000);
}
/**
* Assert a user is visible in the list.
*/
public function assertUserVisible(Browser $browser, string $userName): void
{
$browser->assertSee($userName);
}
/**
* Assert a user is not visible in the list.
*/
public function assertUserNotVisible(Browser $browser, string $userName): void
{
$browser->assertDontSee($userName);
}
/**
* Assert success message is shown.
*/
public function assertSuccessMessage(Browser $browser, string $message): void
{
$browser->assertSee($message);
}
}

View file

@ -1,38 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class RedirectTest extends DuskTestCase
{
use DatabaseTransactions;
/**
* Test that unauthenticated users are redirected to login
*/
public function testUnauthenticatedRedirectsToLogin()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/dashboard')
->assertPathIs('/login')
->assertSee('Login');
});
}
/**
* Test that login page loads correctly
*/
public function testLoginPageLoads()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/login')
->assertPathIs('/login')
->assertSee('Login')
->assertSee('Email')
->assertSee('Password');
});
}
}

View file

@ -1,124 +0,0 @@
<?php
namespace Tests\Browser\Schedule;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\SchedulePage;
class GenerateScheduleTest extends DuskTestCase
{
protected static $planner = null;
protected static $email = null;
protected static $password = 'password';
protected static $user = null;
protected static $dish = null;
protected function setUp(): void
{
parent::setUp();
// Create test data if not exists
if (self::$planner === null) {
self::$email = fake()->unique()->safeEmail();
self::$planner = Planner::factory()->create([
'email' => self::$email,
'password' => Hash::make(self::$password),
]);
// Create a user for this planner
self::$user = User::factory()->create([
'planner_id' => self::$planner->id,
'name' => 'Test User',
]);
// Create a dish and assign to user
self::$dish = Dish::factory()->create([
'planner_id' => self::$planner->id,
'name' => 'Test Dish',
]);
// Attach user to dish (creates UserDish)
self::$dish->users()->attach(self::$user);
}
}
protected function loginAsPlanner(Browser $browser): Browser
{
$browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$email)
->clear('input[id="password"]')
->type('input[id="password"]', self::$password)
->press('Login')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM)
->pause(DuskTestCase::PAUSE_SHORT)
->visit('http://dishplanner_app:8000/schedule')
->pause(DuskTestCase::PAUSE_MEDIUM);
}
public function testCanGenerateScheduleWithUserAndDish(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->assertSee('Test User') // User should be in selection
->clickGenerate()
->pause(2000)
// Verify schedule was generated by checking dish appears on calendar
->assertSee('Test Dish');
});
}
public function testGeneratedScheduleShowsDishOnCalendar(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->clickGenerate()
->pause(2000)
// The dish should appear somewhere on the calendar
->assertSee('Test Dish');
});
}
public function testCanClearMonthSchedule(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// First generate a schedule
->clickGenerate()
->pause(2000)
->assertSee('Test Dish') // Verify generated
// Then clear it
->clickClearMonth()
->pause(1000)
// After clearing, should see "No dishes scheduled" on calendar days
->assertSee('No dishes scheduled');
});
}
public function testUserSelectionAffectsGeneration(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// Verify the user checkbox is present
->assertSee('Test User')
// User should be selected by default
->assertChecked("input[value='" . self::$user->id . "']");
});
}
}

View file

@ -1,107 +0,0 @@
<?php
namespace Tests\Browser\Schedule;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\SchedulePage;
use Tests\Browser\LoginHelpers;
class SchedulePageTest extends DuskTestCase
{
use LoginHelpers;
protected static $schedulePageTestPlanner = null;
protected static $schedulePageTestEmail = null;
protected function setUp(): void
{
parent::setUp();
self::$testPlanner = self::$schedulePageTestPlanner;
self::$testEmail = self::$schedulePageTestEmail;
}
protected function tearDown(): void
{
self::$schedulePageTestPlanner = self::$testPlanner;
self::$schedulePageTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanAccessSchedulePage(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertSee('SCHEDULE')
->assertSee('Generate Schedule');
});
}
public function testSchedulePageHasMonthNavigation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertPresent('@previous-month')
->assertPresent('@next-month')
->assertSee(now()->format('F Y'));
});
}
public function testCanNavigateToNextMonth(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$nextMonth = now()->addMonth();
$browser->on(new SchedulePage)
->goToNextMonth()
->assertSee($nextMonth->format('F Y'));
});
}
public function testCanNavigateToPreviousMonth(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$prevMonth = now()->subMonth();
$browser->on(new SchedulePage)
->goToPreviousMonth()
->assertSee($prevMonth->format('F Y'));
});
}
public function testScheduleGeneratorShowsUserSelection(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertSee('Select Users')
->assertPresent('@generate-button')
->assertPresent('@clear-month-button');
});
}
public function testCalendarDisplaysDaysOfWeek(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertSee('Mon')
->assertSee('Tue')
->assertSee('Wed')
->assertSee('Thu')
->assertSee('Fri')
->assertSee('Sat')
->assertSee('Sun');
});
}
}

View file

@ -1,103 +0,0 @@
<?php
namespace Tests\Browser\Users;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\UsersPage;
use Tests\Browser\LoginHelpers;
class CreateUserTest extends DuskTestCase
{
use LoginHelpers;
protected static $createUserTestPlanner = null;
protected static $createUserTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createUserTestPlanner;
self::$testEmail = self::$createUserTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createUserTestPlanner = self::$testPlanner;
self::$createUserTestEmail = self::$testEmail;
parent::tearDown();
}
public function testCanAccessUsersPage(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->assertSee('MANAGE USERS')
->assertSee('Add User');
});
}
public function testCanOpenCreateUserModal(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->assertSee('Add New User')
->assertSee('Name')
->assertSee('Cancel')
->assertSee('Create User');
});
}
public function testCreateUserFormValidation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('The name field is required');
});
}
public function testCanCreateUser(): void
{
$this->browse(function (Browser $browser) {
$userName = 'TestCreate_' . uniqid();
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', $userName)
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('User created successfully')
->assertSee($userName);
});
}
public function testCanCancelUserCreation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', 'Test Cancel User')
->press('Cancel')
->pause(self::PAUSE_SHORT)
// Modal should be closed, we should be back on users page
->assertSee('MANAGE USERS')
->assertDontSee('Add New User');
});
}
}

View file

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

View file

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

View file

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

View file

@ -1,53 +0,0 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
// Timeout constants for consistent timing across all Dusk tests
public const TIMEOUT_SHORT = 2; // 2 seconds max for most operations
public const TIMEOUT_MEDIUM = 3; // 3 seconds for slower operations
public const PAUSE_SHORT = 500; // 0.5 seconds for quick pauses
public const PAUSE_MEDIUM = 1000; // 1 second for medium pauses
/**
* Prepare for Dusk test execution.
*/
#[BeforeClass]
public static function prepare(): void
{
// Don't start ChromeDriver - we're using Selenium
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments([
'--window-size=1920,1080',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--headless=new',
'--disable-extensions',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
]);
return RemoteWebDriver::create(
'http://selenium:4444/wd/hub', // Connect to Selenium container
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}

View file

@ -1,97 +0,0 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Tests\TestCase;
use App\Models\Planner;
use Illuminate\Foundation\Testing\RefreshDatabase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertViewIs('auth.login');
$response->assertSee('Login');
}
public function test_users_can_authenticate_using_the_login_screen(): void
{
Planner::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$response = $this->post('/login', [
'email' => 'test@example.com',
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect('/dashboard');
}
public function test_users_can_not_authenticate_with_invalid_password(): void
{
Planner::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$this->post('/login', [
'email' => 'test@example.com',
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_session_is_created_on_login_page(): void
{
$response = $this->get('/login');
// Check if session was started
$this->assertNotNull(session()->getId());
// Check if CSRF token is generated
$this->assertNotNull(csrf_token());
// Check session driver
$sessionDriver = config('session.driver');
$this->assertNotEquals('array', $sessionDriver, 'Session driver should not be array for authentication');
$response->assertStatus(200);
$response->assertSessionHasNoErrors();
}
public function test_csrf_token_is_validated_on_login(): void
{
// Try to post without CSRF token by disabling middleware that auto-adds it
$response = $this
->withoutMiddleware(VerifyCsrfToken::class)
->withHeaders([
'Accept' => 'text/html',
])
->post('/login', [
'email' => 'test@example.com',
'password' => 'password',
]);
$response->assertStatus(302);
}
public function test_users_can_logout(): void
{
$user = Planner::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$response->assertRedirect('/');
$this->assertGuest();
}
}

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