From 1d3d6287def4b86b112144b769e8c67c705ec9fe Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Fri, 5 Dec 2025 10:41:29 +0000
Subject: [PATCH 01/34] wip
---
app/Enums/PluginActivityType.php | 45 ++
app/Enums/PluginStatus.php | 28 +
app/Enums/PluginType.php | 17 +
app/Filament/Resources/PluginResource.php | 255 +++++++
.../PluginResource/Pages/ListPlugins.php | 19 +
.../PluginResource/Pages/ViewPlugin.php | 11 +
.../ActivitiesRelationManager.php | 55 ++
.../Controllers/CustomerPluginController.php | 93 +++
.../Controllers/PluginDirectoryController.php | 31 +
app/Http/Requests/SubmitPluginRequest.php | 47 ++
.../UpdatePluginDescriptionRequest.php | 34 +
app/Livewire/PluginDirectory.php | 51 ++
app/Models/Plugin.php | 229 ++++++
app/Models/PluginActivity.php | 35 +
app/Models/User.php | 8 +
app/Notifications/PluginApproved.php | 54 ++
app/Notifications/PluginRejected.php | 57 ++
database/factories/PluginFactory.php | 186 +++++
...2025_12_03_124822_create_plugins_table.php | 35 +
..._and_rejection_reason_to_plugins_table.php | 29 +
..._154716_create_plugin_activities_table.php | 35 +
...3_161416_add_featured_to_plugins_table.php | 30 +
...75340_add_description_to_plugins_table.php | 28 +
database/seeders/PluginSeeder.php | 127 ++++
.../views/components/icons/puzzle.blade.php | 12 +
.../navbar/device-dropdowns.blade.php | 7 +
.../views/components/plugin-card.blade.php | 55 ++
.../views/customer/licenses/index.blade.php | 4 +
.../views/customer/plugins/create.blade.php | 220 ++++++
.../views/customer/plugins/index.blade.php | 189 +++++
.../views/customer/plugins/show.blade.php | 135 ++++
.../views/livewire/plugin-directory.blade.php | 117 ++++
resources/views/plugins.blade.php | 658 ++++++++++++++++++
resources/views/pricing.blade.php | 11 +
routes/web.php | 15 +-
35 files changed, 2960 insertions(+), 2 deletions(-)
create mode 100644 app/Enums/PluginActivityType.php
create mode 100644 app/Enums/PluginStatus.php
create mode 100644 app/Enums/PluginType.php
create mode 100644 app/Filament/Resources/PluginResource.php
create mode 100644 app/Filament/Resources/PluginResource/Pages/ListPlugins.php
create mode 100644 app/Filament/Resources/PluginResource/Pages/ViewPlugin.php
create mode 100644 app/Filament/Resources/PluginResource/RelationManagers/ActivitiesRelationManager.php
create mode 100644 app/Http/Controllers/CustomerPluginController.php
create mode 100644 app/Http/Controllers/PluginDirectoryController.php
create mode 100644 app/Http/Requests/SubmitPluginRequest.php
create mode 100644 app/Http/Requests/UpdatePluginDescriptionRequest.php
create mode 100644 app/Livewire/PluginDirectory.php
create mode 100644 app/Models/Plugin.php
create mode 100644 app/Models/PluginActivity.php
create mode 100644 app/Notifications/PluginApproved.php
create mode 100644 app/Notifications/PluginRejected.php
create mode 100644 database/factories/PluginFactory.php
create mode 100644 database/migrations/2025_12_03_124822_create_plugins_table.php
create mode 100644 database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php
create mode 100644 database/migrations/2025_12_03_154716_create_plugin_activities_table.php
create mode 100644 database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php
create mode 100644 database/migrations/2025_12_03_175340_add_description_to_plugins_table.php
create mode 100644 database/seeders/PluginSeeder.php
create mode 100644 resources/views/components/icons/puzzle.blade.php
create mode 100644 resources/views/components/plugin-card.blade.php
create mode 100644 resources/views/customer/plugins/create.blade.php
create mode 100644 resources/views/customer/plugins/index.blade.php
create mode 100644 resources/views/customer/plugins/show.blade.php
create mode 100644 resources/views/livewire/plugin-directory.blade.php
create mode 100644 resources/views/plugins.blade.php
diff --git a/app/Enums/PluginActivityType.php b/app/Enums/PluginActivityType.php
new file mode 100644
index 00000000..56a26126
--- /dev/null
+++ b/app/Enums/PluginActivityType.php
@@ -0,0 +1,45 @@
+ 'Submitted',
+ self::Resubmitted => 'Resubmitted',
+ self::Approved => 'Approved',
+ self::Rejected => 'Rejected',
+ self::DescriptionUpdated => 'Description Updated',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Submitted => 'info',
+ self::Resubmitted => 'info',
+ self::Approved => 'success',
+ self::Rejected => 'danger',
+ self::DescriptionUpdated => 'gray',
+ };
+ }
+
+ public function icon(): string
+ {
+ return match ($this) {
+ self::Submitted => 'heroicon-o-paper-airplane',
+ self::Resubmitted => 'heroicon-o-arrow-path',
+ self::Approved => 'heroicon-o-check-circle',
+ self::Rejected => 'heroicon-o-x-circle',
+ self::DescriptionUpdated => 'heroicon-o-pencil-square',
+ };
+ }
+}
diff --git a/app/Enums/PluginStatus.php b/app/Enums/PluginStatus.php
new file mode 100644
index 00000000..4fd44020
--- /dev/null
+++ b/app/Enums/PluginStatus.php
@@ -0,0 +1,28 @@
+ 'Pending Review',
+ self::Approved => 'Approved',
+ self::Rejected => 'Rejected',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Pending => 'yellow',
+ self::Approved => 'green',
+ self::Rejected => 'red',
+ };
+ }
+}
diff --git a/app/Enums/PluginType.php b/app/Enums/PluginType.php
new file mode 100644
index 00000000..27df3858
--- /dev/null
+++ b/app/Enums/PluginType.php
@@ -0,0 +1,17 @@
+ 'Free',
+ self::Paid => 'Paid',
+ };
+ }
+}
diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php
new file mode 100644
index 00000000..abc88ccb
--- /dev/null
+++ b/app/Filament/Resources/PluginResource.php
@@ -0,0 +1,255 @@
+schema([
+ Forms\Components\Section::make('Plugin Details')
+ ->schema([
+ Forms\Components\TextInput::make('name')
+ ->label('Composer Package Name')
+ ->disabled(),
+
+ Forms\Components\Select::make('type')
+ ->options(PluginType::class)
+ ->disabled(),
+
+ Forms\Components\TextInput::make('anystack_id')
+ ->label('Anystack Product ID')
+ ->disabled()
+ ->visible(fn (Plugin $record) => $record->isPaid()),
+
+ Forms\Components\Select::make('status')
+ ->options(PluginStatus::class)
+ ->disabled(),
+
+ Forms\Components\Textarea::make('description')
+ ->label('Description')
+ ->disabled()
+ ->columnSpanFull(),
+
+ Forms\Components\Textarea::make('rejection_reason')
+ ->label('Rejection Reason')
+ ->disabled()
+ ->visible(fn (Plugin $record) => $record->isRejected()),
+ ])
+ ->columns(2),
+
+ Forms\Components\Section::make('Submission Info')
+ ->schema([
+ Forms\Components\Select::make('user_id')
+ ->relationship('user', 'email')
+ ->disabled(),
+
+ Forms\Components\DateTimePicker::make('created_at')
+ ->label('Submitted At')
+ ->disabled(),
+
+ Forms\Components\Select::make('approved_by')
+ ->relationship('approvedBy', 'email')
+ ->disabled()
+ ->visible(fn (Plugin $record) => $record->approved_by !== null),
+
+ Forms\Components\DateTimePicker::make('approved_at')
+ ->disabled()
+ ->visible(fn (Plugin $record) => $record->approved_at !== null),
+ ])
+ ->columns(2),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\TextColumn::make('name')
+ ->label('Package Name')
+ ->searchable()
+ ->sortable()
+ ->copyable()
+ ->fontFamily('mono'),
+
+ Tables\Columns\TextColumn::make('type')
+ ->badge()
+ ->color(fn (PluginType $state): string => match ($state) {
+ PluginType::Free => 'gray',
+ PluginType::Paid => 'success',
+ })
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('user.email')
+ ->label('Submitted By')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('status')
+ ->badge()
+ ->color(fn (PluginStatus $state): string => match ($state) {
+ PluginStatus::Pending => 'warning',
+ PluginStatus::Approved => 'success',
+ PluginStatus::Rejected => 'danger',
+ })
+ ->sortable(),
+
+ Tables\Columns\ToggleColumn::make('featured')
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('Submitted')
+ ->dateTime()
+ ->sortable(),
+ ])
+ ->filters([
+ Tables\Filters\SelectFilter::make('status')
+ ->options(PluginStatus::class),
+ Tables\Filters\SelectFilter::make('type')
+ ->options(PluginType::class),
+ Tables\Filters\TernaryFilter::make('featured'),
+ ])
+ ->actions([
+ // Approve Action
+ Tables\Actions\Action::make('approve')
+ ->icon('heroicon-o-check')
+ ->color('success')
+ ->visible(fn (Plugin $record) => $record->isPending())
+ ->action(fn (Plugin $record) => $record->approve(auth()->id()))
+ ->requiresConfirmation()
+ ->modalHeading('Approve Plugin')
+ ->modalDescription(fn (Plugin $record) => "Are you sure you want to approve '{$record->name}'?"),
+
+ // Reject Action
+ Tables\Actions\Action::make('reject')
+ ->icon('heroicon-o-x-mark')
+ ->color('danger')
+ ->visible(fn (Plugin $record) => $record->isPending() || $record->isApproved())
+ ->form([
+ Forms\Components\Textarea::make('rejection_reason')
+ ->label('Reason for Rejection')
+ ->required()
+ ->rows(3)
+ ->placeholder('Please explain why this plugin is being rejected...'),
+ ])
+ ->action(fn (Plugin $record, array $data) => $record->reject($data['rejection_reason'], auth()->id()))
+ ->modalHeading('Reject Plugin')
+ ->modalDescription(fn (Plugin $record) => "Are you sure you want to reject '{$record->name}'?"),
+
+ // External Links Group
+ Tables\Actions\ActionGroup::make([
+ // Packagist Link (Free plugins only)
+ Tables\Actions\Action::make('viewPackagist')
+ ->label('View on Packagist')
+ ->icon('heroicon-o-arrow-top-right-on-square')
+ ->color('gray')
+ ->url(fn (Plugin $record) => $record->getPackagistUrl())
+ ->openUrlInNewTab()
+ ->visible(fn (Plugin $record) => $record->isFree()),
+
+ // GitHub Link (Free plugins only)
+ Tables\Actions\Action::make('viewGithub')
+ ->label('View on GitHub')
+ ->icon('heroicon-o-arrow-top-right-on-square')
+ ->color('gray')
+ ->url(fn (Plugin $record) => $record->getGithubUrl())
+ ->openUrlInNewTab()
+ ->visible(fn (Plugin $record) => $record->isFree()),
+
+ // Anystack Link (Paid plugins only)
+ Tables\Actions\Action::make('viewAnystack')
+ ->label('View on Anystack')
+ ->icon('heroicon-o-arrow-top-right-on-square')
+ ->color('gray')
+ ->url(fn (Plugin $record) => $record->getAnystackUrl())
+ ->openUrlInNewTab()
+ ->visible(fn (Plugin $record) => $record->isPaid() && $record->anystack_id),
+
+ // Anystack Instructions (Paid plugins without ID)
+ Tables\Actions\Action::make('anystackInstructions')
+ ->label('Anystack Setup')
+ ->icon('heroicon-o-information-circle')
+ ->color('warning')
+ ->visible(fn (Plugin $record) => $record->isPaid())
+ ->modalHeading('Anystack Verification Required')
+ ->modalDescription('For paid plugins, verify that the developer has applied to the "NativePHP Plugin Directory" affiliate program in their Anystack dashboard under the Advertising section.')
+ ->modalSubmitAction(false)
+ ->modalCancelActionLabel('Close'),
+
+ // Edit Description Action
+ Tables\Actions\Action::make('editDescription')
+ ->label('Edit Description')
+ ->icon('heroicon-o-pencil-square')
+ ->color('gray')
+ ->form([
+ Forms\Components\Textarea::make('description')
+ ->label('Description')
+ ->required()
+ ->rows(5)
+ ->maxLength(1000)
+ ->default(fn (Plugin $record) => $record->description)
+ ->placeholder('Describe what this plugin does...'),
+ ])
+ ->action(fn (Plugin $record, array $data) => $record->updateDescription($data['description'], auth()->id()))
+ ->modalHeading('Edit Plugin Description')
+ ->modalDescription(fn (Plugin $record) => "Update the description for '{$record->name}'"),
+
+ Tables\Actions\ViewAction::make(),
+ Tables\Actions\DeleteAction::make(),
+ ])
+ ->label('More')
+ ->icon('heroicon-m-ellipsis-vertical'),
+ ])
+ ->bulkActions([
+ Tables\Actions\BulkActionGroup::make([
+ Tables\Actions\BulkAction::make('approve')
+ ->icon('heroicon-o-check')
+ ->color('success')
+ ->action(function ($records) {
+ $records->each(fn (Plugin $record) => $record->approve(auth()->id()));
+ })
+ ->requiresConfirmation()
+ ->modalHeading('Approve Selected Plugins')
+ ->modalDescription('Are you sure you want to approve all selected plugins?'),
+
+ Tables\Actions\DeleteBulkAction::make(),
+ ]),
+ ])
+ ->defaultSort('created_at', 'desc');
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ RelationManagers\ActivitiesRelationManager::class,
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListPlugins::route('/'),
+ 'view' => Pages\ViewPlugin::route('/{record}'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php
new file mode 100644
index 00000000..ba97676c
--- /dev/null
+++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php
@@ -0,0 +1,19 @@
+columns([
+ Tables\Columns\TextColumn::make('type')
+ ->badge()
+ ->color(fn (PluginActivityType $state): string => $state->color())
+ ->icon(fn (PluginActivityType $state): string => $state->icon())
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('from_status')
+ ->label('From')
+ ->badge()
+ ->color('gray')
+ ->placeholder('-'),
+
+ Tables\Columns\TextColumn::make('to_status')
+ ->label('To')
+ ->badge()
+ ->color('gray'),
+
+ Tables\Columns\TextColumn::make('note')
+ ->label('Note/Reason')
+ ->limit(50)
+ ->tooltip(fn ($record) => $record->note)
+ ->placeholder('-'),
+
+ Tables\Columns\TextColumn::make('causer.email')
+ ->label('By')
+ ->placeholder('System'),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('Date')
+ ->dateTime()
+ ->sortable(),
+ ])
+ ->defaultSort('created_at', 'desc')
+ ->paginated([10, 25, 50]);
+ }
+}
diff --git a/app/Http/Controllers/CustomerPluginController.php b/app/Http/Controllers/CustomerPluginController.php
new file mode 100644
index 00000000..9e294b56
--- /dev/null
+++ b/app/Http/Controllers/CustomerPluginController.php
@@ -0,0 +1,93 @@
+middleware('auth');
+ }
+
+ public function index(): View
+ {
+ $user = Auth::user();
+ $plugins = $user->plugins()->orderBy('created_at', 'desc')->get();
+
+ return view('customer.plugins.index', compact('plugins'));
+ }
+
+ public function create(): View
+ {
+ return view('customer.plugins.create');
+ }
+
+ public function store(SubmitPluginRequest $request): RedirectResponse
+ {
+ $user = Auth::user();
+
+ $user->plugins()->create([
+ 'name' => $request->name,
+ 'type' => $request->type,
+ 'anystack_id' => $request->anystack_id,
+ 'status' => PluginStatus::Pending,
+ ]);
+
+ return redirect()->route('customer.plugins.index')
+ ->with('success', 'Your plugin has been submitted for review!');
+ }
+
+ public function show(Plugin $plugin): View
+ {
+ $user = Auth::user();
+
+ if ($plugin->user_id !== $user->id) {
+ abort(403);
+ }
+
+ return view('customer.plugins.show', compact('plugin'));
+ }
+
+ public function update(UpdatePluginDescriptionRequest $request, Plugin $plugin): RedirectResponse
+ {
+ $user = Auth::user();
+
+ if ($plugin->user_id !== $user->id) {
+ abort(403);
+ }
+
+ $plugin->updateDescription($request->description, $user->id);
+
+ return redirect()->route('customer.plugins.show', $plugin)
+ ->with('success', 'Plugin description updated successfully!');
+ }
+
+ public function resubmit(Plugin $plugin): RedirectResponse
+ {
+ $user = Auth::user();
+
+ // Ensure the plugin belongs to the current user
+ if ($plugin->user_id !== $user->id) {
+ abort(403);
+ }
+
+ // Only rejected plugins can be resubmitted
+ if (! $plugin->isRejected()) {
+ return redirect()->route('customer.plugins.index')
+ ->with('error', 'Only rejected plugins can be resubmitted.');
+ }
+
+ $plugin->resubmit();
+
+ return redirect()->route('customer.plugins.index')
+ ->with('success', 'Your plugin has been resubmitted for review!');
+ }
+}
diff --git a/app/Http/Controllers/PluginDirectoryController.php b/app/Http/Controllers/PluginDirectoryController.php
new file mode 100644
index 00000000..f5db3ead
--- /dev/null
+++ b/app/Http/Controllers/PluginDirectoryController.php
@@ -0,0 +1,31 @@
+approved()
+ ->featured()
+ ->latest()
+ ->take(3)
+ ->get();
+
+ $latestPlugins = Plugin::query()
+ ->approved()
+ ->where('featured', false)
+ ->latest()
+ ->take(3)
+ ->get();
+
+ return view('plugins', [
+ 'featuredPlugins' => $featuredPlugins,
+ 'latestPlugins' => $latestPlugins,
+ ]);
+ }
+}
diff --git a/app/Http/Requests/SubmitPluginRequest.php b/app/Http/Requests/SubmitPluginRequest.php
new file mode 100644
index 00000000..b6da8de3
--- /dev/null
+++ b/app/Http/Requests/SubmitPluginRequest.php
@@ -0,0 +1,47 @@
+ [
+ 'required',
+ 'string',
+ 'max:255',
+ 'regex:/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*$/i',
+ 'unique:plugins,name',
+ ],
+ 'type' => ['required', 'string', Rule::enum(PluginType::class)],
+ 'anystack_id' => [
+ 'nullable',
+ 'required_if:type,paid',
+ 'string',
+ 'max:255',
+ ],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'name.required' => 'Please enter your plugin\'s Composer package name.',
+ 'name.regex' => 'Please enter a valid Composer package name (e.g., vendor/package-name).',
+ 'name.unique' => 'This plugin has already been submitted.',
+ 'type.required' => 'Please select whether your plugin is free or paid.',
+ 'type.enum' => 'Please select a valid plugin type.',
+ 'anystack_id.required_if' => 'Please enter your Anystack Product ID for paid plugins.',
+ ];
+ }
+}
diff --git a/app/Http/Requests/UpdatePluginDescriptionRequest.php b/app/Http/Requests/UpdatePluginDescriptionRequest.php
new file mode 100644
index 00000000..ccefdc9e
--- /dev/null
+++ b/app/Http/Requests/UpdatePluginDescriptionRequest.php
@@ -0,0 +1,34 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'description' => ['required', 'string', 'max:1000'],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'description.required' => 'Please provide a description for your plugin.',
+ 'description.max' => 'The description must not exceed 1000 characters.',
+ ];
+ }
+}
diff --git a/app/Livewire/PluginDirectory.php b/app/Livewire/PluginDirectory.php
new file mode 100644
index 00000000..273b1ee2
--- /dev/null
+++ b/app/Livewire/PluginDirectory.php
@@ -0,0 +1,51 @@
+resetPage();
+ }
+
+ public function clearSearch(): void
+ {
+ $this->search = '';
+ $this->resetPage();
+ }
+
+ public function render(): View
+ {
+ $plugins = Plugin::query()
+ ->approved()
+ ->when($this->search, function ($query) {
+ $query->where(function ($q) {
+ $q->where('name', 'like', "%{$this->search}%")
+ ->orWhere('description', 'like', "%{$this->search}%");
+ });
+ })
+ ->orderByDesc('featured')
+ ->latest()
+ ->paginate(15);
+
+ return view('livewire.plugin-directory', [
+ 'plugins' => $plugins,
+ ]);
+ }
+}
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
new file mode 100644
index 00000000..13c48816
--- /dev/null
+++ b/app/Models/Plugin.php
@@ -0,0 +1,229 @@
+ PluginStatus::class,
+ 'type' => PluginType::class,
+ 'approved_at' => 'datetime',
+ 'featured' => 'boolean',
+ ];
+
+ protected static function booted(): void
+ {
+ static::created(function (Plugin $plugin) {
+ $plugin->recordActivity(
+ PluginActivityType::Submitted,
+ null,
+ PluginStatus::Pending,
+ null,
+ $plugin->user_id
+ );
+ });
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function approvedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'approved_by');
+ }
+
+ /**
+ * @return HasMany
+ */
+ public function activities(): HasMany
+ {
+ return $this->hasMany(PluginActivity::class)->orderBy('created_at', 'desc');
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === PluginStatus::Pending;
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->status === PluginStatus::Approved;
+ }
+
+ public function isRejected(): bool
+ {
+ return $this->status === PluginStatus::Rejected;
+ }
+
+ public function isFree(): bool
+ {
+ return $this->type === PluginType::Free;
+ }
+
+ public function isPaid(): bool
+ {
+ return $this->type === PluginType::Paid;
+ }
+
+ public function isFeatured(): bool
+ {
+ return $this->featured;
+ }
+
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeApproved(Builder $query): Builder
+ {
+ return $query->where('status', PluginStatus::Approved);
+ }
+
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeFeatured(Builder $query): Builder
+ {
+ return $query->where('featured', true);
+ }
+
+ public function getPackagistUrl(): string
+ {
+ return "https://packagist.org/packages/{$this->name}";
+ }
+
+ public function getGithubUrl(): string
+ {
+ return "https://github.com/{$this->name}";
+ }
+
+ public function getAnystackUrl(): ?string
+ {
+ if (! $this->anystack_id) {
+ return null;
+ }
+
+ return "https://anystack.sh/products/{$this->anystack_id}";
+ }
+
+ public function approve(int $approvedById): void
+ {
+ $previousStatus = $this->status;
+
+ $this->update([
+ 'status' => PluginStatus::Approved,
+ 'approved_at' => now(),
+ 'approved_by' => $approvedById,
+ 'rejection_reason' => null,
+ ]);
+
+ $this->recordActivity(
+ PluginActivityType::Approved,
+ $previousStatus,
+ PluginStatus::Approved,
+ null,
+ $approvedById
+ );
+
+ $this->user->notify(new PluginApproved($this));
+ }
+
+ public function reject(string $reason, int $rejectedById): void
+ {
+ $previousStatus = $this->status;
+
+ $this->update([
+ 'status' => PluginStatus::Rejected,
+ 'rejection_reason' => $reason,
+ 'approved_at' => null,
+ 'approved_by' => $rejectedById,
+ ]);
+
+ $this->recordActivity(
+ PluginActivityType::Rejected,
+ $previousStatus,
+ PluginStatus::Rejected,
+ $reason,
+ $rejectedById
+ );
+
+ $this->user->notify(new PluginRejected($this));
+ }
+
+ public function resubmit(): void
+ {
+ $previousStatus = $this->status;
+
+ $this->update([
+ 'status' => PluginStatus::Pending,
+ 'rejection_reason' => null,
+ 'approved_at' => null,
+ 'approved_by' => null,
+ ]);
+
+ $this->recordActivity(
+ PluginActivityType::Resubmitted,
+ $previousStatus,
+ PluginStatus::Pending,
+ null,
+ $this->user_id
+ );
+ }
+
+ public function updateDescription(string $description, int $updatedById): void
+ {
+ $oldDescription = $this->description;
+
+ $this->update([
+ 'description' => $description,
+ ]);
+
+ $this->activities()->create([
+ 'type' => PluginActivityType::DescriptionUpdated,
+ 'from_status' => $this->status->value,
+ 'to_status' => $this->status->value,
+ 'note' => $oldDescription ? "Changed from: {$oldDescription}" : 'Initial description set',
+ 'causer_id' => $updatedById,
+ ]);
+ }
+
+ protected function recordActivity(
+ PluginActivityType $type,
+ ?PluginStatus $fromStatus,
+ PluginStatus $toStatus,
+ ?string $note,
+ ?int $causerId
+ ): void {
+ $this->activities()->create([
+ 'type' => $type,
+ 'from_status' => $fromStatus?->value,
+ 'to_status' => $toStatus->value,
+ 'note' => $note,
+ 'causer_id' => $causerId,
+ ]);
+ }
+}
diff --git a/app/Models/PluginActivity.php b/app/Models/PluginActivity.php
new file mode 100644
index 00000000..b41094ea
--- /dev/null
+++ b/app/Models/PluginActivity.php
@@ -0,0 +1,35 @@
+ PluginActivityType::class,
+ 'from_status' => PluginStatus::class,
+ 'to_status' => PluginStatus::class,
+ ];
+
+ /**
+ * @return BelongsTo
+ */
+ public function plugin(): BelongsTo
+ {
+ return $this->belongsTo(Plugin::class);
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function causer(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'causer_id');
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index e1fec6ab..54f94e41 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -55,6 +55,14 @@ public function wallOfLoveSubmissions(): HasMany
return $this->hasMany(WallOfLoveSubmission::class);
}
+ /**
+ * @return HasMany
+ */
+ public function plugins(): HasMany
+ {
+ return $this->hasMany(Plugin::class);
+ }
+
public function getFirstNameAttribute(): ?string
{
if (empty($this->name)) {
diff --git a/app/Notifications/PluginApproved.php b/app/Notifications/PluginApproved.php
new file mode 100644
index 00000000..65eec4d8
--- /dev/null
+++ b/app/Notifications/PluginApproved.php
@@ -0,0 +1,54 @@
+
+ */
+ public function via(object $notifiable): array
+ {
+ return ['mail'];
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ */
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('Your Plugin Has Been Approved!')
+ ->greeting('Great news!')
+ ->line("Your plugin **{$this->plugin->name}** has been approved and is now listed in the NativePHP Plugin Directory.")
+ ->action('View Plugin Directory', url('/plugins'))
+ ->line('Thank you for contributing to the NativePHP ecosystem!');
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @return array
+ */
+ public function toArray(object $notifiable): array
+ {
+ return [
+ 'plugin_id' => $this->plugin->id,
+ 'plugin_name' => $this->plugin->name,
+ ];
+ }
+}
diff --git a/app/Notifications/PluginRejected.php b/app/Notifications/PluginRejected.php
new file mode 100644
index 00000000..a1951f51
--- /dev/null
+++ b/app/Notifications/PluginRejected.php
@@ -0,0 +1,57 @@
+
+ */
+ public function via(object $notifiable): array
+ {
+ return ['mail'];
+ }
+
+ /**
+ * Get the mail representation of the notification.
+ */
+ public function toMail(object $notifiable): MailMessage
+ {
+ return (new MailMessage)
+ ->subject('Plugin Submission Update')
+ ->greeting('Hello,')
+ ->line("Unfortunately, your plugin **{$this->plugin->name}** was not approved for the NativePHP Plugin Directory.")
+ ->line('**Reason:**')
+ ->line($this->plugin->rejection_reason)
+ ->action('View Your Plugins', url('/customer/plugins'))
+ ->line('If you have questions about this decision, please reach out to us.');
+ }
+
+ /**
+ * Get the array representation of the notification.
+ *
+ * @return array
+ */
+ public function toArray(object $notifiable): array
+ {
+ return [
+ 'plugin_id' => $this->plugin->id,
+ 'plugin_name' => $this->plugin->name,
+ 'rejection_reason' => $this->plugin->rejection_reason,
+ ];
+ }
+}
diff --git a/database/factories/PluginFactory.php b/database/factories/PluginFactory.php
new file mode 100644
index 00000000..14f67b92
--- /dev/null
+++ b/database/factories/PluginFactory.php
@@ -0,0 +1,186 @@
+
+ */
+class PluginFactory extends Factory
+{
+ protected $model = Plugin::class;
+
+ /**
+ * @var array
+ */
+ protected array $pluginPrefixes = [
+ 'nativephp',
+ 'laravel',
+ 'acme',
+ 'awesome',
+ 'super',
+ 'native',
+ 'mobile',
+ 'app',
+ ];
+
+ /**
+ * @var array
+ */
+ protected array $pluginSuffixes = [
+ 'camera',
+ 'biometrics',
+ 'push-notifications',
+ 'geolocation',
+ 'bluetooth',
+ 'nfc',
+ 'contacts',
+ 'calendar',
+ 'health-kit',
+ 'share',
+ 'in-app-purchase',
+ 'admob',
+ 'analytics',
+ 'crashlytics',
+ 'deep-links',
+ 'local-auth',
+ 'secure-storage',
+ 'file-picker',
+ 'image-picker',
+ 'video-player',
+ 'audio-player',
+ 'speech-to-text',
+ 'text-to-speech',
+ 'barcode-scanner',
+ 'qr-code',
+ 'maps',
+ 'payments',
+ 'social-auth',
+ 'firebase',
+ 'sentry',
+ 'offline-sync',
+ 'background-tasks',
+ 'sensors',
+ 'haptics',
+ 'clipboard',
+ 'device-info',
+ 'network-info',
+ 'battery',
+ 'screen-brightness',
+ 'orientation',
+ 'keyboard',
+ 'status-bar',
+ 'splash-screen',
+ 'app-icon',
+ 'widgets',
+ ];
+
+ /**
+ * @var array
+ */
+ protected array $descriptions = [
+ 'A powerful plugin that integrates seamlessly with your NativePHP Mobile application, providing essential native functionality.',
+ 'Easily add native capabilities to your Laravel mobile app with this simple-to-use plugin.',
+ 'This plugin bridges the gap between PHP and native platform APIs, giving you full control.',
+ 'Unlock advanced mobile features with minimal configuration. Works on both iOS and Android.',
+ 'A production-ready plugin built with performance and reliability in mind.',
+ 'Simplify complex native integrations with this well-documented and tested plugin.',
+ 'Built by experienced mobile developers, this plugin follows best practices for both platforms.',
+ 'Zero-config setup that just works. Install via Composer and start using immediately.',
+ 'Comprehensive feature set with granular permissions control for enhanced security.',
+ 'Lightweight and fast, this plugin has minimal impact on your app\'s performance.',
+ ];
+
+ public function definition(): array
+ {
+ $vendor = fake()->randomElement($this->pluginPrefixes);
+ $package = fake()->randomElement($this->pluginSuffixes);
+
+ return [
+ 'user_id' => User::factory(),
+ 'name' => fake()->unique()->numerify("{$vendor}/{$package}-###"),
+ 'description' => fake()->randomElement($this->descriptions),
+ 'type' => PluginType::Free,
+ 'status' => PluginStatus::Pending,
+ 'featured' => false,
+ 'anystack_id' => null,
+ 'rejection_reason' => null,
+ 'approved_at' => null,
+ 'approved_by' => null,
+ 'created_at' => fake()->dateTimeBetween('-6 months', 'now'),
+ 'updated_at' => fn (array $attrs) => $attrs['created_at'],
+ ];
+ }
+
+ public function pending(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => PluginStatus::Pending,
+ 'approved_at' => null,
+ 'approved_by' => null,
+ 'rejection_reason' => null,
+ ]);
+ }
+
+ public function approved(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => PluginStatus::Approved,
+ 'approved_at' => fake()->dateTimeBetween($attributes['created_at'], 'now'),
+ 'approved_by' => User::factory(),
+ 'rejection_reason' => null,
+ ]);
+ }
+
+ public function rejected(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => PluginStatus::Rejected,
+ 'approved_at' => null,
+ 'approved_by' => null,
+ 'rejection_reason' => fake()->randomElement([
+ 'Package not found on Packagist. Please ensure your package is published.',
+ 'Plugin does not meet our quality standards. Please review our plugin guidelines.',
+ 'Missing required documentation. Please add a README with installation instructions.',
+ 'Security concerns identified. Please address the issues and resubmit.',
+ 'Plugin name conflicts with an existing package. Please choose a different name.',
+ 'Incomplete implementation. Some advertised features are not working as expected.',
+ ]),
+ ]);
+ }
+
+ public function featured(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'featured' => true,
+ ]);
+ }
+
+ public function free(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => PluginType::Free,
+ 'anystack_id' => null,
+ ]);
+ }
+
+ public function paid(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => PluginType::Paid,
+ 'anystack_id' => fake()->uuid(),
+ ]);
+ }
+
+ public function withoutDescription(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'description' => null,
+ ]);
+ }
+}
diff --git a/database/migrations/2025_12_03_124822_create_plugins_table.php b/database/migrations/2025_12_03_124822_create_plugins_table.php
new file mode 100644
index 00000000..c5549804
--- /dev/null
+++ b/database/migrations/2025_12_03_124822_create_plugins_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->string('name'); // Composer package name e.g. vendor/package-name
+ $table->string('type'); // free or paid
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('status')->default('pending');
+ $table->timestamp('approved_at')->nullable();
+ $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->unique('name');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('plugins');
+ }
+};
diff --git a/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php b/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php
new file mode 100644
index 00000000..000cef6f
--- /dev/null
+++ b/database/migrations/2025_12_03_141900_add_anystack_id_and_rejection_reason_to_plugins_table.php
@@ -0,0 +1,29 @@
+string('anystack_id')->nullable()->after('type');
+ $table->text('rejection_reason')->nullable()->after('status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->dropColumn(['anystack_id', 'rejection_reason']);
+ });
+ }
+};
diff --git a/database/migrations/2025_12_03_154716_create_plugin_activities_table.php b/database/migrations/2025_12_03_154716_create_plugin_activities_table.php
new file mode 100644
index 00000000..2ec3fe42
--- /dev/null
+++ b/database/migrations/2025_12_03_154716_create_plugin_activities_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('plugin_id')->constrained()->cascadeOnDelete();
+ $table->string('type'); // submitted, resubmitted, approved, rejected
+ $table->string('from_status')->nullable();
+ $table->string('to_status');
+ $table->text('note')->nullable(); // rejection reason or other notes
+ $table->foreignId('causer_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->index(['plugin_id', 'created_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('plugin_activities');
+ }
+};
diff --git a/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php b/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php
new file mode 100644
index 00000000..f5d5f55d
--- /dev/null
+++ b/database/migrations/2025_12_03_161416_add_featured_to_plugins_table.php
@@ -0,0 +1,30 @@
+boolean('featured')->default(false)->after('status');
+ $table->index(['status', 'featured']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->dropIndex(['status', 'featured']);
+ $table->dropColumn('featured');
+ });
+ }
+};
diff --git a/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php b/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php
new file mode 100644
index 00000000..368dc1f7
--- /dev/null
+++ b/database/migrations/2025_12_03_175340_add_description_to_plugins_table.php
@@ -0,0 +1,28 @@
+text('description')->nullable()->after('name');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('plugins', function (Blueprint $table) {
+ $table->dropColumn('description');
+ });
+ }
+};
diff --git a/database/seeders/PluginSeeder.php b/database/seeders/PluginSeeder.php
new file mode 100644
index 00000000..4e86658d
--- /dev/null
+++ b/database/seeders/PluginSeeder.php
@@ -0,0 +1,127 @@
+take(20)->get();
+
+ if ($users->count() < 20) {
+ $additionalUsers = User::factory()
+ ->count(20 - $users->count())
+ ->create();
+
+ $users = $users->merge($additionalUsers);
+ }
+
+ // Get or create an admin user for approvals
+ $admin = User::query()
+ ->where('email', 'admin@example.com')
+ ->first() ?? User::factory()->create([
+ 'name' => 'Admin User',
+ 'email' => 'admin@example.com',
+ ]);
+
+ // Create 10 featured approved plugins (free)
+ Plugin::factory()
+ ->count(10)
+ ->approved()
+ ->featured()
+ ->free()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ 'approved_by' => $admin->id,
+ ]);
+
+ // Create 5 featured approved plugins (paid)
+ Plugin::factory()
+ ->count(5)
+ ->approved()
+ ->featured()
+ ->paid()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ 'approved_by' => $admin->id,
+ ]);
+
+ // Create 30 approved free plugins (not featured)
+ Plugin::factory()
+ ->count(30)
+ ->approved()
+ ->free()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ 'approved_by' => $admin->id,
+ ]);
+
+ // Create 15 approved paid plugins (not featured)
+ Plugin::factory()
+ ->count(15)
+ ->approved()
+ ->paid()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ 'approved_by' => $admin->id,
+ ]);
+
+ // Create 20 pending plugins (mix of free and paid)
+ Plugin::factory()
+ ->count(15)
+ ->pending()
+ ->free()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ ]);
+
+ Plugin::factory()
+ ->count(5)
+ ->pending()
+ ->paid()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ ]);
+
+ // Create 15 rejected plugins
+ Plugin::factory()
+ ->count(10)
+ ->rejected()
+ ->free()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ ]);
+
+ Plugin::factory()
+ ->count(5)
+ ->rejected()
+ ->paid()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ ]);
+
+ // Create a few approved plugins without descriptions
+ Plugin::factory()
+ ->count(5)
+ ->approved()
+ ->free()
+ ->withoutDescription()
+ ->create([
+ 'user_id' => fn () => $users->random()->id,
+ 'approved_by' => $admin->id,
+ ]);
+
+ $this->command->info('Created plugins:');
+ $this->command->info(' - 15 featured approved (10 free, 5 paid)');
+ $this->command->info(' - 45 approved non-featured (30 free, 15 paid)');
+ $this->command->info(' - 5 approved without descriptions');
+ $this->command->info(' - 20 pending (15 free, 5 paid)');
+ $this->command->info(' - 15 rejected (10 free, 5 paid)');
+ $this->command->info(' Total: 100 plugins (65 approved)');
+ }
+}
diff --git a/resources/views/components/icons/puzzle.blade.php b/resources/views/components/icons/puzzle.blade.php
new file mode 100644
index 00000000..2ece32d5
--- /dev/null
+++ b/resources/views/components/icons/puzzle.blade.php
@@ -0,0 +1,12 @@
+
+
+
diff --git a/resources/views/components/navbar/device-dropdowns.blade.php b/resources/views/components/navbar/device-dropdowns.blade.php
index 320d676b..06c5e162 100644
--- a/resources/views/components/navbar/device-dropdowns.blade.php
+++ b/resources/views/components/navbar/device-dropdowns.blade.php
@@ -18,6 +18,13 @@
icon="dollar-circle"
icon-class="size-5.5"
/>
+
{{-- 👇 Hidden temporarily --}}
{{--
+
+
+
+
+ @if ($plugin->isPaid())
+
+ Paid
+
+ @else
+
+ Free
+
+ @endif
+
+
+
+
+ {{ $plugin->name }}
+
+ @if ($plugin->description)
+
+ {{ $plugin->description }}
+
+ @endif
+
+
+
+
diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php
index c7fc229d..e1493db4 100644
--- a/resources/views/customer/licenses/index.blade.php
+++ b/resources/views/customer/licenses/index.blade.php
@@ -11,6 +11,10 @@
+
+
+ Plugins
+
Manage Your Subscription
diff --git a/resources/views/customer/plugins/create.blade.php b/resources/views/customer/plugins/create.blade.php
new file mode 100644
index 00000000..372efcc7
--- /dev/null
+++ b/resources/views/customer/plugins/create.blade.php
@@ -0,0 +1,220 @@
+
+
+ {{-- Header --}}
+
+
+
+
+
+
+
+
+ Plugins
+
+
+
+
+
+
+
+
+ Submit Plugin
+
+
+
+
Submit Your Plugin
+
+ Add your plugin to the NativePHP Plugin Directory
+
+
+
+
+
+
+ {{-- Content --}}
+
+
+
diff --git a/resources/views/customer/plugins/index.blade.php b/resources/views/customer/plugins/index.blade.php
new file mode 100644
index 00000000..0005b7fa
--- /dev/null
+++ b/resources/views/customer/plugins/index.blade.php
@@ -0,0 +1,189 @@
+
+
+ {{-- Header --}}
+
+
+
+
+
Plugins
+
+ Extend NativePHP Mobile with powerful native features
+
+
+
+
+
+
+
+ {{-- Content --}}
+
+ {{-- Action Cards --}}
+
+ {{-- Submit Plugin Card (Most Prominent) --}}
+
+
+
+
+
Submit Your Plugin
+
+ Built a plugin? Submit it to the NativePHP Plugin Directory and share it with the community.
+
+
+ Submit a Plugin
+
+
+
+
+
+
+
+ {{-- Browse Plugins Card --}}
+
+
+
+
+
Browse Plugins
+
+ Discover plugins built by the community to add native features to your mobile apps.
+
+
+ View Directory
+
+
+
+
+
+
+ {{-- Learn to Build Card --}}
+
+
+
Learn to Build Plugins
+
+ Read the documentation to learn how to create your own NativePHP Mobile plugins.
+
+
+ Read the Docs
+
+
+
+
+
+
+
+ {{-- Success Message --}}
+ @if (session('success'))
+
+
+
+
+
{{ session('success') }}
+
+
+
+ @endif
+
+ {{-- Submitted Plugins List --}}
+ @if ($plugins->count() > 0)
+
+
Your Submitted Plugins
+
Track the status of your plugin submissions.
+
+
+
+ @foreach ($plugins as $plugin)
+
+
+
+
+ @if ($plugin->isPending())
+
+ @elseif ($plugin->isApproved())
+
+ @else
+
+ @endif
+
+
+
+ {{ $plugin->name }}
+
+
+ {{ $plugin->type->label() }} plugin • Submitted {{ $plugin->created_at->diffForHumans() }}
+
+
+
+
+ @if ($plugin->isPending())
+
+ Pending Review
+
+ @elseif ($plugin->isApproved())
+
+ Approved
+
+ @else
+
+ Rejected
+
+ @endif
+
+ Edit
+
+
+
+
+
+
+
+ {{-- Rejection Reason --}}
+ @if ($plugin->isRejected() && $plugin->rejection_reason)
+
+
+
+
+
Rejection Reason
+
{{ $plugin->rejection_reason }}
+
+
+
+
+ @endif
+
+ @endforeach
+
+
+
+ @endif
+
+
+
diff --git a/resources/views/customer/plugins/show.blade.php b/resources/views/customer/plugins/show.blade.php
new file mode 100644
index 00000000..63041593
--- /dev/null
+++ b/resources/views/customer/plugins/show.blade.php
@@ -0,0 +1,135 @@
+
+
+ {{-- Header --}}
+
+
+ {{-- Content --}}
+
+ {{-- Success Message --}}
+ @if (session('success'))
+
+
+
+
+
{{ session('success') }}
+
+
+
+ @endif
+
+ {{-- Plugin Status --}}
+
+
+
+
+
+
+
+
{{ $plugin->name }}
+
{{ $plugin->type->label() }} plugin
+
+
+ @if ($plugin->isPending())
+
+ Pending Review
+
+ @elseif ($plugin->isApproved())
+
+ Approved
+
+ @else
+
+ Rejected
+
+ @endif
+
+
+
+ {{-- Description Form --}}
+
+
Plugin Description
+
+ Describe what your plugin does. This will be displayed in the plugin directory.
+
+
+
+
+
+ {{-- Rejection Reason --}}
+ @if ($plugin->isRejected() && $plugin->rejection_reason)
+
+
+
+
+
Rejection Reason
+
{{ $plugin->rejection_reason }}
+
+
+ @csrf
+
+
+
+
+ Resubmit for Review
+
+
+
+
+
+
+ @endif
+
+
+
diff --git a/resources/views/livewire/plugin-directory.blade.php b/resources/views/livewire/plugin-directory.blade.php
new file mode 100644
index 00000000..a8b6d5db
--- /dev/null
+++ b/resources/views/livewire/plugin-directory.blade.php
@@ -0,0 +1,117 @@
+
+ {{-- Header --}}
+
+
+
Plugin Directory
+
+ Browse all available plugins for NativePHP Mobile.
+
+
+
+ {{-- Search --}}
+
+
+
+
+
+ @if ($search)
+
+
+
+
+
+ @endif
+
+
+
+
+ {{-- Results count --}}
+ @if ($search)
+
+ {{ $plugins->total() }} {{ Str::plural('result', $plugins->total()) }} for "{{ $search }}"
+
+ @endif
+
+
+ {{-- Plugin Grid --}}
+
+ @if ($plugins->count() > 0)
+
+ @foreach ($plugins as $plugin)
+
+ @endforeach
+
+
+ {{-- Pagination --}}
+ @if ($plugins->hasPages())
+
+ {{ $plugins->links() }}
+
+ @endif
+ @else
+
+
+
No plugins found
+ @if ($search)
+
+ No plugins match your search. Try a different term.
+
+
+ Clear search
+
+ @else
+
+ Be the first to submit a plugin to the directory!
+
+
+ Submit a Plugin
+
+ @endif
+
+ @endif
+
+
+ {{-- Back to plugins landing --}}
+
+
diff --git a/resources/views/plugins.blade.php b/resources/views/plugins.blade.php
new file mode 100644
index 00000000..3a7d3beb
--- /dev/null
+++ b/resources/views/plugins.blade.php
@@ -0,0 +1,658 @@
+
+
+ {{-- Hero Section --}}
+
+
+ {{-- Icon --}}
+
+
+ {{-- Title --}}
+
+
+ {
+
+ Plugins
+
+ }
+
+
+
+ {{-- Subtitle --}}
+
+ Extend your NativePHP Mobile apps with powerful native features.
+ Install with Composer. Build anything for iOS and Android.
+
+
+ {{-- Call to Action Buttons --}}
+
+ {{-- Primary CTA - Browse Plugins --}}
+
+
+ {{-- Secondary CTA - Documentation --}}
+
+
+
+
+
+ {{-- Featured Plugins Section --}}
+
+
+
+ Featured Plugins
+
+
+ Hand-picked plugins to supercharge your mobile apps.
+
+
+ {{-- Plugin Cards Grid --}}
+
+ @forelse ($featuredPlugins as $plugin)
+
+ @empty
+
+
+
+ Featured plugins coming soon
+
+
+
+
+
+ Featured plugins coming soon
+
+
+
+
+
+ Featured plugins coming soon
+
+
+ @endforelse
+
+
+
+
+ {{-- Latest Plugins Section --}}
+
+
+
+ Latest Plugins
+
+
+ Freshly released plugins from our community.
+
+
+ {{-- Plugin Cards Grid --}}
+
+ @forelse ($latestPlugins as $plugin)
+
+ @empty
+
+
+
+ New plugins coming soon
+
+
+
+
+
+ New plugins coming soon
+
+
+
+
+
+ New plugins coming soon
+
+
+ @endforelse
+
+
+
+
+ {{-- Benefits Section --}}
+
+
+
+ Why Use Plugins?
+
+
+ Unlock native capabilities without leaving Laravel.
+
+
+
+
+ {{-- Card - Composer Install --}}
+
+
+
+
+
+
+
+ One Command Install
+
+
+ Add native features with a single composer require. No Xcode or Android Studio knowledge required.
+
+
+
+ {{-- Card - Build Anything --}}
+
+
+
+
+
+
+
+ Build Anything
+
+
+ There's no limit to what plugins can do. Access any native API, sensor, or hardware feature on iOS and Android.
+
+
+
+ {{-- Card - Auto-Registered --}}
+
+
+
+
+
+
+
+ Auto-Registered
+
+
+ Plugins are automatically discovered and registered. Just enable them in your config and you're ready to go.
+
+
+
+ {{-- Card - Platform Dependencies --}}
+
+
+
+
+
+
+
+ Native Dependencies
+
+
+ Plugins can add Gradle dependencies, CocoaPods, and Swift Package Manager packages automatically.
+
+
+
+ {{-- Card - Lifecycle Hooks --}}
+
+
+
+
+
+
+
+ Build Lifecycle Hooks
+
+
+ Hook into critical moments in the build pipeline. Run custom logic before, during, or after builds.
+
+
+
+ {{-- Card - Security --}}
+
+
+
+
+
+
+
+ Security First
+
+
+ Security is our top priority. Plugins are sandboxed and permissions are explicit, keeping your users safe.
+
+
+
+
+
+ {{-- For Plugin Authors Section --}}
+
+
+
+ Build & Sell Your Own Plugins
+
+
+
+ Are you a Swift or Kotlin developer? Create plugins for the NativePHP community and generate revenue from your expertise.
+
+
+
+
+
+
+
+ Write Swift & Kotlin
+
+
+ Build the native code and PHP bridging layer. We handle the rest, mapping everything so it just works.
+
+
+
+
+
+
+
+
+ Full Laravel Power
+
+
+ Set permissions, create config files, publish views, and do everything a Laravel package can do.
+
+
+
+
+
+
+
+
+ Generate Revenue
+
+
+ Sell your plugins through our marketplace and earn money from your native development skills.
+
+
+
+
+
+
+
+
+
+ {{-- Call to Action Section --}}
+
+
+
+ Ready to Extend Your App?
+
+
+
+ Discover plugins that add powerful native features to your NativePHP Mobile apps, or start building your own today.
+
+
+
+ {{-- Primary CTA --}}
+
+
+ {{-- Secondary CTA --}}
+
+
+
+
+
+
diff --git a/resources/views/pricing.blade.php b/resources/views/pricing.blade.php
index ea99d31d..602b703f 100644
--- a/resources/views/pricing.blade.php
+++ b/resources/views/pricing.blade.php
@@ -739,6 +739,17 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"
+
+
+ You will get direct access to our GitHub repository for NativePHP for Mobile. This will allow you
+ to raise issues directly with the team, which are prioritized higher than issues we see on Discord.
+
+
+ It also means you can try out features that are still in development before they're generally
+ available and help us to shape and refine them for release.
+
+
+
diff --git a/routes/web.php b/routes/web.php
index f538e489..cffee69e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -4,7 +4,9 @@
use App\Http\Controllers\ApplinksController;
use App\Http\Controllers\Auth\CustomerAuthController;
use App\Http\Controllers\CustomerLicenseController;
+use App\Http\Controllers\CustomerPluginController;
use App\Http\Controllers\CustomerSubLicenseController;
+use App\Http\Controllers\PluginDirectoryController;
use App\Http\Controllers\ShowBlogController;
use App\Http\Controllers\ShowDocumentationController;
use Illuminate\Support\Facades\Route;
@@ -42,6 +44,8 @@
Route::view('privacy-policy', 'privacy-policy')->name('privacy-policy');
Route::view('terms-of-service', 'terms-of-service')->name('terms-of-service');
Route::view('partners', 'partners')->name('partners');
+Route::get('plugins', [PluginDirectoryController::class, 'index'])->name('plugins');
+Route::get('plugins/directory', App\Livewire\PluginDirectory::class)->name('plugins.directory');
Route::view('sponsor', 'sponsoring')->name('sponsoring');
Route::get('blog', [ShowBlogController::class, 'index'])->name('blog');
@@ -115,7 +119,7 @@
Route::get('callback', function (Illuminate\Http\Request $request) {
$url = $request->query('url');
- if ($url && !str_starts_with($url, 'http')) {
+ if ($url && ! str_starts_with($url, 'http')) {
return redirect()->away($url.'?token='.uuid_create());
}
@@ -131,6 +135,14 @@
// Wall of Love submission
Route::get('wall-of-love/create', [App\Http\Controllers\WallOfLoveSubmissionController::class, 'create'])->name('wall-of-love.create');
+ // Plugin management
+ Route::get('plugins', [CustomerPluginController::class, 'index'])->name('plugins.index');
+ Route::get('plugins/submit', [CustomerPluginController::class, 'create'])->name('plugins.create');
+ Route::post('plugins', [CustomerPluginController::class, 'store'])->name('plugins.store');
+ Route::get('plugins/{plugin}', [CustomerPluginController::class, 'show'])->name('plugins.show');
+ Route::patch('plugins/{plugin}', [CustomerPluginController::class, 'update'])->name('plugins.update');
+ Route::post('plugins/{plugin}/resubmit', [CustomerPluginController::class, 'resubmit'])->name('plugins.resubmit');
+
// Billing portal
Route::get('billing-portal', function (Illuminate\Http\Request $request) {
$user = $request->user();
@@ -151,5 +163,4 @@
Route::post('licenses/{licenseKey}/sub-licenses/{subLicense}/send-email', [CustomerSubLicenseController::class, 'sendEmail'])->name('licenses.sub-licenses.send-email');
});
-
Route::get('.well-known/assetlinks.json', [ApplinksController::class, 'assetLinks']);
From 0b20c8f482cb141bbfbd3bc1a3d90c3fc55bd192 Mon Sep 17 00:00:00 2001
From: MasoodRehman
Date: Fri, 5 Dec 2025 20:00:25 +0500
Subject: [PATCH 02/34] Update seeding command in databases.md (#233)
The version ^2.0 of nativephp/desktop is using `php artisan native:seed`
---
resources/views/docs/desktop/2/digging-deeper/databases.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/views/docs/desktop/2/digging-deeper/databases.md b/resources/views/docs/desktop/2/digging-deeper/databases.md
index 41ad3064..cb64e252 100644
--- a/resources/views/docs/desktop/2/digging-deeper/databases.md
+++ b/resources/views/docs/desktop/2/digging-deeper/databases.md
@@ -83,10 +83,10 @@ php artisan native:migrate:fresh
## Seeding
When developing, it's especially useful to seed your database with sample data. If you've set up
-[Database Seeders](https://laravel.com/docs/seeding), you can run these using the `native:db:seed` command:
+[Database Seeders](https://laravel.com/docs/seeding), you can run these using the `native:seed` command:
```shell
-php artisan native:db:seed
+php artisan native:seed
```
## When not to use a database
From 5507089676f3d6fa17b5233c4b7fc33c5142f38e Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Fri, 5 Dec 2025 14:54:32 -0500
Subject: [PATCH 03/34] Updates vite config
---
resources/views/docs/mobile/2/getting-started/development.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/resources/views/docs/mobile/2/getting-started/development.md b/resources/views/docs/mobile/2/getting-started/development.md
index e232e08a..4d6aab95 100644
--- a/resources/views/docs/mobile/2/getting-started/development.md
+++ b/resources/views/docs/mobile/2/getting-started/development.md
@@ -42,13 +42,14 @@ To make your frontend build process works well with NativePHP, simply add the `n
`vite.config.js`:
```js
-import { nativephpMobile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js'; // [tl! focus]
+import { nativephpMobile, nativephpHotFile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js'; // [tl! focus]
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
+ hotFile: nativephpHotFile(),
}),
tailwindcss(),
nativephpMobile(), // [tl! focus]
From f8563c31bc7a40bb2cc1f80b124461963fef5ae9 Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Fri, 5 Dec 2025 14:56:39 -0500
Subject: [PATCH 04/34] Updates // [tl! focus]
---
resources/views/docs/mobile/2/getting-started/development.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/resources/views/docs/mobile/2/getting-started/development.md b/resources/views/docs/mobile/2/getting-started/development.md
index 4d6aab95..37542322 100644
--- a/resources/views/docs/mobile/2/getting-started/development.md
+++ b/resources/views/docs/mobile/2/getting-started/development.md
@@ -49,7 +49,7 @@ export default defineConfig({
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
- hotFile: nativephpHotFile(),
+ hotFile: nativephpHotFile(), // [tl! focus]
}),
tailwindcss(),
nativephpMobile(), // [tl! focus]
From 2ca9841c4f59cdb1092aeb25c7ab6b07db1c4f28 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Fri, 5 Dec 2025 21:16:55 +0000
Subject: [PATCH 05/34] Mobile release 2025-12-05 (#243)
---
.../docs/mobile/2/concepts/authentication.md | 32 +++--
.../mobile/2/getting-started/development.md | 118 ++++++++++++------
2 files changed, 101 insertions(+), 49 deletions(-)
diff --git a/resources/views/docs/mobile/2/concepts/authentication.md b/resources/views/docs/mobile/2/concepts/authentication.md
index c86ebd80..e0c54a7c 100644
--- a/resources/views/docs/mobile/2/concepts/authentication.md
+++ b/resources/views/docs/mobile/2/concepts/authentication.md
@@ -74,18 +74,34 @@ You will likely want to use an OAuth client library in your app to make interact
When initiating the auth flow for the user, you should use the `Native\Mobile\Facades\Browser::auth()` API, as this is
purpose-built for securely passing authorization codes back from the OAuth service to your app.
-You should set your redirect URL to `nativephp://127.0.0.1/some/route`, where `some/route` is a route you've defined in
-your app's routes that will be able to handle the auth code.
+For this to work, you must set a `NATIVEPHP_DEEPLINK_SCHEME` that will be unique for your application on users' devices.
-Note that the scheme of the redirect URL in this case is **always** `nativephp://`. This has nothing to do with any
-custom deep link scheme you may have set for your app. It is only tied to the `Browser::auth()` session.
+```dotenv
+NATIVEPHP_DEEPLINK_SCHEME=myapp
+```
+
+Then you must define your redirect URL. It should match your scheme and the route in your app that will handle the callback
+data.
+
+```php
+Browser::auth('https://workos.com/my-company/auth?redirect=myapp://auth/handle')
+```
+
+Most services will expect you to pre-define your redirect URLs as a security feature. You should be able to provide your
+exact URL, as this will be the most secure method.
+
+How you handle the response in your app depends on how that particular API operates and the needs of your application.
-Make sure you have good security around your auth service's authentication endpoint. As it will be accessed from many
-devices via an API, standard browser security such as CSRF protections will not be available to you.
+#### Security
+
+If you're running your own auth service, make sure you have good security around its authentication endpoint. As it
+will be accessed by unauthenticated from many devices via an API, standard browser security — such as CSRF protection —
+**will not be available** to you.
-Ensure you have appropriate rate limiting in place and even consider using an authentication key that you distribute
-with your apps. These steps will all help defend the endpoint against abuse.
+Ensure you have appropriate **rate limiting** in place and even consider using an **authentication key** that you
+distribute with your apps and is solely used to for accessing the authentication endpoint. These steps will all help
+defend the endpoint against abuse.
diff --git a/resources/views/docs/mobile/2/getting-started/development.md b/resources/views/docs/mobile/2/getting-started/development.md
index 37542322..90e3a39d 100644
--- a/resources/views/docs/mobile/2/getting-started/development.md
+++ b/resources/views/docs/mobile/2/getting-started/development.md
@@ -99,10 +99,42 @@ If you're familiar with these tools, you can easily open the projects using the
php artisan native:open
```
+### Configuration
+
+You can configure the folders that the `watch` command pays attention to in your `config/nativephp.php` file:
+
+```php
+'hot_reload' => [
+ 'watch_paths' => [
+ 'app',
+ 'routes',
+ 'config',
+ 'database',
+ // Make sure "public" is listed in your config [tl! highlight:1]
+ 'public',
+ ],
+]
+```
+
+
+
+#### Skip the prompts
+
+If you are tired of prompts, you can run most commands - like `native:run` - with arguments and options that allow you
+to skip various prompts. Use the `--help` flag on a command to find out what values you can pass directly to it:
+
+```shell
+php artisan native:run --help
+```
+
+
+
+
## Hot Reloading
We've tried to make compiling your apps as fast as possible, but when coming from the 'make a change; hit refresh'-world
-of PHP development that we all love, compiling apps can feel like a slow and time-consuming process.
+of typical browser-based PHP development that we all love, compiling apps can feel like a slow and time-consuming
+process.
Hot reloading aims to make your app development experience feel just like home.
@@ -112,69 +144,73 @@ You can start hot reloading by running the following command:
php artisan native:watch
```
-You can also pass the `--watch` option to the `native:run` command.
+
+
+#### 🔥 Hot Tip!
+
+You can also pass the `--watch` option to the `native:run` command. This will build and deploy a fresh version of your
+application to the target device and _then_ start the watcher, all in one go.
+
+
This will start a long-lived process that watches your application's source files for changes, pushing them into the
emulator after any updates and reloading the current screen.
-Use this in tandem with Vite's own HMR for the platform you wish to test on:
+If you're using Vite, we'll also use your Node CLI tool of choice (`npm`, `bun`, `pnpm`, or `yarn`) to run Vite's HMR
+server.
-```shell
-npm run dev -- --mode=ios
+### Enabling HMR
-npm run dev -- --mode=android
-```
+To make HMR work, you'll need to add the `hot` file helper to your `laravel` plugin's config in your `vite.config.js`:
-This is useful during development for quickly testing changes without re-compiling your entire app. When you make
-changes to any files in your Laravel app, the web view will be reloaded and your changes should show almost immediately.
+```js
+import { nativephpMobile, nativephpHotFile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js'; // [tl! focus]
-Vite HMR is perfect for apps that use SPA frameworks like Vue or React to build the UI. It even works on real devices,
-not just simulators! As long as the device is on the same network as the development machine.
+export default defineConfig({
+ plugins: [
+ laravel({
+ input: ['resources/css/app.css', 'resources/js/app.js'],
+ refresh: true,
+ hotFile: nativephpHotFile(), // [tl! focus]
+ }),
+ tailwindcss(),
+ nativephpMobile(),
+ ]
+});
+```
-#### Livewire and HMR on real devices
-
-Full hot reloading support for Livewire on real devices is not yet available.
-
-
+#### Two at a time, baby!
-### Configuration
+If you're developing on macOS, you can run both Android and iOS watchers at the same time in separate terminals:
-You can configure the folders that the `watch` command pays attention to in your `config/nativephp.php` file:
+```shell
+# Terminal 1
+php artisan native:watch ios
-```php
-'hot_reload' => [
- 'watch_paths' => [
- 'app',
- 'routes',
- 'config',
- 'database',
- // Make sure "public" is listed in your config [tl! highlight:1]
- 'public',
- ],
-]
+# Terminal 2
+php artisan native:watch android
```
-### Order matters
-
-Depending on which order you run these commands, you may find that hot reloading doesn't work immediately. It's often
-best to get the commands running, get your app open, and then make a request to a new screen to allow your app to pick
-up the `hot` file's presence and connect to the HMR server.
+This way you can see your changes reflected in real-time on both platforms **at the same time**. Wild.
+
-
+This is useful during development for quickly testing changes without re-compiling your entire app. When you make
+changes to any files in your Laravel app, the web view will be reloaded and your changes should show almost immediately.
+Vite HMR is perfect for apps that use SPA frameworks like Vue or React to build the UI. It even works on real devices,
+not just simulators! As long as the device is on the same network as the development machine.
+**Don't forget to add `public/ios-hot` and `public/android-hot` to your `.gitignore` file!**
-#### Skip the prompts
+
-If you are tired of prompts, you can run most commands - like `native:run` - with arguments and options that allow you
-to skip various prompts. Use the `--help` flag on a command to find out what values you can pass directly to it:
+#### Real iOS Devices Support
-```shell
-php artisan native:run --help
-```
+Full hot reloading support works best on simulators. Full hot reloading support for non-JS changes on real iOS devices
+is not yet available.
From 57045835cde625897f0a430d936bf9638d708d7d Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Sat, 6 Dec 2025 00:45:53 +0000
Subject: [PATCH 06/34] Showcase! (#244)
* Showcase!
* style
---
app/Filament/Resources/ShowcaseResource.php | 242 ++++++++++++++++++
.../ShowcaseResource/Pages/CreateShowcase.php | 20 ++
.../ShowcaseResource/Pages/EditShowcase.php | 19 ++
.../ShowcaseResource/Pages/ListShowcases.php | 19 ++
app/Http/Controllers/ApplinksController.php | 2 -
.../CustomerShowcaseController.php | 31 +++
app/Http/Controllers/ShowcaseController.php | 28 ++
app/Livewire/ShowcaseSubmissionForm.php | 213 +++++++++++++++
app/Livewire/WallOfLoveBanner.php | 2 +
app/Models/Showcase.php | 89 +++++++
config/docs.php | 2 +-
database/factories/ShowcaseFactory.php | 109 ++++++++
...25_12_05_131240_create_showcases_table.php | 44 ++++
database/seeders/ShowcaseSeeder.php | 30 +++
.../components/discounts-banner.blade.php | 6 +-
.../navbar/device-dropdowns.blade.php | 29 ++-
.../views/components/navigation-bar.blade.php | 7 -
.../views/components/showcase-card.blade.php | 216 ++++++++++++++++
.../views/customer/licenses/index.blade.php | 16 +-
.../views/customer/showcase/create.blade.php | 63 +++++
.../views/customer/showcase/edit.blade.php | 59 +++++
.../views/customer/showcase/index.blade.php | 143 +++++++++++
.../tables/columns/platforms.blade.php | 8 +
.../showcase-submission-form.blade.php | 211 +++++++++++++++
.../livewire/wall-of-love-banner.blade.php | 48 ++--
resources/views/showcase.blade.php | 177 +++++++++++++
routes/web.php | 8 +
27 files changed, 1795 insertions(+), 46 deletions(-)
create mode 100644 app/Filament/Resources/ShowcaseResource.php
create mode 100644 app/Filament/Resources/ShowcaseResource/Pages/CreateShowcase.php
create mode 100644 app/Filament/Resources/ShowcaseResource/Pages/EditShowcase.php
create mode 100644 app/Filament/Resources/ShowcaseResource/Pages/ListShowcases.php
create mode 100644 app/Http/Controllers/CustomerShowcaseController.php
create mode 100644 app/Http/Controllers/ShowcaseController.php
create mode 100644 app/Livewire/ShowcaseSubmissionForm.php
create mode 100644 app/Models/Showcase.php
create mode 100644 database/factories/ShowcaseFactory.php
create mode 100644 database/migrations/2025_12_05_131240_create_showcases_table.php
create mode 100644 database/seeders/ShowcaseSeeder.php
create mode 100644 resources/views/components/showcase-card.blade.php
create mode 100644 resources/views/customer/showcase/create.blade.php
create mode 100644 resources/views/customer/showcase/edit.blade.php
create mode 100644 resources/views/customer/showcase/index.blade.php
create mode 100644 resources/views/filament/tables/columns/platforms.blade.php
create mode 100644 resources/views/livewire/showcase-submission-form.blade.php
create mode 100644 resources/views/showcase.blade.php
diff --git a/app/Filament/Resources/ShowcaseResource.php b/app/Filament/Resources/ShowcaseResource.php
new file mode 100644
index 00000000..5009fdc7
--- /dev/null
+++ b/app/Filament/Resources/ShowcaseResource.php
@@ -0,0 +1,242 @@
+schema([
+ Forms\Components\Section::make('App Details')
+ ->schema([
+ Forms\Components\Select::make('user_id')
+ ->label('Submitted By')
+ ->relationship('user', 'email')
+ ->getOptionLabelFromRecordUsing(fn ($record) => $record->name ? "{$record->name} ({$record->email})" : $record->email)
+ ->searchable(['name', 'email'])
+ ->preload()
+ ->required(),
+
+ Forms\Components\TextInput::make('title')
+ ->required()
+ ->maxLength(255),
+
+ Forms\Components\Textarea::make('description')
+ ->required()
+ ->rows(4),
+
+ Forms\Components\FileUpload::make('image')
+ ->label('Main Image')
+ ->image()
+ ->disk('public')
+ ->directory('showcase-images'),
+
+ Forms\Components\FileUpload::make('screenshots')
+ ->label('Screenshots (up to 5)')
+ ->image()
+ ->multiple()
+ ->maxFiles(5)
+ ->disk('public')
+ ->directory('showcase-screenshots')
+ ->reorderable(),
+ ]),
+
+ Forms\Components\Section::make('Platform Availability')
+ ->schema([
+ Forms\Components\Toggle::make('has_mobile')
+ ->label('Mobile App')
+ ->live(),
+
+ Forms\Components\Toggle::make('has_desktop')
+ ->label('Desktop App')
+ ->live(),
+
+ Forms\Components\Fieldset::make('Mobile Links')
+ ->visible(fn (Forms\Get $get) => $get('has_mobile'))
+ ->schema([
+ Forms\Components\TextInput::make('app_store_url')
+ ->label('App Store URL')
+ ->url()
+ ->maxLength(255),
+
+ Forms\Components\TextInput::make('play_store_url')
+ ->label('Play Store URL')
+ ->url()
+ ->maxLength(255),
+ ]),
+
+ Forms\Components\Fieldset::make('Desktop Downloads')
+ ->visible(fn (Forms\Get $get) => $get('has_desktop'))
+ ->schema([
+ Forms\Components\TextInput::make('windows_download_url')
+ ->label('Windows Download URL')
+ ->url()
+ ->maxLength(255),
+
+ Forms\Components\TextInput::make('macos_download_url')
+ ->label('macOS Download URL')
+ ->url()
+ ->maxLength(255),
+
+ Forms\Components\TextInput::make('linux_download_url')
+ ->label('Linux Download URL')
+ ->url()
+ ->maxLength(255),
+ ]),
+ ]),
+
+ Forms\Components\Section::make('Certification')
+ ->schema([
+ Forms\Components\Toggle::make('certified_nativephp')
+ ->label('Certified as built with NativePHP')
+ ->disabled(),
+ ]),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\ImageColumn::make('image')
+ ->label('Image')
+ ->disk('public')
+ ->height(40)
+ ->toggleable(),
+
+ Tables\Columns\TextColumn::make('title')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\ViewColumn::make('platforms')
+ ->label('Platforms')
+ ->view('filament.tables.columns.platforms'),
+
+ Tables\Columns\TextColumn::make('approvedBy.name')
+ ->label('Approved By')
+ ->toggleable(),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('Submitted')
+ ->dateTime()
+ ->sortable()
+ ->toggleable(),
+
+ Tables\Columns\TextColumn::make('updated_at')
+ ->label('Updated')
+ ->dateTime()
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
+ ])
+ ->filters([
+ Tables\Filters\TernaryFilter::make('approved_at')
+ ->label('Status')
+ ->placeholder('All submissions')
+ ->trueLabel('Approved')
+ ->falseLabel('Pending')
+ ->queries(
+ true: fn (Builder $query) => $query->whereNotNull('approved_at'),
+ false: fn (Builder $query) => $query->whereNull('approved_at'),
+ ),
+
+ Tables\Filters\TernaryFilter::make('has_mobile')
+ ->label('Mobile App')
+ ->placeholder('All')
+ ->trueLabel('Has Mobile')
+ ->falseLabel('No Mobile'),
+
+ Tables\Filters\TernaryFilter::make('has_desktop')
+ ->label('Desktop App')
+ ->placeholder('All')
+ ->trueLabel('Has Desktop')
+ ->falseLabel('No Desktop'),
+
+ Tables\Filters\Filter::make('needs_re_review')
+ ->label('Needs Re-Review')
+ ->query(fn (Builder $query): Builder => $query
+ ->whereNotNull('approved_at')
+ ->whereColumn('updated_at', '>', 'approved_at')),
+ ])
+ ->actions([
+ Tables\Actions\Action::make('approve')
+ ->icon('heroicon-o-check')
+ ->color('success')
+ ->visible(fn (Showcase $record) => $record->isPending())
+ ->action(fn (Showcase $record) => $record->update([
+ 'approved_at' => now(),
+ 'approved_by' => auth()->id(),
+ ]))
+ ->requiresConfirmation()
+ ->modalHeading('Approve Submission')
+ ->modalDescription('Are you sure you want to approve this app for the Showcase?'),
+
+ Tables\Actions\Action::make('unapprove')
+ ->icon('heroicon-o-x-mark')
+ ->color('warning')
+ ->visible(fn (Showcase $record) => $record->isApproved())
+ ->action(fn (Showcase $record) => $record->update([
+ 'approved_at' => null,
+ 'approved_by' => null,
+ ]))
+ ->requiresConfirmation()
+ ->modalHeading('Unapprove Submission')
+ ->modalDescription('This will remove the app from the public Showcase.'),
+
+ Tables\Actions\EditAction::make(),
+ Tables\Actions\DeleteAction::make(),
+ ])
+ ->bulkActions([
+ Tables\Actions\BulkActionGroup::make([
+ Tables\Actions\BulkAction::make('approve')
+ ->icon('heroicon-o-check')
+ ->color('success')
+ ->action(function ($records) {
+ $records->each(fn (Showcase $record) => $record->update([
+ 'approved_at' => now(),
+ 'approved_by' => auth()->id(),
+ ]));
+ })
+ ->requiresConfirmation()
+ ->modalHeading('Approve Selected Submissions')
+ ->modalDescription('Are you sure you want to approve all selected submissions?'),
+
+ Tables\Actions\DeleteBulkAction::make(),
+ ]),
+ ])
+ ->defaultSort('created_at', 'desc');
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ //
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListShowcases::route('/'),
+ 'create' => Pages\CreateShowcase::route('/create'),
+ 'edit' => Pages\EditShowcase::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/ShowcaseResource/Pages/CreateShowcase.php b/app/Filament/Resources/ShowcaseResource/Pages/CreateShowcase.php
new file mode 100644
index 00000000..2a09b89e
--- /dev/null
+++ b/app/Filament/Resources/ShowcaseResource/Pages/CreateShowcase.php
@@ -0,0 +1,20 @@
+id();
+
+ return $data;
+ }
+}
diff --git a/app/Filament/Resources/ShowcaseResource/Pages/EditShowcase.php b/app/Filament/Resources/ShowcaseResource/Pages/EditShowcase.php
new file mode 100644
index 00000000..7badf7c8
--- /dev/null
+++ b/app/Filament/Resources/ShowcaseResource/Pages/EditShowcase.php
@@ -0,0 +1,19 @@
+user()->id)
+ ->latest()
+ ->get();
+
+ return view('customer.showcase.index', compact('showcases'));
+ }
+
+ public function create(): View
+ {
+ return view('customer.showcase.create');
+ }
+
+ public function edit(Showcase $showcase): View
+ {
+ abort_if($showcase->user_id !== auth()->id(), 403);
+
+ return view('customer.showcase.edit', compact('showcase'));
+ }
+}
diff --git a/app/Http/Controllers/ShowcaseController.php b/app/Http/Controllers/ShowcaseController.php
new file mode 100644
index 00000000..3bc767b4
--- /dev/null
+++ b/app/Http/Controllers/ShowcaseController.php
@@ -0,0 +1,28 @@
+latest('approved_at');
+
+ if ($platform === 'mobile') {
+ $query->withMobile();
+ } elseif ($platform === 'desktop') {
+ $query->withDesktop();
+ }
+
+ $showcases = $query->paginate(10);
+
+ return view('showcase', [
+ 'showcases' => $showcases,
+ 'platform' => $platform,
+ ]);
+ }
+}
diff --git a/app/Livewire/ShowcaseSubmissionForm.php b/app/Livewire/ShowcaseSubmissionForm.php
new file mode 100644
index 00000000..018cc019
--- /dev/null
+++ b/app/Livewire/ShowcaseSubmissionForm.php
@@ -0,0 +1,213 @@
+exists && $showcase->user_id === auth()->id()) {
+ $this->showcase = $showcase;
+ $this->isEditing = true;
+ $this->title = $showcase->title;
+ $this->description = $showcase->description;
+ $this->existingImage = $showcase->image;
+ $this->existingScreenshots = $showcase->screenshots ?? [];
+ $this->hasMobile = $showcase->has_mobile;
+ $this->hasDesktop = $showcase->has_desktop;
+ $this->playStoreUrl = $showcase->play_store_url ?? '';
+ $this->appStoreUrl = $showcase->app_store_url ?? '';
+ $this->windowsDownloadUrl = $showcase->windows_download_url ?? '';
+ $this->macosDownloadUrl = $showcase->macos_download_url ?? '';
+ $this->linuxDownloadUrl = $showcase->linux_download_url ?? '';
+ $this->certifiedNativephp = $showcase->certified_nativephp;
+ }
+ }
+
+ public function rules(): array
+ {
+ $rules = [
+ 'title' => 'required|string|max:255',
+ 'description' => 'required|string|max:2000',
+ 'image' => 'nullable|image|max:2048',
+ 'screenshots.*' => 'nullable|image|max:2048',
+ 'hasMobile' => 'boolean',
+ 'hasDesktop' => 'boolean',
+ 'playStoreUrl' => 'nullable|url|max:255',
+ 'appStoreUrl' => 'nullable|url|max:255',
+ 'windowsDownloadUrl' => 'nullable|url|max:255',
+ 'macosDownloadUrl' => 'nullable|url|max:255',
+ 'linuxDownloadUrl' => 'nullable|url|max:255',
+ 'certifiedNativephp' => 'accepted',
+ ];
+
+ return $rules;
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'certifiedNativephp.accepted' => 'You must certify that your app is built with NativePHP.',
+ 'hasMobile.required_without' => 'Please select at least one platform (Mobile or Desktop).',
+ 'hasDesktop.required_without' => 'Please select at least one platform (Mobile or Desktop).',
+ ];
+ }
+
+ public function removeExistingScreenshot(int $index): void
+ {
+ if (isset($this->existingScreenshots[$index])) {
+ unset($this->existingScreenshots[$index]);
+ $this->existingScreenshots = array_values($this->existingScreenshots);
+ }
+ }
+
+ public function removeExistingImage(): void
+ {
+ $this->existingImage = null;
+ }
+
+ public function submit(): mixed
+ {
+ $this->validate();
+
+ if (! $this->hasMobile && ! $this->hasDesktop) {
+ $this->addError('hasMobile', 'Please select at least one platform (Mobile or Desktop).');
+
+ return null;
+ }
+
+ $imagePath = $this->existingImage;
+ if ($this->image) {
+ $imagePath = $this->image->store('showcase-images', 'public');
+ }
+
+ $screenshotPaths = $this->existingScreenshots;
+ foreach ($this->screenshots as $screenshot) {
+ if (count($screenshotPaths) >= 5) {
+ break;
+ }
+ $screenshotPaths[] = $screenshot->store('showcase-screenshots', 'public');
+ }
+
+ $data = [
+ 'title' => $this->title,
+ 'description' => $this->description,
+ 'image' => $imagePath,
+ 'screenshots' => $screenshotPaths ?: null,
+ 'has_mobile' => $this->hasMobile,
+ 'has_desktop' => $this->hasDesktop,
+ 'play_store_url' => $this->hasMobile ? ($this->playStoreUrl ?: null) : null,
+ 'app_store_url' => $this->hasMobile ? ($this->appStoreUrl ?: null) : null,
+ 'windows_download_url' => $this->hasDesktop ? ($this->windowsDownloadUrl ?: null) : null,
+ 'macos_download_url' => $this->hasDesktop ? ($this->macosDownloadUrl ?: null) : null,
+ 'linux_download_url' => $this->hasDesktop ? ($this->linuxDownloadUrl ?: null) : null,
+ 'certified_nativephp' => true,
+ ];
+
+ if ($this->isEditing && $this->showcase) {
+ $wasApproved = $this->showcase->isApproved();
+
+ $this->showcase->update($data);
+
+ if ($wasApproved) {
+ $this->showcase->update([
+ 'approved_at' => null,
+ 'approved_by' => null,
+ ]);
+
+ return redirect()->route('customer.showcase.index')
+ ->with('warning', 'Your submission has been updated and sent back for review.');
+ }
+
+ return redirect()->route('customer.showcase.index')
+ ->with('success', 'Your submission has been updated.');
+ }
+
+ Showcase::create([
+ 'user_id' => auth()->id(),
+ ...$data,
+ ]);
+
+ return redirect()->route('customer.showcase.index')
+ ->with('success', 'Thank you! Your app has been submitted for review.');
+ }
+
+ public function delete(): mixed
+ {
+ if ($this->showcase && $this->showcase->user_id === auth()->id()) {
+ if ($this->showcase->image) {
+ Storage::disk('public')->delete($this->showcase->image);
+ }
+
+ if ($this->showcase->screenshots) {
+ foreach ($this->showcase->screenshots as $screenshot) {
+ Storage::disk('public')->delete($screenshot);
+ }
+ }
+
+ $this->showcase->delete();
+
+ return redirect()->route('customer.showcase.index')
+ ->with('success', 'Your submission has been deleted.');
+ }
+
+ return null;
+ }
+
+ public function render()
+ {
+ return view('livewire.showcase-submission-form');
+ }
+}
diff --git a/app/Livewire/WallOfLoveBanner.php b/app/Livewire/WallOfLoveBanner.php
index 3041eed1..292ecd7d 100644
--- a/app/Livewire/WallOfLoveBanner.php
+++ b/app/Livewire/WallOfLoveBanner.php
@@ -6,6 +6,8 @@
class WallOfLoveBanner extends Component
{
+ public bool $inline = false;
+
public function dismissBanner(): void
{
cache()->put('wall_of_love_dismissed_'.auth()->id(), true, now()->addWeek());
diff --git a/app/Models/Showcase.php b/app/Models/Showcase.php
new file mode 100644
index 00000000..84e602d4
--- /dev/null
+++ b/app/Models/Showcase.php
@@ -0,0 +1,89 @@
+ 'array',
+ 'has_mobile' => 'boolean',
+ 'has_desktop' => 'boolean',
+ 'certified_nativephp' => 'boolean',
+ 'approved_at' => 'datetime',
+ ];
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function approvedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'approved_by');
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->approved_at !== null;
+ }
+
+ public function isPending(): bool
+ {
+ return $this->approved_at === null;
+ }
+
+ public function isNew(): bool
+ {
+ return $this->approved_at !== null && $this->approved_at->isAfter(now()->subMonth());
+ }
+
+ public function needsReReview(): bool
+ {
+ return $this->approved_at !== null && $this->updated_at->isAfter($this->approved_at);
+ }
+
+ public function scopeApproved(Builder $query): Builder
+ {
+ return $query->whereNotNull('approved_at');
+ }
+
+ public function scopePending(Builder $query): Builder
+ {
+ return $query->whereNull('approved_at');
+ }
+
+ public function scopeWithMobile(Builder $query): Builder
+ {
+ return $query->where('has_mobile', true);
+ }
+
+ public function scopeWithDesktop(Builder $query): Builder
+ {
+ return $query->where('has_desktop', true);
+ }
+}
diff --git a/config/docs.php b/config/docs.php
index f50cdaa6..47e5dc63 100644
--- a/config/docs.php
+++ b/config/docs.php
@@ -18,4 +18,4 @@
'mobile' => 2,
],
-];
\ No newline at end of file
+];
diff --git a/database/factories/ShowcaseFactory.php b/database/factories/ShowcaseFactory.php
new file mode 100644
index 00000000..8bd33cc8
--- /dev/null
+++ b/database/factories/ShowcaseFactory.php
@@ -0,0 +1,109 @@
+
+ */
+class ShowcaseFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ $hasMobile = fake()->boolean(50);
+ $hasDesktop = fake()->boolean(50);
+
+ if (! $hasMobile && ! $hasDesktop) {
+ $hasMobile = fake()->boolean();
+ $hasDesktop = ! $hasMobile;
+ }
+
+ return [
+ 'user_id' => User::factory(),
+ 'title' => fake()->words(rand(2, 4), true),
+ 'description' => fake()->paragraph(3),
+ 'image' => null,
+ 'screenshots' => null,
+ 'has_mobile' => $hasMobile,
+ 'has_desktop' => $hasDesktop,
+ 'play_store_url' => $hasMobile ? fake()->optional(0.7)->url() : null,
+ 'app_store_url' => $hasMobile ? fake()->optional(0.7)->url() : null,
+ 'windows_download_url' => $hasDesktop ? fake()->optional(0.6)->url() : null,
+ 'macos_download_url' => $hasDesktop ? fake()->optional(0.6)->url() : null,
+ 'linux_download_url' => $hasDesktop ? fake()->optional(0.5)->url() : null,
+ 'certified_nativephp' => true,
+ 'approved_at' => null,
+ 'approved_by' => null,
+ ];
+ }
+
+ public function approved(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'approved_at' => fake()->dateTimeBetween('-60 days', 'now'),
+ 'approved_by' => User::factory(),
+ ]);
+ }
+
+ public function recentlyApproved(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'approved_at' => fake()->dateTimeBetween('-25 days', 'now'),
+ 'approved_by' => User::factory(),
+ ]);
+ }
+
+ public function pending(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'approved_at' => null,
+ 'approved_by' => null,
+ ]);
+ }
+
+ public function mobile(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'has_mobile' => true,
+ 'has_desktop' => false,
+ 'play_store_url' => fake()->optional(0.7)->url(),
+ 'app_store_url' => fake()->optional(0.7)->url(),
+ 'windows_download_url' => null,
+ 'macos_download_url' => null,
+ 'linux_download_url' => null,
+ ]);
+ }
+
+ public function desktop(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'has_mobile' => false,
+ 'has_desktop' => true,
+ 'play_store_url' => null,
+ 'app_store_url' => null,
+ 'windows_download_url' => fake()->optional(0.6)->url(),
+ 'macos_download_url' => fake()->optional(0.6)->url(),
+ 'linux_download_url' => fake()->optional(0.5)->url(),
+ ]);
+ }
+
+ public function both(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'has_mobile' => true,
+ 'has_desktop' => true,
+ 'play_store_url' => fake()->optional(0.7)->url(),
+ 'app_store_url' => fake()->optional(0.7)->url(),
+ 'windows_download_url' => fake()->optional(0.6)->url(),
+ 'macos_download_url' => fake()->optional(0.6)->url(),
+ 'linux_download_url' => fake()->optional(0.5)->url(),
+ ]);
+ }
+}
diff --git a/database/migrations/2025_12_05_131240_create_showcases_table.php b/database/migrations/2025_12_05_131240_create_showcases_table.php
new file mode 100644
index 00000000..1cf41ab1
--- /dev/null
+++ b/database/migrations/2025_12_05_131240_create_showcases_table.php
@@ -0,0 +1,44 @@
+id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('title');
+ $table->text('description');
+ $table->string('image')->nullable();
+ $table->json('screenshots')->nullable();
+ $table->boolean('has_mobile')->default(false);
+ $table->boolean('has_desktop')->default(false);
+ $table->string('play_store_url')->nullable();
+ $table->string('app_store_url')->nullable();
+ $table->string('windows_download_url')->nullable();
+ $table->string('macos_download_url')->nullable();
+ $table->string('linux_download_url')->nullable();
+ $table->boolean('certified_nativephp')->default(false);
+ $table->timestamp('approved_at')->nullable();
+ $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->index(['approved_at', 'has_mobile', 'has_desktop']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('showcases');
+ }
+};
diff --git a/database/seeders/ShowcaseSeeder.php b/database/seeders/ShowcaseSeeder.php
new file mode 100644
index 00000000..40cbeb81
--- /dev/null
+++ b/database/seeders/ShowcaseSeeder.php
@@ -0,0 +1,30 @@
+approved()->mobile()->create();
+ Showcase::factory(4)->approved()->desktop()->create();
+ Showcase::factory(2)->approved()->both()->create();
+
+ // 5 recently approved (will show as "new")
+ Showcase::factory(2)->recentlyApproved()->mobile()->create();
+ Showcase::factory(2)->recentlyApproved()->desktop()->create();
+ Showcase::factory(1)->recentlyApproved()->both()->create();
+
+ // 5 pending review
+ Showcase::factory(2)->pending()->mobile()->create();
+ Showcase::factory(2)->pending()->desktop()->create();
+ Showcase::factory(1)->pending()->both()->create();
+ }
+}
diff --git a/resources/views/components/discounts-banner.blade.php b/resources/views/components/discounts-banner.blade.php
index 26a6fe6e..0837b561 100644
--- a/resources/views/components/discounts-banner.blade.php
+++ b/resources/views/components/discounts-banner.blade.php
@@ -1,5 +1,7 @@
-
-
+@props(['inline' => false])
+
+
!$inline])>
+
diff --git a/resources/views/components/navbar/device-dropdowns.blade.php b/resources/views/components/navbar/device-dropdowns.blade.php
index 06c5e162..f7904173 100644
--- a/resources/views/components/navbar/device-dropdowns.blade.php
+++ b/resources/views/components/navbar/device-dropdowns.blade.php
@@ -1,3 +1,7 @@
+@php
+ $showShowcase = \App\Models\Showcase::approved()->count() >= 4;
+@endphp
+
{{-- Mobile dropdown --}}
- {{-- 👇 Hidden temporarily --}}
- {{--
+ @if($showShowcase)
+ @endif
+
+ {{-- 👇 Hidden temporarily --}}
+ {{--
- --}}
+ />--}}
{{-- Desktop dropdown --}}
@@ -56,6 +68,15 @@
icon="heart"
icon-class="size-4"
/>
+ @if($showShowcase)
+
+ @endif
- {{-- Announcement banner goes here --}}
-
-
+ {{-- NEW Badge --}}
+ @if($showcase->isNew())
+
+
+ NEW
+
+
+ @endif
+
+ {{-- Screenshot Carousel --}}
+
+ @if($showcase->screenshots && count($showcase->screenshots) > 0)
+
+ @foreach($showcase->screenshots as $index => $screenshot)
+
+ @endforeach
+
+ {{-- Navigation Arrows --}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{-- Slide Indicators --}}
+
+
+
+
+
+
+
+
+ @elseif($showcase->image)
+
+ @else
+
+ @endif
+
+
+ {{-- Content --}}
+
+ {{-- Header with icon and title --}}
+
+ @if($showcase->image)
+
+ @else
+
+ {{ substr($showcase->title, 0, 1) }}
+
+ @endif
+
+
+
+ {{ $showcase->title }}
+
+
+ {{-- Platform badges --}}
+
+ @if($showcase->has_mobile)
+
+
+
+
+ Mobile
+
+ @endif
+ @if($showcase->has_desktop)
+
+
+
+
+ Desktop
+
+ @endif
+
+
+
+
+ {{-- Description --}}
+
+ {{ $showcase->description }}
+
+
+ {{-- Download/Store Links --}}
+
+ @if($showcase->has_mobile)
+ @if($showcase->app_store_url)
+
+
+ App Store
+
+ @endif
+ @if($showcase->play_store_url)
+
+
+ Play Store
+
+ @endif
+ @endif
+
+ @if($showcase->has_desktop)
+ @if($showcase->macos_download_url)
+
+
+ macOS
+
+ @endif
+ @if($showcase->windows_download_url)
+
+
+ Windows
+
+ @endif
+ @if($showcase->linux_download_url)
+
+
+ Linux
+
+ @endif
+ @endif
+
+
+
diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php
index e1493db4..f9a244df 100644
--- a/resources/views/customer/licenses/index.blade.php
+++ b/resources/views/customer/licenses/index.blade.php
@@ -11,22 +11,28 @@
- {{-- Wall of Love Callout for Early Adopters --}}
-
-
-
+ {{-- Banners --}}
+
{{-- Content --}}
diff --git a/resources/views/customer/showcase/create.blade.php b/resources/views/customer/showcase/create.blade.php
new file mode 100644
index 00000000..559f8437
--- /dev/null
+++ b/resources/views/customer/showcase/create.blade.php
@@ -0,0 +1,63 @@
+
+
+ {{-- Header --}}
+
+
+
+ {{-- Breadcrumb --}}
+
+
+
+
+
+
+
+ Back to Submissions
+
+
+
+
+ Submit App
+
+
+
+
+
+
+
Submit Your App to the Showcase
+
+ Share your NativePHP app with the community! Your submission will be reviewed by our team.
+
+
+
+
+
+
+ {{-- Content --}}
+
+
+
+ {{-- Info Box --}}
+
+
+
+
+ Showcase Guidelines
+
+
+ Your app must be built with NativePHP
+ Include clear screenshots showcasing your app
+ Provide download links or store URLs where users can get your app
+ Submissions are reviewed before being published
+
+
+
+
+
+ {{-- Submission Form --}}
+
+
+
+
+
+
diff --git a/resources/views/customer/showcase/edit.blade.php b/resources/views/customer/showcase/edit.blade.php
new file mode 100644
index 00000000..f4888849
--- /dev/null
+++ b/resources/views/customer/showcase/edit.blade.php
@@ -0,0 +1,59 @@
+
+
+ {{-- Header --}}
+
+
+
+ {{-- Breadcrumb --}}
+
+
+
+
+
+
+
+ Back to Submissions
+
+
+
+
+ Edit: {{ $showcase->title }}
+
+
+
+
+
+
+
+
Edit Your Submission
+
+ Update the details of your showcase submission.
+
+
+
+ @if($showcase->isApproved())
+
+ Approved
+
+ @else
+
+ Pending Review
+
+ @endif
+
+
+
+
+
+
+ {{-- Content --}}
+
+
+
+ {{-- Submission Form --}}
+
+
+
+
+
+
diff --git a/resources/views/customer/showcase/index.blade.php b/resources/views/customer/showcase/index.blade.php
new file mode 100644
index 00000000..58b9ce03
--- /dev/null
+++ b/resources/views/customer/showcase/index.blade.php
@@ -0,0 +1,143 @@
+
+
+ {{-- Header --}}
+
+
+
+
+
+
+
+
+
+
+
+ Back to Licenses
+
+
+
+
+ App Showcase
+
+
+
+
+
Your Showcase Submissions
+
+ Submit your NativePHP apps to be featured on our showcase
+
+
+
+
+
+
+
+ {{-- Messages --}}
+
+ @if(session()->has('success'))
+
+
+
+
+
+
{{ session('success') }}
+
+
+ @endif
+
+ @if(session()->has('warning'))
+
+
+
+
+
+
{{ session('warning') }}
+
+
+ @endif
+
+
+ {{-- Content --}}
+
+ @if($showcases->count() > 0)
+
+ @else
+
+
+
+
+
+
No submissions yet
+
+ Get started by submitting your first NativePHP app to the showcase.
+
+
+
+
+ @endif
+
+
+
diff --git a/resources/views/filament/tables/columns/platforms.blade.php b/resources/views/filament/tables/columns/platforms.blade.php
new file mode 100644
index 00000000..07e28213
--- /dev/null
+++ b/resources/views/filament/tables/columns/platforms.blade.php
@@ -0,0 +1,8 @@
+
+ @if($getRecord()->has_mobile)
+
+ @endif
+ @if($getRecord()->has_desktop)
+
+ @endif
+
diff --git a/resources/views/livewire/showcase-submission-form.blade.php b/resources/views/livewire/showcase-submission-form.blade.php
new file mode 100644
index 00000000..771ec43f
--- /dev/null
+++ b/resources/views/livewire/showcase-submission-form.blade.php
@@ -0,0 +1,211 @@
+
+ {{-- Warning for approved submissions being edited --}}
+ @if ($isEditing && $showcase?->isApproved())
+
+
+
+
+
Re-review Required
+
+ This submission is currently approved. If you make changes, it will need to be reviewed again before appearing in the showcase.
+
+
+
+
+ @endif
+
+ {{-- Title Field --}}
+
+
+ App Name *
+
+
+ @error('title')
{{ $message }}
@enderror
+
+
+ {{-- Description Field --}}
+
+
+ Description *
+
+
+ @error('description')
{{ $message }}
@enderror
+
Maximum 2000 characters.
+
+
+ {{-- Main Image Field --}}
+
+
+ App Icon / Main Image
+
+ @if ($existingImage)
+
+
+
+ Remove
+
+
+ @endif
+
+
+
+ @error('image')
{{ $message }}
@enderror
+
Max 2MB. Recommended: Square image, at least 256x256px.
+
+
+ {{-- Screenshots Field --}}
+
+
+ Screenshots (up to 5)
+
+ @if (count($existingScreenshots) > 0)
+
+ @foreach ($existingScreenshots as $index => $screenshot)
+
+
+
+
+
+
+
+
+ @endforeach
+
+ @endif
+ @if (count($existingScreenshots) < 5)
+
+
+
+
+ Max 2MB each. You can add {{ 5 - count($existingScreenshots) }} more screenshot(s).
+
+ @endif
+ @error('screenshots.*')
{{ $message }}
@enderror
+
+
+ {{-- Platform Selection --}}
+
+
+ Available Platforms *
+
+
+
+ {{-- Mobile Toggle --}}
+
+
+
+
+
+
Mobile App
+
iOS and/or Android
+
+
+
+ {{-- Mobile Links (shown when hasMobile is true) --}}
+ @if ($hasMobile)
+
+
+
+ App Store URL
+
+
+ @error('appStoreUrl')
{{ $message }}
@enderror
+
+
+
+
+ Play Store URL
+
+
+ @error('playStoreUrl')
{{ $message }}
@enderror
+
+
+ @endif
+
+ {{-- Desktop Toggle --}}
+
+
+
+
+
+
Desktop App
+
Windows, macOS, and/or Linux
+
+
+
+ {{-- Desktop Links (shown when hasDesktop is true) --}}
+ @if ($hasDesktop)
+
+
+
+ Windows Download URL
+
+
+ @error('windowsDownloadUrl')
{{ $message }}
@enderror
+
+
+
+
+ macOS Download URL
+
+
+ @error('macosDownloadUrl')
{{ $message }}
@enderror
+
+
+
+
+ Linux Download URL
+
+
+ @error('linuxDownloadUrl')
{{ $message }}
@enderror
+
+
+ @endif
+
+
+ @error('hasMobile')
{{ $message }}
@enderror
+
+
+ {{-- Certification Checkbox --}}
+
+
+
+
+
+
+
+ I certify this app is built with NativePHP *
+
+
+ By checking this box, you confirm that your application is built using NativePHP.
+ Submissions found not to be built with NativePHP may be rejected or removed from the showcase.
+
+
+
+ @error('certifiedNativephp')
{{ $message }}
@enderror
+
+
+ {{-- Form Actions --}}
+
+
+ @if ($isEditing)
+
+ Delete Submission
+
+ @endif
+
+
+
+ Cancel
+
+
+ {{ $isEditing ? 'Update Submission' : 'Submit App' }}
+ Saving...
+
+
+
+
diff --git a/resources/views/livewire/wall-of-love-banner.blade.php b/resources/views/livewire/wall-of-love-banner.blade.php
index 9f5e5079..77f6e7de 100644
--- a/resources/views/livewire/wall-of-love-banner.blade.php
+++ b/resources/views/livewire/wall-of-love-banner.blade.php
@@ -1,29 +1,27 @@
-
+
!$inline])>
@if($this->shouldShowBanner())
-
-
-
-
-
-
- Join our Wall of Love!
-
-
- As an early adopter who purchased a license before June 1st, 2025, we'd love to feature you on
- our Wall of Love page .
-
-
+
+
+
+
+
+ Join our Wall of Love!
+
+
+ As an early adopter who purchased a license before June 1st, 2025, we'd love to feature you on
+ our Wall of Love page .
+
+
diff --git a/resources/views/showcase.blade.php b/resources/views/showcase.blade.php
new file mode 100644
index 00000000..19a59e58
--- /dev/null
+++ b/resources/views/showcase.blade.php
@@ -0,0 +1,177 @@
+
+ {{-- Hero Section --}}
+
+
+ {{-- Showcase Grid --}}
+
+ @if ($showcases->count() > 0)
+
+ @foreach ($showcases as $showcase)
+
+ @endforeach
+
+
+ {{-- Pagination --}}
+ @if ($showcases->hasPages())
+
+ {{ $showcases->links() }}
+
+ @endif
+ @else
+
+
+
🚀
+
+ No Apps Yet
+
+
+ @if($platform)
+ No {{ $platform }} apps have been showcased yet. Be the first to submit yours!
+ @else
+ The showcase is empty. Be the first to submit your NativePHP app!
+ @endif
+
+
+
+ @endif
+
+
+ {{-- CTA Section --}}
+
+
+
+ Built something with NativePHP?
+
+
+ We'd love to feature your app in our showcase. Share your creation with the NativePHP community!
+
+ @auth
+
+ Submit Your App
+
+ @else
+
+ Log in to Submit
+
+ @endauth
+
+
+
diff --git a/routes/web.php b/routes/web.php
index cffee69e..bee0f594 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -40,6 +40,9 @@
Route::view('alt-pricing', 'alt-pricing')->name('alt-pricing')->middleware('signed');
Route::view('wall-of-love', 'wall-of-love')->name('wall-of-love');
Route::view('brand', 'brand')->name('brand');
+Route::get('showcase/{platform?}', [App\Http\Controllers\ShowcaseController::class, 'index'])
+ ->where('platform', 'mobile|desktop')
+ ->name('showcase');
Route::view('laracon-us-2025-giveaway', 'laracon-us-2025-giveaway')->name('laracon-us-2025-giveaway');
Route::view('privacy-policy', 'privacy-policy')->name('privacy-policy');
Route::view('terms-of-service', 'terms-of-service')->name('terms-of-service');
@@ -135,6 +138,11 @@
// Wall of Love submission
Route::get('wall-of-love/create', [App\Http\Controllers\WallOfLoveSubmissionController::class, 'create'])->name('wall-of-love.create');
+ // Showcase submissions
+ Route::get('showcase', [App\Http\Controllers\CustomerShowcaseController::class, 'index'])->name('showcase.index');
+ Route::get('showcase/create', [App\Http\Controllers\CustomerShowcaseController::class, 'create'])->name('showcase.create');
+ Route::get('showcase/{showcase}/edit', [App\Http\Controllers\CustomerShowcaseController::class, 'edit'])->name('showcase.edit');
+
// Plugin management
Route::get('plugins', [CustomerPluginController::class, 'index'])->name('plugins.index');
Route::get('plugins/submit', [CustomerPluginController::class, 'create'])->name('plugins.create');
From af96933368975a42e793c8fe3d898dd5d286c941 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Sat, 6 Dec 2025 01:10:03 +0000
Subject: [PATCH 07/34] Changelog
---
.../mobile/2/getting-started/changelog.md | 111 ++++++++++++++----
1 file changed, 87 insertions(+), 24 deletions(-)
diff --git a/resources/views/docs/mobile/2/getting-started/changelog.md b/resources/views/docs/mobile/2/getting-started/changelog.md
index 48ec34e3..03386c44 100644
--- a/resources/views/docs/mobile/2/getting-started/changelog.md
+++ b/resources/views/docs/mobile/2/getting-started/changelog.md
@@ -3,18 +3,81 @@ title: Changelog
order: 2
---
-## JavaScript/TypeScript Library
+## v2.1.0
+
+### Cleaner Console Output
+The `native:run` command now provides cleaner, more readable output, making it easier to follow what's happening during development.
+
+### Improved Windows Support
+Better compatibility and smoother development experience for Windows users.
+
+### Blade Directives
+New Blade directives for conditional rendering based on platform:
+
+```blade
+@mobile
+ {{-- Only rendered in mobile apps --}}
+@endmobile
+
+@web
+ {{-- Only rendered in web browsers --}}
+@endweb
+
+@ios
+ {{-- Only rendered on iOS --}}
+@endios
+
+@android
+ {{-- Only rendered on Android --}}
+@endandroid
+```
+
+### Improved File Watcher
+The file watcher has been completely overhauled, switching from fswatch to [Watchman](https://facebook.github.io/watchman/) for better performance and reliability. The watcher is now combined with Vite HMR for a unified development experience.
+
+### Common URL Schemes
+NativePHP now automatically handles common URL schemes, opening them in the appropriate native app:
+- `tel:` - Phone calls
+- `mailto:` - Email
+- `sms:` - Text messages
+- `geo:` - Maps/location
+- `facetime:` - FaceTime video calls
+- `facetime-audio:` - FaceTime audio calls
+
+### Android Deep Links
+Support for custom deep links and app links on Android, allowing other apps and websites to link directly into your app.
+
+### Other Changes
+- `System::appSettings()` to open your app's settings screen in the OS Settings app
+- `Edge::clear()` to remove all EDGE components
+- Added `Native.shareUrl()` to the JavaScript library
+- `native:install`: Added `--fresh` and `-F` as aliases of `--force`
+- `native:install`: Increased timeout for slower networks
+
+### Bug Fixes
+- Fixed Scanner permissions
+- Fixed Android edge-to-edge display
+- Fixed `Browser::auth` on iOS
+- Fixed text alignment in native top-bar component on iOS
+- Fixed plist issues on iOS
+- Fixed `NATIVEPHP_START_URL` configuration
+- Fixed camera cancelled events on Android
+- Fixed bottom-nav values not updating dynamically
+
+## v2.0.0
+
+### JavaScript/TypeScript Library
A brand-new JavaScript bridge library with full TypeScript declarations for Vue, React, Inertia, and vanilla JS apps.
This enables calling native device features directly from your frontend code. Read more about it
-[here](../the-basics/native-functions#run-from-anywhere).
+[here](../the-basics/native-functions#run-from-anywhere).
-## EDGE - Element Definition and Generation Engine
+### EDGE - Element Definition and Generation Engine
A new native UI system for rendering navigation components natively on device using Blade. Read more about it [here](../edge-components/introduction).
-## Laravel Boost Support
+### Laravel Boost Support
Full integration with Laravel Boost for AI-assisted development. Read more about it [here](../getting-started/development#laravel-boost).
-## Hot Module Replacement (HMR) Overhauled
+### Hot Module Replacement (HMR) Overhauled
Full Vite HMR for rapid development. Read more about it [here](../getting-started/development#hot-reloading).
Features:
@@ -23,7 +86,7 @@ Features:
- PHP protocol adapter for axios on iOS (no more `patch-inertia` command!)
- Works over the network even without a physical device plugged in!
-## Fluent Pending API (PHP)
+### Fluent Pending API (PHP)
All [Asynchronous Methods](../the-basics/events#understanding-async-vs-sync) now implement a fluent API for better IDE support and ease of use.
@@ -62,7 +125,7 @@ onMounted(() => {
-## `#[OnNative]` Livewire Attribute
+### `#[OnNative]` Livewire Attribute
Forget the silly string concatenation of yesterday; get into today's fashionable attribute usage with this drop-in
replacement:
@@ -75,19 +138,19 @@ use Native\Mobile\Attributes\OnNative; // [tl! add]
public function handle()
```
-## Video Recording
+### Video Recording
Learn more about the new Video Recorder support [here](../apis/camera#coderecordvideocode).
-## QR/Barcode Scanner
+### QR/Barcode Scanner
Learn more about the new QR/Barcode Scanner support [here](../apis/scanner).
-## Microphone
+### Microphone
Learn more about the new Microphone support [here](../apis/microphone).
-## Network Detection
+### Network Detection
Learn more about the new Network Detection support [here](../apis/network).
-## Background Audio Recording
+### Background Audio Recording
Just update your config and record audio even while the device is locked!
```php
@@ -97,7 +160,7 @@ Just update your config and record audio even while the device is locked!
'microphone_background' => true,
],
```
-## Push Notifications API
+### Push Notifications API
New fluent API for push notification enrollment:
@@ -145,16 +208,16 @@ onUnmounted(() => {
- `enrollForPushNotifications()` → use `enroll()`
- `getPushNotificationsToken()` → use `getToken()`
-## Platform Improvements
+### Platform Improvements
-### iOS
+#### iOS
- **Platform detection** - `nativephp-ios` class on body
- **Keyboard detection** - `keyboard-visible` class when keyboard shown
- **iOS 26 Liquid Glass** support
- **Improved device selector** on `native:run` showing last-used device
- **Load Times** dramatically improved. Now 60-80% faster!
-### Android
+#### Android
- **Complete Android 16+ 16KB page size** compatibility
- **Jetpack Compose UI** - Migrated from XML layouts
- **Platform detection** - `nativephp-android` class on body
@@ -164,9 +227,9 @@ onUnmounted(() => {
- **Page Load Times** dramatically decreased by ~40%!
---
-## Configuration
+### Configuration
-### New Options
+#### New Options
```php
'start_url' => env('NATIVEPHP_START_URL', '/'),
@@ -185,19 +248,19 @@ onUnmounted(() => {
],
```
-### Custom Permission Reasons (iOS)
+#### Custom Permission Reasons (iOS)
```php
'camera' => 'We need camera access to scan membership cards.',
'location' => 'Location is used to find nearby stores.',
```
-## New Events
+### New Events
- `Camera\VideoRecorded`, `Camera\VideoCancelled`, `Camera\PhotoCancelled`
- `Microphone\MicrophoneRecorded`, `Microphone\MicrophoneCancelled`
- `Scanner\CodeScanned`
-## Custom Events
+### Custom Events
Many native calls now accept custom event classes!
@@ -206,7 +269,7 @@ Dialog::alert('Confirm', 'Delete this?', ['Cancel', 'Delete'])
->event(MyCustomEvent::class)
```
-## Better File System Support
+### Better File System Support
NativePHP now symlinks your filesystems! Persisted storage stays in storage but is symlinked to the public directory for
display in the web view! Plus a pre-configured `mobile_public` filesystem disk.
@@ -220,13 +283,13 @@ $imageUrl = Storage::url($path);
```
-## Bug Fixes
+### Bug Fixes
- Fixed infinite recursion during bundling in some Laravel setups
- Fixed iOS toolbar padding for different device sizes
- Fixed Android debug mode forcing `APP_DEBUG=true`
- Fixed orientation config key case sensitivity (`iPhone` vs `iphone`)
-## Breaking Changes
+### Breaking Changes
- None
From 64b23c7c55e17c0b35f36de78c5a2c1048843aae Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Fri, 5 Dec 2025 20:31:54 -0500
Subject: [PATCH 08/34] Minor tweaks
---
.../mobile/2/getting-started/changelog.md | 26 ++++++++++---------
1 file changed, 14 insertions(+), 12 deletions(-)
diff --git a/resources/views/docs/mobile/2/getting-started/changelog.md b/resources/views/docs/mobile/2/getting-started/changelog.md
index 03386c44..c5ae1dec 100644
--- a/resources/views/docs/mobile/2/getting-started/changelog.md
+++ b/resources/views/docs/mobile/2/getting-started/changelog.md
@@ -10,26 +10,28 @@ The `native:run` command now provides cleaner, more readable output, making it e
### Improved Windows Support
Better compatibility and smoother development experience for Windows users.
+
+
+#### Note to all users
+Internally, Gradle has been upgraded, the first time you run an Android build it will take several minutes longer to download and install the new dependencies.
+
+
### Blade Directives
New Blade directives for conditional rendering based on platform:
```blade
-@mobile
- {{-- Only rendered in mobile apps --}}
-@endmobile
+Only rendered in mobile apps
+@mobile / @endmobile
-@web
- {{-- Only rendered in web browsers --}}
-@endweb
+Only rendered in web browsers
+@web / @endweb
-@ios
- {{-- Only rendered on iOS --}}
-@endios
+Only rendered on iOS
+@ios / @endios
-@android
- {{-- Only rendered on Android --}}
-@endandroid
+Only rendered on Android
+@android / @endandroid
```
### Improved File Watcher
From c5229f4a85e65c5d3126869f425405b519de0ab3 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Mon, 8 Dec 2025 12:10:47 +0000
Subject: [PATCH 09/34] Improve showcase galleries for mobile apps
---
database/factories/ShowcaseFactory.php | 72 +++++++++++++++++++
database/seeders/ShowcaseSeeder.php | 24 ++++---
.../views/components/showcase-card.blade.php | 4 +-
3 files changed, 87 insertions(+), 13 deletions(-)
diff --git a/database/factories/ShowcaseFactory.php b/database/factories/ShowcaseFactory.php
index 8bd33cc8..34c02235 100644
--- a/database/factories/ShowcaseFactory.php
+++ b/database/factories/ShowcaseFactory.php
@@ -4,6 +4,7 @@
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Facades\Storage;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Showcase>
@@ -106,4 +107,75 @@ public function both(): static
'linux_download_url' => fake()->optional(0.5)->url(),
]);
}
+
+ public function withScreenshots(int $count = 3, bool $tall = false): static
+ {
+ return $this->state(function (array $attributes) use ($count, $tall) {
+ $screenshots = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $screenshots[] = $this->generatePlaceholderScreenshot($tall);
+ }
+
+ return ['screenshots' => $screenshots];
+ });
+ }
+
+ public function withTallScreenshots(int $count = 3): static
+ {
+ return $this->withScreenshots($count, tall: true);
+ }
+
+ public function withWideScreenshots(int $count = 3): static
+ {
+ return $this->withScreenshots($count, tall: false);
+ }
+
+ protected function generatePlaceholderScreenshot(bool $tall = false): string
+ {
+ $width = $tall ? 390 : 1280;
+ $height = $tall ? 844 : 720;
+
+ $image = imagecreatetruecolor($width, $height);
+
+ $colors = [
+ [99, 102, 241], // Indigo
+ [139, 92, 246], // Purple
+ [236, 72, 153], // Pink
+ [14, 165, 233], // Sky
+ [34, 197, 94], // Green
+ [249, 115, 22], // Orange
+ ];
+
+ $color = fake()->randomElement($colors);
+ $bgColor = imagecolorallocate($image, $color[0], $color[1], $color[2]);
+ imagefill($image, 0, 0, $bgColor);
+
+ $white = imagecolorallocate($image, 255, 255, 255);
+
+ if ($tall) {
+ // Draw phone UI elements
+ imagefilledrectangle($image, 20, 60, $width - 20, 120, $white);
+ imagefilledrectangle($image, 20, 140, $width - 20, 400, imagecolorallocatealpha($image, 255, 255, 255, 80));
+ imagefilledrectangle($image, 20, 420, ($width - 20) / 2 - 10, 600, imagecolorallocatealpha($image, 255, 255, 255, 80));
+ imagefilledrectangle($image, ($width - 20) / 2 + 10, 420, $width - 20, 600, imagecolorallocatealpha($image, 255, 255, 255, 80));
+ } else {
+ // Draw desktop UI elements
+ imagefilledrectangle($image, 0, 0, $width, 40, imagecolorallocatealpha($image, 0, 0, 0, 80));
+ imagefilledrectangle($image, 20, 60, 250, $height - 20, imagecolorallocatealpha($image, 255, 255, 255, 80));
+ imagefilledrectangle($image, 270, 60, $width - 20, $height - 20, imagecolorallocatealpha($image, 255, 255, 255, 90));
+ }
+
+ $filename = 'showcase-screenshots/'.fake()->uuid().'.png';
+ Storage::disk('public')->makeDirectory('showcase-screenshots');
+
+ ob_start();
+ imagepng($image);
+ $imageData = ob_get_clean();
+ imagedestroy($image);
+
+ Storage::disk('public')->put($filename, $imageData);
+
+ return $filename;
+ }
}
diff --git a/database/seeders/ShowcaseSeeder.php b/database/seeders/ShowcaseSeeder.php
index 40cbeb81..50d187d6 100644
--- a/database/seeders/ShowcaseSeeder.php
+++ b/database/seeders/ShowcaseSeeder.php
@@ -12,19 +12,21 @@ class ShowcaseSeeder extends Seeder
*/
public function run(): void
{
- // 10 approved showcases (mix of platforms)
- Showcase::factory(4)->approved()->mobile()->create();
- Showcase::factory(4)->approved()->desktop()->create();
- Showcase::factory(2)->approved()->both()->create();
+ // Approved showcases with screenshots
+ Showcase::factory(2)->approved()->mobile()->withTallScreenshots(3)->create();
+ Showcase::factory(2)->approved()->mobile()->create();
+ Showcase::factory(2)->approved()->desktop()->withWideScreenshots(3)->create();
+ Showcase::factory(2)->approved()->desktop()->create();
+ Showcase::factory(2)->approved()->both()->withTallScreenshots(2)->create();
- // 5 recently approved (will show as "new")
- Showcase::factory(2)->recentlyApproved()->mobile()->create();
- Showcase::factory(2)->recentlyApproved()->desktop()->create();
- Showcase::factory(1)->recentlyApproved()->both()->create();
+ // Recently approved (will show as "new") with screenshots
+ Showcase::factory(2)->recentlyApproved()->mobile()->withTallScreenshots(4)->create();
+ Showcase::factory(2)->recentlyApproved()->desktop()->withWideScreenshots(3)->create();
+ Showcase::factory(1)->recentlyApproved()->both()->withWideScreenshots(2)->create();
- // 5 pending review
- Showcase::factory(2)->pending()->mobile()->create();
- Showcase::factory(2)->pending()->desktop()->create();
+ // Pending review
+ Showcase::factory(2)->pending()->mobile()->withTallScreenshots(2)->create();
+ Showcase::factory(2)->pending()->desktop()->withWideScreenshots(2)->create();
Showcase::factory(1)->pending()->both()->create();
}
}
diff --git a/resources/views/components/showcase-card.blade.php b/resources/views/components/showcase-card.blade.php
index 106db9ad..18aa09c2 100644
--- a/resources/views/components/showcase-card.blade.php
+++ b/resources/views/components/showcase-card.blade.php
@@ -44,7 +44,7 @@ class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-whit
x-transition:enter-end="opacity-100"
src="{{ Storage::disk('public')->url($screenshot) }}"
alt="{{ $showcase->title }} screenshot {{ $index + 1 }}"
- class="absolute inset-0 w-full h-full object-cover"
+ class="absolute inset-0 w-full h-full object-contain"
>
@endforeach
@@ -90,7 +90,7 @@ class="w-2 h-2 rounded-full transition-colors"
@else
From e9536b742ee96e2d2c9eede4783159eac1390cb0 Mon Sep 17 00:00:00 2001
From: CodingwithRK <107883290+codingwithrk@users.noreply.github.com>
Date: Mon, 8 Dec 2025 17:44:25 +0530
Subject: [PATCH 10/34] Update mobile and desktop links in platform switcher
(#246)
---
resources/views/components/docs/platform-switcher.blade.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/views/components/docs/platform-switcher.blade.php b/resources/views/components/docs/platform-switcher.blade.php
index a19eacea..d0f8dee9 100644
--- a/resources/views/components/docs/platform-switcher.blade.php
+++ b/resources/views/components/docs/platform-switcher.blade.php
@@ -1,7 +1,7 @@
@php
$isMobile = request()->is('docs/mobile/*');
- $mobileHref = '/docs/mobile/1';
- $desktopHref = '/docs/desktop/1';
+ $mobileHref = '/docs/mobile/2';
+ $desktopHref = '/docs/desktop/2';
@endphp
Date: Mon, 8 Dec 2025 12:24:47 +0000
Subject: [PATCH 11/34] Add lightbox
---
.../views/components/showcase-card.blade.php | 106 +++++++++++++++++-
1 file changed, 102 insertions(+), 4 deletions(-)
diff --git a/resources/views/components/showcase-card.blade.php b/resources/views/components/showcase-card.blade.php
index 18aa09c2..27490e9d 100644
--- a/resources/views/components/showcase-card.blade.php
+++ b/resources/views/components/showcase-card.blade.php
@@ -3,7 +3,10 @@
{{-- NEW Badge --}}
@@ -37,15 +63,21 @@ class="group relative overflow-hidden rounded-2xl border border-gray-200 bg-whit
@if($showcase->screenshots && count($showcase->screenshots) > 0)
@foreach($showcase->screenshots as $index => $screenshot)
-
+
+
@endforeach
{{-- Navigation Arrows --}}
@@ -213,4 +245,70 @@ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gra
@endif
+
+ {{-- Lightbox Modal --}}
+
+
+ {{-- Close Button --}}
+
+
+
+
+
+
+ {{-- Image Counter --}}
+
+ /
+
+
+ {{-- Previous Button --}}
+
+
+
+
+
+
+ {{-- Next Button --}}
+
+
+
+
+
+
+ {{-- Main Image --}}
+
+
+
From e43a63e4a95c43b0c68980bdd490e19addd94fcd Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Mon, 8 Dec 2025 13:28:16 +0000
Subject: [PATCH 12/34] Update Haptics documentation to remove deprecated
notice
Removed deprecation notice for Haptics facade.
---
resources/views/docs/mobile/2/apis/haptics.md | 8 --------
1 file changed, 8 deletions(-)
diff --git a/resources/views/docs/mobile/2/apis/haptics.md b/resources/views/docs/mobile/2/apis/haptics.md
index ca20b1fd..14876f75 100644
--- a/resources/views/docs/mobile/2/apis/haptics.md
+++ b/resources/views/docs/mobile/2/apis/haptics.md
@@ -3,14 +3,6 @@ title: Haptics
order: 800
---
-
-
-#### Deprecated
-
-The `Haptics` facade is marked deprecated and will be removed in a future release, use [Device](../apis/device) instead.
-
-
-
## Overview
The Haptics API provides access to the device's vibration and haptic feedback system for tactile user interactions.
From e58f284221bb18b96245a9d55fe2709c3624ab4b Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Mon, 8 Dec 2025 15:58:56 -0500
Subject: [PATCH 13/34] Updates changelog mobile 2.1.1 (#248)
---
.../docs/mobile/2/getting-started/changelog.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/resources/views/docs/mobile/2/getting-started/changelog.md b/resources/views/docs/mobile/2/getting-started/changelog.md
index c5ae1dec..1d84782c 100644
--- a/resources/views/docs/mobile/2/getting-started/changelog.md
+++ b/resources/views/docs/mobile/2/getting-started/changelog.md
@@ -3,6 +3,20 @@ title: Changelog
order: 2
---
+## v2.1.1
+
+### Foreground permissions
+Prevent removal of FOREGROUND_SERVICE and POST_NOTIFICATIONS permissions when they're needed by camera features, even if push notifications are disabled
+
+### FSymlink fix
+Run storage:unlink before storage:link to handle stale symlinks, and exclude public/storage from build to prevent symlink conflicts
+
+### iOS Push Notifications
+Handles push notification APNS flow differently, fires off the native event as soon as the token is received from FCM vs assuming the AppDelegate will ahndle it.
+
+### Fix Missing $id param on some events
+Some events were missing an `$id` parameter, which would cause users to experience errors when trying to receive an ID from the event.
+
## v2.1.0
### Cleaner Console Output
From 779a4fce81a664069ca1fbbef664a50e16100c4e Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Mon, 8 Dec 2025 16:01:15 -0500
Subject: [PATCH 14/34] Updates changelog
---
resources/views/docs/mobile/2/getting-started/changelog.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/resources/views/docs/mobile/2/getting-started/changelog.md b/resources/views/docs/mobile/2/getting-started/changelog.md
index 1d84782c..3a59799f 100644
--- a/resources/views/docs/mobile/2/getting-started/changelog.md
+++ b/resources/views/docs/mobile/2/getting-started/changelog.md
@@ -6,10 +6,10 @@ order: 2
## v2.1.1
### Foreground permissions
-Prevent removal of FOREGROUND_SERVICE and POST_NOTIFICATIONS permissions when they're needed by camera features, even if push notifications are disabled
+Prevent removal of FOREGROUND_SERVICE and POST_NOTIFICATIONS permissions when they're needed by camera features
-### FSymlink fix
-Run storage:unlink before storage:link to handle stale symlinks, and exclude public/storage from build to prevent symlink conflicts
+### Symlink fix
+Exclude public/storage from build to prevent symlink conflicts
### iOS Push Notifications
Handles push notification APNS flow differently, fires off the native event as soon as the token is received from FCM vs assuming the AppDelegate will ahndle it.
From 4b8e4eb3176974179da26278f6b7d508b61b14d5 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Tue, 9 Dec 2025 17:32:38 +0000
Subject: [PATCH 15/34] Comparisons
---
.../components/comparison/bar-chart.blade.php | 65 ++
.../comparison/video-placeholder.blade.php | 42 +
resources/views/components/footer.blade.php | 16 +
resources/views/vs-flutter.blade.php | 947 ++++++++++++++++++
.../views/vs-react-native-expo.blade.php | 887 ++++++++++++++++
routes/web.php | 2 +
6 files changed, 1959 insertions(+)
create mode 100644 resources/views/components/comparison/bar-chart.blade.php
create mode 100644 resources/views/components/comparison/video-placeholder.blade.php
create mode 100644 resources/views/vs-flutter.blade.php
create mode 100644 resources/views/vs-react-native-expo.blade.php
diff --git a/resources/views/components/comparison/bar-chart.blade.php b/resources/views/components/comparison/bar-chart.blade.php
new file mode 100644
index 00000000..9d122425
--- /dev/null
+++ b/resources/views/components/comparison/bar-chart.blade.php
@@ -0,0 +1,65 @@
+@props([
+ 'items' => [],
+ 'label' => '',
+ 'unit' => '',
+])
+
+
+ @if ($label)
+
+ {{ $label }}
+
+ @endif
+
+
+ @foreach ($items as $item)
+
+
+ {{ $item['name'] }}
+
+
+
+
+ {{ $item['value'] }}{{ $unit }}
+
+
+
+ @endforeach
+
+
diff --git a/resources/views/components/comparison/video-placeholder.blade.php b/resources/views/components/comparison/video-placeholder.blade.php
new file mode 100644
index 00000000..ec8c45ad
--- /dev/null
+++ b/resources/views/components/comparison/video-placeholder.blade.php
@@ -0,0 +1,42 @@
+@props([
+ 'title' => 'App Boot Demo',
+ 'description' => 'Video coming soon',
+])
+
+merge(['class' => 'relative overflow-hidden rounded-2xl bg-gray-100 dark:bg-mirage']) }}
+>
+
+ {{-- Video slot for when videos are ready --}}
+ @if (isset($video))
+ {{ $video }}
+ @else
+ {{-- Placeholder state --}}
+
+ {{-- Play button icon --}}
+
+
+
+ {{ $title }}
+
+
+ {{ $description }}
+
+
+
+ @endif
+
+
diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php
index 5af7f092..d35b5a8b 100644
--- a/resources/views/components/footer.blade.php
+++ b/resources/views/components/footer.blade.php
@@ -299,6 +299,22 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h
Pricing
+
+
+ vs React Native
+
+
+
+
+ vs Flutter
+
+
{{--
+
+ {{-- Hero Section --}}
+
+
+ {{-- Title --}}
+
+ NativePHP
+ vs
+
+ Flutter
+
+
+
+ {{-- Subtitle --}}
+
+ Build native mobile apps without learning a new language.
+ Use the PHP and Laravel skills you already have.
+
+
+
+
+ {{-- Quick Stats Section --}}
+
+
+ Quick comparison stats
+
+
+ {{-- Stat Card --}}
+
+
+ ~50MB
+
+
+ NativePHP Download
+
+
+
+ {{-- Stat Card --}}
+
+
+ 3GB+
+
+
+ Flutter SDK + Tools
+
+
+
+ {{-- Stat Card --}}
+
+
+ 0
+
+
+ New Languages to Learn
+
+
+
+ {{-- Stat Card --}}
+
+
+ 1
+
+
+ Dart Required for Flutter
+
+
+
+
+
+ {{-- Developer Experience Section --}}
+
+
+
+ Developer Experience Comparison
+
+
+ See how NativePHP simplifies mobile development compared to
+ Flutter.
+
+
+
+ {{-- Comparison Cards --}}
+
+ {{-- NativePHP Card --}}
+
+
+
+
+
+
+
+
+
+
+ Use your existing PHP/Laravel skills
+
+
+ No need to learn Dart or Flutter's widget system
+
+
+
+
+
+
+
+
+
+
+ ~50MB total download
+
+
+ Just add Xcode and Android Studio
+
+
+
+
+
+
+
+
+
+
+ Leverage Laravel ecosystem
+
+
+ Eloquent, Blade, Livewire, and thousands of packages
+
+
+
+
+
+
+
+
+
+
+ Easily share code with your web app
+
+
+ Reuse your PHP models, services, and business logic
+
+
+
+
+
+
+ {{-- Flutter Card --}}
+
+
+
+
+
+
+
+
+ Must learn Dart programming
+
+
+ A completely new language and paradigm
+
+
+
+
+
+
+
+
+ 3GB+ SDK download
+
+
+ Plus Flutter SDK, Dart, and Android SDK
+
+
+
+
+
+
+
+
+ Separate ecosystem
+
+
+ pub.dev packages, different from your backend
+
+
+
+
+
+
+
+
+ Slow first builds
+
+
+ First flutter run downloads and compiles extensively
+
+
+
+
+
+
+
+
+ {{-- Size Comparison Charts --}}
+
+
+
+ Size & Speed Comparison
+
+
+
+
+ {{-- SDK Download Size Chart --}}
+
+
+ SDK Download Size
+
+
+
+
+
+ Flutter requires the Dart SDK, Flutter framework, and
+ often Android SDK components
+
+
+
+ {{-- App Bundle Size Chart --}}
+
+
+ Minimum App Size
+
+
+
+
+
+ App size varies widely based on bundled features, assets
+ and platform optimizations
+
+
+
+ {{-- First Boot Time Chart --}}
+
+
+ First Boot Time
+
+
+
+
+
+ Cold start after fresh install
+
+
+
+
+
+ {{-- Getting Started Comparison --}}
+
+
+
+ Getting Started
+
+
+ Compare the setup process side by side
+
+
+
+
+ {{-- NativePHP Setup --}}
+
+
+
+ NativePHP Setup
+
+
+
+ $
+ composer require nativephp/mobile
+
+
+ $
+ php artisan native:install
+
+
+ $
+ php artisan native:run
+
+
+
+ That's it. Your app is running.
+
+
+
+ {{-- Flutter Setup --}}
+
+
+ Flutter Setup
+
+
+
+ #
+ Download & extract Flutter SDK (~1.6GB)
+
+
+ $
+ export PATH="$PATH:/path/to/flutter/bin"
+
+
+ $
+ flutter doctor
+
+
+ $
+ flutter create my_app
+
+
+ $
+ cd my_app && flutter run
+
+
+
+ First run downloads Dart SDK and compiles the engine...
+
+
+
+
+
+ {{-- Language Comparison --}}
+
+
+
+ Use What You Know
+
+
+ Why learn a new language when you can build mobile apps
+ with PHP?
+
+
+
+
+ {{-- PHP Code Example --}}
+
+
+
+ NativePHP (PHP)
+
+
<button
+ wire:click="increment"
+ class="btn btn-primary"
+>
+ Count: @{{ $count }}
+</button>
+
+
+ {{-- Dart Code Example --}}
+
+
+ Flutter (Dart)
+
+
ElevatedButton(
+ onPressed: () {
+ setState(() {
+ _count++;
+ });
+ },
+ child: Text('Count: $_count'),
+)
+
+
+
+
+ {{-- Video Comparison Section --}}
+ {{--
+
+
+
+ See the Difference
+
+
+ Watch real apps boot up side by side
+
+
+
+
+
+
+
+
+ --}}
+
+ {{-- Bifrost Section --}}
+
+
+ {{-- Background decoration --}}
+
+
+
+
+
+
+
+
+
+ Supercharge with Bifrost
+
+
+
+
+ Bifrost is our first-party Continuous Deployment
+ platform that integrates tightly with NativePHP. Get
+ your apps built and into the stores in
+ minutes
+ , not hours.
+
+
+
+
+
+ Cloud builds for iOS & Android
+
+
+
+ Automatic code signing
+
+
+
+ One-click App Store submission
+
+
+
+ Team collaboration built-in
+
+
+
+
+
+
+
+
+ {{-- CTA Section --}}
+
+
+
+ Ready to Try NativePHP?
+
+
+ Skip learning Dart. Build native mobile apps with the PHP
+ skills you already have.
+
+
+
+
+
+
diff --git a/resources/views/vs-react-native-expo.blade.php b/resources/views/vs-react-native-expo.blade.php
new file mode 100644
index 00000000..9c2a2dbe
--- /dev/null
+++ b/resources/views/vs-react-native-expo.blade.php
@@ -0,0 +1,887 @@
+
+
+ {{-- Hero Section --}}
+
+
+ {{-- Title --}}
+
+ NativePHP
+ vs
+
+ React Native & Expo
+
+
+
+ {{-- Subtitle --}}
+
+ Build native mobile apps with the tools you already know.
+ No JavaScript ecosystem complexity required.
+
+
+
+
+ {{-- Quick Stats Section --}}
+
+
+ Quick comparison stats
+
+
+ {{-- Stat Card --}}
+
+
+ ~50MB
+
+
+ NativePHP Download
+
+
+
+ {{-- Stat Card --}}
+
+
+ 200MB+
+
+
+ node_modules Typical
+
+
+
+ {{-- Stat Card --}}
+
+
+ 3
+
+
+ Commands to Build
+
+
+
+ {{-- Stat Card --}}
+
+
+ 1
+
+
+ Language to Learn
+
+
+
+
+
+ {{-- Developer Experience Section --}}
+
+
+
+ Developer Experience Comparison
+
+
+ See how NativePHP simplifies mobile development compared to
+ the React Native ecosystem.
+
+
+
+ {{-- Comparison Cards --}}
+
+ {{-- NativePHP Card --}}
+
+
+
+
+
+
+
+
+
+
+ Use your existing PHP/Laravel skills
+
+
+ No need to learn JavaScript, TypeScript, or JSX
+
+
+
+
+
+
+
+
+
+
+ ~50MB total download
+
+
+ Just add Xcode and Android Studio
+
+
+
+
+
+
+
+
+
+
+ Three commands to build
+
+
+ require, native:install, native:run
+
+
+
+
+
+
+
+
+
+
+ No complex build tooling
+
+
+ No Babel, Metro, or bundler configuration
+
+
+
+
+
+
+ {{-- React Native Card --}}
+
+
+
+
+ React Native / Expo
+
+
+
+
+
+
+
+
+ Must learn JavaScript/TypeScript
+
+
+ Plus React, JSX, and the entire ecosystem
+
+
+
+
+
+
+
+
+ 200MB+ node_modules
+
+
+ Typical create-react-native-app project
+
+
+
+
+
+
+
+
+ Complex setup process
+
+
+ npm install, configure Metro, Babel, CocoaPods...
+
+
+
+
+
+
+
+
+ JavaScript ecosystem complexity
+
+
+ Multiple bundlers, transpilers, and package managers
+
+
+
+
+
+
+
+
+ {{-- Size Comparison Charts --}}
+
+
+
+ Size & Speed Comparison
+
+
+
+
+ {{-- Download Size Chart --}}
+
+
+ Initial Download Size
+
+
+
+
+
+
+ {{-- App Bundle Size Chart --}}
+
+
+ Minimum App Size
+
+
+
+
+
+ App size varies widely based on bundled features, assets
+ and platform optimizations
+
+
+
+ {{-- First Boot Time Chart --}}
+
+
+ First Boot Time
+
+
+
+
+
+ Cold start after fresh install
+
+
+
+
+
+ {{-- Getting Started Comparison --}}
+
+
+
+ Getting Started
+
+
+ Compare the setup process side by side
+
+
+
+
+ {{-- NativePHP Setup --}}
+
+
+
+ NativePHP Setup
+
+
+
+ $
+ composer require nativephp/mobile
+
+
+ $
+ php artisan native:install
+
+
+ $
+ php artisan native:run
+
+
+
+ That's it. Your app is running.
+
+
+
+ {{-- React Native Setup --}}
+
+
+ React Native / Expo Setup
+
+
+
+ $
+ npx create-expo-app my-app
+
+
+ $
+ cd my-app && npm install
+
+
+ $
+ npx expo prebuild
+
+
+ $
+ cd ios && pod install
+
+
+ $
+ npx expo run:ios
+
+
+
+ Plus configuring Metro, Babel, and native dependencies...
+
+
+
+
+
+ {{-- Video Comparison Section --}}
+ {{--
+
+
+
+ See the Difference
+
+
+ Watch real apps boot up side by side
+
+
+
+
+
+
+
+
+ --}}
+
+ {{-- Bifrost Section --}}
+
+
+ {{-- Background decoration --}}
+
+
+
+
+
+
+
+
+
+ Supercharge with Bifrost
+
+
+
+
+ Bifrost is our first-party Continuous Deployment
+ platform that integrates tightly with NativePHP. Get
+ your apps built and into the stores in
+ minutes
+ , not hours.
+
+
+
+
+
+ Cloud builds for iOS & Android
+
+
+
+ Automatic code signing
+
+
+
+ One-click App Store submission
+
+
+
+ Team collaboration built-in
+
+
+
+
+
+
+
+
+ {{-- CTA Section --}}
+
+
+
+ Ready to Try NativePHP?
+
+
+ Skip the JavaScript complexity. Build native mobile apps
+ with the PHP skills you already have.
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index bee0f594..c2fa5efe 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -50,6 +50,8 @@
Route::get('plugins', [PluginDirectoryController::class, 'index'])->name('plugins');
Route::get('plugins/directory', App\Livewire\PluginDirectory::class)->name('plugins.directory');
Route::view('sponsor', 'sponsoring')->name('sponsoring');
+Route::view('vs-react-native-expo', 'vs-react-native-expo')->name('vs-react-native-expo');
+Route::view('vs-flutter', 'vs-flutter')->name('vs-flutter');
Route::get('blog', [ShowBlogController::class, 'index'])->name('blog');
Route::get('blog/{article}', [ShowBlogController::class, 'show'])->name('article');
From 4a44c1203604d29de1f6f9a0691dd7ff1576ff1a Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Tue, 9 Dec 2025 19:21:22 +0000
Subject: [PATCH 16/34] Add features
---
.../comparison/native-features.blade.php | 227 ++++++++++++++++++
resources/views/vs-flutter.blade.php | 3 +
.../views/vs-react-native-expo.blade.php | 3 +
routes/web.php | 24 ++
4 files changed, 257 insertions(+)
create mode 100644 resources/views/components/comparison/native-features.blade.php
diff --git a/resources/views/components/comparison/native-features.blade.php b/resources/views/components/comparison/native-features.blade.php
new file mode 100644
index 00000000..a5e0bd51
--- /dev/null
+++ b/resources/views/components/comparison/native-features.blade.php
@@ -0,0 +1,227 @@
+{{-- Native Features Grid Component --}}
+
+
+
+ Native Features Built In
+
+
+ Access powerful device capabilities with simple PHP facades
+
+
+
+
+
+
+ All accessible via simple PHP facades like
+ Biometrics::prompt()
+
+
diff --git a/resources/views/vs-flutter.blade.php b/resources/views/vs-flutter.blade.php
index f5f59fd1..aef24da0 100644
--- a/resources/views/vs-flutter.blade.php
+++ b/resources/views/vs-flutter.blade.php
@@ -409,6 +409,9 @@ class="size-4"
+ {{-- Native Features Grid --}}
+
+
{{-- Size Comparison Charts --}}
+ {{-- Native Features Grid --}}
+
+
{{-- Size Comparison Charts --}}
where('version', '[0-9]+')
->name('docs.show');
+// Forward platform requests without version to the latest version
+Route::get('docs/{platform}/{page?}', function (string $platform, $page = null) {
+ $page ??= 'getting-started/introduction';
+
+ // Find the latest version for this platform
+ $docsPath = resource_path('views/docs/'.$platform);
+
+ if (! is_dir($docsPath)) {
+ abort(404);
+ }
+
+ $versions = collect(scandir($docsPath))
+ ->filter(fn ($dir) => is_numeric($dir))
+ ->sort()
+ ->values();
+
+ $latestVersion = $versions->last() ?? '1';
+
+ return redirect("/docs/{$platform}/{$latestVersion}/{$page}", 301);
+})
+ ->where('platform', 'desktop|mobile')
+ ->where('page', '.*')
+ ->name('docs.latest');
+
// Forward unversioned requests to the latest version
Route::get('docs/{page?}', function ($page = null) {
$page ??= 'introduction';
From 306921e1fba07d7a0b58161fe9aa20ba1175f86b Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Tue, 9 Dec 2025 20:53:14 -0500
Subject: [PATCH 17/34] Updates API docs for mobile (JS) (#249)
---
.../views/docs/mobile/2/apis/biometrics.md | 102 ++++++
resources/views/docs/mobile/2/apis/browser.md | 56 ++++
resources/views/docs/mobile/2/apis/camera.md | 314 ++++++++++++++++++
resources/views/docs/mobile/2/apis/device.md | 108 ++++++
resources/views/docs/mobile/2/apis/dialog.md | 129 ++++++-
resources/views/docs/mobile/2/apis/file.md | 62 ++++
.../views/docs/mobile/2/apis/geolocation.md | 259 ++++++++++++++-
resources/views/docs/mobile/2/apis/haptics.md | 28 ++
.../views/docs/mobile/2/apis/microphone.md | 164 +++++++++
resources/views/docs/mobile/2/apis/network.md | 35 ++
.../docs/mobile/2/apis/push-notifications.md | 108 ++++++
resources/views/docs/mobile/2/apis/scanner.md | 214 ++++++++++++
.../docs/mobile/2/apis/secure-storage.md | 69 ++++
resources/views/docs/mobile/2/apis/share.md | 123 +++++++
resources/views/docs/mobile/2/apis/system.md | 105 ++++++
15 files changed, 1872 insertions(+), 4 deletions(-)
diff --git a/resources/views/docs/mobile/2/apis/biometrics.md b/resources/views/docs/mobile/2/apis/biometrics.md
index c70bc7e4..2a189cef 100644
--- a/resources/views/docs/mobile/2/apis/biometrics.md
+++ b/resources/views/docs/mobile/2/apis/biometrics.md
@@ -8,28 +8,65 @@ order: 100
The Biometrics API allows you to authenticate users using their device's biometric sensors like Face ID, Touch ID, or
fingerprint scanners.
+
+
+
+
```php
use Native\Mobile\Facades\Biometrics;
```
+
+
+
+```js
+import { biometric, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `prompt()`
Prompts the user for biometric authentication.
+
+
+
+
```php
use Native\Mobile\Facades\Biometrics;
Biometrics::prompt();
```
+
+
+
+```js
+// Basic usage
+await biometric.prompt();
+
+// With an identifier for tracking
+await biometric.prompt()
+ .id('secure-action-auth');
+```
+
+
+
+
## Events
### `Completed`
Fired when biometric authentication completes (success or failure).
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Biometric\Completed;
@@ -47,6 +84,71 @@ public function handle(bool $success)
}
```
+
+
+
+```js
+import { biometric, on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const isAuthenticated = ref(false);
+
+const handleBiometricComplete = (payload) => {
+ if (payload.success) {
+ isAuthenticated.value = true;
+ unlockSecureFeature();
+ } else {
+ showErrorMessage();
+ }
+};
+
+const authenticate = async () => {
+ await biometric.prompt();
+};
+
+onMounted(() => {
+ on(Events.Biometric.Completed, handleBiometricComplete);
+});
+
+onUnmounted(() => {
+ off(Events.Biometric.Completed, handleBiometricComplete);
+});
+```
+
+
+
+
+```jsx
+import { biometric, on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+const handleBiometricComplete = (payload) => {
+ if (payload.success) {
+ setIsAuthenticated(true);
+ unlockSecureFeature();
+ } else {
+ showErrorMessage();
+ }
+};
+
+const authenticate = async () => {
+ await biometric.prompt();
+};
+
+useEffect(() => {
+ on(Events.Biometric.Completed, handleBiometricComplete);
+
+ return () => {
+ off(Events.Biometric.Completed, handleBiometricComplete);
+ };
+}, []);
+```
+
+
+
+
## Platform Support
- **iOS:** Face ID, Touch ID
diff --git a/resources/views/docs/mobile/2/apis/browser.md b/resources/views/docs/mobile/2/apis/browser.md
index 246ef73d..56c158aa 100644
--- a/resources/views/docs/mobile/2/apis/browser.md
+++ b/resources/views/docs/mobile/2/apis/browser.md
@@ -8,36 +8,92 @@ order: 200
The Browser API provides three methods for opening URLs, each designed for specific use cases:
in-app browsing, system browser navigation, and web authentication flows.
+
+
+
+
```php
use Native\Mobile\Facades\Browser;
```
+
+
+
+```js
+import { browser } from '#nativephp';
+```
+
+
+
+
## Methods
### `inApp()`
Opens a URL in an embedded browser within your app using Custom Tabs (Android) or SFSafariViewController (iOS).
+
+
+
+
```php
Browser::inApp('https://nativephp.com/mobile');
```
+
+
+
+```js
+await browser.inApp('https://nativephp.com/mobile');
+```
+
+
+
+
### `open()`
Opens a URL in the device's default browser app, leaving your application entirely.
+
+
+
+
```php
Browser::open('https://nativephp.com/mobile');
```
+
+
+
+```js
+await browser.open('https://nativephp.com/mobile');
+```
+
+
+
+
### `auth()`
Opens a URL in a specialized authentication browser designed for OAuth flows with automatic `nativephp://` redirect handling.
+
+
+
+
```php
Browser::auth('https://provider.com/oauth/authorize?client_id=123&redirect_uri=nativephp://127.0.0.1/auth/callback');
```
+
+
+
+```js
+await browser.auth('https://provider.com/oauth/authorize?client_id=123&redirect_uri=nativephp://127.0.0.1/auth/callback');
+```
+
+
+
+
## Use Cases
### When to Use Each Method
diff --git a/resources/views/docs/mobile/2/apis/camera.md b/resources/views/docs/mobile/2/apis/camera.md
index c5e61311..aeeb7a91 100644
--- a/resources/views/docs/mobile/2/apis/camera.md
+++ b/resources/views/docs/mobile/2/apis/camera.md
@@ -7,20 +7,53 @@ order: 300
The Camera API provides access to the device's camera for taking photos, recording videos, and selecting media from the gallery.
+
+
+
+
```php
use Native\Mobile\Facades\Camera;
```
+
+
+
+```js
+import { camera, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `getPhoto()`
Opens the camera interface to take a photo.
+
+
+
+
```php
Camera::getPhoto();
```
+
+
+
+```js
+// Basic usage
+await camera.getPhoto();
+
+// With identifier for tracking
+await camera.getPhoto()
+ .id('profile-pic');
+```
+
+
+
+
### `recordVideo()`
Opens the camera interface to record a video with optional configuration.
@@ -30,6 +63,10 @@ Opens the camera interface to record a video with optional configuration.
**Returns:** `PendingVideoRecorder` - Fluent interface for configuring video recording
+
+
+
+
```php
// Basic video recording
Camera::recordVideo();
@@ -44,6 +81,26 @@ Camera::recordVideo()
->start();
```
+
+
+
+```js
+// Basic video recording
+await camera.recordVideo();
+
+// With maximum duration
+await camera.recordVideo()
+ .maxDuration(60);
+
+// With identifier for tracking
+await camera.recordVideo()
+ .maxDuration(30)
+ .id('my-video-123');
+```
+
+
+
+
### `pickImages()`
Opens the gallery/photo picker to select existing images.
@@ -54,6 +111,10 @@ Opens the gallery/photo picker to select existing images.
**Returns:** `bool` - `true` if picker opened successfully
+
+
+
+
```php
// Pick a single image
Camera::pickImages('images', false);
@@ -65,6 +126,35 @@ Camera::pickImages('images', true);
Camera::pickImages('all', true);
```
+
+
+
+```js
+// Pick images using fluent API
+await camera.pickImages()
+ .images()
+ .multiple()
+ .maxItems(5);
+
+// Pick only videos
+await camera.pickImages()
+ .videos()
+ .multiple();
+
+// Pick any media type
+await camera.pickImages()
+ .all()
+ .multiple()
+ .maxItems(10);
+
+// Single image selection
+await camera.pickImages()
+ .images();
+```
+
+
+
+
## PendingVideoRecorder
The fluent API returned by `recordVideo()` provides several methods for configuring video recording:
@@ -139,6 +229,10 @@ Fired when a photo is taken with the camera.
**Payload:** `string $path` - File path to the captured photo
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Camera\PhotoTaken;
@@ -151,6 +245,55 @@ public function handlePhotoTaken(string $path)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const photoPath = ref('');
+
+const handlePhotoTaken = (payload) => {
+ photoPath.value = payload.path;
+ processPhoto(payload.path);
+};
+
+onMounted(() => {
+ on(Events.Camera.PhotoTaken, handlePhotoTaken);
+});
+
+onUnmounted(() => {
+ off(Events.Camera.PhotoTaken, handlePhotoTaken);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [photoPath, setPhotoPath] = useState('');
+
+const handlePhotoTaken = (payload) => {
+ setPhotoPath(payload.path);
+ processPhoto(payload.path);
+};
+
+useEffect(() => {
+ on(Events.Camera.PhotoTaken, handlePhotoTaken);
+
+ return () => {
+ off(Events.Camera.PhotoTaken, handlePhotoTaken);
+ };
+}, []);
+```
+
+
+
+
### `VideoRecorded`
Fired when a video is successfully recorded.
@@ -160,6 +303,10 @@ Fired when a video is successfully recorded.
- `string $mimeType` - Video MIME type (default: `'video/mp4'`)
- `?string $id` - Optional identifier if set via `id()` method
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Camera\VideoRecorded;
@@ -177,6 +324,65 @@ public function handleVideoRecorded(string $path, string $mimeType, ?string $id
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const videoPath = ref('');
+
+const handleVideoRecorded = (payload) => {
+ const { path, mimeType, id } = payload;
+ videoPath.value = path;
+ processVideo(path);
+
+ if (id === 'my-upload-video') {
+ uploadVideo(path);
+ }
+};
+
+onMounted(() => {
+ on(Events.Camera.VideoRecorded, handleVideoRecorded);
+});
+
+onUnmounted(() => {
+ off(Events.Camera.VideoRecorded, handleVideoRecorded);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [videoPath, setVideoPath] = useState('');
+
+const handleVideoRecorded = (payload) => {
+ const { path, mimeType, id } = payload;
+ setVideoPath(path);
+ processVideo(path);
+
+ if (id === 'my-upload-video') {
+ uploadVideo(path);
+ }
+};
+
+useEffect(() => {
+ on(Events.Camera.VideoRecorded, handleVideoRecorded);
+
+ return () => {
+ off(Events.Camera.VideoRecorded, handleVideoRecorded);
+ };
+}, []);
+```
+
+
+
+
### `VideoCancelled`
Fired when video recording is cancelled by the user.
@@ -185,6 +391,10 @@ Fired when video recording is cancelled by the user.
- `bool $cancelled` - Always `true`
- `?string $id` - Optional identifier if set via `id()` method
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Camera\VideoCancelled;
@@ -197,12 +407,59 @@ public function handleVideoCancelled(bool $cancelled, ?string $id = null)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { onMounted, onUnmounted } from 'vue';
+
+const handleVideoCancelled = (payload) => {
+ notifyUser('Video recording was cancelled');
+};
+
+onMounted(() => {
+ on(Events.Camera.VideoCancelled, handleVideoCancelled);
+});
+
+onUnmounted(() => {
+ off(Events.Camera.VideoCancelled, handleVideoCancelled);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useEffect } from 'react';
+
+const handleVideoCancelled = (payload) => {
+ notifyUser('Video recording was cancelled');
+};
+
+useEffect(() => {
+ on(Events.Camera.VideoCancelled, handleVideoCancelled);
+
+ return () => {
+ off(Events.Camera.VideoCancelled, handleVideoCancelled);
+ };
+}, []);
+```
+
+
+
+
### `MediaSelected`
Fired when media is selected from the gallery.
**Payload:** `array $media` - Array of selected media items
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Gallery\MediaSelected;
@@ -217,6 +474,63 @@ public function handleMediaSelected($success, $files, $count)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const selectedFiles = ref([]);
+
+const handleMediaSelected = (payload) => {
+ const { success, files, count } = payload;
+
+ if (success) {
+ selectedFiles.value = files;
+ files.forEach(file => processMedia(file));
+ }
+};
+
+onMounted(() => {
+ on(Events.Gallery.MediaSelected, handleMediaSelected);
+});
+
+onUnmounted(() => {
+ off(Events.Gallery.MediaSelected, handleMediaSelected);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [selectedFiles, setSelectedFiles] = useState([]);
+
+const handleMediaSelected = (payload) => {
+ const { success, files, count } = payload;
+
+ if (success) {
+ setSelectedFiles(files);
+ files.forEach(file => processMedia(file));
+ }
+};
+
+useEffect(() => {
+ on(Events.Gallery.MediaSelected, handleMediaSelected);
+
+ return () => {
+ off(Events.Gallery.MediaSelected, handleMediaSelected);
+ };
+}, []);
+```
+
+
+
+
## Storage Locations
Media files are stored in different locations depending on the platform:
diff --git a/resources/views/docs/mobile/2/apis/device.md b/resources/views/docs/mobile/2/apis/device.md
index e0a6a24b..ccf79314 100644
--- a/resources/views/docs/mobile/2/apis/device.md
+++ b/resources/views/docs/mobile/2/apis/device.md
@@ -6,10 +6,25 @@ order: 400
## Overview
The Device API exposes internal information about the device, such as the model and operating system version, along with user information such as unique ids.
+
+
+
+
+
```php
use Native\Mobile\Facades\Device;
```
+
+
+
+```js
+import { device } from '#nativephp';
+```
+
+
+
+
## Methods
### `getId()`
@@ -18,12 +33,54 @@ Return a unique identifier for the device.
Returns: `string`
+
+
+
+
+```php
+$id = Device::getId();
+```
+
+
+
+
+```js
+const result = await device.getId();
+const deviceId = result.id;
+```
+
+
+
+
### `getInfo()`
Return information about the underlying device/os/platform.
Returns JSON encoded: `string`
+
+
+
+
+```php
+$info = Device::getInfo();
+$deviceInfo = json_decode($info);
+```
+
+
+
+
+```js
+const result = await device.getInfo();
+const deviceInfo = JSON.parse(result.info);
+
+console.log(deviceInfo.platform); // 'ios' or 'android'
+console.log(deviceInfo.model); // e.g., 'iPhone13,4'
+console.log(deviceInfo.osVersion); // e.g., '17.0'
+```
+
+
+
### `vibrate()`
@@ -31,20 +88,48 @@ Triggers device vibration for tactile feedback.
**Returns:** `void`
+
+
+
+
```php
Device::vibrate();
```
+
+
+
+```js
+await device.vibrate();
+```
+
+
+
+
### `flashlight()`
Toggles the device flashlight (camera flash LED) on and off.
**Returns:** `void`
+
+
+
+
```php
Device::flashlight(); // Toggle flashlight state
```
+
+
+
+```js
+const result = await device.flashlight();
+console.log(result.state); // true = on, false = off
+```
+
+
+
### `getBatteryInfo()`
@@ -52,6 +137,29 @@ Return information about the battery.
Returns JSON encoded: `string`
+
+
+
+
+```php
+$info = Device::getBatteryInfo();
+$batteryInfo = json_decode($info);
+```
+
+
+
+
+```js
+const result = await device.getBatteryInfo();
+const batteryInfo = JSON.parse(result.info);
+
+console.log(batteryInfo.batteryLevel); // 0-1 (e.g., 0.85 = 85%)
+console.log(batteryInfo.isCharging); // true/false
+```
+
+
+
+
## Device Info
| Prop | Type | Description
diff --git a/resources/views/docs/mobile/2/apis/dialog.md b/resources/views/docs/mobile/2/apis/dialog.md
index 16b583ca..1a72f6be 100644
--- a/resources/views/docs/mobile/2/apis/dialog.md
+++ b/resources/views/docs/mobile/2/apis/dialog.md
@@ -7,10 +7,24 @@ order: 500
The Dialog API provides access to native UI elements like alerts, toasts, and sharing interfaces.
+
+
+
+
```php
use Native\Mobile\Facades\Dialog;
```
+
+
+
+```js
+import { dialog, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `alert()`
@@ -24,9 +38,13 @@ Displays a native alert dialog with customizable buttons.
**Button Positioning:**
- **1 button** - Positive (OK/Confirm)
-- **2 buttons** - Negative (Cancel) + Positive (OK/Confirm)
+- **2 buttons** - Negative (Cancel) + Positive (OK/Confirm)
- **3 buttons** - Negative (Cancel) + Neutral (Maybe) + Positive (OK/Confirm)
+
+
+
+
```php
Dialog::alert(
'Confirm Action',
@@ -35,18 +53,56 @@ Dialog::alert(
);
```
+
+
+
+```js
+// Simple usage
+await dialog.alert('Confirm Action', 'Are you sure you want to delete this item?', ['Cancel', 'Delete']);
+
+// Fluent builder API
+await dialog.alert()
+ .title('Confirm Action')
+ .message('Are you sure you want to delete this item?')
+ .buttons(['Cancel', 'Delete']);
+
+// Quick confirm dialog (OK/Cancel)
+await dialog.alert()
+ .confirm('Confirm Action', 'Are you sure?');
+
+// Quick destructive confirm (Cancel/Delete)
+await dialog.alert()
+ .confirmDelete('Delete Item', 'This action cannot be undone.');
+```
+
+
+
+
### `toast()`
Displays a brief toast notification message.
-
**Parameters:**
- `string $message` - The message to display
+
+
+
+
```php
Dialog::toast('Item saved successfully!');
```
+
+
+
+```js
+await dialog.toast('Item saved successfully!');
+```
+
+
+
+
#### Good toast messages
- Short and clear
@@ -83,10 +139,14 @@ Dialog::share(
Fired when a button is pressed in an alert dialog.
-**Payload:**
+**Payload:**
- `int $index` - Index of the pressed button (0-based)
- `string $label` - Label/text of the pressed button
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Alert\ButtonPressed;
@@ -107,3 +167,66 @@ public function handleAlertButton($index, $label)
}
}
```
+
+
+
+
+```js
+import { dialog, on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const buttonLabel = ref('');
+
+const handleButtonPressed = (payload) => {
+ const { index, label } = payload;
+ buttonLabel.value = label;
+
+ if (index === 0) {
+ dialog.toast(`You pressed '${label}'`);
+ } else if (index === 1) {
+ performAction();
+ dialog.toast(`You pressed '${label}'`);
+ }
+};
+
+onMounted(() => {
+ on(Events.Alert.ButtonPressed, handleButtonPressed);
+});
+
+onUnmounted(() => {
+ off(Events.Alert.ButtonPressed, handleButtonPressed);
+});
+```
+
+
+
+
+```jsx
+import { dialog, on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [buttonLabel, setButtonLabel] = useState('');
+
+const handleButtonPressed = (payload) => {
+ const { index, label } = payload;
+ setButtonLabel(label);
+
+ if (index === 0) {
+ dialog.toast(`You pressed '${label}'`);
+ } else if (index === 1) {
+ performAction();
+ dialog.toast(`You pressed '${label}'`);
+ }
+};
+
+useEffect(() => {
+ on(Events.Alert.ButtonPressed, handleButtonPressed);
+
+ return () => {
+ off(Events.Alert.ButtonPressed, handleButtonPressed);
+ };
+}, []);
+```
+
+
+
diff --git a/resources/views/docs/mobile/2/apis/file.md b/resources/views/docs/mobile/2/apis/file.md
index 4317e8b7..a2c266d0 100644
--- a/resources/views/docs/mobile/2/apis/file.md
+++ b/resources/views/docs/mobile/2/apis/file.md
@@ -7,10 +7,24 @@ order: 600
The File API provides utilities for managing files on the device. You can move files between directories or copy files to new locations. These operations execute synchronously and return a boolean indicating success or failure.
+
+
+
+
```php
use Native\Mobile\Facades\File;
```
+
+
+
+```js
+import { file } from '#nativephp';
+```
+
+
+
+
## Methods
### `move(string $from, string $to)`
@@ -23,6 +37,10 @@ Moves a file from one location to another. The source file is removed from its o
**Returns:** `bool` - `true` on success, `false` on failure
+
+
+
+
```php
// Move a captured photo to the app's storage directory
$success = File::move(
@@ -37,6 +55,26 @@ if ($success) {
}
```
+
+
+
+```js
+// Move a captured photo to the app's storage directory
+const result = await file.move(
+ '/var/mobile/Containers/Data/tmp/photo.jpg',
+ '/var/mobile/Containers/Data/Documents/photos/photo.jpg'
+);
+
+if (result.success) {
+ // File moved successfully
+} else {
+ // Move operation failed
+}
+```
+
+
+
+
### `copy(string $from, string $to)`
Copies a file to a new location. The source file remains in its original location.
@@ -47,6 +85,10 @@ Copies a file to a new location. The source file remains in its original locatio
**Returns:** `bool` - `true` on success, `false` on failure
+
+
+
+
```php
// Copy a file to create a backup
$success = File::copy(
@@ -61,6 +103,26 @@ if ($success) {
}
```
+
+
+
+```js
+// Copy a file to create a backup
+const result = await file.copy(
+ '/var/mobile/Containers/Data/Documents/document.pdf',
+ '/var/mobile/Containers/Data/Documents/backups/document.pdf'
+);
+
+if (result.success) {
+ // File copied successfully
+} else {
+ // Copy operation failed
+}
+```
+
+
+
+
## Examples
### Moving Captured Media
diff --git a/resources/views/docs/mobile/2/apis/geolocation.md b/resources/views/docs/mobile/2/apis/geolocation.md
index 7528bfe9..6e0d361d 100644
--- a/resources/views/docs/mobile/2/apis/geolocation.md
+++ b/resources/views/docs/mobile/2/apis/geolocation.md
@@ -7,10 +7,24 @@ order: 700
The Geolocation API provides access to the device's GPS and location services to determine the user's current position.
+
+
+
+
```php
use Native\Mobile\Facades\Geolocation;
```
+
+
+
+```js
+import { geolocation, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `getCurrentPosition()`
@@ -22,6 +36,10 @@ Gets the current GPS location of the device.
**Returns:** Location data via events
+
+
+
+
```php
// Get location using network positioning (faster, less accurate)
Geolocation::getCurrentPosition();
@@ -30,26 +48,78 @@ Geolocation::getCurrentPosition();
Geolocation::getCurrentPosition(true);
```
+
+
+
+```js
+// Get location using network positioning (faster, less accurate)
+await geolocation.getCurrentPosition();
+
+// Get location using GPS (slower, more accurate)
+await geolocation.getCurrentPosition()
+ .fineAccuracy(true);
+
+// With identifier for tracking
+await geolocation.getCurrentPosition()
+ .fineAccuracy(true)
+ .id('current-loc');
+```
+
+
+
+
### `checkPermissions()`
Checks the current location permissions status.
**Returns:** Permission status via events
+
+
+
+
```php
Geolocation::checkPermissions();
```
+
+
+
+```js
+await geolocation.checkPermissions();
+```
+
+
+
+
### `requestPermissions()`
Requests location permissions from the user.
**Returns:** Permission status after request via events
+
+
+
+
```php
Geolocation::requestPermissions();
```
+
+
+
+```js
+await geolocation.requestPermissions();
+
+// With remember flag
+await geolocation.requestPermissions()
+ .remember();
+```
+
+
+
+
## Events
### `LocationReceived`
@@ -65,6 +135,10 @@ Fired when location data is requested (success or failure).
- `string $provider` - Location provider used (GPS, network, etc.)
- `string $error` - Error message (when unsuccessful)
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Geolocation\LocationReceived;
@@ -83,13 +157,76 @@ public function handleLocationReceived(
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const location = ref({ latitude: null, longitude: null });
+const error = ref('');
+
+const handleLocationReceived = (payload) => {
+ if (payload.success) {
+ location.value = {
+ latitude: payload.latitude,
+ longitude: payload.longitude
+ };
+ } else {
+ error.value = payload.error;
+ }
+};
+
+onMounted(() => {
+ on(Events.Geolocation.LocationReceived, handleLocationReceived);
+});
+
+onUnmounted(() => {
+ off(Events.Geolocation.LocationReceived, handleLocationReceived);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [location, setLocation] = useState({ latitude: null, longitude: null });
+const [error, setError] = useState('');
+
+const handleLocationReceived = (payload) => {
+ if (payload.success) {
+ setLocation({
+ latitude: payload.latitude,
+ longitude: payload.longitude
+ });
+ } else {
+ setError(payload.error);
+ }
+};
+
+useEffect(() => {
+ on(Events.Geolocation.LocationReceived, handleLocationReceived);
+
+ return () => {
+ off(Events.Geolocation.LocationReceived, handleLocationReceived);
+ };
+}, []);
+```
+
+
+
+
### `PermissionStatusReceived`
Fired when permission status is checked.
**Event Parameters:**
- `string $location` - Overall location permission status
-- `string $coarseLocation` - Coarse location permission status
+- `string $coarseLocation` - Coarse location permission status
- `string $fineLocation` - Fine location permission status
**Permission Values:**
@@ -97,6 +234,10 @@ Fired when permission status is checked.
- `'denied'` - Permission is denied
- `'not_determined'` - Permission not yet requested
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Geolocation\PermissionStatusReceived;
@@ -108,6 +249,55 @@ public function handlePermissionStatus($location, $coarseLocation, $fineLocation
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const permissionStatus = ref('');
+
+const handlePermissionStatus = (payload) => {
+ const { location } = payload;
+ permissionStatus.value = location;
+};
+
+onMounted(() => {
+ on(Events.Geolocation.PermissionStatusReceived, handlePermissionStatus);
+});
+
+onUnmounted(() => {
+ off(Events.Geolocation.PermissionStatusReceived, handlePermissionStatus);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [permissionStatus, setPermissionStatus] = useState('');
+
+const handlePermissionStatus = (payload) => {
+ const { location } = payload;
+ setPermissionStatus(location);
+};
+
+useEffect(() => {
+ on(Events.Geolocation.PermissionStatusReceived, handlePermissionStatus);
+
+ return () => {
+ off(Events.Geolocation.PermissionStatusReceived, handlePermissionStatus);
+ };
+}, []);
+```
+
+
+
+
### `PermissionRequestResult`
Fired when a permission request completes.
@@ -122,6 +312,10 @@ Fired when a permission request completes.
**Special Values:**
- `'permanently_denied'` - User has permanently denied permission
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Geolocation\PermissionRequestResult;
@@ -139,6 +333,69 @@ public function handlePermissionRequest($location, $coarseLocation, $fineLocatio
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const error = ref('');
+
+const handlePermissionRequest = (payload) => {
+ const { location, coarseLocation, fineLocation } = payload;
+
+ if (location === 'permanently_denied') {
+ error.value = 'Please enable location in Settings.';
+ } else if (coarseLocation === 'granted' || fineLocation === 'granted') {
+ getCurrentLocation();
+ } else {
+ error.value = 'Location permission is required.';
+ }
+};
+
+onMounted(() => {
+ on(Events.Geolocation.PermissionRequestResult, handlePermissionRequest);
+});
+
+onUnmounted(() => {
+ off(Events.Geolocation.PermissionRequestResult, handlePermissionRequest);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [error, setError] = useState('');
+
+const handlePermissionRequest = (payload) => {
+ const { location, coarseLocation, fineLocation } = payload;
+
+ if (location === 'permanently_denied') {
+ setError('Please enable location in Settings.');
+ } else if (coarseLocation === 'granted' || fineLocation === 'granted') {
+ getCurrentLocation();
+ } else {
+ setError('Location permission is required.');
+ }
+};
+
+useEffect(() => {
+ on(Events.Geolocation.PermissionRequestResult, handlePermissionRequest);
+
+ return () => {
+ off(Events.Geolocation.PermissionRequestResult, handlePermissionRequest);
+ };
+}, []);
+```
+
+
+
+
## Privacy Considerations
- **Explain why** you need location access before requesting
diff --git a/resources/views/docs/mobile/2/apis/haptics.md b/resources/views/docs/mobile/2/apis/haptics.md
index 14876f75..84e8ad2f 100644
--- a/resources/views/docs/mobile/2/apis/haptics.md
+++ b/resources/views/docs/mobile/2/apis/haptics.md
@@ -7,10 +7,24 @@ order: 800
The Haptics API provides access to the device's vibration and haptic feedback system for tactile user interactions.
+
+
+
+
```php
use Native\Mobile\Facades\Haptics;
```
+
+
+
+```js
+import { device } from '#nativephp';
+```
+
+
+
+
## Methods
### `vibrate()`
@@ -19,10 +33,24 @@ Triggers device vibration for tactile feedback.
**Returns:** `void`
+
+
+
+
```php
Haptics::vibrate();
```
+
+
+
+```js
+await device.vibrate();
+```
+
+
+
+
**Use haptics for:** Button presses, form validation, important notifications, game events.
**Avoid haptics for:** Frequent events, background processes, minor updates.
diff --git a/resources/views/docs/mobile/2/apis/microphone.md b/resources/views/docs/mobile/2/apis/microphone.md
index 88c2ab6a..067d0d63 100644
--- a/resources/views/docs/mobile/2/apis/microphone.md
+++ b/resources/views/docs/mobile/2/apis/microphone.md
@@ -8,51 +8,130 @@ order: 900
The Microphone API provides access to the device's microphone for recording audio. It offers a fluent interface for
starting and managing recordings, tracking them with unique identifiers, and responding to completion events.
+
+
+
+
```php
use Native\Mobile\Facades\Microphone;
```
+
+
+
+```js
+import { microphone, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `record()`
Start an audio recording. Returns a `PendingMicrophone` instance that controls the recording lifecycle.
+
+
+
+
```php
Microphone::record()->start();
```
+
+
+
+```js
+// Basic recording
+await microphone.record();
+
+// With identifier for tracking
+await microphone.record()
+ .id('voice-memo');
+```
+
+
+
+
### `stop()`
Stop the current audio recording. If this results in a saved file, this dispatches the `AudioRecorded` event with the
recording file path.
+
+
+
+
```php
Microphone::stop();
```
+
+
+
+```js
+await microphone.stop();
+```
+
+
+
+
### `pause()`
Pause the current audio recording without ending it.
+
+
+
+
```php
Microphone::pause();
```
+
+
+
+```js
+await microphone.pause();
+```
+
+
+
+
### `resume()`
Resume a paused audio recording.
+
+
+
+
```php
Microphone::resume();
```
+
+
+
+```js
+await microphone.resume();
+```
+
+
+
+
### `getStatus()`
Get the current recording status.
**Returns:** `string` - One of: `"idle"`, `"recording"`, or `"paused"`
+
+
+
+
```php
$status = Microphone::getStatus();
@@ -61,12 +140,30 @@ if ($status === 'recording') {
}
```
+
+
+
+```js
+const result = await microphone.getStatus();
+
+if (result.status === 'recording') {
+ // A recording is in progress
+}
+```
+
+
+
+
### `getRecording()`
Get the file path to the last recorded audio file.
**Returns:** `string|null` - Path to the last recording, or `null` if none exists
+
+
+
+
```php
$path = Microphone::getRecording();
@@ -75,6 +172,20 @@ if ($path) {
}
```
+
+
+
+```js
+const result = await microphone.getRecording();
+
+if (result.path) {
+ // Process the recording file
+}
+```
+
+
+
+
## PendingMicrophone
The `PendingMicrophone` provides a fluent interface for configuring and starting audio recordings. Most methods return
@@ -174,6 +285,10 @@ Dispatched when an audio recording completes. The event includes the file path a
- `string $mimeType` - MIME type of the audio (default: `'audio/m4a'`)
- `?string $id` - The recorder's ID, if one was set
+
+
+
+
```php
#[OnNative(MicrophoneRecorded::class)]
public function handleAudioRecorded(string $path, string $mimeType, ?string $id)
@@ -187,6 +302,55 @@ public function handleAudioRecorded(string $path, string $mimeType, ?string $id)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { ref, onMounted, onUnmounted } from 'vue';
+
+const recordings = ref([]);
+
+const handleAudioRecorded = (payload) => {
+ const { path, mimeType, id } = payload;
+ recordings.value.push({ path, mimeType, id });
+};
+
+onMounted(() => {
+ on(Events.Microphone.MicrophoneRecorded, handleAudioRecorded);
+});
+
+onUnmounted(() => {
+ off(Events.Microphone.MicrophoneRecorded, handleAudioRecorded);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useState, useEffect } from 'react';
+
+const [recordings, setRecordings] = useState([]);
+
+const handleAudioRecorded = (payload) => {
+ const { path, mimeType, id } = payload;
+ setRecordings(prev => [...prev, { path, mimeType, id }]);
+};
+
+useEffect(() => {
+ on(Events.Microphone.MicrophoneRecorded, handleAudioRecorded);
+
+ return () => {
+ off(Events.Microphone.MicrophoneRecorded, handleAudioRecorded);
+ };
+}, []);
+```
+
+
+
+
## Notes
- **Microphone Permission:** The first time your app requests microphone access, users will be prompted for permission. If denied, recording functions will fail silently.
diff --git a/resources/views/docs/mobile/2/apis/network.md b/resources/views/docs/mobile/2/apis/network.md
index 985b848c..b206dc8c 100644
--- a/resources/views/docs/mobile/2/apis/network.md
+++ b/resources/views/docs/mobile/2/apis/network.md
@@ -7,10 +7,24 @@ order: 1000
The Network API provides access to the device's current network status and connection information. You can check whether the device is connected, determine the connection type, and detect metered or low-bandwidth conditions.
+
+
+
+
```php
use Native\Mobile\Facades\Network;
```
+
+
+
+```js
+import { network } from '#nativephp';
+```
+
+
+
+
## Methods
### `status()`
@@ -26,6 +40,10 @@ The returned object contains the following properties:
- `isExpensive` (bool) - Whether the connection is metered/cellular (iOS only, always `false` on Android)
- `isConstrained` (bool) - Whether Low Data Mode is enabled (iOS only, always `false` on Android)
+
+
+
+
```php
$status = Network::status();
@@ -37,6 +55,23 @@ if ($status) {
}
```
+
+
+
+```js
+const status = await network.status();
+
+if (status) {
+ console.log(status.connected); // true/false
+ console.log(status.type); // "wifi", "cellular", "ethernet", or "unknown"
+ console.log(status.isExpensive); // true/false (iOS only)
+ console.log(status.isConstrained); // true/false (iOS only)
+}
+```
+
+
+
+
## Notes
- **iOS-specific properties:** The `isExpensive` and `isConstrained` properties are only meaningful on iOS. On Android, these values will always be `false`.
diff --git a/resources/views/docs/mobile/2/apis/push-notifications.md b/resources/views/docs/mobile/2/apis/push-notifications.md
index ce8c837c..a919d5f7 100644
--- a/resources/views/docs/mobile/2/apis/push-notifications.md
+++ b/resources/views/docs/mobile/2/apis/push-notifications.md
@@ -7,10 +7,24 @@ order: 1100
The PushNotifications API handles device registration for Firebase Cloud Messaging to receive push notifications.
+
+
+
+
```php
use Native\Mobile\Facades\PushNotifications;
```
+
+
+
+```js
+import { pushNotifications, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Methods
### `enroll()`
@@ -19,12 +33,55 @@ Requests permission and enrolls the device for push notifications.
**Returns:** `void`
+
+
+
+
+```php
+PushNotifications::enroll();
+```
+
+
+
+
+```js
+// Basic enrollment
+await pushNotifications.enroll();
+
+// With identifier for tracking
+await pushNotifications.enroll()
+ .id('main-enrollment')
+ .remember();
+```
+
+
+
+
### `getToken()`
Retrieves the current push notification token for this device.
**Returns:** `string|null` - The FCM token, or `null` if not available
+
+
+
+
+```php
+$token = PushNotifications::getToken();
+```
+
+
+
+
+```js
+const result = await pushNotifications.getToken();
+const token = result.token; // APNS token on iOS, FCM token on Android
+```
+
+
+
+
## Events
### `TokenGenerated`
@@ -33,6 +90,10 @@ Fired when a push notification token is successfully generated.
**Payload:** `string $token` - The FCM token for this device
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\PushNotification\TokenGenerated;
@@ -45,6 +106,53 @@ public function handlePushToken(string $token)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { onMounted, onUnmounted } from 'vue';
+
+const handleTokenGenerated = (payload) => {
+ const { token } = payload;
+ // Send token to your backend
+ sendTokenToServer(token);
+};
+
+onMounted(() => {
+ on(Events.PushNotification.TokenGenerated, handleTokenGenerated);
+});
+
+onUnmounted(() => {
+ off(Events.PushNotification.TokenGenerated, handleTokenGenerated);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useEffect } from 'react';
+
+const handleTokenGenerated = (payload) => {
+ const { token } = payload;
+ // Send token to your backend
+ sendTokenToServer(token);
+};
+
+useEffect(() => {
+ on(Events.PushNotification.TokenGenerated, handleTokenGenerated);
+
+ return () => {
+ off(Events.PushNotification.TokenGenerated, handleTokenGenerated);
+ };
+}, []);
+```
+
+
+
+
## Permission Flow
1. User taps "Enable Notifications"
diff --git a/resources/views/docs/mobile/2/apis/scanner.md b/resources/views/docs/mobile/2/apis/scanner.md
index 1f465489..4702d8ca 100644
--- a/resources/views/docs/mobile/2/apis/scanner.md
+++ b/resources/views/docs/mobile/2/apis/scanner.md
@@ -7,13 +7,31 @@ order: 1200
The Scanner API provides cross-platform barcode and QR code scanning capabilities through a native camera interface.
+
+
+
+
```php
use Native\Mobile\Facades\Scanner;
use Native\Mobile\Events\Scanner\CodeScanned;
```
+
+
+
+```js
+import { scanner, on, off, Events } from '#nativephp';
+```
+
+
+
+
## Basic Usage
+
+
+
+
```php
// Open scanner
Scanner::scan();
@@ -26,42 +44,183 @@ public function handleScan($data, $format, $id = null)
}
```
+
+
+
+```js
+import { scanner, dialog, on, off, Events } from '#nativephp';
+import { onMounted, onUnmounted } from 'vue';
+
+// Open scanner
+await scanner.scan();
+
+// Listen for scan results
+const handleScan = (payload) => {
+ const { data, format, id } = payload;
+ dialog.toast(`Scanned: ${data}`);
+};
+
+onMounted(() => {
+ on(Events.Scanner.CodeScanned, handleScan);
+});
+
+onUnmounted(() => {
+ off(Events.Scanner.CodeScanned, handleScan);
+});
+```
+
+
+
+
+```jsx
+import { scanner, dialog, on, off, Events } from '#nativephp';
+import { useEffect } from 'react';
+
+// Open scanner
+await scanner.scan();
+
+// Listen for scan results
+const handleScan = (payload) => {
+ const { data, format, id } = payload;
+ dialog.toast(`Scanned: ${data}`);
+};
+
+useEffect(() => {
+ on(Events.Scanner.CodeScanned, handleScan);
+
+ return () => {
+ off(Events.Scanner.CodeScanned, handleScan);
+ };
+}, []);
+```
+
+
+
+
## Configuration Methods
### `prompt(string $prompt)`
Set custom prompt text displayed on the scanner screen.
+
+
+
+
```php
Scanner::scan()->prompt('Scan product barcode');
```
+
+
+
+```js
+await scanner.scan()
+ .prompt('Scan product barcode');
+```
+
+
+
+
### `continuous(bool $continuous = true)`
Keep scanner open to scan multiple codes. Default is `false` (closes after first scan).
+
+
+
+
```php
Scanner::scan()->continuous(true);
```
+
+
+
+```js
+await scanner.scan()
+ .continuous(true);
+```
+
+
+
+
### `formats(array $formats)`
Specify which barcode formats to scan. Default is `['qr']`.
**Available formats:** `qr`, `ean13`, `ean8`, `code128`, `code39`, `upca`, `upce`, `all`
+
+
+
+
```php
Scanner::scan()->formats(['qr', 'ean13', 'code128']);
```
+
+
+
+```js
+await scanner.scan()
+ .formats(['qr', 'ean13', 'code128']);
+```
+
+
+
+
### `id(string $id)`
Set a unique identifier for the scan session. Useful for handling different scan contexts.
+
+
+
+
```php
Scanner::scan()->id('checkout-scanner');
```
+
+
+
+```js
+await scanner.scan()
+ .id('checkout-scanner');
+```
+
+
+
+
+### Combined Example
+
+
+
+
+
+```php
+Scanner::scan()
+ ->prompt('Scan your ticket')
+ ->continuous(true)
+ ->formats(['qr', 'ean13'])
+ ->id('ticket-scanner');
+```
+
+
+
+
+```js
+await scanner.scan()
+ .prompt('Scan your ticket')
+ .continuous(true)
+ .formats(['qr', 'ean13'])
+ .id('ticket-scanner');
+```
+
+
+
+
## Events
### `CodeScanned`
@@ -73,6 +232,10 @@ Fired when a barcode is successfully scanned.
- `string $format` - The barcode format
- `string|null $id` - The scan session ID (if set)
+
+
+
+
```php
#[OnNative(CodeScanned::class)]
public function handleScan($data, $format, $id = null)
@@ -83,6 +246,57 @@ public function handleScan($data, $format, $id = null)
}
```
+
+
+
+```js
+import { on, off, Events } from '#nativephp';
+import { onMounted, onUnmounted } from 'vue';
+
+const handleScan = (payload) => {
+ const { data, format, id } = payload;
+
+ if (id === 'product-scanner') {
+ addProduct(data);
+ }
+};
+
+onMounted(() => {
+ on(Events.Scanner.CodeScanned, handleScan);
+});
+
+onUnmounted(() => {
+ off(Events.Scanner.CodeScanned, handleScan);
+});
+```
+
+
+
+
+```jsx
+import { on, off, Events } from '#nativephp';
+import { useEffect } from 'react';
+
+const handleScan = (payload) => {
+ const { data, format, id } = payload;
+
+ if (id === 'product-scanner') {
+ addProduct(data);
+ }
+};
+
+useEffect(() => {
+ on(Events.Scanner.CodeScanned, handleScan);
+
+ return () => {
+ off(Events.Scanner.CodeScanned, handleScan);
+ };
+}, []);
+```
+
+
+
+
## Notes
- **Platform Support:**
diff --git a/resources/views/docs/mobile/2/apis/secure-storage.md b/resources/views/docs/mobile/2/apis/secure-storage.md
index 35a58127..7dac7fb4 100644
--- a/resources/views/docs/mobile/2/apis/secure-storage.md
+++ b/resources/views/docs/mobile/2/apis/secure-storage.md
@@ -8,10 +8,24 @@ order: 1300
The SecureStorage API provides secure storage using the device's native keychain (iOS) or keystore (Android). It's
ideal for storing sensitive data like tokens, passwords, and user credentials.
+
+
+
+
```php
use Native\Mobile\Facades\SecureStorage;
```
+
+
+
+```js
+import { secureStorage } from '#nativephp';
+```
+
+
+
+
## Methods
### `set()`
@@ -24,10 +38,28 @@ Stores a secure value in the native keychain or keystore.
**Returns:** `bool` - `true` if successfully stored, `false` otherwise
+
+
+
+
```php
SecureStorage::set('api_token', 'abc123xyz');
```
+
+
+
+```js
+const result = await secureStorage.set('api_token', 'abc123xyz');
+
+if (result.success) {
+ // Value stored securely
+}
+```
+
+
+
+
### `get()`
Retrieves a secure value from the native keychain or keystore.
@@ -37,10 +69,25 @@ Retrieves a secure value from the native keychain or keystore.
**Returns:** `string|null` - The stored value or `null` if not found
+
+
+
+
```php
$token = SecureStorage::get('api_token');
```
+
+
+
+```js
+const result = await secureStorage.get('api_token');
+const token = result.value; // or null if not found
+```
+
+
+
+
### `delete()`
Deletes a secure value from the native keychain or keystore.
@@ -50,6 +97,28 @@ Deletes a secure value from the native keychain or keystore.
**Returns:** `bool` - `true` if successfully deleted, `false` otherwise
+
+
+
+
+```php
+SecureStorage::delete('api_token');
+```
+
+
+
+
+```js
+const result = await secureStorage.delete('api_token');
+
+if (result.success) {
+ // Value deleted
+}
+```
+
+
+
+
## Platform Implementation
### iOS - Keychain Services
diff --git a/resources/views/docs/mobile/2/apis/share.md b/resources/views/docs/mobile/2/apis/share.md
index 38fc154d..ddfd28ef 100644
--- a/resources/views/docs/mobile/2/apis/share.md
+++ b/resources/views/docs/mobile/2/apis/share.md
@@ -7,10 +7,24 @@ order: 1400
The Share API enables users to share content from your app using the native share sheet. On iOS, this opens the native share menu with options like Messages, Mail, and social media apps. On Android, it launches the system share intent with available apps.
+
+
+
+
```php
use Native\Mobile\Facades\Share;
```
+
+
+
+```js
+import { share } from '#nativephp';
+```
+
+
+
+
## Methods
### `url()`
@@ -24,6 +38,10 @@ Share a URL using the native share dialog.
**Returns:** `void`
+
+
+
+
```php
Share::url(
title: 'Check this out',
@@ -32,6 +50,20 @@ Share::url(
);
```
+
+
+
+```js
+await share.url(
+ 'Check this out',
+ 'I found something interesting',
+ 'https://example.com/article'
+);
+```
+
+
+
+
### `file()`
Share a file using the native share dialog.
@@ -43,6 +75,10 @@ Share a file using the native share dialog.
**Returns:** `void`
+
+
+
+
```php
Share::file(
title: 'Share Document',
@@ -51,12 +87,30 @@ Share::file(
);
```
+
+
+
+```js
+await share.file(
+ 'Share Document',
+ 'Check out this PDF',
+ '/path/to/document.pdf'
+);
+```
+
+
+
+
## Examples
### Sharing a Website Link
Share a link to your app's website or external content.
+
+
+
+
```php
Share::url(
title: 'My Awesome App',
@@ -65,10 +119,28 @@ Share::url(
);
```
+
+
+
+```js
+await share.url(
+ 'My Awesome App',
+ 'Download my app today!',
+ 'https://myapp.com'
+);
+```
+
+
+
+
### Sharing Captured Photos
Share a photo that was captured with the camera.
+
+
+
+
```php
use Native\Mobile\Attributes\OnNative;
use Native\Mobile\Events\Camera\PhotoTaken;
@@ -84,6 +156,57 @@ public function handlePhotoTaken(string $path)
}
```
+
+
+
+```js
+import { share, on, off, Events } from '#nativephp';
+import { onMounted, onUnmounted } from 'vue';
+
+const handlePhotoTaken = (payload) => {
+ share.file(
+ 'My Photo',
+ 'Check out this photo I just took!',
+ payload.path
+ );
+};
+
+onMounted(() => {
+ on(Events.Camera.PhotoTaken, handlePhotoTaken);
+});
+
+onUnmounted(() => {
+ off(Events.Camera.PhotoTaken, handlePhotoTaken);
+});
+```
+
+
+
+
+```jsx
+import { share, on, off, Events } from '#nativephp';
+import { useEffect } from 'react';
+
+const handlePhotoTaken = (payload) => {
+ share.file(
+ 'My Photo',
+ 'Check out this photo I just took!',
+ payload.path
+ );
+};
+
+useEffect(() => {
+ on(Events.Camera.PhotoTaken, handlePhotoTaken);
+
+ return () => {
+ off(Events.Camera.PhotoTaken, handlePhotoTaken);
+ };
+}, []);
+```
+
+
+
+
## Notes
- The native share sheet opens, allowing users to choose which app to share with (Messages, Email, social media, etc.)
diff --git a/resources/views/docs/mobile/2/apis/system.md b/resources/views/docs/mobile/2/apis/system.md
index 5ea860e7..78c165af 100644
--- a/resources/views/docs/mobile/2/apis/system.md
+++ b/resources/views/docs/mobile/2/apis/system.md
@@ -7,10 +7,26 @@ order: 1500
The System API provides access to basic system functions like flashlight control and platform detection.
+
+
+
+
```php
use Native\Mobile\Facades\System;
```
+
+
+
+```js
+import { system } from '#nativephp';
+// or import individual functions
+import { isIos, isAndroid, isMobile } from '#nativephp';
+```
+
+
+
+
## Methods
### `flashlight()` - Deprecated, see [Device](device)
@@ -19,24 +35,113 @@ Toggles the device flashlight (camera flash LED) on and off.
**Returns:** `void`
+
+
+
+
```php
System::flashlight(); // Toggle flashlight state
```
+
+
+
+```js
+// Use device.flashlight() instead
+import { device } from '#nativephp';
+
+await device.flashlight();
+```
+
+
+
+
### `isIos()`
Determines if the current device is running iOS.
**Returns:** `true` if iOS, `false` otherwise
+
+
+
+
+```php
+if (System::isIos()) {
+ // iOS-specific code
+}
+```
+
+
+
+
+```js
+const ios = await system.isIos();
+
+if (ios) {
+ // iOS-specific code
+}
+```
+
+
+
+
### `isAndroid()`
Determines if the current device is running Android.
**Returns:** `true` if Android, `false` otherwise
+
+
+
+
+```php
+if (System::isAndroid()) {
+ // Android-specific code
+}
+```
+
+
+
+
+```js
+const android = await system.isAndroid();
+
+if (android) {
+ // Android-specific code
+}
+```
+
+
+
+
### `isMobile()`
Determines if the current device is running Android or iOS.
**Returns:** `true` if Android or iOS, `false` otherwise
+
+
+
+
+
+```php
+if (System::isMobile()) {
+ // Mobile-specific code
+}
+```
+
+
+
+
+```js
+const mobile = await system.isMobile();
+
+if (mobile) {
+ // Mobile-specific code
+}
+```
+
+
+
From 7acf8ac80054f3d679430ee915e8a4cf2849ac39 Mon Sep 17 00:00:00 2001
From: Shane Rosenthal
Date: Wed, 10 Dec 2025 09:22:50 -0500
Subject: [PATCH 18/34] Update top-bar.md for top-bar-actions
---
.../docs/mobile/2/edge-components/top-bar.md | 18 ++++++++++--------
1 file changed, 10 insertions(+), 8 deletions(-)
diff --git a/resources/views/docs/mobile/2/edge-components/top-bar.md b/resources/views/docs/mobile/2/edge-components/top-bar.md
index 56069551..95232d2c 100644
--- a/resources/views/docs/mobile/2/edge-components/top-bar.md
+++ b/resources/views/docs/mobile/2/edge-components/top-bar.md
@@ -36,22 +36,24 @@ A top bar with title and action buttons. This renders at the top of the screen.
## Props
- `title` - The title text
+- `subtitle` - Secondary text displayed below the title (optional)
- `show-navigation-icon` - Show back/menu button (optional, default: `true`)
-- `label` - If more than 5 actions, iOS will display an overflow menu and the labels assigned to each item
- `background-color` - Background color. Hex code (optional)
- `text-color` - Text color. Hex code (optional)
- `elevation` - Shadow depth 0-24 (optional) [Android]
## Children
-A `` can contain up to 10 `` elements. These populate the trailing edge,
-collapsing to a menu if there are too many.
+A `` can contain up to 10 `` elements. These are displayed on the trailing edge of the bar.
-### Props
-- `id` - Unique identifier
-- `icon` - A named [icon](icons)
-- `label` - Accessibility label (optional)
-- `url` - A URL to navigate to in the web view (optional)
+On Android, the first 3 actions are shown as icon buttons; additional actions collapse into an overflow menu (â‹®). On iOS, if more than 5 actions are provided, they collapse into an overflow menu.
+
+### `` Props
+
+- `id` - Unique identifier (required)
+- `icon` - A named [icon](icons) (required)
+- `label` - Text label for the action. Used for accessibility and displayed in overflow menus (optional but recommended)
+- `url` - A URL to navigate to when tapped
From 1a5edb0646650908311c980fb41663bc4ee6a435 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Thu, 11 Dec 2025 03:59:59 +0000
Subject: [PATCH 19/34] Leads
---
app/Filament/Resources/LeadResource.php | 122 +++++++++
.../LeadResource/Pages/ListLeads.php | 19 ++
.../Resources/LeadResource/Pages/ViewLead.php | 19 ++
app/Livewire/LeadSubmissionForm.php | 92 +++++++
app/Models/Lead.php | 35 +++
app/Notifications/LeadReceived.php | 30 +++
app/Notifications/NewLeadSubmitted.php | 37 +++
app/Providers/RouteServiceProvider.php | 4 +
app/Rules/Turnstile.php | 40 +++
config/services.php | 5 +
database/factories/LeadFactory.php | 29 +++
.../2025_12_11_003746_create_leads_table.php | 33 +++
resources/images/home/macbook.jpg | Bin 0 -> 63846 bytes
resources/views/build-my-app.blade.php | 30 +++
resources/views/components/footer.blade.php | 10 +-
resources/views/components/layout.blade.php | 1 +
.../components/navbar/mobile-menu.blade.php | 30 ++-
.../views/components/navigation-bar.blade.php | 28 ++-
.../livewire/lead-submission-form.blade.php | 126 ++++++++++
routes/web.php | 1 +
tests/Feature/LeadSubmissionTest.php | 233 ++++++++++++++++++
21 files changed, 918 insertions(+), 6 deletions(-)
create mode 100644 app/Filament/Resources/LeadResource.php
create mode 100644 app/Filament/Resources/LeadResource/Pages/ListLeads.php
create mode 100644 app/Filament/Resources/LeadResource/Pages/ViewLead.php
create mode 100644 app/Livewire/LeadSubmissionForm.php
create mode 100644 app/Models/Lead.php
create mode 100644 app/Notifications/LeadReceived.php
create mode 100644 app/Notifications/NewLeadSubmitted.php
create mode 100644 app/Rules/Turnstile.php
create mode 100644 database/factories/LeadFactory.php
create mode 100644 database/migrations/2025_12_11_003746_create_leads_table.php
create mode 100644 resources/images/home/macbook.jpg
create mode 100644 resources/views/build-my-app.blade.php
create mode 100644 resources/views/livewire/lead-submission-form.blade.php
create mode 100644 tests/Feature/LeadSubmissionTest.php
diff --git a/app/Filament/Resources/LeadResource.php b/app/Filament/Resources/LeadResource.php
new file mode 100644
index 00000000..5640e3c8
--- /dev/null
+++ b/app/Filament/Resources/LeadResource.php
@@ -0,0 +1,122 @@
+schema([
+ Infolists\Components\Section::make('Contact Information')
+ ->schema([
+ Infolists\Components\TextEntry::make('name')
+ ->label('Name'),
+ Infolists\Components\TextEntry::make('email')
+ ->label('Email')
+ ->copyable(),
+ Infolists\Components\TextEntry::make('company')
+ ->label('Company'),
+ ])
+ ->columns(3),
+
+ Infolists\Components\Section::make('Project Details')
+ ->schema([
+ Infolists\Components\TextEntry::make('budget_label')
+ ->label('Budget'),
+ Infolists\Components\TextEntry::make('description')
+ ->label('App Description')
+ ->columnSpanFull(),
+ ]),
+
+ Infolists\Components\Section::make('Metadata')
+ ->schema([
+ Infolists\Components\TextEntry::make('ip_address')
+ ->label('IP Address'),
+ Infolists\Components\TextEntry::make('created_at')
+ ->label('Submitted')
+ ->dateTime(),
+ ])
+ ->columns(2)
+ ->collapsed(),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\TextColumn::make('name')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('email')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('company')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('budget_label')
+ ->label('Budget')
+ ->sortable(query: function ($query, string $direction) {
+ return $query->orderBy('budget', $direction);
+ }),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('Submitted')
+ ->dateTime()
+ ->sortable(),
+ ])
+ ->filters([
+ Tables\Filters\SelectFilter::make('budget')
+ ->options(Lead::BUDGETS),
+ ])
+ ->actions([
+ Tables\Actions\ViewAction::make(),
+ ])
+ ->bulkActions([
+ Tables\Actions\BulkActionGroup::make([
+ Tables\Actions\DeleteBulkAction::make(),
+ ]),
+ ])
+ ->defaultSort('created_at', 'desc');
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ //
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListLeads::route('/'),
+ 'view' => Pages\ViewLead::route('/{record}'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/LeadResource/Pages/ListLeads.php b/app/Filament/Resources/LeadResource/Pages/ListLeads.php
new file mode 100644
index 00000000..fec6d710
--- /dev/null
+++ b/app/Filament/Resources/LeadResource/Pages/ListLeads.php
@@ -0,0 +1,19 @@
+ ['required', 'string', 'max:255'],
+ 'email' => ['required', 'email', 'max:255'],
+ 'company' => ['required', 'string', 'max:255'],
+ 'description' => ['required', 'string', 'max:5000'],
+ 'budget' => ['required', 'string', 'in:'.implode(',', array_keys(Lead::BUDGETS))],
+ ];
+
+ if (config('services.turnstile.secret_key')) {
+ $rules['turnstileToken'] = ['required', new Turnstile];
+ }
+
+ return $rules;
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'budget.in' => 'Please select a budget range.',
+ 'turnstileToken.required' => 'Please complete the security check.',
+ ];
+ }
+
+ public function submit(): void
+ {
+ $key = 'leads:'.request()->ip();
+
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ $seconds = RateLimiter::availableIn($key);
+ $this->addError('form', "Too many submissions. Please try again in {$seconds} seconds.");
+
+ return;
+ }
+
+ $this->validate();
+
+ RateLimiter::hit($key, 60);
+
+ $lead = Lead::create([
+ 'name' => $this->name,
+ 'email' => $this->email,
+ 'company' => $this->company,
+ 'description' => $this->description,
+ 'budget' => $this->budget,
+ 'ip_address' => request()->ip(),
+ ]);
+
+ $lead->notify(new LeadReceived);
+
+ Notification::route('mail', 'support@nativephp.com')
+ ->notify(new NewLeadSubmitted($lead));
+
+ $this->submitted = true;
+ }
+
+ public function render()
+ {
+ return view('livewire.lead-submission-form', [
+ 'budgets' => Lead::BUDGETS,
+ ]);
+ }
+}
diff --git a/app/Models/Lead.php b/app/Models/Lead.php
new file mode 100644
index 00000000..a1f4308c
--- /dev/null
+++ b/app/Models/Lead.php
@@ -0,0 +1,35 @@
+ 'Less than $5,000',
+ '5k_to_10k' => '$5,000 - $10,000',
+ '10k_to_25k' => '$10,000 - $25,000',
+ '25k_to_50k' => '$25,000 - $50,000',
+ '50k_plus' => '$50,000+',
+ ];
+
+ public function getBudgetLabelAttribute(): string
+ {
+ return self::BUDGETS[$this->budget] ?? $this->budget;
+ }
+}
diff --git a/app/Notifications/LeadReceived.php b/app/Notifications/LeadReceived.php
new file mode 100644
index 00000000..44b8b2ab
--- /dev/null
+++ b/app/Notifications/LeadReceived.php
@@ -0,0 +1,30 @@
+subject('Thank you for your enquiry')
+ ->greeting("Hi {$notifiable->name},")
+ ->line('Thank you for reaching out to NativePHP about your app development project.')
+ ->line('We have received your enquiry and one of our team members will be in touch soon to discuss your requirements.')
+ ->line('In the meantime, feel free to explore our documentation or join our Discord community.')
+ ->action('Visit NativePHP', url('/'))
+ ->salutation('Best regards, The NativePHP Team');
+ }
+}
diff --git a/app/Notifications/NewLeadSubmitted.php b/app/Notifications/NewLeadSubmitted.php
new file mode 100644
index 00000000..c6c15b16
--- /dev/null
+++ b/app/Notifications/NewLeadSubmitted.php
@@ -0,0 +1,37 @@
+subject('New Build My App Enquiry: '.$this->lead->company)
+ ->replyTo($this->lead->email, $this->lead->name)
+ ->greeting('New lead received!')
+ ->line("**Name:** {$this->lead->name}")
+ ->line("**Email:** {$this->lead->email}")
+ ->line("**Company:** {$this->lead->company}")
+ ->line("**Budget:** {$this->lead->budget_label}")
+ ->line('**Project Description:**')
+ ->line($this->lead->description);
+ }
+}
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index dd39ace7..091a8f97 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -28,6 +28,10 @@ public function boot(): void
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
+ RateLimiter::for('leads', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+ });
+
$this->routes(function () {
Route::middleware('api')
->prefix('api')
diff --git a/app/Rules/Turnstile.php b/app/Rules/Turnstile.php
new file mode 100644
index 00000000..b5c833fb
--- /dev/null
+++ b/app/Rules/Turnstile.php
@@ -0,0 +1,40 @@
+post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
+ 'secret' => $secretKey,
+ 'response' => $value,
+ 'remoteip' => request()->ip(),
+ ]);
+
+ if (! $response->successful() || ! $response->json('success')) {
+ $fail('Security verification failed. Please try again.');
+ }
+ }
+}
diff --git a/config/services.php b/config/services.php
index 238da67e..446c24a8 100644
--- a/config/services.php
+++ b/config/services.php
@@ -38,4 +38,9 @@
'bifrost' => [
'api_key' => env('BIFROST_API_KEY'),
],
+
+ 'turnstile' => [
+ 'site_key' => env('TURNSTILE_SITE_KEY'),
+ 'secret_key' => env('TURNSTILE_SECRET_KEY'),
+ ],
];
diff --git a/database/factories/LeadFactory.php b/database/factories/LeadFactory.php
new file mode 100644
index 00000000..ab0b491b
--- /dev/null
+++ b/database/factories/LeadFactory.php
@@ -0,0 +1,29 @@
+
+ */
+class LeadFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->name(),
+ 'email' => fake()->safeEmail(),
+ 'company' => fake()->company(),
+ 'description' => fake()->paragraphs(2, true),
+ 'budget' => fake()->randomElement(array_keys(Lead::BUDGETS)),
+ 'ip_address' => fake()->ipv4(),
+ ];
+ }
+}
diff --git a/database/migrations/2025_12_11_003746_create_leads_table.php b/database/migrations/2025_12_11_003746_create_leads_table.php
new file mode 100644
index 00000000..46f49735
--- /dev/null
+++ b/database/migrations/2025_12_11_003746_create_leads_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->string('name');
+ $table->string('email');
+ $table->string('company');
+ $table->text('description');
+ $table->string('budget');
+ $table->string('ip_address')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('leads');
+ }
+};
diff --git a/resources/images/home/macbook.jpg b/resources/images/home/macbook.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..0fc3591ba758b95487eba5657da8ec33653fb799
GIT binary patch
literal 63846
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 {fd?ufIhtSW1z9p
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