Tutorial Laravel 12 Filament Spatie Midtrans Membaangun SaaS Gym Membership dari Admin Hingga Checkout

Halo teman-teaman! Kali ini kita akan membangun sebuah aplikasi SaaS yang cukup menarik dan punya potensi bisnis yang bagus nih. Kita akan bikin sistem manajemen membership gym yang bisa digunakan oleh banyak pemilik gym sekaligus.

Bayangkan ada satu platform yang bisa menampung puluhan bahkan ratusan gym, dan setiap pemilik gym bisa mengelola bisnisnya sendiri-sendiri. Sistem yang akan kita bangun ini punya dua sisi yang berbeda.

Pertama, ada dashboard admin yang dibangun menggunakan Filament. Dashboard ini akan digunakan oleh dua tipe pengguna: super admin SaaS yang mengelola seluruh platform, dan gym owner yang hanya bisa mengakses data gym mereka sendiri.

Kedua, ada halaman publik yang akan diakses oleh calon member. Di halaman ini, mereka bisa browsing gym-gym yang tersedia, lihat-lihat paket membership yang ditawarkan, dan langsung checkout dengan pembayaran real-time menggunakan Midtrans.

Yang bikin sistem ini menarik adalah konsep multi-tenant nya. Jadi satu aplikasi bisa melayani banyak gym sekaligus, tapi setiap gym tetap punya otonomi penuh untuk mengelola data mereka.

Pemilik gym bisa mandiri mengatur paket-paket membership, lihat siapa saja yang jadi member, dan pantau transaksi yang masuk. Sementara pengelola SaaS tetap bisa memantau performa seluruh platform dari satu dashboard.

Setup Laravel & Struktur Data Utama

Sekarang kita mulai dari hal yang paling fundamental dulu ya, yaitu setup Laravel dan perancangan database. Untuk tutorial ini, kita akan menggunakan Laravel 12 yang merupakan versi terbaru dengan fitur-fitur yang lebih powerful.

Pertama-tama, mari kita buat project Laravel baru menggunakan Composer. Pastikan kamu sudah punya PHP 8.2 atau lebih tinggi dan Composer yang terinstall di komputer kamu.

composer create-project laravel/laravel gym-saas-app
cd gym-saas-app

Setelah project berhasil dibuat, kita perlu mengonfigurasi file environment. Buka file .env dan sesuaikan konfigurasi database serta beberapa setting lainnya.

APP_NAME="Gym SaaS Platform"
APP_ENV=local
APP_KEY=base64:generated_key_here
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Jakarta
APP_LOCALE=id

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=gym_saas
DB_USERNAME=root
DB_PASSWORD=

Perhatikan bahwa kita set timezone ke Asia/Jakarta dan locale ke id supaya aplikasi kita lebih sesuai dengan kondisi lokal Indonesia. Jangan lupa buat database gym_saas di MySQL kamu ya.

Sekarang mari kita rancang struktur database yang akan menjadi tulang punggung aplikasi kita. Database design yang baik itu sangat penting karena akan menentukan bagaimana data saling berhubungan dan performa aplikasi ke depannya.

Kita akan punya lima tabel utama yang saling berkaitan. Tabel gyms akan menyimpan informasi setiap cabang gym seperti nama, alamat, dan kota. Tabel ini akan jadi parent dari hampir semua data lainnya.

Tabel plans akan menyimpan berbagai paket membership yang ditawarkan oleh setiap gym. Setiap gym bisa punya banyak paket dengan harga dan durasi yang berbeda-beda. Misalnya ada paket bulanan, tiga bulanan, atau tahunan.

Tabel members akan menyimpan data calon pengguna atau pelanggan yang sudah mendaftar di sistem kita. Mereka belum tentu punya subscription aktif, tapi datanya sudah tersimpan untuk keperluan transaksi.

Tabel subscriptions akan mencatat status langganan setiap member. Di sini kita simpan informasi kapan subscription dimulai, kapan berakhir, dan statusnya masih aktif atau tidak.

Terakhir, tabel transactions akan menyimpan histori semua pembayaran yang terjadi di sistem. Setiap kali ada transaksi, baik berhasil maupun gagal, akan tercatat di tabel ini lengkap dengan status dan detailnya.

Setup Migration, Factory, dan Seeder

Sebelum kita masuk ke dashboard admin, kita perlu mempersiapkan struktur database yang solid terlebih dahulu. Kita akan buat migration, factory, dan seeder untuk semua tabel utama yang sudah kita rencanakan tadi.

Mari kita mulai dengan membuat migration untuk tabel gyms:

php artisan make:migration create_gyms_table
php artisan make:model Gym -f

Command di atas akan membuat file migration dan model Gym sekaligus dengan factory-nya. Di file migration, kita definisikan struktur tabel gyms dengan kolom-kolom yang diperlukan.

Selanjutnya kita buat migration untuk tabel plans:

php artisan make:migration create_plans_table
php artisan make:model Plan -f

Tabel plans akan punya foreign key ke tabel gyms karena setiap plan belongs to satu gym tertentu. Jangan lupa definisikan relationship ini di model juga.

Kemudian kita buat migration untuk tabel members:

php artisan make:migration create_members_table
php artisan make:model Member -f

Tabel members akan menyimpan data calon pelanggan yang sudah register di sistem kita. Mereka bisa jadi punya banyak subscription dan transaction.

Lalu kita buat migration untuk tabel subscriptions:

php artisan make:migration create_subscriptions_table
php artisan make:model Subscription -f

Tabel subscriptions akan menghubungkan member dengan plan tertentu, lengkap dengan tanggal mulai dan berakhir subscription.

Terakhir kita buat migration untuk tabel transactions:

php artisan make:migration create_transactions_table
php artisan make:model Transaction -f

Setelah semua migration dibuat, jangan lupa definisikan relationship antar model dengan benar. Misalnya Gym hasMany Plans, Member hasMany Subscriptions, dll.

Factory akan sangat membantu kita untuk generate dummy data saat development. Kita bisa buat data gym, plan, dan member palsu untuk testing dashboard admin nanti.

Mari kita buat seeder juga untuk populate data awal:

php artisan make:seeder GymSeeder
php artisan make:seeder PlanSeeder
php artisan make:seeder MemberSeeder

Setelah semua migration, factory, dan seeder siap, jalankan command berikut:

php artisan migrate --seed

Konfigurasi Model dan Relationship

Sebelum masuk ke Filament resource, kita perlu mengatur model-model kita dengan benar. Ini termasuk fillable attributes dan relationship antar model yang akan memudahkan kita dalam mengelola data.

Mari kita mulai dengan model Gym. Buka file app/Models/Gym.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Gym extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'description',
        'address',
        'city',
        'phone',
        'image',
        'user_id', // untuk gym owner
    ];

    // Relationship: Gym has many Plans
    public function plans()
    {
        return $this->hasMany(Plan::class);
    }

    // Relationship: Gym belongs to User (gym owner)
    public function owner()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    // Relationship: Gym has many Subscriptions through Plans
    public function subscriptions()
    {
        return $this->hasManyThrough(Subscription::class, Plan::class);
    }
}

Selanjutnya model Plan. Buka file app/Models/Plan.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Plan extends Model
{
    use HasFactory;

    protected $fillable = [
        'gym_id',
        'name',
        'description',
        'price',
        'duration_months',
        'is_active',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'duration_months' => 'integer',
        'is_active' => 'boolean',
    ];

    // Relationship: Plan belongs to Gym
    public function gym()
    {
        return $this->belongsTo(Gym::class);
    }

    // Relationship: Plan has many Subscriptions
    public function subscriptions()
    {
        return $this->hasMany(Subscription::class);
    }

    // Relationship: Plan has many Transactions
    public function transactions()
    {
        return $this->hasMany(Transaction::class);
    }
}

Kemudian model Member. Buka file app/Models/Member.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Member extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'email',
        'phone',
        'birth_date',
        'gender',
        'password', // jika member bisa login
    ];

    protected $casts = [
        'birth_date' => 'date',
        'password' => 'hashed',
    ];

    protected $hidden = [
        'password',
    ];

    // Relationship: Member has many Subscriptions
    public function subscriptions()
    {
        return $this->hasMany(Subscription::class);
    }

    // Relationship: Member has many Transactions
    public function transactions()
    {
        return $this->hasMany(Transaction::class);
    }

    // Helper method: Get active subscription
    public function activeSubscription()
    {
        return $this->subscriptions()
            ->where('active_until', '>', now())
            ->where('is_active', true)
            ->first();
    }
}

Lalu model Subscription. Buka file app/Models/Subscription.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Subscription extends Model
{
    use HasFactory;

    protected $fillable = [
        'member_id',
        'plan_id',
        'transaction_id',
        'started_at',
        'active_until',
        'is_active',
    ];

    protected $casts = [
        'started_at' => 'datetime',
        'active_until' => 'datetime',
        'is_active' => 'boolean',
    ];

    // Relationship: Subscription belongs to Member
    public function member()
    {
        return $this->belongsTo(Member::class);
    }

    // Relationship: Subscription belongs to Plan
    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }

    // Relationship: Subscription belongs to Transaction
    public function transaction()
    {
        return $this->belongsTo(Transaction::class);
    }

    // Helper method: Check if subscription is still active
    public function isActive()
    {
        return $this->is_active && $this->active_until > now();
    }
}

Terakhir model Transaction. Buka file app/Models/Transaction.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;

class Transaction extends Model
{
    use HasFactory;

    protected $fillable = [
        'member_id',
        'plan_id',
        'amount',
        'status',
        'midtrans_order_id',
        'midtrans_transaction_id',
        'payment_method',
        'paid_at',
        'expired_at',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'paid_at' => 'datetime',
        'expired_at' => 'datetime',
    ];

    // Relationship: Transaction belongs to Member
    public function member()
    {
        return $this->belongsTo(Member::class);
    }

    // Relationship: Transaction belongs to Plan
    public function plan()
    {
        return $this->belongsTo(Plan::class);
    }

    // Relationship: Transaction has one Subscription
    public function subscription()
    {
        return $this->hasOne(Subscription::class);
    }

    // Helper method: Check if transaction is successful
    public function isPaid()
    {
        return $this->status === 'paid';
    }

    // Helper method: Check if transaction is expired
    public function isExpired()
    {
        return $this->expired_at && $this->expired_at < now();
    }
}

Jangan lupa juga update model User untuk menambahkan relationship dengan Gym:

// Di app/Models/User.php, tambahkan method ini:

