diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e6e2c8e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security baseline +- Enforce HTTPS/HSTS in production. +- Keep private/verification uploads outside public storage; authorize every download. +- Validate MIME and image decoding; strip metadata where appropriate. +- Use policies for every tenant-owned resource. +- Enable 2FA for admin/DJ accounts. +- Verify Stripe webhook signatures and make handlers idempotent. +- Rate-limit auth, enquiry, review and messaging endpoints. +- Audit privileged actions; hash IP addresses if retained. +- Never store card details. +- Run queues under Supervisor/systemd and keep dependencies patched. +- Replace placeholder legal policies before launch. diff --git a/app/Http/Controllers/Admin/SectionController.php b/app/Http/Controllers/Admin/SectionController.php new file mode 100644 index 0000000..667e7f0 --- /dev/null +++ b/app/Http/Controllers/Admin/SectionController.php @@ -0,0 +1,37 @@ + User::count(), + 'djs' => DjProfile::count(), + 'businesses' => Business::count(), + 'clients' => User::role('client')->count(), + 'bookings' => Booking::count(), + 'enquiries' => Enquiry::count(), + 'reviews' => Review::count(), + ]; + + return view('admin.section', compact('section', 'counts')); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..4f4923e --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,42 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if (! Auth::attempt($credentials, $request->boolean('remember'))) { + return back()->withErrors(['email' => 'The provided credentials do not match our records.'])->onlyInput('email'); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('home'); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..b553d4a --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,47 @@ + $request]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + return $status === Password::PASSWORD_RESET + ? redirect()->route('login')->with('status', __($status)) + : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..d5ebfef --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,28 @@ +validate(['email' => ['required', 'email']]); + + $status = Password::sendResetLink($request->only('email')); + + return $status === Password::RESET_LINK_SENT + ? back()->with('status', __($status)) + : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..c54dbfe --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,37 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $user = User::create($data); + $user->assignRole('client'); + + event(new Registered($user)); + Auth::login($user); + + return redirect()->route('dashboard'); + } +} diff --git a/app/Models/Booking.php b/app/Models/Booking.php index e4d6721..3e748b0 100644 --- a/app/Models/Booking.php +++ b/app/Models/Booking.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; class Booking extends Model { @@ -15,4 +17,24 @@ class Booking extends Model { return ['event_date' => 'date', 'total' => 'decimal:2', 'deposit' => 'decimal:2', 'paid_at' => 'datetime']; } + + public function enquiry(): BelongsTo + { + return $this->belongsTo(Enquiry::class); + } + + public function client(): BelongsTo + { + return $this->belongsTo(User::class, 'client_id'); + } + + public function djProfile(): BelongsTo + { + return $this->belongsTo(DjProfile::class); + } + + public function review(): HasOne + { + return $this->hasOne(Review::class); + } } diff --git a/app/Models/Enquiry.php b/app/Models/Enquiry.php index 9fba6b3..6a51f2c 100644 --- a/app/Models/Enquiry.php +++ b/app/Models/Enquiry.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; class Enquiry extends Model { @@ -15,4 +17,19 @@ class Enquiry extends Model { return ['event_date' => 'date', 'budget' => 'decimal:2']; } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function djProfile(): BelongsTo + { + return $this->belongsTo(DjProfile::class); + } + + public function booking(): HasOne + { + return $this->hasOne(Booking::class); + } } diff --git a/app/Models/Review.php b/app/Models/Review.php index a77ecd2..b939714 100644 --- a/app/Models/Review.php +++ b/app/Models/Review.php @@ -4,10 +4,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Review extends Model { use HasUlids; protected $fillable = ['booking_id', 'user_id', 'dj_profile_id', 'rating', 'title', 'body', 'status', 'dj_reply']; + + public function booking(): BelongsTo + { + return $this->belongsTo(Booking::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function djProfile(): BelongsTo + { + return $this->belongsTo(DjProfile::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 33d9e28..533c12b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,14 +2,19 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { - use HasApiTokens,HasRoles,Notifiable; + use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable; protected $fillable = ['name', 'email', 'password', 'phone', 'avatar_path', 'marketing_opt_in']; @@ -17,16 +22,31 @@ class User extends Authenticatable protected function casts(): array { - return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'marketing_opt_in' => 'boolean']; + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'marketing_opt_in' => 'boolean', + 'trial_ends_at' => 'datetime', + ]; } - public function businesses() + public function businesses(): BelongsToMany { return $this->belongsToMany(Business::class)->withPivot(['role'])->withTimestamps(); } - public function djProfile() + public function djProfile(): HasOne { return $this->hasOne(DjProfile::class); } + + public function enquiries(): HasMany + { + return $this->hasMany(Enquiry::class); + } + + public function bookings(): HasMany + { + return $this->hasMany(Booking::class, 'client_id'); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..70a18d2 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,18 @@ +withRouting( @@ -13,7 +16,13 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->validateCsrfTokens(except: ['stripe/*']); + + $middleware->alias([ + 'role' => RoleMiddleware::class, + 'permission' => PermissionMiddleware::class, + 'role_or_permission' => RoleOrPermissionMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/bootstrap/providers.php b/bootstrap/providers.php index a6f5126..fc94ae6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,7 @@ env('APP_NAME', 'BeatBook'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost'), + 'timezone' => env('APP_TIMEZONE', 'UTC'), + 'locale' => env('APP_LOCALE', 'en'), + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_GB'), + 'cipher' => 'AES-256-CBC', + 'key' => env('APP_KEY'), + 'previous_keys' => array_filter(explode(',', (string) env('APP_PREVIOUS_KEYS', ''))), + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d0b631b --- /dev/null +++ b/config/auth.php @@ -0,0 +1,35 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + ], + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..5a69bfc --- /dev/null +++ b/config/cache.php @@ -0,0 +1,14 @@ + env('CACHE_STORE', env('CACHE_DRIVER', 'database')), + 'stores' => [ + 'array' => ['driver' => 'array', 'serialize' => false], + 'database' => ['driver' => 'database', 'connection' => env('DB_CACHE_CONNECTION'), 'table' => env('DB_CACHE_TABLE', 'cache'), 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 'lock_table' => env('DB_CACHE_LOCK_TABLE')], + 'file' => ['driver' => 'file', 'path' => storage_path('framework/cache/data'), 'lock_path' => storage_path('framework/cache/data')], + 'redis' => ['driver' => 'redis', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default')], + ], + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..f30042d --- /dev/null +++ b/config/database.php @@ -0,0 +1,78 @@ + env('DB_CONNECTION', 'sqlite'), + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::ATTR_TIMEOUT => env('DB_TIMEOUT') ? (int) env('DB_TIMEOUT') : null, + ]) : [], + ], + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + ], + 'migrations' => ['table' => 'migrations', 'update_date_on_publish' => true], + 'redis' => [ + 'client' => env('REDIS_CLIENT', 'phpredis'), + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + ], +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..f7dbb3c --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,35 @@ + env('FILESYSTEM_DISK', 'local'), + 'disks' => [ + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + ], + 'links' => [public_path('storage') => storage_path('app/public')], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..ca7ceca --- /dev/null +++ b/config/logging.php @@ -0,0 +1,20 @@ + env('LOG_CHANNEL', 'stack'), + 'deprecations' => ['channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 'trace' => env('LOG_DEPRECATIONS_TRACE', false)], + 'channels' => [ + 'stack' => ['driver' => 'stack', 'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'ignore_exceptions' => false], + 'single' => ['driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true], + 'daily' => ['driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => env('LOG_DAILY_DAYS', 14), 'replace_placeholders' => true], + 'stderr' => ['driver' => 'monolog', 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'handler_with' => ['stream' => 'php://stderr'], 'processors' => [PsrLogMessageProcessor::class]], + 'syslog' => ['driver' => 'syslog', 'level' => env('LOG_LEVEL', 'debug'), 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 'replace_placeholders' => true], + 'errorlog' => ['driver' => 'errorlog', 'level' => env('LOG_LEVEL', 'debug'), 'replace_placeholders' => true], + 'null' => ['driver' => 'monolog', 'handler' => NullHandler::class], + 'emergency' => ['path' => storage_path('logs/laravel.log')], + ], +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..7b88d07 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,22 @@ + env('MAIL_MAILER', 'log'), + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + 'log' => ['transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL')], + 'array' => ['transport' => 'array'], + 'failover' => ['transport' => 'failover', 'mailers' => ['smtp', 'log'], 'retry_after' => 60], + ], + 'from' => ['address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'name' => env('MAIL_FROM_NAME', 'BeatBook')], +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..0f95c1e --- /dev/null +++ b/config/queue.php @@ -0,0 +1,12 @@ + env('QUEUE_CONNECTION', 'database'), + 'connections' => [ + 'sync' => ['driver' => 'sync'], + 'database' => ['driver' => 'database', 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), 'after_commit' => false], + 'redis' => ['driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'block_for' => null, 'after_commit' => false], + ], + 'batching' => ['database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'job_batches'], + 'failed' => ['driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'database' => env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs'], +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..0d824bf --- /dev/null +++ b/config/services.php @@ -0,0 +1,8 @@ + ['token' => env('POSTMARK_TOKEN')], + 'ses' => ['key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1')], + 'slack' => ['notifications' => ['bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL')]], + 'stripe' => ['key' => env('STRIPE_KEY'), 'secret' => env('STRIPE_SECRET'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET')], +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..bb0c4b5 --- /dev/null +++ b/config/session.php @@ -0,0 +1,22 @@ + env('SESSION_DRIVER', 'database'), + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + 'encrypt' => env('SESSION_ENCRYPT', false), + 'files' => storage_path('framework/sessions'), + 'connection' => env('SESSION_CONNECTION'), + 'table' => env('SESSION_TABLE', 'sessions'), + 'store' => env('SESSION_STORE'), + 'lottery' => [2, 100], + 'cookie' => env('SESSION_COOKIE', Str::slug((string) env('APP_NAME', 'laravel')).'-session'), + 'path' => env('SESSION_PATH', '/'), + 'domain' => env('SESSION_DOMAIN'), + 'secure' => env('SESSION_SECURE_COOKIE'), + 'http_only' => env('SESSION_HTTP_ONLY', true), + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), +]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..7accb5c --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,28 @@ + fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + public function unverified(): static + { + return $this->state(fn () => ['email_verified_at' => null]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..a5c779a 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -17,6 +17,9 @@ return new class extends Migration $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); + $table->string('phone')->nullable(); + $table->string('avatar_path')->nullable(); + $table->boolean('marketing_opt_in')->default(false); $table->rememberToken(); $table->timestamps(); }); diff --git a/database/migrations/2026_09_12_000001_create_beatbook_tables.php b/database/migrations/2026_09_12_000001_create_beatbook_tables.php index fa2c77a..77a1ca5 100644 --- a/database/migrations/2026_09_12_000001_create_beatbook_tables.php +++ b/database/migrations/2026_09_12_000001_create_beatbook_tables.php @@ -8,122 +8,132 @@ return new class extends Migration { public function up(): void { - Schema::create('businesses', function (Blueprint $t) { - $t->ulid('id')->primary(); - $t->string('name'); - $t->string('slug')->unique(); - $t->text('description')->nullable(); - $t->string('email')->nullable(); - $t->string('phone')->nullable(); - $t->string('website')->nullable(); - $t->string('status')->default('active'); - $t->timestamp('verified_at')->nullable(); - $t->timestamps(); + Schema::create('businesses', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('website')->nullable(); + $table->string('status')->default('active')->index(); + $table->timestamp('verified_at')->nullable(); + $table->timestamps(); }); - Schema::create('business_user', function (Blueprint $t) { - $t->id(); - $t->ulid('business_id'); - $t->foreignId('user_id')->constrained()->cascadeOnDelete(); - $t->string('role')->default('member'); - $t->timestamps(); - $t->unique(['business_id', 'user_id']); + + Schema::create('business_user', function (Blueprint $table) { + $table->id(); + $table->foreignUlid('business_id')->constrained('businesses')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('member'); + $table->timestamps(); + $table->unique(['business_id', 'user_id']); }); - Schema::create('dj_profiles', function (Blueprint $t) { - $t->ulid('id')->primary(); - $t->foreignId('user_id')->constrained()->cascadeOnDelete(); - $t->ulid('business_id')->nullable(); - $t->string('stage_name'); - $t->string('slug')->unique(); - $t->string('headline')->nullable(); - $t->text('bio')->nullable(); - $t->string('city')->index(); - $t->unsignedSmallInteger('service_radius_miles')->default(30); - $t->decimal('price_from', 10, 2)->nullable(); - $t->string('profile_photo')->nullable(); - $t->string('cover_photo')->nullable(); - $t->timestamp('verified_at')->nullable(); - $t->boolean('is_featured')->default(false); - $t->boolean('is_published')->default(false); - $t->decimal('average_rating', 3, 2)->default(0); - $t->unsignedInteger('review_count')->default(0); - $t->timestamps(); + + Schema::create('dj_profiles', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('business_id')->nullable()->constrained('businesses')->nullOnDelete(); + $table->string('stage_name'); + $table->string('slug')->unique(); + $table->string('headline')->nullable(); + $table->text('bio')->nullable(); + $table->string('city')->index(); + $table->unsignedSmallInteger('service_radius_miles')->default(30); + $table->decimal('price_from', 10, 2)->nullable(); + $table->string('profile_photo')->nullable(); + $table->string('cover_photo')->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->boolean('is_featured')->default(false)->index(); + $table->boolean('is_published')->default(false)->index(); + $table->decimal('average_rating', 3, 2)->default(0); + $table->unsignedInteger('review_count')->default(0); + $table->timestamps(); }); - Schema::create('genres', function (Blueprint $t) { - $t->id(); - $t->string('name'); - $t->string('slug')->unique(); - $t->timestamps(); + + Schema::create('genres', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); }); - Schema::create('dj_profile_genre', function (Blueprint $t) { - $t->ulid('dj_profile_id'); - $t->foreignId('genre_id')->constrained()->cascadeOnDelete(); - $t->primary(['dj_profile_id', 'genre_id']); + + Schema::create('dj_profile_genre', function (Blueprint $table) { + $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete(); + $table->foreignId('genre_id')->constrained()->cascadeOnDelete(); + $table->primary(['dj_profile_id', 'genre_id']); }); - Schema::create('enquiries', function (Blueprint $t) { - $t->ulid('id')->primary(); - $t->foreignId('user_id')->constrained(); - $t->ulid('dj_profile_id'); - $t->string('event_type'); - $t->date('event_date'); - $t->time('start_time')->nullable(); - $t->time('end_time')->nullable(); - $t->string('venue'); - $t->string('postcode')->nullable(); - $t->unsignedInteger('guest_count')->nullable(); - $t->decimal('budget', 10, 2)->nullable(); - $t->text('message'); - $t->string('status')->default('new')->index(); - $t->timestamps(); + + Schema::create('enquiries', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete(); + $table->string('event_type'); + $table->date('event_date')->index(); + $table->time('start_time')->nullable(); + $table->time('end_time')->nullable(); + $table->string('venue'); + $table->string('postcode')->nullable(); + $table->unsignedInteger('guest_count')->nullable(); + $table->decimal('budget', 10, 2)->nullable(); + $table->text('message'); + $table->string('status')->default('new')->index(); + $table->timestamps(); }); - Schema::create('bookings', function (Blueprint $t) { - $t->ulid('id')->primary(); - $t->ulid('enquiry_id')->nullable(); - $t->foreignId('client_id')->constrained('users'); - $t->ulid('dj_profile_id'); - $t->date('event_date'); - $t->decimal('total', 10, 2); - $t->decimal('deposit', 10, 2)->default(0); - $t->string('status')->default('pending')->index(); - $t->timestamp('paid_at')->nullable(); - $t->timestamps(); + + Schema::create('bookings', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('enquiry_id')->nullable()->constrained('enquiries')->nullOnDelete(); + $table->foreignId('client_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete(); + $table->date('event_date')->index(); + $table->decimal('total', 10, 2); + $table->decimal('deposit', 10, 2)->default(0); + $table->string('status')->default('pending')->index(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); }); - Schema::create('reviews', function (Blueprint $t) { - $t->ulid('id')->primary(); - $t->ulid('booking_id'); - $t->foreignId('user_id')->constrained(); - $t->ulid('dj_profile_id'); - $t->unsignedTinyInteger('rating'); - $t->string('title')->nullable(); - $t->text('body'); - $t->string('status')->default('pending')->index(); - $t->text('dj_reply')->nullable(); - $t->timestamps(); + + Schema::create('reviews', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('booking_id')->constrained('bookings')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('dj_profile_id')->constrained('dj_profiles')->cascadeOnDelete(); + $table->unsignedTinyInteger('rating'); + $table->string('title')->nullable(); + $table->text('body'); + $table->string('status')->default('pending')->index(); + $table->text('dj_reply')->nullable(); + $table->timestamps(); + $table->unique(['booking_id', 'user_id']); }); - Schema::create('cookie_consents', function (Blueprint $t) { - $t->id(); - $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); - $t->string('anonymous_id')->nullable()->index(); - $t->boolean('analytics')->default(false); - $t->boolean('marketing')->default(false); - $t->string('policy_version'); - $t->timestamp('consented_at'); - $t->timestamps(); + + Schema::create('cookie_consents', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('anonymous_id')->nullable()->index(); + $table->boolean('analytics')->default(false); + $table->boolean('marketing')->default(false); + $table->string('policy_version'); + $table->timestamp('consented_at'); + $table->timestamps(); }); - Schema::create('audit_logs', function (Blueprint $t) { - $t->id(); - $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); - $t->string('action')->index(); - $t->nullableMorphs('subject'); - $t->json('meta')->nullable(); - $t->string('ip_hash', 64)->nullable(); - $t->timestamps(); + + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('action')->index(); + $table->nullableMorphs('subject'); + $table->json('meta')->nullable(); + $table->string('ip_hash', 64)->nullable(); + $table->timestamps(); }); } public function down(): void { - foreach (['audit_logs', 'cookie_consents', 'reviews', 'bookings', 'enquiries', 'dj_profile_genre', 'genres', 'dj_profiles', 'business_user', 'businesses'] as $x) { - Schema::dropIfExists($x); + foreach (['audit_logs', 'cookie_consents', 'reviews', 'bookings', 'enquiries', 'dj_profile_genre', 'genres', 'dj_profiles', 'business_user', 'businesses'] as $table) { + Schema::dropIfExists($table); } } }; diff --git a/database/migrations/2026_09_12_000002_create_permission_tables.php b/database/migrations/2026_09_12_000002_create_permission_tables.php new file mode 100644 index 0000000..0b238ef --- /dev/null +++ b/database/migrations/2026_09_12_000002_create_permission_tables.php @@ -0,0 +1,62 @@ +id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + $table->unique(['name', 'guard_name']); + }); + + Schema::create('roles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + $table->unique(['name', 'guard_name']); + }); + + Schema::create('model_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->string('model_type'); + $table->unsignedBigInteger('model_id'); + $table->index(['model_id', 'model_type']); + $table->foreign('permission_id')->references('id')->on('permissions')->cascadeOnDelete(); + $table->primary(['permission_id', 'model_id', 'model_type']); + }); + + Schema::create('model_has_roles', function (Blueprint $table) { + $table->unsignedBigInteger('role_id'); + $table->string('model_type'); + $table->unsignedBigInteger('model_id'); + $table->index(['model_id', 'model_type']); + $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete(); + $table->primary(['role_id', 'model_id', 'model_type']); + }); + + Schema::create('role_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->unsignedBigInteger('role_id'); + $table->foreign('permission_id')->references('id')->on('permissions')->cascadeOnDelete(); + $table->foreign('role_id')->references('id')->on('roles')->cascadeOnDelete(); + $table->primary(['permission_id', 'role_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('role_has_permissions'); + Schema::dropIfExists('model_has_roles'); + Schema::dropIfExists('model_has_permissions'); + Schema::dropIfExists('roles'); + Schema::dropIfExists('permissions'); + } +}; diff --git a/database/migrations/2026_09_12_000003_add_cashier_columns.php b/database/migrations/2026_09_12_000003_add_cashier_columns.php new file mode 100644 index 0000000..af38fe5 --- /dev/null +++ b/database/migrations/2026_09_12_000003_add_cashier_columns.php @@ -0,0 +1,55 @@ +string('stripe_id')->nullable()->index(); + $table->string('pm_type')->nullable(); + $table->string('pm_last_four', 4)->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + }); + + Schema::create('subscriptions', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $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(['user_id', 'stripe_status']); + }); + + Schema::create('subscription_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('subscription_id')->constrained()->cascadeOnDelete(); + $table->string('stripe_id')->unique(); + $table->string('stripe_product'); + $table->string('stripe_price'); + $table->integer('quantity')->nullable(); + $table->string('meter_event_name')->nullable(); + $table->string('meter_id')->nullable(); + $table->timestamps(); + $table->index(['subscription_id', 'stripe_price']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_items'); + Schema::dropIfExists('subscriptions'); + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['stripe_id']); + $table->dropColumn(['stripe_id', 'pm_type', 'pm_last_four', 'trial_ends_at']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c2d47d6..f1a5d46 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,7 +13,7 @@ class DatabaseSeeder extends Seeder public function run(): void { foreach (['super-admin', 'admin', 'dj', 'client', 'moderator', 'support'] as $r) { - Role::firstOrCreate(['name' => $r]); + Role::firstOrCreate(['name' => $r, 'guard_name' => 'web']); } $admin = User::firstOrCreate(['email' => 'admin@beatbook.test'], ['name' => 'BeatBook Admin', 'password' => 'password']); $admin->assignRole('admin'); $djUser = User::firstOrCreate(['email' => 'dj@beatbook.test'], ['name' => 'Luna Hart', 'password' => 'password']); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ae175e7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + tests/Unit + tests/Feature + + app + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/build/assets/app-4ylyCDk1.css b/public/build/assets/app-4ylyCDk1.css new file mode 100644 index 0000000..ee26f1e --- /dev/null +++ b/public/build/assets/app-4ylyCDk1.css @@ -0,0 +1 @@ +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.hidden{display:none}.table{display:table}.lowercase{text-transform:lowercase}}:root{--ink:#070a19;--panel:#0d1228;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24}*{box-sizing:border-box}body{color:#f8fafc;background:#070a19;margin:0;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.shell{max-width:1280px;margin:auto;padding:0 24px}.nav{justify-content:space-between;align-items:center;height:76px;display:flex}.brand{color:#fff;font-size:25px;font-weight:900;text-decoration:none}.brand b{color:#ec4899}.navlinks{gap:28px;display:flex}.navlinks a{color:#dbeafe;font-size:14px;text-decoration:none}.button{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;border:0;border-radius:12px;padding:13px 20px;font-weight:800;text-decoration:none;display:inline-flex}.button.ghost{background:0 0;border:1px solid #334155}.hero{background:linear-gradient(90deg,#070a19f7,#070a19b8 50%,#070a1940),url(https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2000&q=85) 50%/cover;border-bottom:1px solid #202743;min-height:610px}.hero-copy{max-width:760px;padding:105px 0 35px}.eyebrow{color:#67e8f9;letter-spacing:.18em;text-transform:uppercase;font-weight:800}.hero h1{letter-spacing:-.05em;margin:14px 0 22px;font-size:72px;line-height:.95}.hero h1 span{color:#0000;background:linear-gradient(90deg,#c084fc,#f472b6,#22d3ee);-webkit-background-clip:text;background-clip:text}.hero p{color:#cbd5e1;max-width:620px;font-size:19px}.searchbox{background:#fff;border-radius:18px;grid-template-columns:1.2fr 1fr 1fr auto;gap:8px;margin-top:34px;padding:10px;display:grid;box-shadow:0 25px 80px #0008}.searchbox input,.searchbox select{color:#0f172a;background:#f8fafc;border:0;border-radius:10px;outline:none;padding:17px}.section{padding:72px 0}.section-head{justify-content:space-between;align-items:end;margin-bottom:25px;display:flex}.section h2{margin:0;font-size:34px}.muted{color:#94a3b8}.cards{grid-template-columns:repeat(4,1fr);gap:18px;display:grid}.card{background:#10162d;border:1px solid #27304d;border-radius:18px;overflow:hidden;box-shadow:0 14px 35px #0004}.card-img{background:50%/cover;height:235px}.card-body{padding:18px}.card h3{margin:0 0 8px;font-size:20px}.stars{color:var(--gold)}.price{margin:14px 0;font-size:20px;font-weight:900}.chips{flex-wrap:wrap;gap:7px;display:flex}.chip{color:#cbd5e1;background:#1e293b;border-radius:99px;padding:6px 9px;font-size:12px}.profile-hero{background:50%/cover;height:350px;position:relative}.profile-panel{color:#0f172a;background:#fff;border-radius:22px;grid-template-columns:130px 1fr 220px;gap:25px;margin:-90px auto 40px;padding:30px;display:grid;position:relative;box-shadow:0 30px 70px #0007}.avatar{object-fit:cover;border-radius:16px;width:130px;height:130px}.admin{color:#0f172a;background:#f8fafc;grid-template-columns:240px 1fr;min-height:100vh;display:grid}.sidebar{color:#fff;background:#0b1022;padding:28px 18px}.sidebar a{color:#cbd5e1;border-radius:8px;padding:11px;text-decoration:none;display:block}.sidebar a:hover{background:#1e293b}.admin-main{padding:35px}.stats{grid-template-columns:repeat(4,1fr);gap:18px;display:grid}.stat{background:#fff;border-radius:14px;padding:24px;box-shadow:0 3px 15px #0f172a0d}.stat strong{color:#7c3aed;font-size:30px;display:block}.cookie{color:#0f172a;z-index:50;background:#fff;border-radius:16px;max-width:650px;padding:18px;position:fixed;bottom:18px;left:18px;right:18px;box-shadow:0 20px 60px #0008}@media(max-width:900px){.navlinks{display:none}.hero h1{font-size:50px}.searchbox{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.profile-panel{grid-template-columns:1fr;margin:-40px 16px 30px}.admin{grid-template-columns:1fr}.sidebar{display:none}.stats{grid-template-columns:1fr 1fr}}@media(max-width:600px){.cards,.stats{grid-template-columns:1fr}.hero h1{font-size:42px}.hero-copy{padding-top:70px}}.nav-actions{align-items:center;gap:9px;display:flex}.inline-form{margin:0;display:inline}.footer{border-top:1px solid #1e293b}.flash{border-radius:12px;margin-top:14px;padding:14px 18px}.flash.success{background:#064e3b}.cookie p{margin:6px 0 12px}.cookie-secondary{color:#0f172a}.auth-page{background:radial-gradient(circle at 20% 15%,#3b076455,#0000 35%),radial-gradient(circle at 85% 35%,#164e6355,#0000 32%),#070a19;min-height:calc(100vh - 160px);padding:70px 24px}.auth-card{background:#10162d;border:1px solid #27304d;border-radius:24px;width:min(100%,520px);margin:0 auto;padding:34px;box-shadow:0 28px 80px #0008}.auth-card h1{margin:10px 0 8px;font-size:36px}.auth-form{gap:16px;margin-top:26px;display:grid}.auth-form label{color:#e2e8f0;gap:8px;font-weight:700;display:grid}.auth-form input{color:#f8fafc;width:100%;font:inherit;background:#0b1022;border:1px solid #334155;border-radius:12px;outline:none;padding:14px 15px}.auth-form input:focus{border-color:#a855f7;box-shadow:0 0 0 3px #a855f722}.auth-form .check-row{grid-template-columns:auto 1fr;align-items:center;font-weight:500;display:flex}.auth-form .check-row input{width:auto}.button.full{cursor:pointer;justify-content:center;width:100%}.auth-links{color:#94a3b8;flex-direction:column;gap:10px;margin-top:22px;display:flex}.auth-links a{color:#67e8f9;text-decoration:none}.form-errors{color:#fecaca;background:#450a0a;border:1px solid #7f1d1d;border-radius:12px;gap:5px;margin-top:18px;padding:14px 16px;display:grid}@media(max-width:900px){.nav-actions{gap:6px}.nav-actions .button{padding:10px 12px}.auth-page{padding:40px 16px}}@media(max-width:600px){.nav{gap:12px;height:auto;min-height:70px}.nav-actions{flex-wrap:wrap;justify-content:flex-end}.auth-card{padding:24px}.auth-card h1{font-size:30px}} diff --git a/public/build/assets/app-DkQDD-ks.css b/public/build/assets/app-DkQDD-ks.css deleted file mode 100644 index c7b9177..0000000 --- a/public/build/assets/app-DkQDD-ks.css +++ /dev/null @@ -1 +0,0 @@ -@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components,utilities;:root{--ink:#070a19;--panel:#0d1228;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24}*{box-sizing:border-box}body{color:#f8fafc;background:#070a19;margin:0;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.shell{max-width:1280px;margin:auto;padding:0 24px}.nav{justify-content:space-between;align-items:center;height:76px;display:flex}.brand{color:#fff;font-size:25px;font-weight:900;text-decoration:none}.brand b{color:#ec4899}.navlinks{gap:28px;display:flex}.navlinks a{color:#dbeafe;font-size:14px;text-decoration:none}.button{background:linear-gradient(135deg,var(--purple),var(--pink));color:#fff;border:0;border-radius:12px;padding:13px 20px;font-weight:800;text-decoration:none;display:inline-flex}.button.ghost{background:0 0;border:1px solid #334155}.hero{background:linear-gradient(90deg,#070a19f7,#070a19b8 50%,#070a1940),url(https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2000&q=85) 50%/cover;border-bottom:1px solid #202743;min-height:610px}.hero-copy{max-width:760px;padding:105px 0 35px}.eyebrow{color:#67e8f9;letter-spacing:.18em;text-transform:uppercase;font-weight:800}.hero h1{letter-spacing:-.05em;margin:14px 0 22px;font-size:72px;line-height:.95}.hero h1 span{color:#0000;background:linear-gradient(90deg,#c084fc,#f472b6,#22d3ee);-webkit-background-clip:text;background-clip:text}.hero p{color:#cbd5e1;max-width:620px;font-size:19px}.searchbox{background:#fff;border-radius:18px;grid-template-columns:1.2fr 1fr 1fr auto;gap:8px;margin-top:34px;padding:10px;display:grid;box-shadow:0 25px 80px #0008}.searchbox input,.searchbox select{color:#0f172a;background:#f8fafc;border:0;border-radius:10px;outline:none;padding:17px}.section{padding:72px 0}.section-head{justify-content:space-between;align-items:end;margin-bottom:25px;display:flex}.section h2{margin:0;font-size:34px}.muted{color:#94a3b8}.cards{grid-template-columns:repeat(4,1fr);gap:18px;display:grid}.card{background:#10162d;border:1px solid #27304d;border-radius:18px;overflow:hidden;box-shadow:0 14px 35px #0004}.card-img{background:50%/cover;height:235px}.card-body{padding:18px}.card h3{margin:0 0 8px;font-size:20px}.stars{color:var(--gold)}.price{margin:14px 0;font-size:20px;font-weight:900}.chips{flex-wrap:wrap;gap:7px;display:flex}.chip{color:#cbd5e1;background:#1e293b;border-radius:99px;padding:6px 9px;font-size:12px}.profile-hero{background:50%/cover;height:350px;position:relative}.profile-panel{color:#0f172a;background:#fff;border-radius:22px;grid-template-columns:130px 1fr 220px;gap:25px;margin:-90px auto 40px;padding:30px;display:grid;position:relative;box-shadow:0 30px 70px #0007}.avatar{object-fit:cover;border-radius:16px;width:130px;height:130px}.admin{color:#0f172a;background:#f8fafc;grid-template-columns:240px 1fr;min-height:100vh;display:grid}.sidebar{color:#fff;background:#0b1022;padding:28px 18px}.sidebar a{color:#cbd5e1;border-radius:8px;padding:11px;text-decoration:none;display:block}.sidebar a:hover{background:#1e293b}.admin-main{padding:35px}.stats{grid-template-columns:repeat(4,1fr);gap:18px;display:grid}.stat{background:#fff;border-radius:14px;padding:24px;box-shadow:0 3px 15px #0f172a0d}.stat strong{color:#7c3aed;font-size:30px;display:block}.cookie{color:#0f172a;z-index:50;background:#fff;border-radius:16px;max-width:650px;padding:18px;position:fixed;bottom:18px;left:18px;right:18px;box-shadow:0 20px 60px #0008}@media(max-width:900px){.navlinks{display:none}.hero h1{font-size:50px}.searchbox{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.profile-panel{grid-template-columns:1fr;margin:-40px 16px 30px}.admin{grid-template-columns:1fr}.sidebar{display:none}.stats{grid-template-columns:1fr 1fr}}@media(max-width:600px){.cards,.stats{grid-template-columns:1fr}.hero h1{font-size:42px}.hero-copy{padding-top:70px}} diff --git a/public/build/manifest.json b/public/build/manifest.json index 49be2dd..1d9b51c 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,6 +1,6 @@ { "resources/css/app.css": { - "file": "assets/app-DkQDD-ks.css", + "file": "assets/app-4ylyCDk1.css", "src": "resources/css/app.css", "isEntry": true, "name": "app", diff --git a/resources/css/app.css b/resources/css/app.css index afbec76..fcc9947 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,2 +1,4 @@ @import "tailwindcss"; -:root{--ink:#070a19;--panel:#0d1228;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24}*{box-sizing:border-box}body{margin:0;background:#070a19;color:#f8fafc;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.shell{max-width:1280px;margin:auto;padding:0 24px}.nav{height:76px;display:flex;align-items:center;justify-content:space-between}.brand{font-size:25px;font-weight:900;color:white;text-decoration:none}.brand b{color:#ec4899}.navlinks{display:flex;gap:28px}.navlinks a{color:#dbeafe;text-decoration:none;font-size:14px}.button{display:inline-flex;padding:13px 20px;border-radius:12px;background:linear-gradient(135deg,var(--purple),var(--pink));color:white;text-decoration:none;border:0;font-weight:800}.button.ghost{background:transparent;border:1px solid #334155}.hero{min-height:610px;background:linear-gradient(90deg,rgba(7,10,25,.97) 0%,rgba(7,10,25,.72) 50%,rgba(7,10,25,.25)),url('https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2000&q=85') center/cover;border-bottom:1px solid #202743}.hero-copy{padding:105px 0 35px;max-width:760px}.eyebrow{color:#67e8f9;font-weight:800;letter-spacing:.18em;text-transform:uppercase}.hero h1{font-size:72px;line-height:.95;margin:14px 0 22px;letter-spacing:-.05em}.hero h1 span{background:linear-gradient(90deg,#c084fc,#f472b6,#22d3ee);background-clip:text;color:transparent}.hero p{font-size:19px;color:#cbd5e1;max-width:620px}.searchbox{margin-top:34px;padding:10px;background:white;border-radius:18px;display:grid;grid-template-columns:1.2fr 1fr 1fr auto;gap:8px;box-shadow:0 25px 80px #0008}.searchbox input,.searchbox select{border:0;padding:17px;color:#0f172a;background:#f8fafc;border-radius:10px;outline:none}.section{padding:72px 0}.section-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.section h2{font-size:34px;margin:0}.muted{color:#94a3b8}.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.card{background:#10162d;border:1px solid #27304d;border-radius:18px;overflow:hidden;box-shadow:0 14px 35px #0004}.card-img{height:235px;background:center/cover}.card-body{padding:18px}.card h3{margin:0 0 8px;font-size:20px}.stars{color:var(--gold)}.price{font-size:20px;font-weight:900;margin:14px 0}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font-size:12px;padding:6px 9px;background:#1e293b;border-radius:99px;color:#cbd5e1}.profile-hero{height:350px;background:center/cover;position:relative}.profile-panel{margin:-90px auto 40px;position:relative;background:white;color:#0f172a;border-radius:22px;padding:30px;display:grid;grid-template-columns:130px 1fr 220px;gap:25px;box-shadow:0 30px 70px #0007}.avatar{width:130px;height:130px;border-radius:16px;object-fit:cover}.admin{display:grid;grid-template-columns:240px 1fr;min-height:100vh;background:#f8fafc;color:#0f172a}.sidebar{background:#0b1022;color:white;padding:28px 18px}.sidebar a{display:block;color:#cbd5e1;text-decoration:none;padding:11px;border-radius:8px}.sidebar a:hover{background:#1e293b}.admin-main{padding:35px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.stat{background:white;padding:24px;border-radius:14px;box-shadow:0 3px 15px #0f172a0d}.stat strong{display:block;font-size:30px;color:#7c3aed}.cookie{position:fixed;bottom:18px;left:18px;right:18px;max-width:650px;background:#fff;color:#0f172a;border-radius:16px;padding:18px;box-shadow:0 20px 60px #0008;z-index:50}@media(max-width:900px){.navlinks{display:none}.hero h1{font-size:50px}.searchbox{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.profile-panel{grid-template-columns:1fr;margin:-40px 16px 30px}.admin{grid-template-columns:1fr}.sidebar{display:none}.stats{grid-template-columns:1fr 1fr}}@media(max-width:600px){.cards,.stats{grid-template-columns:1fr}.hero h1{font-size:42px}.hero-copy{padding-top:70px}} \ No newline at end of file +:root{--ink:#070a19;--panel:#0d1228;--purple:#7c3aed;--pink:#ec4899;--cyan:#22d3ee;--gold:#fbbf24}*{box-sizing:border-box}body{margin:0;background:#070a19;color:#f8fafc;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.shell{max-width:1280px;margin:auto;padding:0 24px}.nav{height:76px;display:flex;align-items:center;justify-content:space-between}.brand{font-size:25px;font-weight:900;color:white;text-decoration:none}.brand b{color:#ec4899}.navlinks{display:flex;gap:28px}.navlinks a{color:#dbeafe;text-decoration:none;font-size:14px}.button{display:inline-flex;padding:13px 20px;border-radius:12px;background:linear-gradient(135deg,var(--purple),var(--pink));color:white;text-decoration:none;border:0;font-weight:800}.button.ghost{background:transparent;border:1px solid #334155}.hero{min-height:610px;background:linear-gradient(90deg,rgba(7,10,25,.97) 0%,rgba(7,10,25,.72) 50%,rgba(7,10,25,.25)),url('https://images.unsplash.com/photo-1571266028243-d220c9c3b2d2?auto=format&fit=crop&w=2000&q=85') center/cover;border-bottom:1px solid #202743}.hero-copy{padding:105px 0 35px;max-width:760px}.eyebrow{color:#67e8f9;font-weight:800;letter-spacing:.18em;text-transform:uppercase}.hero h1{font-size:72px;line-height:.95;margin:14px 0 22px;letter-spacing:-.05em}.hero h1 span{background:linear-gradient(90deg,#c084fc,#f472b6,#22d3ee);background-clip:text;color:transparent}.hero p{font-size:19px;color:#cbd5e1;max-width:620px}.searchbox{margin-top:34px;padding:10px;background:white;border-radius:18px;display:grid;grid-template-columns:1.2fr 1fr 1fr auto;gap:8px;box-shadow:0 25px 80px #0008}.searchbox input,.searchbox select{border:0;padding:17px;color:#0f172a;background:#f8fafc;border-radius:10px;outline:none}.section{padding:72px 0}.section-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:25px}.section h2{font-size:34px;margin:0}.muted{color:#94a3b8}.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.card{background:#10162d;border:1px solid #27304d;border-radius:18px;overflow:hidden;box-shadow:0 14px 35px #0004}.card-img{height:235px;background:center/cover}.card-body{padding:18px}.card h3{margin:0 0 8px;font-size:20px}.stars{color:var(--gold)}.price{font-size:20px;font-weight:900;margin:14px 0}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font-size:12px;padding:6px 9px;background:#1e293b;border-radius:99px;color:#cbd5e1}.profile-hero{height:350px;background:center/cover;position:relative}.profile-panel{margin:-90px auto 40px;position:relative;background:white;color:#0f172a;border-radius:22px;padding:30px;display:grid;grid-template-columns:130px 1fr 220px;gap:25px;box-shadow:0 30px 70px #0007}.avatar{width:130px;height:130px;border-radius:16px;object-fit:cover}.admin{display:grid;grid-template-columns:240px 1fr;min-height:100vh;background:#f8fafc;color:#0f172a}.sidebar{background:#0b1022;color:white;padding:28px 18px}.sidebar a{display:block;color:#cbd5e1;text-decoration:none;padding:11px;border-radius:8px}.sidebar a:hover{background:#1e293b}.admin-main{padding:35px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}.stat{background:white;padding:24px;border-radius:14px;box-shadow:0 3px 15px #0f172a0d}.stat strong{display:block;font-size:30px;color:#7c3aed}.cookie{position:fixed;bottom:18px;left:18px;right:18px;max-width:650px;background:#fff;color:#0f172a;border-radius:16px;padding:18px;box-shadow:0 20px 60px #0008;z-index:50}@media(max-width:900px){.navlinks{display:none}.hero h1{font-size:50px}.searchbox{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}.profile-panel{grid-template-columns:1fr;margin:-40px 16px 30px}.admin{grid-template-columns:1fr}.sidebar{display:none}.stats{grid-template-columns:1fr 1fr}}@media(max-width:600px){.cards,.stats{grid-template-columns:1fr}.hero h1{font-size:42px}.hero-copy{padding-top:70px}} +.nav-actions{display:flex;gap:9px;align-items:center}.inline-form{display:inline;margin:0}.footer{border-top:1px solid #1e293b}.flash{margin-top:14px;padding:14px 18px;border-radius:12px}.flash.success{background:#064e3b}.cookie p{margin:6px 0 12px}.cookie-secondary{color:#0f172a}.auth-page{min-height:calc(100vh - 160px);padding:70px 24px;background:radial-gradient(circle at 20% 15%,#3b076455,transparent 35%),radial-gradient(circle at 85% 35%,#164e6355,transparent 32%),#070a19}.auth-card{width:min(100%,520px);margin:0 auto;background:#10162d;border:1px solid #27304d;border-radius:24px;padding:34px;box-shadow:0 28px 80px #0008}.auth-card h1{margin:10px 0 8px;font-size:36px}.auth-form{display:grid;gap:16px;margin-top:26px}.auth-form label{display:grid;gap:8px;font-weight:700;color:#e2e8f0}.auth-form input{width:100%;border:1px solid #334155;background:#0b1022;color:#f8fafc;border-radius:12px;padding:14px 15px;font:inherit;outline:none}.auth-form input:focus{border-color:#a855f7;box-shadow:0 0 0 3px #a855f722}.auth-form .check-row{display:flex;grid-template-columns:auto 1fr;align-items:center;font-weight:500}.auth-form .check-row input{width:auto}.button.full{justify-content:center;width:100%;cursor:pointer}.auth-links{display:flex;flex-direction:column;gap:10px;margin-top:22px;color:#94a3b8}.auth-links a{color:#67e8f9;text-decoration:none}.form-errors{margin-top:18px;padding:14px 16px;border:1px solid #7f1d1d;background:#450a0a;color:#fecaca;border-radius:12px;display:grid;gap:5px} +@media(max-width:900px){.nav-actions{gap:6px}.nav-actions .button{padding:10px 12px}.auth-page{padding:40px 16px}}@media(max-width:600px){.nav{height:auto;min-height:70px;gap:12px}.nav-actions{flex-wrap:wrap;justify-content:flex-end}.auth-card{padding:24px}.auth-card h1{font-size:30px}} diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 195efbb..9eb1aef 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -1 +1,41 @@ -@vite(['resources/css/app.css'])BeatBook Admin

Dashboard

Marketplace overview and moderation queue.

@foreach($stats as $k=>$v)
{{ ucfirst($k) }}{{ number_format($v) }}
@endforeach

Operations

Revenue charts, recent bookings, verification requests, support tickets and review moderation plug into this dashboard.

@foreach([25,42,36,61,55,78,70,92,86,110,120] as $h)@endforeach
\ No newline at end of file + + + + + + BeatBook Admin + @vite(['resources/css/app.css']) + + +
+ +
+

Dashboard

+

Marketplace overview and moderation queue.

+
+ @foreach($stats as $key => $value) +
{{ ucfirst($key) }}{{ number_format($value) }}
+ @endforeach +
+
+

Operations

+

Revenue charts, recent bookings, verification requests, support tickets and review moderation plug into this dashboard.

+
+
+
+ + diff --git a/resources/views/admin/section.blade.php b/resources/views/admin/section.blade.php new file mode 100644 index 0000000..bdbedf7 --- /dev/null +++ b/resources/views/admin/section.blade.php @@ -0,0 +1,38 @@ + + + + + + {{ ucwords(str_replace('-', ' ', $section)) }} — BeatBook Admin + @vite(['resources/css/app.css']) + + +
+ +
+
Administration
+

{{ ucwords(str_replace('-', ' ', $section)) }}

+

This BeatBook administration section is connected and ready for its management workflow.

+
+ @foreach($counts as $label => $value) +
{{ ucfirst($label) }}{{ number_format($value) }}
+ @endforeach +
+
+
+ + diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..4731fff --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.app') + +@section('title', 'Reset password — BeatBook') + +@section('content') +
+
+
Account recovery
+

Forgot your password?

+

Enter your email and we will send a password reset link.

+ + @if($errors->any()) +
+ @foreach($errors->all() as $error)
{{ $error }}
@endforeach +
+ @endif + +
+ @csrf + + +
+ +
+
+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..486a4fb --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,36 @@ +@extends('layouts.app') + +@section('title', 'Sign in — BeatBook') + +@section('content') +
+
+
Welcome back
+

Sign in to BeatBook

+

Manage enquiries, bookings and your account.

+ + @if($errors->any()) +
+ @foreach($errors->all() as $error)
{{ $error }}
@endforeach +
+ @endif + +
+ @csrf + + + + +
+ + +
+
+@endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..8ebd45c --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('title', 'Create account — BeatBook') + +@section('content') +
+
+
Join BeatBook
+

Create your account

+

Start enquiring with DJs and managing your bookings.

+ + @if($errors->any()) +
+ @foreach($errors->all() as $error)
{{ $error }}
@endforeach +
+ @endif + +
+ @csrf + + + + + +
+ + +
+
+@endsection diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..36b01a0 --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,33 @@ +@extends('layouts.app') + +@section('title', 'Choose a new password — BeatBook') + +@section('content') +
+
+
Account recovery
+

Choose a new password

+ + @if($errors->any()) +
+ @foreach($errors->all() as $error)
{{ $error }}
@endforeach +
+ @endif + +
+ @csrf + + + + + +
+
+
+@endsection diff --git a/resources/views/djs/show.blade.php b/resources/views/djs/show.blade.php index 66aa569..b52948a 100644 --- a/resources/views/djs/show.blade.php +++ b/resources/views/djs/show.blade.php @@ -1 +1 @@ -@extends('layouts.app') @section('title',$dj->stage_name.' — BeatBook') @section('content')
{{ $dj->stage_name }}

{{ $dj->stage_name }} @if($dj->verified_at)✓@endif

★★★★★ {{ $dj->average_rating }} · {{ $dj->review_count }} reviews

{{ $dj->headline }}

@foreach($dj->genres as $genre){{ $genre->name }}@endforeach

About {{ $dj->stage_name }}

{{ $dj->bio }}

Reviews

@foreach($dj->reviews as $review)
{{ str_repeat('★',$review->rating) }}

{{ $review->title }}

{{ $review->body }}

@endforeach
@endsection \ No newline at end of file +@extends('layouts.app') @section('title',$dj->stage_name.' — BeatBook') @section('content')
{{ $dj->stage_name }}

{{ $dj->stage_name }} @if($dj->verified_at)✓@endif

★★★★★ {{ $dj->average_rating }} · {{ $dj->review_count }} reviews

{{ $dj->headline }}

@foreach($dj->genres as $genre){{ $genre->name }}@endforeach

About {{ $dj->stage_name }}

{{ $dj->bio }}

Reviews

@foreach($dj->reviews as $review)
{{ str_repeat('★',$review->rating) }}

{{ $review->title }}

{{ $review->body }}

@endforeach
@endsection \ No newline at end of file diff --git a/resources/views/home/index.blade.php b/resources/views/home/index.blade.php index 5483b3b..7182c85 100644 --- a/resources/views/home/index.blade.php +++ b/resources/views/home/index.blade.php @@ -1 +1 @@ -@extends('layouts.app') @section('content')
Music creates memories

Find your perfect DJ

Discover trusted DJs for weddings, parties, corporate events and clubs. Compare profiles, reviews and prices, then enquire with confidence.

Top talent

Featured DJs

View all DJs
@forelse($featured as $dj) @include('djs._card',['dj'=>$dj]) @empty @foreach([['DJ Luna','London',350,'photo-1516280440614-37939bbacd81'],['DJ Maverick','Manchester',400,'photo-1571266028243-d220c9c3b2d2'],['DJ Rhythm','Birmingham',300,'photo-1524368535928-5b5e00ddc76b'],['DJ Serenity','Leeds',320,'photo-1506157786151-b8491531f063']] as $d)

{{ $d[0] }}

★★★★★ 4.9

{{ $d[1] }} · Weddings · Parties

From £{{ $d[2] }}
View profile
@endforeach @endforelse
Simple & secure

From search to dance floor

@foreach(['Search'=>'Find DJs by location, date, event and budget.','Compare'=>'Review portfolios, pricing and verified feedback.','Enquire'=>'Send your event details without exposing private contact data.','Book & review'=>'Confirm securely and review after the event.'] as $a=>$b)

{{ $a }}

{{ $b }}

@endforeach
@endsection \ No newline at end of file +@extends('layouts.app') @section('content')
Music creates memories

Find your perfect DJ

Discover trusted DJs for weddings, parties, corporate events and clubs. Compare profiles, reviews and prices, then enquire with confidence.

Top talent

Featured DJs

View all DJs
@forelse($featured as $dj) @include('djs._card',['dj'=>$dj]) @empty @foreach([['DJ Luna','London',350,'photo-1516280440614-37939bbacd81'],['DJ Maverick','Manchester',400,'photo-1571266028243-d220c9c3b2d2'],['DJ Rhythm','Birmingham',300,'photo-1524368535928-5b5e00ddc76b'],['DJ Serenity','Leeds',320,'photo-1506157786151-b8491531f063']] as $d)

{{ $d[0] }}

★★★★★ 4.9

{{ $d[1] }} · Weddings · Parties

From £{{ $d[2] }}
View profile
@endforeach @endforelse
Simple & secure

From search to dance floor

@foreach(['Search'=>'Find DJs by location, date, event and budget.','Compare'=>'Review portfolios, pricing and verified feedback.','Enquire'=>'Send your event details without exposing private contact data.','Book & review'=>'Confirm securely and review after the event.'] as $a=>$b)

{{ $a }}

{{ $b }}

@endforeach
For professionals

Grow your DJ business

Professional profile

Show your sound, experience, service area and pricing in one trusted profile.

Qualified enquiries

Receive structured event details so you can quote quickly and accurately.

Verified reviews

Build reputation from completed BeatBook bookings.

Business tools

Manage bookings, payments and customer communication from your dashboard.

Straightforward pricing

Built for clients and DJs

Browsing and creating a client account is free. DJ subscription and payment options can be configured from the BeatBook billing area as the marketplace launches.

@endsection \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index ce7ccc3..800327e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1 +1,70 @@ -@yield('title','BeatBook — Find Your Perfect DJ')@vite(['resources/css/app.css','resources/js/app.js'])
@if(session('success'))
{{ session('success') }}
@endif>@yield('content') \ No newline at end of file + + + + + + + + @yield('title', 'BeatBook — Find Your Perfect DJ') + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ +
+ +@if(session('success')) +
{{ session('success') }}
+@endif +@if(session('status')) +
{{ session('status') }}
+@endif + +@yield('content') + + + + + + + diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..4ef11e5 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,7 @@ +comment('BeatBook'); +})->purpose('Display the application name'); diff --git a/routes/web.php b/routes/web.php index 74244b0..532e998 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,11 @@ name('home'); Route::get('/djs', [DjController::class, 'index'])->name('djs.index'); Route::get('/dj/{djProfile:slug}', [DjController::class, 'show'])->name('djs.show'); -Route::middleware(['auth', 'verified'])->group(function () { + +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('/login', [AuthenticatedSessionController::class, 'store']); + Route::get('/register', [RegisteredUserController::class, 'create'])->name('register'); + Route::post('/register', [RegisteredUserController::class, 'store']); + Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])->name('password.request'); + Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])->name('password.email'); + Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])->name('password.reset'); + Route::post('/reset-password', [NewPasswordController::class, 'store'])->name('password.store'); +}); + +Route::middleware('auth')->group(function () { + Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); Route::post('/dj/{djProfile}/enquire', [EnquiryController::class, 'store'])->name('enquiries.store'); Route::post('/bookings/{booking}/reviews', [ReviewController::class, 'store'])->name('reviews.store'); Route::view('/dashboard', 'client.dashboard')->name('dashboard'); }); + Route::middleware(['auth', 'role:admin|super-admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/', DashboardController::class)->name('dashboard'); + Route::get('/{section}', SectionController::class)->name('section'); }); + Route::view('/privacy', 'legal.privacy')->name('privacy'); Route::view('/cookies', 'legal.cookies')->name('cookies'); Route::view('/terms', 'legal.terms')->name('terms'); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..4492031 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,41 @@ + 'client', 'guard_name' => 'web']); + + $response = $this->post('/register', [ + 'name' => 'Test Client', + 'email' => 'client@example.test', + 'password' => 'password12345', + 'password_confirmation' => 'password12345', + ]); + + $response->assertRedirect('/dashboard'); + $this->assertAuthenticated(); + $this->assertDatabaseHas('users', ['email' => 'client@example.test']); + } + + public function test_user_can_login_and_logout(): void + { + $user = User::factory()->create(['password' => 'password12345']); + + $this->post('/login', ['email' => $user->email, 'password' => 'password12345']) + ->assertRedirect('/dashboard'); + $this->assertAuthenticatedAs($user); + + $this->post('/logout')->assertRedirect('/'); + $this->assertGuest(); + } +} diff --git a/tests/Feature/PublicRoutesTest.php b/tests/Feature/PublicRoutesTest.php new file mode 100644 index 0000000..51baec7 --- /dev/null +++ b/tests/Feature/PublicRoutesTest.php @@ -0,0 +1,28 @@ +get($uri)->assertSuccessful(); + } + } + + public function test_dashboard_redirects_guests_to_login(): void + { + $this->get('/dashboard')->assertRedirect('/login'); + } + + public function test_admin_redirects_guests_to_login(): void + { + $this->get('/admin')->assertRedirect('/login'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +