Onboarding Upload

This commit is contained in:
2025-12-04 02:52:21 +01:00
parent 1b5b51d183
commit 4414401187
13 changed files with 740 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Admin\Resources;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages\CreateOnboardingSlide;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages\EditOnboardingSlide;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages\ListOnboardingSlides;
use Paymenter\Extensions\Others\Onboarding\Models\OnboardingSlide;
class OnboardingResource extends Resource
{
protected static ?string $model = OnboardingSlide::class;
protected static string|\BackedEnum|null $navigationIcon = 'ri-slideshow-line';
protected static string|\BackedEnum|null $activeNavigationIcon = 'ri-slideshow-fill';
protected static string|\UnitEnum|null $navigationGroup = 'Administration';
protected static ?string $navigationLabel = 'Onboarding';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
TextInput::make('title')
->label('Title')
->required()
->maxLength(255)
->placeholder('Welcome to Our Platform'),
Textarea::make('description')
->label('Description')
->required()
->rows(4)
->placeholder('A brief description of this onboarding step...'),
FileUpload::make('image')
->label('Image')
->image()
->disk('public')
->directory('onboarding')
->visibility('public')
->imageEditor()
->imageEditorAspectRatios([
'16:9',
'4:3',
'1:1',
])
->maxSize(2048)
->helperText('Upload an image for this slide (max 2MB). Recommended aspect ratio: 16:9'),
TextInput::make('sort')
->label('Sort Order')
->numeric()
->default(0)
->required()
->helperText('Lower numbers appear first'),
Toggle::make('enabled')
->label('Enabled')
->default(true)
->helperText('Only enabled slides will be shown to users'),
Toggle::make('is_guided')
->label('Guided Slide')
->default(false)
->helperText('If enabled, this slide will redirect the user to a specific page')
->live(),
Select::make('page_route')
->label('Page to Guide To')
->visible(fn (Get $get): bool => $get('is_guided'))
->required(fn (Get $get): bool => $get('is_guided'))
->options([
'dashboard' => 'Dashboard',
'services' => 'Services',
'services.first' => 'First Active Service',
'invoices' => 'Invoices',
'tickets' => 'Tickets',
'tickets.create' => 'Create Ticket',
'account' => 'Account Settings',
'account.security' => 'Account Security',
'account.payment-methods' => 'Payment Methods',
'account.notifications' => 'Notifications',
'products' => 'Browse Products',
])
->helperText('Select which page the user should be guided to'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('image')
->label('Image')
->disk('public')
->square()
->defaultImageUrl(url('/images/placeholder.png')),
TextColumn::make('title')
->label('Title')
->searchable()
->sortable()
->limit(50),
TextColumn::make('description')
->label('Description')
->limit(80)
->wrap(),
TextColumn::make('sort')
->label('Order')
->sortable()
->alignCenter(),
ToggleColumn::make('enabled')
->label('Enabled')
->alignCenter(),
TextColumn::make('is_guided')
->label('Guided')
->badge()
->color(fn (bool $state): string => $state ? 'success' : 'gray')
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
->alignCenter(),
TextColumn::make('created_at')
->label('Created')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('sort', 'asc')
->reorderable('sort');
}
public static function getPages(): array
{
return [
'index' => ListOnboardingSlides::route('/'),
'create' => CreateOnboardingSlide::route('/create'),
'edit' => EditOnboardingSlide::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource;
class CreateOnboardingSlide extends CreateRecord
{
protected static string $resource = OnboardingResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource;
class EditOnboardingSlide extends EditRecord
{
protected static string $resource = OnboardingResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource\Pages;
use Filament\Resources\Pages\ListRecords;
use Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource;
class ListOnboardingSlides extends ListRecords
{
protected static string $resource = OnboardingResource::class;
protected function getHeaderActions(): array
{
return [
\Filament\Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Livewire\Onboarding;
use Livewire\Component;
use Paymenter\Extensions\Others\Onboarding\Models\OnboardingSlide;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use App\Helpers\ExtensionHelper;
class Modal extends Component
{
public $currentSlide = 0;
public $slides = [];
public $showModal = false;
public function mount()
{
// Check if test mode and per session mode are enabled
$extension = ExtensionHelper::getExtension('other', 'Onboarding');
$testMode = $extension->config('test_mode', false);
$perSession = $extension->config('per_session', false);
$userId = Auth::id();
// Check if user has seen onboarding before
if ($perSession) {
// Use cookie for per-session tracking
$hasSeenOnboarding = $testMode ? false : \Illuminate\Support\Facades\Cookie::has('onboarding_completed');
$this->currentSlide = (int) \Illuminate\Support\Facades\Cookie::get('onboarding_current_slide', 0);
} else {
// Use database session for persistent tracking
$hasSeenOnboarding = $testMode ? false : session()->get("onboarding_seen_{$userId}", false);
$this->currentSlide = session()->get("onboarding_current_slide_{$userId}", 0);
}
// Only show if user hasn't seen it and there are slides
$this->slides = OnboardingSlide::where('enabled', true)
->orderBy('sort', 'asc')
->get()
->toArray();
$this->showModal = !$hasSeenOnboarding && count($this->slides) > 0 && $this->currentSlide < count($this->slides);
}
public function getShowBackdropProperty()
{
$extension = ExtensionHelper::getExtension('other', 'Onboarding');
return $extension->config('show_backdrop', false);
}
public function nextSlide()
{
$currentSlideData = $this->slides[$this->currentSlide] ?? null;
$extension = ExtensionHelper::getExtension('other', 'Onboarding');
$perSession = $extension->config('per_session', false);
// If current slide is guided, redirect to the page
if ($currentSlideData && $currentSlideData['is_guided'] && $currentSlideData['page_route']) {
// Save current slide progress before redirecting
$this->currentSlide++;
if ($perSession) {
\Illuminate\Support\Facades\Cookie::queue('onboarding_current_slide', $this->currentSlide, 60 * 24 * 7); // 7 days
} else {
$userId = Auth::id();
session()->put("onboarding_current_slide_{$userId}", $this->currentSlide);
}
$this->redirectToPage($currentSlideData['page_route']);
return;
}
if ($this->currentSlide < count($this->slides) - 1) {
$this->currentSlide++;
// Save progress
if ($perSession) {
\Illuminate\Support\Facades\Cookie::queue('onboarding_current_slide', $this->currentSlide, 60 * 24 * 7); // 7 days
} else {
$userId = Auth::id();
session()->put("onboarding_current_slide_{$userId}", $this->currentSlide);
}
} else {
$this->finish();
}
}
public function previousSlide()
{
if ($this->currentSlide > 0) {
$this->currentSlide--;
// Save progress
$extension = ExtensionHelper::getExtension('other', 'Onboarding');
$perSession = $extension->config('per_session', false);
if ($perSession) {
\Illuminate\Support\Facades\Cookie::queue('onboarding_current_slide', $this->currentSlide, 60 * 24 * 7); // 7 days
} else {
$userId = Auth::id();
session()->put("onboarding_current_slide_{$userId}", $this->currentSlide);
}
}
}
public function finish()
{
// Check if test mode and per session mode are enabled
$extension = ExtensionHelper::getExtension('other', 'Onboarding');
$testMode = $extension->config('test_mode', false);
$perSession = $extension->config('per_session', false);
if ($perSession) {
// Clear current slide progress cookie
\Illuminate\Support\Facades\Cookie::queue(\Illuminate\Support\Facades\Cookie::forget('onboarding_current_slide'));
// Only mark as completed if not in test mode (30 days expiration)
if (!$testMode) {
\Illuminate\Support\Facades\Cookie::queue('onboarding_completed', '1', 60 * 24 * 30);
}
} else {
$userId = Auth::id();
// Clear current slide progress
session()->forget("onboarding_current_slide_{$userId}");
// Only mark as seen if not in test mode
if (!$testMode) {
session()->put("onboarding_seen_{$userId}", true);
}
}
$this->showModal = false;
}
protected function redirectToPage($pageRoute)
{
$url = match ($pageRoute) {
'dashboard' => route('dashboard'),
'services' => route('services'),
'services.first' => $this->getFirstServiceUrl(),
'invoices' => route('invoices'),
'tickets' => route('tickets'),
'tickets.create' => route('tickets.create'),
'account' => route('account'),
'account.security' => route('account.security'),
'account.payment-methods' => route('account.payment-methods'),
'account.notifications' => route('account.notifications'),
'products' => route('home') . '#shop',
default => route('dashboard'),
};
if ($url) {
return $this->redirect($url, navigate: true);
}
}
protected function getFirstServiceUrl()
{
$service = Auth::user()->services()->where('status', 'active')->first();
return $service ? route('services.show', $service) : route('services');
}
public function render()
{
return view('onboarding::modal');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Models;
use Illuminate\Database\Eloquent\Model;
class OnboardingSlide extends Model
{
protected $table = 'ext_onboarding_slides';
protected $fillable = [
'title',
'description',
'image',
'sort',
'enabled',
'is_guided',
'page_route',
];
protected $casts = [
'enabled' => 'boolean',
'is_guided' => 'boolean',
'sort' => 'integer',
];
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding;
use App\Classes\Extension\Extension;
use App\Helpers\ExtensionHelper;
class Onboarding extends Extension
{
public function getMetadata()
{
return [
'display_name' => 'Onboarding',
'version' => '1.0.0',
'author' => 'Paymenter',
'website' => 'https://paymenter.org',
];
}
public function getConfig($values = [])
{
return [
[
'name' => 'test_mode',
'type' => 'checkbox',
'label' => 'Test Mode',
'description' => 'Enable test mode to always show onboarding modal (bypass session tracking). Useful for testing while setting up slides.',
'required' => false,
'default' => false,
],
[
'name' => 'show_backdrop',
'type' => 'checkbox',
'label' => 'Show Backdrop',
'description' => 'Display a dark backdrop behind the onboarding modal.',
'required' => false,
'default' => false,
],
[
'name' => 'per_session',
'type' => 'checkbox',
'label' => 'Per Session Onboarding',
'description' => 'Use cookies instead of database session. Onboarding will show again if user clears cookies or uses a different browser/device.',
'required' => false,
'default' => false,
],
];
}
public function installed()
{
ExtensionHelper::runMigrations('extensions/Others/Onboarding/database/migrations');
}
public function uninstalled()
{
ExtensionHelper::rollbackMigrations('extensions/Others/Onboarding/database/migrations');
}
public function boot()
{
if (app()->runningInConsole() === false) {
\Filament\Facades\Filament::getCurrentPanel()?->resources([
\Paymenter\Extensions\Others\Onboarding\Admin\Resources\OnboardingResource::class,
]);
}
app('view')->addNamespace('onboarding', __DIR__ . '/resources/views');
\Livewire\Livewire::component('onboarding.modal', \Paymenter\Extensions\Others\Onboarding\Livewire\Onboarding\Modal::class);
\Illuminate\Support\Facades\Gate::policy(
\Paymenter\Extensions\Others\Onboarding\Models\OnboardingSlide::class,
\Paymenter\Extensions\Others\Onboarding\Policies\OnboardingSlidePolicy::class
);
\Illuminate\Support\Facades\Event::listen('permissions', function () {
return [
'admin.onboarding.view' => 'View Onboarding Slides',
'admin.onboarding.create' => 'Create Onboarding Slides',
'admin.onboarding.update' => 'Update Onboarding Slides',
'admin.onboarding.delete' => 'Delete Onboarding Slides',
];
});
\Illuminate\Support\Facades\Event::listen('body', function () {
if (!request()->is('admin/*') &&
!request()->is('login') &&
!request()->is('register') &&
!request()->is('2fa') &&
!request()->is('forgot-password') &&
!request()->is('reset-password/*') &&
!request()->is('verify-email') &&
!request()->is('/') &&
\Illuminate\Support\Facades\Auth::check()) {
return [
'view' => view('onboarding::widget'),
];
}
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Paymenter\Extensions\Others\Onboarding\Policies;
use App\Models\User;
use App\Policies\BasePolicy;
use Paymenter\Extensions\Others\Onboarding\Models\OnboardingSlide;
class OnboardingSlidePolicy extends BasePolicy
{
public function viewAny(User $user): bool
{
return $user->hasPermission('admin.onboarding.view');
}
public function view(User $user, OnboardingSlide $onboardingSlide): bool
{
return $user->hasPermission('admin.onboarding.view');
}
public function create(User $user): bool
{
return $user->hasPermission('admin.onboarding.create');
}
public function update(User $user, OnboardingSlide $onboardingSlide): bool
{
return $user->hasPermission('admin.onboarding.update');
}
public function delete(User $user, OnboardingSlide $onboardingSlide): bool
{
return $user->hasPermission('admin.onboarding.delete');
}
public function deleteAny(User $user): bool
{
return $user->hasPermission('admin.onboarding.delete');
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "paymenter/onboarding",
"description": "Onboarding extension for Paymenter",
"type": "paymenter-extension",
"autoload": {
"psr-4": {
"Paymenter\\Extensions\\Others\\Onboarding\\": ""
}
},
"extra": {
"paymenter": {
"extension": "Onboarding",
"type": "other"
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ext_onboarding_slides', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description');
$table->string('image')->nullable();
$table->integer('sort')->default(0);
$table->boolean('enabled')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ext_onboarding_slides');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('ext_onboarding_slides', function (Blueprint $table) {
$table->boolean('is_guided')->default(false)->after('enabled');
$table->string('page_route')->nullable()->after('is_guided');
});
}
public function down()
{
Schema::table('ext_onboarding_slides', function (Blueprint $table) {
$table->dropColumn(['is_guided', 'page_route']);
});
}
};

View File

@@ -0,0 +1,104 @@
<div>
@if($showModal && count($slides) > 0)
<div x-data="{ open: @entangle('showModal'), currentSlide: @entangle('currentSlide') }"
x-show="open"
x-cloak
x-init="$watch('open', value => {
if(value) {
document.documentElement.style.overflow = 'hidden';
} else {
document.documentElement.style.overflow = '';
}
})"
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
@if($this->showBackdrop)
<div class="absolute inset-0 bg-black/50 transition-opacity duration-300"
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"></div>
@endif
<div class="w-full max-w-xl mx-4 md:mx-auto bg-background-secondary border border-neutral rounded-lg shadow-2xl overflow-hidden h-[500px] flex flex-col relative z-10"
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-90">
@foreach($slides as $index => $slide)
<div wire:key="slide-{{ $slide['id'] }}"
x-show="currentSlide === {{ $index }}"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="flex flex-col h-full absolute inset-0">
@if($slide['image'])
<div class="w-full h-56 flex-shrink-0 overflow-hidden rounded-t-lg">
<img class="w-full h-full object-cover"
src="{{ Storage::url($slide['image']) }}"
alt="{{ $slide['title'] }}">
</div>
@endif
<div class="p-6 flex-1 overflow-y-auto">
<h3 class="text-xl font-bold text-primary-100 mb-2.5">
{{ $slide['title'] }}
</h3>
<p class="text-sm text-base/80 leading-relaxed">
{{ $slide['description'] }}
</p>
</div>
<div class="flex justify-center gap-2 px-6 pb-4 flex-shrink-0">
@foreach($slides as $dotIndex => $dot)
<div class="h-1.5 rounded-full transition-all duration-300"
:class="currentSlide === {{ $dotIndex }} ? 'w-6 bg-primary' : 'w-1.5 bg-neutral'"></div>
@endforeach
</div>
<div class="flex justify-between items-center px-6 pb-5 pt-1 flex-shrink-0">
<button wire:click="previousSlide"
:disabled="currentSlide === 0"
class="px-4 py-1.5 text-sm text-base/70 hover:text-primary-100 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed">
<div class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back
</div>
</button>
<button @click="currentSlide === {{ count($slides) - 1 }} ? $wire.finish() : $wire.nextSlide()"
class="px-5 py-1.5 text-sm bg-primary hover:bg-primary/90 text-white font-medium rounded-md transition shadow-sm">
<div class="flex items-center gap-1.5">
<span x-text="currentSlide === {{ count($slides) - 1 }} ? 'Finish' : (@js($slide['is_guided'] ?? false) ? 'Continue' : 'Next')"></span>
<svg x-show="currentSlide !== {{ count($slides) - 1 }}" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</button>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>

View File

@@ -0,0 +1 @@
<livewire:onboarding.modal />