public function gyms()
{
    return $this->hasMany(Gym::class);
}

// Helper method untuk check apakah user adalah gym owner
public function isGymOwner()
{
    return $this->hasRole('gym_owner') && $this->gyms()->exists();
}

Dengan setup model dan relationship seperti ini, kita bisa dengan mudah mengakses data yang saling berkaitan. Misalnya kita bisa ambil semua member dari gym tertentu, atau semua transaksi dari plan tertentu.

Casting yang kita definisikan juga akan memastikan tipe data yang konsisten. Misalnya price akan selalu dalam format decimal, dan is_active akan selalu boolean.

Helper methods seperti isActive() dan isPaid() akan memudahkan kita dalam business logic nanti, terutama saat validasi subscription dan payment status.

Dashboard Admin dengan Filament

Sekarang setelah struktur database kita siap, waktunya membangun dashboard admin yang akan jadi jantung sistem manajemen kita. Untuk dashboard admin, kita akan menggunakan Filament yang merupakan admin panel Laravel yang sangat powerful dan mudah digunakan.

Filament itu seperti magic wand buat developer Laravel. Dengan sedikit konfigurasi, kita bisa punya dashboard admin yang cantik, responsive, dan feature-rich tanpa perlu coding dari nol. Perfect banget buat project SaaS seperti yang kita bangun ini.

Mari kita install Filament terlebih dahulu. Jalankan command berikut di terminal:

composer require filament/filament:"^3.0"

Setelah installation selesai, kita perlu publish dan run migration untuk Filament:

php artisan filament:install --panels
php artisan migrate

Command di atas akan membuat panel admin default dan mengatur semua file konfigurasi yang diperlukan. Filament akan secara otomatis membuat folder app/Filament yang berisi semua resource dan konfigurasi panel admin kita.

Sekarang kita perlu membuat user admin pertama yang akan bisa mengakses dashboard. Filament punya command khusus untuk ini:

php artisan make:filament-user

Command ini akan meminta kamu input nama, email, dan password untuk user admin. User yang dibuat akan langsung punya akses penuh ke dashboard Filament.

Setelah user admin dibuat, kita bisa langsung test dashboard dengan menjalankan server Laravel dan mengakses /admin. Kamu akan melihat login page Filament yang elegan dengan form login standar.

Sekarang mari kita buat resource untuk mengelola data-data utama aplikasi kita. Resource di Filament itu seperti CRUD controller yang sudah jadi, lengkap dengan form create, edit, list, dan delete.

Mari kita mulai dengan resource Gym:

php artisan make:filament-resource Gym

Command ini akan generate beberapa file sekaligus. File utamanya ada di app/Filament/Resources/GymResource.php yang berisi konfigurasi untuk list, form, dan table gym.

Selanjutnya kita buat resource untuk Plan:

php artisan make:filament-resource Plan

Resource Plan akan mengelola paket-paket membership yang bisa ditawarkan oleh setiap gym. Setiap plan akan terkait dengan gym tertentu melalui foreign key.

Kemudian kita buat resource untuk Member:

php artisan make:filament-resource Member

Resource Member akan menampilkan daftar semua calon pelanggan yang sudah mendaftar di sistem, baik yang sudah punya subscription aktif maupun yang belum.

Terakhir, kita buat resource untuk Transaction:

php artisan make:filament-resource Transaction

Resource Transaction akan menampilkan histori semua pembayaran yang terjadi di sistem. Ini sangat penting untuk monitoring dan audit financial.

Sekarang mari kita konfigurasi setiap resource agar dashboard admin kita lebih fungsional dan user-friendly. Kita mulai dengan mengatur form dan table untuk masing-masing resource.

Konfigurasi Gym Resource

Buka file app/Filament/Resources/GymResource.php dan kita atur form serta table untuk data gym:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            TextInput::make('name')
                ->required()
                ->maxLength(255)
                ->label('Nama Gym'),
            Textarea::make('description')
                ->rows(3)
                ->label('Deskripsi'),
            TextInput::make('address')
                ->required()
                ->maxLength(500)
                ->label('Alamat'),
            TextInput::make('city')
                ->required()
                ->maxLength(100)
                ->label('Kota'),
            TextInput::make('phone')
                ->tel()
                ->label('No. Telepon'),
            FileUpload::make('image')
                ->image()
                ->directory('gyms')
                ->label('Foto Gym'),
        ]);
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('name')
                ->searchable()
                ->sortable()
                ->label('Nama Gym'),
            TextColumn::make('city')
                ->searchable()
                ->sortable()
                ->label('Kota'),
            TextColumn::make('phone')
                ->label('Telepon'),
            ImageColumn::make('image')
                ->label('Foto'),
            TextColumn::make('created_at')
                ->dateTime()
                ->sortable()
                ->label('Dibuat'),
        ])
        ->filters([
            SelectFilter::make('city')
                ->options(Gym::distinct()->pluck('city', 'city')->toArray())
                ->label('Filter Kota'),
        ]);
}

Konfigurasi Plan Resource

Untuk Plan resource, kita perlu menampilkan relationship dengan Gym:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Select::make('gym_id')
                ->relationship('gym', 'name')
                ->required()
                ->label('Gym'),
            TextInput::make('name')
                ->required()
                ->maxLength(255)
                ->label('Nama Paket'),
            Textarea::make('description')
                ->rows(3)
                ->label('Deskripsi Paket'),
            TextInput::make('price')
                ->numeric()
                ->required()
                ->prefix('Rp')
                ->label('Harga'),
            TextInput::make('duration_months')
                ->numeric()
                ->required()
                ->suffix('bulan')
                ->label('Durasi'),
            Toggle::make('is_active')
                ->default(true)
                ->label('Status Aktif'),
        ]);
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('gym.name')
                ->searchable()
                ->sortable()
                ->label('Gym'),
            TextColumn::make('name')
                ->searchable()
                ->sortable()
                ->label('Nama Paket'),
            TextColumn::make('price')
                ->money('IDR')
                ->sortable()
                ->label('Harga'),
            TextColumn::make('duration_months')
                ->suffix(' bulan')
                ->label('Durasi'),
            IconColumn::make('is_active')
                ->boolean()
                ->label('Status'),
        ])
        ->filters([
            SelectFilter::make('gym_id')
                ->relationship('gym', 'name')
                ->label('Filter Gym'),
            TernaryFilter::make('is_active')
                ->label('Status Aktif'),
        ]);
}

Konfigurasi Member Resource

Member resource akan menampilkan data pelanggan beserta informasi subscription mereka:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            TextInput::make('name')
                ->required()
                ->maxLength(255)
                ->label('Nama Lengkap'),
            TextInput::make('email')
                ->email()
                ->required()
                ->unique(ignoreRecord: true)
                ->label('Email'),
            TextInput::make('phone')
                ->tel()
                ->required()
                ->label('No. Telepon'),
            DatePicker::make('birth_date')
                ->label('Tanggal Lahir'),
            Select::make('gender')
                ->options([
                    'male' => 'Laki-laki',
                    'female' => 'Perempuan',
                ])
                ->label('Jenis Kelamin'),
        ]);
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('name')
                ->searchable()
                ->sortable()
                ->label('Nama'),
            TextColumn::make('email')
                ->searchable()
                ->label('Email'),
            TextColumn::make('phone')
                ->searchable()
                ->label('Telepon'),
            TextColumn::make('subscriptions_count')
                ->counts('subscriptions')
                ->label('Total Subscription'),
            TextColumn::make('created_at')
                ->dateTime()
                ->sortable()
                ->label('Bergabung'),
        ])
        ->filters([
            DateFilter::make('created_at')
                ->label('Tanggal Bergabung'),
        ]);
}

Konfigurasi Transaction Resource

Transaction resource akan menampilkan semua histori pembayaran dengan status yang jelas:

public static function form(Form $form): Form
{
    return $form
        ->schema([
            Select::make('member_id')
                ->relationship('member', 'name')
                ->required()
                ->label('Member'),
            Select::make('plan_id')
                ->relationship('plan', 'name')
                ->required()
                ->label('Paket'),
            TextInput::make('amount')
                ->numeric()
                ->required()
                ->prefix('Rp')
                ->label('Jumlah'),
            Select::make('status')
                ->options([
                    'pending' => 'Pending',
                    'paid' => 'Berhasil',
                    'failed' => 'Gagal',
                    'cancelled' => 'Dibatalkan',
                ])
                ->required()
                ->label('Status'),
            TextInput::make('midtrans_order_id')
                ->maxLength(255)
                ->label('Order ID Midtrans'),
        ]);
}

public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('midtrans_order_id')
                ->searchable()
                ->label('Order ID'),
            TextColumn::make('member.name')
                ->searchable()
                ->label('Member'),
            TextColumn::make('plan.name')
                ->label('Paket'),
            TextColumn::make('amount')
                ->money('IDR')
                ->sortable()
                ->label('Jumlah'),
            BadgeColumn::make('status')
                ->colors([
                    'warning' => 'pending',
                    'success' => 'paid',
                    'danger' => 'failed',
                    'secondary' => 'cancelled',
                ])
                ->label('Status'),
            TextColumn::make('created_at')
                ->dateTime()
                ->sortable()
                ->label('Tanggal'),
        ])
        ->filters([
            SelectFilter::make('status')
                ->options([
                    'pending' => 'Pending',
                    'paid' => 'Berhasil',
                    'failed' => 'Gagal',
                    'cancelled' => 'Dibatalkan',
                ])
                ->label('Filter Status'),
            DateRangeFilter::make('created_at')
                ->label('Periode Transaksi'),
        ])
        ->defaultSort('created_at', 'desc');
}

Dashboard Widgets dan Analytics

Filament juga memungkinkan kita menambahkan widget untuk menampilkan statistik penting di dashboard. Mari kita buat beberapa widget:

php artisan make:filament-widget StatsOverview --stats
php artisan make:filament-widget RevenueChart --chart

Widget stats akan menampilkan angka-angka penting seperti total gym, total member aktif, dan revenue bulan ini. Widget chart akan menampilkan grafik pendapatan per bulan atau per gym.

Yang menarik dari setup ini adalah pemilik gym akan bisa login ke dashboard yang sama dan mengelola data gym mereka sendiri. Mereka bisa tambah paket baru, lihat member yang bergabung, dan pantau transaksi yang masuk.

Filament juga dilengkapi dengan sistem authorization yang fleksibel. Kita bisa atur siapa saja yang bisa lihat, edit, atau delete data tertentu. Misalnya gym owner hanya bisa lihat data gym mereka sendiri, tapi super admin bisa lihat semua data.

Dashboard yang akan kita hasilkan nanti akan punya sidebar navigation yang rapi, dengan menu untuk Gyms, Plans, Members, dan Transactions. Setiap menu akan punya fungsi filtering, searching, dan pagination yang sudah built-in dari Filament.

Interface yang dihasilkan Filament juga sudah responsive secara default. Jadi pemilik gym bisa mengakses dashboard dari smartphone atau tablet mereka tanpa masalah. Sangat cocok untuk pemilik bisnis yang selalu mobile.

Multi-role Auth dengan Spatie Laravel Permission

Setelah dashboard admin kita siap, sekarang waktunya mengatur sistem authorization yang solid. Kita akan menggunakan Spatie Laravel Permission yang merupakan package paling populer untuk mengelola role dan permission di Laravel.

Sistem SaaS kita membutuhkan pembagian akses yang jelas antara super admin, gym owner, dan member. Setiap role punya batasan akses yang berbeda-beda sesuai dengan kebutuhan bisnis.

Mari kita install Spatie Laravel Permission terlebih dahulu:

composer require spatie/laravel-permission

Setelah installation selesai, kita perlu publish migration dan menjalankannya:

php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"
php artisan migrate

Command di atas akan membuat tabel roles, permissions, model_has_roles, dan model_has_permissions yang diperlukan untuk sistem authorization kita.

Setup Model User

Pertama-tama, kita perlu menambahkan trait HasRoles ke model User kita. Buka file app/Models/User.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Spatie\\Permission\\Traits\\HasRoles;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function gyms()
    {
        return $this->hasMany(Gym::class);
    }

    public function isGymOwner()
    {
        return $this->hasRole('gym_owner') && $this->gyms()->exists();
    }

    public function isSuperAdmin()
    {
        return $this->hasRole('super_admin');
    }
}

Pembagian Role dan Permission

Sekarang mari kita definisikan role dan permission yang diperlukan. Buat seeder untuk role dan permission:

php artisan make:seeder RolePermissionSeeder

Buka file database/seeders/RolePermissionSeeder.php:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Role;
use Spatie\\Permission\\Models\\Permission;

class RolePermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\\Spatie\\Permission\\PermissionRegistrar::class]->forgetCachedPermissions();

        // Create permissions
        $permissions = [
            // Gym permissions
            'manage_all_gyms',
            'manage_own_gym',
            'view_gym',

            // Plan permissions
            'manage_all_plans',
            'manage_own_plans',
            'view_plan',

            // Member permissions
            'manage_all_members',
            'view_all_members',
            'view_own_profile',

            // Transaction permissions
            'manage_all_transactions',
            'view_all_transactions',
            'view_own_transactions',

            // Subscription permissions
            'manage_all_subscriptions',
            'view_all_subscriptions',
            'view_own_subscriptions',
        ];

        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Create roles and assign permissions
        $superAdmin = Role::create(['name' => 'super_admin']);
        $superAdmin->givePermissionTo([
            'manage_all_gyms',
            'manage_all_plans',
            'manage_all_members',
            'manage_all_transactions',
            'manage_all_subscriptions',
            'view_all_members',
            'view_all_transactions',
            'view_all_subscriptions',
        ]);

        $gymOwner = Role::create(['name' => 'gym_owner']);
        $gymOwner->givePermissionTo([
            'manage_own_gym',
            'manage_own_plans',
            'view_all_members',
            'view_all_transactions',
            'view_all_subscriptions',
            'view_gym',
            'view_plan',
        ]);

        $member = Role::create(['name' => 'member']);
        $member->givePermissionTo([
            'view_own_profile',
            'view_own_transactions',
            'view_own_subscriptions',
            'view_gym',
            'view_plan',
        ]);
    }
}

Jangan lupa tambahkan seeder ini ke DatabaseSeeder.php:

public function run(): void
{
    $this->call([
        RolePermissionSeeder::class,
        // seeder lainnya...
    ]);
}

Konfigurasi Filament dengan Authorization

Sekarang kita perlu mengintegrasikan sistem role dan permission dengan Filament resources. Mari kita update setiap resource untuk menerapkan authorization.

Buka file app/Filament/Resources/GymResource.php dan tambahkan method untuk authorization:

public static function canViewAny(): bool
{
    return auth()->user()->can('manage_all_gyms') ||
           auth()->user()->can('manage_own_gym');
}

public static function canView(Model $record): bool
{
    $user = auth()->user();

    if ($user->can('manage_all_gyms')) {
        return true;
    }

    if ($user->can('manage_own_gym')) {
        return $record->user_id === $user->id;
    }

    return false;
}

public static function canCreate(): bool
{
    return auth()->user()->can('manage_all_gyms') ||
           auth()->user()->can('manage_own_gym');
}

public static function canEdit(Model $record): bool
{
    return static::canView($record);
}

public static function canDelete(Model $record): bool
{
    return static::canView($record);
}

Kita juga perlu memodifikasi query untuk membatasi data yang bisa diakses gym owner. Tambahkan method ini di GymResource:

public static function getEloquentQuery(): Builder
{
    $query = parent::getEloquentQuery();
    $user = auth()->user();

    if ($user->hasRole('super_admin')) {
        return $query;
    }

    if ($user->hasRole('gym_owner')) {
        return $query->where('user_id', $user->id);
    }

    return $query->whereRaw('1 = 0'); // Return empty result for other roles
}

Authorization untuk Plan Resource

Untuk Plan resource, kita perlu memastikan gym owner hanya bisa mengelola plan dari gym mereka sendiri:

// Di app/Filament/Resources/PlanResource.php

public static function canViewAny(): bool
{
    return auth()->user()->can('manage_all_plans') ||
           auth()->user()->can('manage_own_plans');
}

public static function getEloquentQuery(): Builder
{
    $query = parent::getEloquentQuery();
    $user = auth()->user();

    if ($user->hasRole('super_admin')) {
        return $query;
    }

    if ($user->hasRole('gym_owner')) {
        $gymIds = $user->gyms()->pluck('id');
        return $query->whereIn('gym_id', $gymIds);
    }

    return $query->whereRaw('1 = 0');
}

Authorization untuk Member dan Transaction

Member dan Transaction resource akan mengikuti pola yang sama. Super admin bisa lihat semua data, gym owner hanya bisa lihat data member dan transaksi dari gym mereka:

// Di MemberResource dan TransactionResource

public static function getEloquentQuery(): Builder
{
    $query = parent::getEloquentQuery();
    $user = auth()->user();

    if ($user->hasRole('super_admin')) {
        return $query;
    }

    if ($user->hasRole('gym_owner')) {
        $gymIds = $user->gyms()->pluck('id');

        // Untuk MemberResource - filter berdasarkan subscription
        if (static::getModel() === Member::class) {
            return $query->whereHas('subscriptions.plan', function ($q) use ($gymIds) {
                $q->whereIn('gym_id', $gymIds);
            });
        }

        // Untuk TransactionResource - filter berdasarkan plan
        if (static::getModel() === Transaction::class) {
            return $query->whereHas('plan', function ($q) use ($gymIds) {
                $q->whereIn('gym_id', $gymIds);
            });
        }
    }

    return $query->whereRaw('1 = 0');
}

Middleware dan Route Protection

Untuk melindungi route-route tertentu, kita bisa menggunakan middleware yang disediakan Spatie. Buat middleware custom untuk proteksi dashboard:

php artisan make:middleware CheckDashboardAccess

Buka file app/Http/Middleware/CheckDashboardAccess.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;

class CheckDashboardAccess
{
    public function handle(Request $request, Closure $next)
    {
        if (!auth()->check()) {
            return redirect()->route('login');
        }

        $user = auth()->user();

        // Hanya super_admin dan gym_owner yang bisa akses dashboard
        if (!$user->hasAnyRole(['super_admin', 'gym_owner'])) {
            abort(403, 'Access denied. You do not have permission to access this area.');
        }

        return $next($request);
    }
}

Daftarkan middleware ini di bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'dashboard.access' => \\App\\Http\\Middleware\\CheckDashboardAccess::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

Panel Configuration untuk Filament

Terakhir, kita perlu mengkonfigurasi Filament panel untuk menggunakan middleware kita. Buka file app/Providers/Filament/AdminPanelProvider.php:

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('/admin')
        ->login()
        ->colors([
            'primary' => Color::Amber,
        ])
        ->middleware([
            'auth',
            'dashboard.access',
        ])
        ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
        ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
        ->pages([
            Pages\\Dashboard::class,
        ])
        ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
        ->widgets([
            Widgets\\AccountWidget::class,
            Widgets\\FilamentInfoWidget::class,
        ])
        ->navigationItems([
            // Custom navigation items jika diperlukan
        ]);
}

Dengan setup ini, sistem authorization kita sudah solid. Super admin punya akses penuh ke semua data, gym owner hanya bisa mengakses data gym mereka sendiri, dan member hanya bisa lihat profil dan histori langganan mereka.

Sistem ini juga scalable. Jika nanti kita perlu menambah role baru seperti staff atau manager, kita tinggal tambah role dan permission yang sesuai tanpa mengganggu kode yang sudah ada.

Proses Customer Memilih Gym & Checkout dengan Midtrans

Setelah backend dan dashboard admin kita siap, sekarang waktunya membangun frontend yang akan digunakan oleh customer. Bagian ini adalah yang paling krusial karena di sinilah customer akan berinteraksi langsung dengan sistem kita dan melakukan pembelian.

Kita akan membangun customer journey yang smooth mulai dari browsing gym, memilih paket, sampai checkout. Interface yang kita bangun harus user-friendly dan responsive karena banyak customer yang akan mengakses dari mobile.

Setup Route dan Controller

Pertama-tama, mari kita buat controller untuk menangani frontend customer. Buat controller baru:

php artisan make:controller Frontend/GymController
php artisan make:controller Frontend/CheckoutController

Kemudian tambahkan route di routes/web.php:

<?php

use App\\Http\\Controllers\\Frontend\\GymController;
use App\\Http\\Controllers\\Frontend\\CheckoutController;
use Illuminate\\Support\\Facades\\Route;

// Frontend routes
Route::get('/', [GymController::class, 'home'])->name('home');
Route::get('/gyms', [GymController::class, 'index'])->name('gyms.index');
Route::get('/gyms/{slug}', [GymController::class, 'show'])->name('gyms.show');
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('checkout.show');
Route::post('/checkout/{plan}', [CheckoutController::class, 'process'])->name('checkout.process');
Route::get('/thank-you', [CheckoutController::class, 'thankYou'])->name('thank-you');

// Admin routes (Filament)
Route::redirect('/admin', '/admin/login');

Halaman Home

Sebelum masuk ke daftar gym, mari kita buat halaman home yang menarik sebagai landing page. Buka app/Http/Controllers/Frontend/GymController.php:

<?php

namespace App\\Http\\Controllers\\Frontend;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Gym;
use App\\Models\\Plan;
use Illuminate\\Http\\Request;

class GymController extends Controller
{
    public function home()
    {
        $featuredGyms = Gym::with(['plans' => function($query) {
            $query->where('is_active', true)->orderBy('price');
        }])
        ->limit(6)
        ->get();

        return view('frontend.home', compact('featuredGyms'));
    }

    public function index(Request $request)
    {
        $query = Gym::with(['plans' => function($q) {
            $q->where('is_active', true);
        }]);

        // Search by name or city
        if ($request->filled('search')) {
            $search = $request->get('search');
            $query->where(function($q) use ($search) {
                $q->where('name', 'like', "%{$search}%")
                  ->orWhere('city', 'like', "%{$search}%");
            });
        }

        // Filter by city
        if ($request->filled('city')) {
            $query->where('city', $request->get('city'));
        }

        $gyms = $query->paginate(12);
        $cities = Gym::distinct()->pluck('city')->sort();

        return view('frontend.gyms.index', compact('gyms', 'cities'));
    }

    public function show($slug)
    {
        $gym = Gym::with(['plans' => function($query) {
            $query->where('is_active', true)->orderBy('price');
        }])
        ->where('slug', $slug)
        ->firstOrFail();

        return view('frontend.gyms.show', compact('gym'));
    }
}

Sekarang mari kita buat view untuk halaman home. Buat file resources/views/frontend/home.blade.php:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>FitHub - Platform Gym Membership Terbaik</title>
    <script src="<https://cdn.tailwindcss.com>"></script>
    <link href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css>" rel="stylesheet">
</head>
<body class="bg-gray-50">
    <!-- Navbar -->
    <nav class="bg-white shadow-lg sticky top-0 z-50">
        <div class="container mx-auto px-4">
            <div class="flex justify-between items-center py-4">
                <div class="flex items-center space-x-2">
                    <i class="fas fa-dumbbell text-2xl text-blue-600"></i>
                    <span class="text-2xl font-bold text-gray-800">FitHub</span>
                </div>
                <div class="hidden md:flex space-x-6">
                    <a href="{{ route('home') }}" class="text-gray-700 hover:text-blue-600 transition">Home</a>
                    <a href="{{ route('gyms.index') }}" class="text-gray-700 hover:text-blue-600 transition">Cari Gym</a>
                    <a href="#about" class="text-gray-700 hover:text-blue-600 transition">Tentang</a>
                    <a href="#contact" class="text-gray-700 hover:text-blue-600 transition">Kontak</a>
                </div>
                <div class="md:hidden">
                    <button class="text-gray-700 hover:text-blue-600">
                        <i class="fas fa-bars text-xl"></i>
                    </button>
                </div>
            </div>
        </div>
    </nav>

    <!-- Hero Section -->
    <section class="bg-gradient-to-r from-blue-600 to-purple-700 text-white py-20">
        <div class="container mx-auto px-4 text-center">
            <h1 class="text-5xl font-bold mb-6">Temukan Gym Terbaik di Kota Anda</h1>
            <p class="text-xl mb-8 max-w-2xl mx-auto">Bergabunglah dengan ribuan member yang sudah merasakan pengalaman fitness terbaik di gym-gym pilihan</p>
            <a href="{{ route('gyms.index') }}" class="bg-white text-blue-600 px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition inline-flex items-center">
                <i class="fas fa-search mr-2"></i>
                Mulai Cari Gym
            </a>
        </div>
    </section>

    <!-- Featured Gyms -->
    <section class="py-16">
        <div class="container mx-auto px-4">
            <div class="text-center mb-12">
                <h2 class="text-4xl font-bold text-gray-800 mb-4">Gym Populer</h2>
                <p class="text-gray-600 max-w-2xl mx-auto">Gym-gym pilihan terbaik dengan fasilitas lengkap dan harga terjangkau</p>
            </div>

            <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
                @foreach($featuredGyms as $gym)
                <div class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition">
                    <div class="h-48 bg-gradient-to-r from-blue-500 to-purple-600 relative">
                        @if($gym->image)
                            <img src="{{ Storage::url($gym->image) }}" alt="{{ $gym->name }}" class="w-full h-full object-cover">
                        @endif
                        @if($gym->plans->where('price', '<', 200000)->count() > 0)
                            <div class="absolute top-4 right-4">
                                <span class="bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
                                    <i class="fas fa-fire mr-1"></i>Promo
                                </span>
                            </div>
                        @endif
                    </div>
                    <div class="p-6">
                        <h3 class="text-xl font-bold text-gray-800 mb-2">{{ $gym->name }}</h3>
                        <p class="text-gray-600 mb-3 flex items-center">
                            <i class="fas fa-map-marker-alt mr-2 text-blue-500"></i>
                            {{ $gym->city }}
                        </p>
                        <p class="text-gray-700 mb-4 line-clamp-2">{{ $gym->description }}</p>

                        @if($gym->plans->count() > 0)
                            <div class="mb-4">
                                <span class="text-sm text-gray-500">Mulai dari:</span>
                                <div class="text-2xl font-bold text-blue-600">
                                    Rp {{ number_format($gym->plans->min('price'), 0, ',', '.') }}
                                    <span class="text-sm text-gray-500 font-normal">/bulan</span>
                                </div>
                            </div>
                        @endif

                        <a href="{{ route('gyms.show', $gym->slug) }}" class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition inline-flex items-center justify-center">
                            <i class="fas fa-eye mr-2"></i>
                            Lihat Detail
                        </a>
                    </div>
                </div>
                @endforeach
            </div>

            <div class="text-center mt-12">
                <a href="{{ route('gyms.index') }}" class="bg-gray-800 text-white px-8 py-3 rounded-lg font-semibold hover:bg-gray-700 transition inline-flex items-center">
                    Lihat Semua Gym
                    <i class="fas fa-arrow-right ml-2"></i>
                </a>
            </div>
        </div>
    </section>

    <!-- Features -->
    <section class="bg-gray-100 py-16">
        <div class="container mx-auto px-4">
            <div class="text-center mb-12">
                <h2 class="text-4xl font-bold text-gray-800 mb-4">Mengapa Pilih FitHub?</h2>
            </div>

            <div class="grid md:grid-cols-3 gap-8">
                <div class="text-center">
                    <div class="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
                        <i class="fas fa-search text-white text-2xl"></i>
                    </div>
                    <h3 class="text-xl font-bold mb-3">Pencarian Mudah</h3>
                    <p class="text-gray-600">Temukan gym terdekat dengan mudah berdasarkan lokasi dan preferensi Anda</p>
                </div>

                <div class="text-center">
                    <div class="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
                        <i class="fas fa-credit-card text-white text-2xl"></i>
                    </div>
                    <h3 class="text-xl font-bold mb-3">Pembayaran Aman</h3>
                    <p class="text-gray-600">Sistem pembayaran terintegrasi dengan berbagai metode pembayaran yang aman</p>
                </div>

                <div class="text-center">
                    <div class="w-16 h-16 bg-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
                        <i class="fas fa-star text-white text-2xl"></i>
                    </div>
                    <h3 class="text-xl font-bold mb-3">Gym Berkualitas</h3>
                    <p class="text-gray-600">Hanya gym-gym terpilih dengan fasilitas lengkap dan instruktur berpengalaman</p>
                </div>
            </div>
        </div>
    </section>

    <!-- Footer -->
    <footer class="bg-gray-800 text-white py-12">
        <div class="container mx-auto px-4">
            <div class="grid md:grid-cols-4 gap-8">
                <div>
                    <div class="flex items-center space-x-2 mb-4">
                        <i class="fas fa-dumbbell text-2xl text-blue-400"></i>
                        <span class="text-2xl font-bold">FitHub</span>
                    </div>
                    <p class="text-gray-400">Platform terbaik untuk menemukan gym impian Anda di seluruh Indonesia.</p>
                </div>

                <div>
                    <h4 class="text-lg font-semibold mb-4">Quick Links</h4>
                    <ul class="space-y-2">
                        <li><a href="{{ route('gyms.index') }}" class="text-gray-400 hover:text-white transition">Cari Gym</a></li>
                        <li><a href="#" class="text-gray-400 hover:text-white transition">Tentang Kami</a></li>
                        <li><a href="#" class="text-gray-400 hover:text-white transition">FAQ</a></li>
                        <li><a href="#" class="text-gray-400 hover:text-white transition">Kontak</a></li>
                    </ul>
                </div>

                <div>
                    <h4 class="text-lg font-semibold mb-4">Support</h4>
                    <ul class="space-y-2">
                        <li><a href="#" class="text-gray-400 hover:text-white transition">Help Center</a></li>
                        <li><a href="#" class="text-gray-400 hover:text-white transition">Terms of Service</a></li>
                        <li><a href="#" class="text-gray-400 hover:text-white transition">Privacy Policy</a></li>
                    </ul>
                </div>

                <div>
                    <h4 class="text-lg font-semibold mb-4">Follow Us</h4>
                    <div class="flex space-x-4">
                        <a href="#" class="text-gray-400 hover:text-white transition text-xl">
                            <i class="fab fa-instagram"></i>
                        </a>
                        <a href="#" class="text-gray-400 hover:text-white transition text-xl">
                            <i class="fab fa-facebook"></i>
                        </a>
                        <a href="#" class="text-gray-400 hover:text-white transition text-xl">
                            <i class="fab fa-twitter"></i>
                        </a>
                    </div>
                </div>
            </div>

            <div class="border-t border-gray-700 mt-8 pt-8 text-center">
                <p class="text-gray-400">&copy; 2025 FitHub. All rights reserved.</p>
            </div>
        </div>
    </footer>
</body>
</html>

Halaman Daftar Gym

Sekarang mari kita buat halaman untuk menampilkan daftar semua gym. Buat file resources/views/frontend/gyms/index.blade.php:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Daftar Gym - FitHub</title>
    <script src="<https://cdn.tailwindcss.com>"></script>
    <link href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css>" rel="stylesheet">
</head>
<body class="bg-gray-50">
    <!-- Navbar -->
    <nav class="bg-white shadow-lg sticky top-0 z-50">
        <div class="container mx-auto px-4">
            <div class="flex justify-between items-center py-4">
                <div class="flex items-center space-x-2">
                    <i class="fas fa-dumbbell text-2xl text-blue-600"></i>
                    <a href="{{ route('home') }}" class="text-2xl font-bold text-gray-800">FitHub</a>
                </div>
                <div class="hidden md:flex space-x-6">
                    <a href="{{ route('home') }}" class="text-gray-700 hover:text-blue-600 transition">Home</a>
                    <a href="{{ route('gyms.index') }}" class="text-blue-600 font-semibold">Cari Gym</a>
                </div>
            </div>
        </div>
    </nav>

    <!-- Header & Search -->
    <section class="bg-gradient-to-r from-blue-600 to-purple-700 text-white py-12">
        <div class="container mx-auto px-4">
            <h1 class="text-4xl font-bold text-center mb-8">Temukan Gym Impian Anda</h1>

            <!-- Search Form -->
            <form method="GET" class="max-w-4xl mx-auto">
                <div class="bg-white rounded-lg p-6 shadow-lg">
                    <div class="grid md:grid-cols-3 gap-4">
                        <div>
                            <label class="block text-gray-700 text-sm font-semibold mb-2">Pencarian</label>
                            <div class="relative">
                                <input type="text" name="search" value="{{ request('search') }}"
                                       placeholder="Nama gym atau kota..."
                                       class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-700">
                                <i class="fas fa-search absolute right-3 top-3.5 text-gray-400"></i>
                            </div>
                        </div>

                        <div>
                            <label class="block text-gray-700 text-sm font-semibold mb-2">Kota</label>
                            <select name="city" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-700">
                                <option value="">Semua Kota</option>
                                @foreach($cities as $city)
                                    <option value="{{ $city }}" {{ request('city') == $city ? 'selected' : '' }}>
                                        {{ $city }}
                                    </option>
                                @endforeach
                            </select>
                        </div>

                        <div class="flex items-end">
                            <button type="submit" class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-blue-700 transition">
                                <i class="fas fa-search mr-2"></i>Cari
                            </button>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </section>

    <!-- Results -->
    <section class="py-12">
        <div class="container mx-auto px-4">
            <!-- Results Info -->
            <div class="mb-8">
                <h2 class="text-2xl font-bold text-gray-800">
                    {{ $gyms->total() }} Gym Ditemukan
                    @if(request('search') || request('city'))
                        <span class="text-lg text-gray-600">untuk</span>
                        @if(request('search'))
                            <span class="text-blue-600">"{{ request('search') }}"</span>
                        @endif
                        @if(request('city'))
                            <span class="text-blue-600">di {{ request('city') }}</span>
                        @endif
                    @endif
                </h2>

                @if(request('search') || request('city'))
                    <a href="{{ route('gyms.index') }}" class="text-blue-600 hover:underline mt-2 inline-block">
                        <i class="fas fa-times mr-1"></i>Reset Filter
                    </a>
                @endif
            </div>

            <!-- Gym Grid -->
            <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
                @forelse($gyms as $gym)
                <div class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl transition">
                    <div class="h-48 bg-gradient-to-r from-blue-500 to-purple-600 relative">
                        @if($gym->image)
                            <img src="{{ Storage::url($gym->image) }}" alt="{{ $gym->name }}" class="w-full h-full object-cover">
                        @endif

                        @if($gym->plans->where('price', '<', 200000)->count() > 0)
                            <div class="absolute top-4 right-4">
                                <span class="bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
                                    <i class="fas fa-fire mr-1"></i>Tersedia Paket Promo
                                </span>
                            </div>
                        @endif
                    </div>

                    <div class="p-6">
                        <h3 class="text-xl font-bold text-gray-800 mb-2">{{ $gym->name }}</h3>
                        <p class="text-gray-600 mb-3 flex items-center">
                            <i class="fas fa-map-marker-alt mr-2 text-blue-500"></i>
                            {{ $gym->city }}
                        </p>
                        <p class="text-gray-700 mb-4 line-clamp-2">{{ $gym->description }}</p>

                        @if($gym->plans->count() > 0)
                            <div class="mb-4">
                                <span class="text-sm text-gray-500">Paket mulai dari:</span>
                                <div class="text-2xl font-bold text-blue-600">
                                    Rp {{ number_format($gym->plans->min('price'), 0, ',', '.') }}
                                    <span class="text-sm text-gray-500 font-normal">/{{ $gym->plans->where('price', $gym->plans->min('price'))->first()->duration_months }} bulan</span>
                                </div>
                                <div class="text-sm text-gray-500">
                                    {{ $gym->plans->count() }} paket tersedia
                                </div>
                            </div>
                        @endif

                        <a href="{{ route('gyms.show', $gym->slug) }}" class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition inline-flex items-center justify-center">
                            <i class="fas fa-eye mr-2"></i>
                            Lihat Detail & Paket
                        </a>
                    </div>
                </div>
                @empty
                <div class="col-span-full text-center py-12">
                    <i class="fas fa-search text-6xl text-gray-300 mb-4"></i>
                    <h3 class="text-xl font-semibold text-gray-600 mb-2">Tidak ada gym yang ditemukan</h3>
                    <p class="text-gray-500 mb-4">Coba ubah kata kunci pencarian atau filter kota</p>
                    <a href="{{ route('gyms.index') }}" class="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">
                        Lihat Semua Gym
                    </a>
                </div>
                @endforelse
            </div>

            <!-- Pagination -->
            @if($gyms->hasPages())
                <div class="mt-12">
                    {{ $gyms->links() }}
                </div>
            @endif
        </div>
    </section>

    <!-- Footer -->
    <footer class="bg-gray-800 text-white py-8">
        <div class="container mx-auto px-4 text-center">
            <div class="flex items-center justify-center space-x-2 mb-4">
                <i class="fas fa-dumbbell text-xl text-blue-400"></i>
                <span class="text-xl font-bold">FitHub</span>
            </div>
            <p class="text-gray-400">&copy; 2025 FitHub. All rights reserved.</p>
        </div>
    </footer>
</body>
</html>

Halaman Detail Gym

Terakhir, mari kita buat halaman detail gym yang menampilkan informasi lengkap dan daftar paket. Buat file resources/views/frontend/gyms/show.blade.php:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $gym->name }} - FitHub</title>
    <script src="<https://cdn.tailwindcss.com>"></script>
    <link href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css>" rel="stylesheet">
</head>
<body class="bg-gray-50">
    <!-- Navbar -->
    <nav class="bg-white shadow-lg sticky top-0 z-50">
        <div class="container mx-auto px-4">
            <div class="flex justify-between items-center py-4">
                <div class="flex items-center space-x-2">
                    <i class="fas fa-dumbbell text-2xl text-blue-600"></i>
                    <a href="{{ route('home') }}" class="text-2xl font-bold text-gray-800">FitHub</a>
                </div>
                <div class="hidden md:flex space-x-6">
                    <a href="{{ route('home') }}" class="text-gray-700 hover:text-blue-600 transition">Home</a>
                    <a href="{{ route('gyms.index') }}" class="text-gray-700 hover:text-blue-600 transition">Cari Gym</a>
                </div>
            </div>
        </div>
    </nav>

    <!-- Breadcrumb -->
    <div class="bg-gray-100 py-4">
        <div class="container mx-auto px-4">
            <nav class="text-sm">
                <a href="{{ route('home') }}" class="text-blue-600 hover:underline">Home</a>
                <i class="fas fa-chevron-right mx-2 text-gray-400"></i>
                <a href="{{ route('gyms.index') }}" class="text-blue-600 hover:underline">Gym</a>
                <i class="fas fa-chevron-right mx-2 text-gray-400"></i>
                <span class="text-gray-600">{{ $gym->name }}</span>
            </nav>
        </div>
    </div>

    <!-- Gym Header -->
    <section class="py-8">
        <div class="container mx-auto px-4">
            <div class="grid lg:grid-cols-2 gap-8 items-center">
                <div>
                    <h1 class="text-4xl font-bold text-gray-800 mb-4">{{ $gym->name }}</h1>
                    <div class="flex items-center text-gray-600 mb-4">
                        <i class="fas fa-map-marker-alt mr-2 text-blue-500"></i>
                        <span class="text-lg">{{ $gym->address }}, {{ $gym->city }}</span>
                    </div>
                    @if($gym->phone)
                        <div class="flex items-center text-gray-600 mb-6">
                            <i class="fas fa-phone mr-2 text-green-500"></i>
                            <span>{{ $gym->phone }}</span>
                        </div>
                    @endif
                    <p class="text-gray-700 text-lg leading-relaxed">{{ $gym->description }}</p>
                </div>

                <div class="lg:order-first">
                    <div class="h-80 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg overflow-hidden">
                        @if($gym->image)
                            <img src="{{ Storage::url($gym->image) }}" alt="{{ $gym->name }}" class="w-full h-full object-cover">
                        @else
                            <div class="w-full h-full flex items-center justify-center">
                                <i class="fas fa-dumbbell text-6xl text-white opacity-50"></i>
                            </div>
                        @endif
                    </div>
                </div>
            </div>
        </div>
    </section>

    <!-- Membership Plans -->
    <section class="py-12 bg-white">
        <div class="container mx-auto px-4">
            <div class="text-center mb-12">
                <h2 class="text-3xl font-bold text-gray-800 mb-4">Paket Membership</h2>
                <p class="text-gray-600 max-w-2xl mx-auto">Pilih paket membership yang sesuai dengan kebutuhan dan budget Anda</p>
            </div>

            @if($gym->plans->count() > 0)
                <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
                    @foreach($gym->plans as $plan)
                    <div class="bg-white border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition {{ $loop->iteration == 2 ? 'ring-2 ring-blue-500 transform scale-105' : '' }}">
                        @if($loop->iteration == 2)
                            <div class="bg-blue-500 text-white text-center py-2 text-sm font-semibold">
                                <i class="fas fa-star mr-1"></i>TERPOPULER
                            </div>
                        @endif

                        <div class="p-6">
                            <h3 class="text-2xl font-bold text-gray-800 mb-2">{{ $plan->name }}</h3>
                            <p class="text-gray-600 mb-4">{{ $plan->description }}</p>

                            <div class="mb-6">
                                <div class="text-3xl font-bold text-blue-600">
                                    Rp {{ number_format($plan->price, 0, ',', '.') }}
                                </div>
                                <div class="text-gray-500">untuk {{ $plan->duration_months }} bulan</div>
                                @if($plan->duration_months > 1)
                                    <div class="text-sm text-green-600 font-semibold">
                                        ~Rp {{ number_format($plan->price / $plan->duration_months, 0, ',', '.') }}/bulan
                                    </div>
                                @endif
                            </div>

                            <div class="mb-6">
                                <h4 class="font-semibold text-gray-800 mb-3">Yang Anda Dapatkan:</h4>
                                <ul class="space-y-2 text-gray-600">
                                    <li class="flex items-center">
                                        <i class="fas fa-check text-green-500 mr-2"></i>
                                        Akses gym {{ $plan->duration_months }} bulan
                                    </li>
                                    <li class="flex items-center">
                                        <i class="fas fa-check text-green-500 mr-2"></i>
                                        Semua peralatan fitness
                                    </li>
                                    <li class="flex items-center">
                                        <i class="fas fa-check text-green-500 mr-2"></i>
                                        Konsultasi dengan trainer
                                    </li>
                                    @if($plan->duration_months >= 3)
                                        <li class="flex items-center">
                                            <i class="fas fa-check text-green-500 mr-2"></i>
                                            Program diet gratis
                                        </li>
                                    @endif
                                    @if($plan->duration_months >= 6)
                                        <li class="flex items-center">
                                            <i class="fas fa-check text-green-500 mr-2"></i>
                                            Personal training 2x
                                        </li>
                                    @endif
                                </ul>
                            </div>

                            <a href="{{ route('checkout.show', $plan) }}" class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition inline-flex items-center justify-center">
                                <i class="fas fa-credit-card mr-2"></i>
                                Daftar Sekarang
                            </a>
                        </div>
                    </div>
                    @endforeach
                </div>
            @else
                <div class="text-center py-12">
                    <i class="fas fa-info-circle text-4xl text-gray-300 mb-4"></i>
                    <h3 class="text-xl font-semibold text-gray-600 mb-2">Belum Ada Paket Tersedia</h3>
                    <p class="text-gray-500">Gym ini sedang mempersiapkan paket membership. Silakan cek kembali nanti.</p>
                </div>
            @endif
        </div>
    </section>

    <!-- Footer -->
    <footer class="bg-gray-800 text-white py-8">
        <div class="container mx-auto px-4 text-center">
            <div class="flex items-center justify-center space-x-2 mb-4">
                <i class="fas fa-dumbbell text-xl text-blue-400"></i>
                <span class="text-xl font-bold">FitHub</span>
            </div>
            <p class="text-gray-400">&copy; 2025 FitHub. All rights reserved.</p>
        </div>
    </footer>

    <script>
        // Smooth scroll untuk anchor links
        document.querySelectorAll('a[href^="#"]').forEach(anchor => {
            anchor.addEventListener('click', function (e) {
                e.preventDefault();
                document.querySelector(this.getAttribute('href')).scrollIntoView({
                    behavior: 'smooth'
                });
            });
        });
    </script>
</body>
</html>

Dengan setup ini, kita sudah punya customer journey yang lengkap mulai dari landing page yang menarik, halaman pencarian gym dengan fitur filter dan search, sampai halaman detail gym yang menampilkan semua informasi dan paket yang tersedia.

Interface yang kita bangun sudah responsive dan menggunakan Tailwind CSS untuk styling yang modern. Customer bisa dengan mudah browsing gym, compare paket, dan langsung lanjut ke proses checkout.

Halaman Checkout & Integrasi Midtrans

Setelah customer memilih paket membership, mereka akan diarahkan ke halaman checkout untuk melengkapi pembayaran. Ini adalah bagian paling krusial dalam customer journey karena di sinilah konversi terjadi.

Proses checkout kita akan seamless dan user-friendly. Customer cukup mengisi data diri minimal, review pembayaran, dan langsung bisa bayar melalui berbagai metode pembayaran yang disediakan Midtrans.

Setup Midtrans

Pertama-tama, mari kita install package Midtrans untuk Laravel:

composer require midtrans/midtrans-php

Kemudian tambahkan konfigurasi Midtrans di file .env:

MIDTRANS_SERVER_KEY=your_server_key_here
MIDTRANS_CLIENT_KEY=your_client_key_here
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Buat service class untuk handle Midtrans operations. Buat file app/Services/MidtransService.php:

<?php

namespace App\\Services;

use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;

class MidtransService
{
    public function __construct()
    {
        Config::$serverKey = config('services.midtrans.server_key');
        Config::$isProduction = config('services.midtrans.is_production');
        Config::$isSanitized = config('services.midtrans.is_sanitized');
        Config::$is3ds = config('services.midtrans.is_3ds');
    }

    public function createSnapToken($orderId, $amount, $customerDetails, $itemDetails)
    {
        $params = [
            'transaction_details' => [
                'order_id' => $orderId,
                'gross_amount' => $amount,
            ],
            'customer_details' => $customerDetails,
            'item_details' => $itemDetails,
            'callbacks' => [
                'finish' => route('thank-you')
            ]
        ];

        return Snap::getSnapToken($params);
    }

    public function getTransactionStatus($orderId)
    {
        return Transaction::status($orderId);
    }
}

Tambahkan konfigurasi Midtrans di config/services.php:

'midtrans' => [
    'server_key' => env('MIDTRANS_SERVER_KEY'),
    'client_key' => env('MIDTRANS_CLIENT_KEY'),
    'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
    'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
    'is_3ds' => env('MIDTRANS_IS_3DS', true),
],

Checkout Controller Logic

Sekarang mari kita buat logic untuk checkout controller. Buka file app/Http/Controllers/Frontend/CheckoutController.php:

<?php

namespace App\\Http\\Controllers\\Frontend;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Plan;
use App\\Models\\Member;
use App\\Models\\Transaction;
use App\\Models\\Subscription;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Str;
use Illuminate\\Support\\Facades\\DB;
use Carbon\\Carbon;

class CheckoutController extends Controller
{
    protected $midtransService;

    public function __construct(MidtransService $midtransService)
    {
        $this->midtransService = $midtransService;
    }

    public function show(Plan $plan)
    {
        $plan->load('gym');

        return view('frontend.checkout.show', compact('plan'));
    }

    public function process(Request $request, Plan $plan)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|max:255',
            'phone' => 'required|string|max:20',
        ]);

        try {
            DB::beginTransaction();

            // Check or create member
            $member = Member::firstOrCreate(
                ['email' => $request->email],
                [
                    'name' => $request->name,
                    'phone' => $request->phone,
                    'password' => bcrypt(Str::random(8)), // Random password for auto-created accounts
                ]
            );

            // Update member data if exists but with different info
            $member->update([
                'name' => $request->name,
                'phone' => $request->phone,
            ]);

            // Generate unique order ID
            $orderId = 'GYM-' . strtoupper(Str::random(8)) . '-' . time();

            // Create transaction
            $transaction = Transaction::create([
                'member_id' => $member->id,
                'plan_id' => $plan->id,
                'amount' => $plan->price,
                'status' => 'pending',
                'midtrans_order_id' => $orderId,
                'expired_at' => now()->addHours(24), // 24 hours to complete payment
            ]);

            // Create subscription (inactive until payment success)
            $subscription = Subscription::create([
                'member_id' => $member->id,
                'plan_id' => $plan->id,
                'transaction_id' => $transaction->id,
                'is_active' => false, // Will be activated when payment success
                'started_at' => null, // Will be set when payment success
                'active_until' => null, // Will be calculated when payment success
            ]);

            // Prepare Midtrans data
            $customerDetails = [
                'first_name' => $member->name,
                'email' => $member->email,
                'phone' => $member->phone,
            ];

            $itemDetails = [
                [
                    'id' => $plan->id,
                    'price' => $plan->price,
                    'quantity' => 1,
                    'name' => $plan->name . ' - ' . $plan->gym->name,
                    'category' => 'Gym Membership',
                ]
            ];

            // Get Snap Token
            $snapToken = $this->midtransService->createSnapToken(
                $orderId,
                $plan->price,
                $customerDetails,
                $itemDetails
            );

            DB::commit();

            return response()->json([
                'snap_token' => $snapToken,
                'order_id' => $orderId
            ]);

        } catch (\\Exception $e) {
            DB::rollBack();

            return response()->json([
                'error' => 'Terjadi kesalahan saat memproses checkout. Silakan coba lagi.'
            ], 500);
        }
    }

    public function callback(Request $request)
    {
        try {
            $orderId = $request->order_id;
            $statusCode = $request->status_code;
            $grossAmount = $request->gross_amount;
            $signature = $request->signature_key;

            // Verify signature
            $serverKey = config('services.midtrans.server_key');
            $hashed = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);

            if ($hashed !== $signature) {
                return response()->json(['message' => 'Invalid signature'], 400);
            }

            $transaction = Transaction::where('midtrans_order_id', $orderId)->first();

            if (!$transaction) {
                return response()->json(['message' => 'Transaction not found'], 404);
            }

            // Update transaction status based on Midtrans response
            $transactionStatus = $request->transaction_status;
            $paymentType = $request->payment_type;

            if (in_array($transactionStatus, ['capture', 'settlement'])) {
                $transaction->update([
                    'status' => 'paid',
                    'payment_method' => $paymentType,
                    'paid_at' => now(),
                    'midtrans_transaction_id' => $request->transaction_id ?? null,
                ]);

                // Activate subscription
                $subscription = $transaction->subscription;
                $startDate = now();
                $endDate = $startDate->copy()->addMonths($transaction->plan->duration_months);

                $subscription->update([
                    'is_active' => true,
                    'started_at' => $startDate,
                    'active_until' => $endDate,
                ]);

                // TODO: Send confirmation email/WhatsApp

            } elseif (in_array($transactionStatus, ['cancel', 'deny', 'expire'])) {
                $transaction->update([
                    'status' => 'failed',
                ]);
            } elseif ($transactionStatus == 'pending') {
                $transaction->update([
                    'status' => 'pending',
                ]);
            }

            return response()->json(['message' => 'OK']);

        } catch (\\Exception $e) {
            return response()->json(['message' => 'Error processing callback'], 500);
        }
    }

    public function thankYou(Request $request)
    {
        $orderId = $request->order_id;
        $transaction = null;

        if ($orderId) {
            $transaction = Transaction::with(['member', 'plan.gym', 'subscription'])
                ->where('midtrans_order_id', $orderId)
                ->first();
        }

        return view('frontend.checkout.thank-you', compact('transaction'));
    }
}

Halaman Checkout

Mari kita buat view untuk halaman checkout. Buat file resources/views/frontend/checkout/show.blade.php:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Checkout - {{ $plan->name }} | FitHub</title>
    <script src="<https://cdn.tailwindcss.com>"></script>
    <link href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css>" rel="stylesheet">
    <script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ config('services.midtrans.client_key') }}"></script>
</head>
<body class="bg-gray-50">
    <!-- Navbar -->
    <nav class="bg-white shadow-lg">
        <div class="container mx-auto px-4">
            <div class="flex justify-between items-center py-4">
                <div class="flex items-center space-x-2">
                    <i class="fas fa-dumbbell text-2xl text-blue-600"></i>
                    <a href="{{ route('home') }}" class="text-2xl font-bold text-gray-800">FitHub</a>
                </div>
                <div class="text-gray-600">
                    <i class="fas fa-lock mr-2"></i>Pembayaran Aman
                </div>
            </div>
        </div>
    </nav>

    <!-- Checkout Content -->
    <div class="container mx-auto px-4 py-8">
        <div class="max-w-4xl mx-auto">
            <!-- Progress Steps -->
            <div class="mb-8">
                <div class="flex items-center justify-center space-x-4">
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">1</div>
                        <span class="ml-2 text-blue-600 font-semibold">Pilih Paket</span>
                    </div>
                    <div class="w-16 h-1 bg-blue-600"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">2</div>
                        <span class="ml-2 text-blue-600 font-semibold">Data Diri</span>
                    </div>
                    <div class="w-16 h-1 bg-gray-300"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center text-sm font-semibold">3</div>
                        <span class="ml-2 text-gray-600">Pembayaran</span>
                    </div>
                </div>
            </div>

            <div class="grid lg:grid-cols-3 gap-8">
                <!-- Checkout Form -->
                <div class="lg:col-span-2">
                    <div class="bg-white rounded-lg shadow-lg p-6">
                        <h2 class="text-2xl font-bold text-gray-800 mb-6">Data Diri</h2>

                        <form id="checkoutForm">
                            @csrf
                            <div class="space-y-6">
                                <div>
                                    <label class="block text-gray-700 text-sm font-semibold mb-2">Nama Lengkap *</label>
                                    <input type="text" name="name" required
                                           class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                                           placeholder="Masukkan nama lengkap Anda">
                                </div>

                                <div>
                                    <label class="block text-gray-700 text-sm font-semibold mb-2">Email *</label>
                                    <input type="email" name="email" required
                                           class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                                           placeholder="[email protected]">
                                    <p class="text-sm text-gray-500 mt-1">Kami akan mengirim konfirmasi ke email ini</p>
                                </div>

                                <div>
                                    <label class="block text-gray-700 text-sm font-semibold mb-2">No. WhatsApp *</label>
                                    <input type="tel" name="phone" required
                                           class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                                           placeholder="08xxxxxxxxxx">
                                    <p class="text-sm text-gray-500 mt-1">Untuk keperluan koordinasi dan konfirmasi</p>
                                </div>
                            </div>

                            <div class="mt-8">
                                <div class="bg-gray-50 rounded-lg p-4 mb-6">
                                    <div class="flex items-center mb-2">
                                        <input type="checkbox" id="terms" required class="mr-2">
                                        <label for="terms" class="text-sm text-gray-700">
                                            Saya setuju dengan <a href="#" class="text-blue-600 hover:underline">Syarat & Ketentuan</a>
                                            dan <a href="#" class="text-blue-600 hover:underline">Kebijakan Privasi</a>
                                        </label>
                                    </div>
                                    <div class="flex items-center">
                                        <input type="checkbox" id="newsletter" class="mr-2">
                                        <label for="newsletter" class="text-sm text-gray-700">
                                            Saya ingin menerima informasi promo dan penawaran menarik
                                        </label>
                                    </div>
                                </div>

                                <button type="submit" id="payButton"
                                        class="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold text-lg hover:bg-blue-700 transition flex items-center justify-center">
                                    <i class="fas fa-credit-card mr-2"></i>
                                    <span id="payButtonText">Bayar Sekarang</span>
                                    <div id="payButtonSpinner" class="hidden ml-2">
                                        <i class="fas fa-spinner fa-spin"></i>
                                    </div>
                                </button>
                            </div>
                        </form>
                    </div>
                </div>

                <!-- Order Summary -->
                <div class="lg:col-span-1">
                    <div class="bg-white rounded-lg shadow-lg p-6 sticky top-8">
                        <h3 class="text-xl font-bold text-gray-800 mb-4">Ringkasan Pesanan</h3>

                        <div class="space-y-4">
                            <div class="border-b pb-4">
                                <h4 class="font-semibold text-gray-800">{{ $plan->name }}</h4>
                                <p class="text-sm text-gray-600">{{ $plan->gym->name }}</p>
                                <p class="text-sm text-gray-500 mt-1">{{ $plan->description }}</p>
                            </div>

                            <div class="space-y-2">
                                <div class="flex justify-between">
                                    <span class="text-gray-600">Durasi</span>
                                    <span class="font-semibold">{{ $plan->duration_months }} bulan</span>
                                </div>
                                <div class="flex justify-between">
                                    <span class="text-gray-600">Harga per bulan</span>
                                    <span>Rp {{ number_format($plan->price / $plan->duration_months, 0, ',', '.') }}</span>
                                </div>
                            </div>

                            <div class="border-t pt-4">
                                <div class="flex justify-between items-center">
                                    <span class="text-lg font-bold text-gray-800">Total Pembayaran</span>
                                    <span class="text-2xl font-bold text-blue-600">Rp {{ number_format($plan->price, 0, ',', '.') }}</span>
                                </div>
                            </div>
                        </div>

                        <!-- Payment Methods -->
                        <div class="mt-6 pt-6 border-t">
                            <h4 class="font-semibold text-gray-800 mb-3">Metode Pembayaran</h4>
                            <div class="grid grid-cols-3 gap-2">
                                <div class="text-center p-2 border rounded">
                                    <i class="fab fa-cc-visa text-2xl text-blue-600"></i>
                                    <p class="text-xs mt-1">Visa</p>
                                </div>
                                <div class="text-center p-2 border rounded">
                                    <i class="fab fa-cc-mastercard text-2xl text-red-500"></i>
                                    <p class="text-xs mt-1">Mastercard</p>
                                </div>
                                <div class="text-center p-2 border rounded">
                                    <i class="fas fa-mobile-alt text-2xl text-green-500"></i>
                                    <p class="text-xs mt-1">E-Wallet</p>
                                </div>
                            </div>
                        </div>

                        <!-- Security -->
                        <div class="mt-6 pt-6 border-t">
                            <div class="flex items-center text-green-600 mb-2">
                                <i class="fas fa-shield-alt mr-2"></i>
                                <span class="text-sm font-semibold">Pembayaran Aman</span>
                            </div>
                            <p class="text-xs text-gray-500">Transaksi Anda dilindungi dengan enkripsi SSL 256-bit</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- Footer -->
    <footer class="bg-gray-800 text-white py-8 mt-16">
        <div class="container mx-auto px-4 text-center">
            <div class="flex items-center justify-center space-x-2 mb-4">
                <i class="fas fa-dumbbell text-xl text-blue-400"></i>
                <span class="text-xl font-bold">FitHub</span>
            </div>
            <p class="text-gray-400">&copy; 2025 FitHub. All rights reserved.</p>
        </div>
    </footer>

    <script>
        document.getElementById('checkoutForm').addEventListener('submit', function(e) {
            e.preventDefault();

            const payButton = document.getElementById('payButton');
            const payButtonText = document.getElementById('payButtonText');
            const payButtonSpinner = document.getElementById('payButtonSpinner');

            // Show loading state
            payButton.disabled = true;
            payButtonText.textContent = 'Memproses...';
            payButtonSpinner.classList.remove('hidden');

            // Get form data
            const formData = new FormData(this);

            // Send to backend
            fetch('{{ route("checkout.process", $plan) }}', {
                method: 'POST',
                body: formData,
                headers: {
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
                }
            })
            .then(response => response.json())
            .then(data => {
                if (data.snap_token) {
                    // Open Midtrans Snap
                    snap.pay(data.snap_token, {
                        onSuccess: function(result) {
                            window.location.href = '{{ route("thank-you") }}?order_id=' + data.order_id;
                        },
                        onPending: function(result) {
                            alert('Pembayaran pending. Silakan selesaikan pembayaran Anda.');
                            window.location.href = '{{ route("thank-you") }}?order_id=' + data.order_id;
                        },
                        onError: function(result) {
                            alert('Terjadi kesalahan saat pembayaran. Silakan coba lagi.');
                            resetButton();
                        },
                        onClose: function() {
                            resetButton();
                        }
                    });
                } else {
                    alert(data.error || 'Terjadi kesalahan saat memproses checkout');
                    resetButton();
                }
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Terjadi kesalahan saat memproses checkout');
                resetButton();
            });

            function resetButton() {
                payButton.disabled = false;
                payButtonText.textContent = 'Bayar Sekarang';
                payButtonSpinner.classList.add('hidden');
            }
        });
    </script>
</body>
</html>

Halaman Thank You

Terakhir, mari kita buat halaman thank you untuk setelah pembayaran. Buat file resources/views/frontend/checkout/thank-you.blade.php:

<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Terima Kasih - FitHub</title>
    <script src="<https://cdn.tailwindcss.com>"></script>
    <link href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css>" rel="stylesheet">
</head>
<body class="bg-gray-50">
    <!-- Navbar -->
    <nav class="bg-white shadow-lg">
        <div class="container mx-auto px-4">
            <div class="flex justify-between items-center py-4">
                <div class="flex items-center space-x-2">
                    <i class="fas fa-dumbbell text-2xl text-blue-600"></i>
                    <a href="{{ route('home') }}" class="text-2xl font-bold text-gray-800">FitHub</a>
                </div>
            </div>
        </div>
    </nav>

    <div class="container mx-auto px-4 py-12">
        <div class="max-w-2xl mx-auto text-center">
            @if($transaction && $transaction->status === 'paid')
                <!-- Success State -->
                <div class="bg-green-100 rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6">
                    <i class="fas fa-check text-4xl text-green-600"></i>
                </div>

                <h1 class="text-3xl font-bold text-gray-800 mb-4">Pembayaran Berhasil!</h1>
                <p class="text-lg text-gray-600 mb-8">Selamat! Membership Anda telah aktif dan siap digunakan.</p>

                <!-- Transaction Details -->
                <div class="bg-white rounded-lg shadow-lg p-6 mb-8">
                    <h2 class="text-xl font-bold text-gray-800 mb-4">Detail Transaksi</h2>

                    <div class="space-y-3 text-left">
                        <div class="flex justify-between">
                            <span class="text-gray-600">Order ID:</span>
                            <span class="font-semibold">{{ $transaction->midtrans_order_id }}</span>
                        </div>
                        <div class="flex justify-between">
                            <span class="text-gray-600">Paket:</span>
                            <span class="font-semibold">{{ $transaction->plan->name }}</span>
                        </div>
                        <div class="flex justify-between">
                            <span class="text-gray-600">Gym:</span>
                            <span class="font-semibold">{{ $transaction->plan->gym->name }}</span>
                        </div>
                        <div class="flex justify-between">
                            <span class="text-gray-600">Durasi:</span>
                            <span class="font-semibold">{{ $transaction->plan->duration_months }} bulan</span>
                        </div>
                        <div class="flex justify-between">
                            <span class="text-gray-600">Total Pembayaran:</span>
                            <span class="font-semibold text-blue-600">Rp {{ number_format($transaction->amount, 0, ',', '.') }}</span>
                        </div>
                        @if($transaction->subscription && $transaction->subscription->active_until)
                            <div class="flex justify-between border-t pt-3">
                                <span class="text-gray-600">Masa Aktif:</span>
                                <span class="font-semibold text-green-600">
                                    {{ $transaction->subscription->started_at->format('d M Y') }} -
                                    {{ $transaction->subscription->active_until->format('d M Y') }}
                                </span>
                            </div>
                        @endif
                    </div>
                </div>

                <!-- Next Steps -->
                <div class="bg-blue-50 rounded-lg p-6 mb-8">
                    <h3 class="text-lg font-bold text-gray-800 mb-4">Langkah Selanjutnya</h3>
                    <div class="space-y-3 text-left">
                        <div class="flex items-start">
                            <i class="fas fa-envelope text-blue-600 mt-1 mr-3"></i>
                            <div>
                                <p class="font-semibold">Cek Email Anda</p>
                                <p class="text-sm text-gray-600">Konfirmasi pembayaran telah dikirim ke {{ $transaction->member->email }}</p>
                            </div>
                        </div>
                        <div class="flex items-start">
                            <i class="fas fa-id-card text-blue-600 mt-1 mr-3"></i>
                            <div>
                                <p class="font-semibold">Tunjukkan Bukti Pembayaran</p>
                                <p class="text-sm text-gray-600">Bawa screenshot halaman ini ke gym untuk aktivasi membership</p>
                            </div>
                        </div>
                        <div class="flex items-start">
                            <i class="fas fa-dumbbell text-blue-600 mt-1 mr-3"></i>
                            <div>
                                <p class="font-semibold">Mulai Workout!</p>
                                <p class="text-sm text-gray-600">Membership Anda sudah aktif dan siap digunakan</p>
                            </div>
                        </div>
                    </div>
                </div>

            @elseif($transaction && $transaction->status === 'pending')
                <!-- Pending State -->
                <div class="bg-yellow-100 rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6">
                    <i class="fas fa-clock text-4xl text-yellow-600"></i>
                </div>

                <h1 class="text-3xl font-bold text-gray-800 mb-4">Pembayaran Pending</h1>
                <p class="text-lg text-gray-600 mb-8">Transaksi Anda sedang diproses. Silakan selesaikan pembayaran Anda.</p>

                <div class="bg-yellow-50 rounded-lg p-6 mb-8">
                    <p class="text-sm text-gray-700">Order ID: <strong>{{ $transaction->midtrans_order_id }}</strong></p>
                    <p class="text-sm text-gray-700 mt-2">Batas waktu pembayaran: {{ $transaction->expired_at->format('d M Y H:i') }} WIB</p>
                </div>

            @else
                <!-- Error/Unknown State -->
                <div class="bg-red-100 rounded-full w-24 h-24 flex items-center justify-center mx-auto mb-6">
                    <i class="fas fa-exclamation-triangle text-4xl text-red-600"></i>
                </div>

                <h1 class="text-3xl font-bold text-gray-800 mb-4">Transaksi Tidak Ditemukan</h1>
                <p class="text-lg text-gray-600 mb-8">Maaf, kami tidak dapat menemukan detail transaksi Anda.</p>
            @endif

            <!-- Action Buttons -->
            <div class="space-y-4">
                <a href="{{ route('gyms.index') }}" class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition inline-flex items-center">
                    <i class="fas fa-search mr-2"></i>
                    Cari Gym Lain
                </a>
                <div>
                    <a href="{{ route('home') }}" class="text-gray-600 hover:text-blue-600 transition">
                        Kembali ke Beranda
                    </a>
                </div>
            </div>
        </div>
    </div>

    <!-- Footer -->
    <footer class="bg-gray-800 text-white py-8 mt-16">
        <div class="container mx-auto px-4 text-center">
            <div class="flex items-center justify-center space-x-2 mb-4">
                <i class="fas fa-dumbbell text-xl text-blue-400"></i>
                <span class="text-xl font-bold">FitHub</span>
            </div>
            <p class="text-gray-400">&copy; 2025 FitHub. All rights reserved.</p>
        </div>
    </footer>
</body>
</html>

Update Route untuk Callback

Jangan lupa tambahkan route untuk Midtrans callback di routes/web.php:

// Midtrans callback (exclude from CSRF)
Route::post('/midtrans/callback', [CheckoutController::class, 'callback'])
    ->name('midtrans.callback')
    ->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class]);

Dan tambahkan route ini ke pengecualian CSRF di app/Http/Middleware/VerifyCsrfToken.php:

protected $except = [
    'midtrans/callback',
];

Dengan setup ini, kita sudah punya sistem checkout yang lengkap dengan:

1. Halaman Checkout - form yang clean dan user-friendly dengan progress indicator 2. Registrasi Member Otomatis - jika email belum terdaftar, sistem akan otomatis buatkan akun 3. Integrasi Midtrans - pembayaran real-time dengan berbagai metode 4. Callback Handling - otomatis update status transaksi dan aktivasi subscription 5. Halaman Thank You - dengan detail transaksi dan next steps yang jelas

Flow pembayaran yang seamless ini akan meningkatkan conversion rate dan memberikan user experience yang excellent bagi customer.

Penutup

Selamat! Kamu sudah berhasil membangun sebuah aplikasi SaaS yang cukup kompleks dan powerful. Tutorial ini memang panjang, tapi hasilnya sepadan kok. Kita udah ngcover hampir semua aspek penting dalam membangun aplikasi modern.

Dari awal tutorial ini, kita udah belajar banyak hal teknis yang sangat berharga. Mulai dari setup Laravel 12 yang proper, desain database yang solid, sampai implementasi multi-role authentication menggunakan Spatie Permission. Semua ini adalah fondasi yang krusial untuk aplikasi SaaS apapun.

Backend admin menggunaken Filament juga jadi salah satu highlight utama tutorial ini. Dengan sedikit konfigurasi, kita bisa punya dashboard admin yang profesional dan feature-rich. Gym owners bisa dengan mudah mengelola data mereka, sementara super admin tetap punya kontrol penuh atas seluruh platform.

Yang nggak kalah menarik adalah bagian frontend customer journey nya. Kita berhasil membangun experience yang smooth mulai dari landing page yang eye-catching, sistem pencarian gym yang intuitif, sampai proses checkout yang seamless. User experience seperti ini yang bikin customer betah dan mau balik lagi.

Integrasi dengan Midtrans juga membuat aplikasi kita siap untuk real-world usage. Customer bisa bayar pakai berbagai metode pembayaran, dan sistemnya otomatis handle semua status transaksi. Plus dengan callback handling yang proper, subscription langsung aktif begitu pembayaran berhasil.

Apa yang Sudah Kita Capai

Mari kita recap apa aja yang udah kita bangun dalam tutorial ini:

Pertama, kita punya sistem multi-tenant SaaS yang beneran scalable. Satu aplikasi bisa melayani ratusan gym sekaligus, tapi setiap gym tetep punya otonomi penuh untuk mengelola bisnisnya. Ini konsep yang bisa diterapin ke berbagai jenis bisnis lainnya.

Kedua, security dan authorization yang solid. Dengan Spatie Permission, kita bisa atur dengan granular siapa boleh akses apa. Super admin bisa lihat semua data, gym owner cuma bisa akses gym mereka sendiri, dan member cuma bisa lihat profil mereka. Clean dan aman.

Ketiga, user interface yang modern dan responsive. Baik dashboard admin maupun frontend customer dibuat dengan standar UI/UX yang tinggi. Menggunakan Tailwind CSS juga bikin development jadi lebih cepat dan konsisten.

Keempat, payment gateway integration yang production-ready. Midtrans Snap memberikan flexibility pembayaran yang luas, dan callback handling kita memastikan semua transaksi ter-track dengan baik.

Belajar Lebih Dalam

Kalau kamu tertarik untuk ngembangkin skill Laravel lebih dalam lagi, ada beberapa hal yang bisa dijelajahi lebih lanjut.

Performance optimization misalnya. Aplikasi SaaS dengan ratusan gym dan ribuan member butuh optimasi yang serius. Database indexing, query optimization, caching strategy - semua ini crucial untuk scalability.

Testing juga aspek yang super penting. Unit testing, feature testing, dan integration testing memastikan kode kita robust dan nggak mudah break saat ada perubahan. Laravel punya testing framework yang powerfull banget.

DevOps dan deployment juga skill yang wajib dikuasai. CI/CD pipeline, containerization dengan Docker, cloud deployment - semua ini skills yang dicari banget di industri.

API development juga worth exploring. Bikin mobile app atau integrate dengan third-party services jadi lebih mudah kalau kita punya REST API atau GraphQL endpoint yang well-designed.


Mau belajar Laravel lebih dalam lagi dengan guidance dari mentor berpengalaman? Di BuildWithAngga, kamu bisa ikut kelas Laravel yang nggak cuma ngajarin teori, tapi juga hands-on bikin project nyata yang bisa jadi portfolio kuat buat lamar kerja remote atau freelancing.

Kelasnya dirancang step-by-step dengan mentor yang udah bertahun-tahun berkecimpung di industri tech. Plus ada community support yang solid, jadi kamu nggak belajar sendirian. Investasi skill terbaik untuk masa depan career kamu di tech industry!