Add all files

This commit is contained in:
root
2025-11-30 11:57:22 +01:00
parent 2ee51cac4f
commit 044271db3d
1476 changed files with 142544 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
/.phpunit.cache
/node_modules
/public/default/*
/public/lucentui/*
/public/hot
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
themes/*
!themes/default
!themes/lucentui
*/.DS_Store
.DS_Store
**/caddy
frankenphp
frankenphp-worker.php

26
Captcha.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Captcha extends Component
{
/**
* Create a new component instance.
*/
public function __construct()
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.captcha');
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Admin\Resources;
use App\Admin\Components\UserComponent;
use App\Helpers\ExtensionHelper;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource\Pages;
use Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource\RelationManagers;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
class AffiliateResource extends Resource
{
protected static ?string $model = Affiliate::class;
protected static ?string $navigationIcon = 'ri-hand-coin-line';
protected static ?string $activeNavigationIcon = 'ri-hand-coin-fill';
protected static ?string $navigationGroup = 'Administration';
public static function form(Form $form): Form
{
$extension = ExtensionHelper::getExtension('other', 'Affiliates');
return $form
->schema([
Toggle::make('enabled')->default(true)->columnSpanFull(),
UserComponent::make('user_id'),
TextInput::make('code')
->label('Referral Code')
->required()
->minLength(5)
->maxLength(20)
->unique(ignoreRecord: true),
TextInput::make('reward')
->label('Affiliate Reward')
->helperText('Percentage of the purchase amount the affiliated user would receive as a reward.')
->placeholder($extension->config('default_reward'))
->numeric()
->minValue(0)
->maxValue(100)
->suffix('%'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')->description(function (Affiliate $affiliate) {
if (count($affiliate->earnings) <= 0) {
return null;
}
return 'Earnings - ' . implode(', ', array_map(function ($key, $value) {
return "$key: $value";
}, array_keys($affiliate->earnings), $affiliate->earnings));
}),
TextColumn::make('code')
->badge(),
TextColumn::make('visitors')
->numeric()
->sortable(),
TextColumn::make('signups')
->numeric()
->sortable(),
TextColumn::make('created_at')
->label('Signed Up')
->since()
->sortable()
->dateTimeTooltip(),
])
->filters([])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\AffiliatesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAffiliates::route('/'),
'edit' => Pages\EditAffiliate::route('/{record}/edit'),
];
}
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()
->formId('form'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource;
class CreateAffiliate extends CreateRecord
{
protected static string $resource = AffiliateResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource;
class EditAffiliate extends EditRecord
{
protected static string $resource = AffiliateResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -0,0 +1,52 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Admin\Resources\AffiliateResource\RelationManagers;
use App\Admin\Resources\OrderResource;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Paymenter\Extensions\Others\Affiliates\Models\AffiliateOrder;
class AffiliatesRelationManager extends RelationManager
{
protected static string $relationship = 'orders';
protected static ?string $model = AffiliateOrder::class;
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('affiliate_id')
->columns([
TextColumn::make('order.id'),
TextColumn::make('earnings')->formatStateUsing(function (AffiliateOrder $affiliateOrder) {
if (count($affiliateOrder->affiliate->earnings) <= 0) {
return null;
}
return implode(', ', array_map(function ($key, $value) {
return "$key: $value";
}, array_keys($affiliateOrder->affiliate->earnings), $affiliateOrder->affiliate->earnings));
})->label('Earning'),
TextColumn::make('order.created_at')
->label('Created At')
->since()
->sortable()
->dateTimeTooltip(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make()->url(fn ($record) => OrderResource::getUrl('edit', ['record' => $record->order])),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates;
use App\Classes\Extension\Extension;
use App\Events\Invoice\Paid as InvoicePaid;
use App\Events\Order\Created as OrderCreated;
use App\Events\User\Created as UserCreated;
use App\Helpers\ExtensionHelper;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\View;
use Livewire\Livewire;
use Paymenter\Extensions\Others\Affiliates\Listeners\AssociateOrderWithAffiliate;
use Paymenter\Extensions\Others\Affiliates\Listeners\IncreamentAffiliateSignups;
use Paymenter\Extensions\Others\Affiliates\Listeners\RewardAffiliate;
use Paymenter\Extensions\Others\Affiliates\Livewire\Affiliates\Affiliate as AffiliateComponent;
use Paymenter\Extensions\Others\Affiliates\Middleware\AffiliatesMiddleware;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
class Affiliates extends Extension
{
public function __construct(public $config = []) {}
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
return [
[
'name' => 'default_reward',
'label' => 'Default Affiliate Reward',
'type' => 'number',
'description' => 'Percentage of the purchase amount the affiliated user would receive as a reward.',
'required' => true,
'suffix' => '%',
'validation' => 'integer|min:0|max:100',
],
[
'name' => 'cookie_max_age',
'label' => 'Referral Cookie Max-Age',
'type' => 'number',
'description' => 'Amount of days for which the referral cookie be valid. (Set 0 for infinite)',
'required' => true,
'validation' => 'integer|min:0',
],
[
'name' => 'type',
'label' => 'Affiliate Code Type',
'type' => 'select',
'default' => 'random',
'description' => 'How the affiliate would be assigned.',
'required' => true,
'options' => [
'random' => 'Random',
'custom' => 'Custom',
],
],
];
}
public function enabled()
{
// Run migrations
Artisan::call('migrate', ['--path' => 'extensions/Others/Affiliates/database/migrations/2024_12_25_075634_create_ext_affiliates_table.php', '--force' => true]);
Artisan::call('migrate', ['--path' => 'extensions/Others/Affiliates/database/migrations/2025_01_31_155928_create_ext_affiliate_orders_table.php', '--force' => true]);
}
public function disabled() {}
public function boot()
{
require __DIR__ . '/routes/web.php';
View::addNamespace('affiliates', __DIR__ . '/resources/views');
Lang::addNamespace('affiliates', __DIR__ . '/resources/lang');
Livewire::component('affiliate', AffiliateComponent::class);
User::resolveRelationUsing('affiliate', function (User $userModel) {
return $userModel->hasOne(Affiliate::class, 'user_id');
});
ExtensionHelper::registerMiddleware(AffiliatesMiddleware::class);
// Listen for UserCreated and InvoicePaid events
Event::listen(
UserCreated::class,
IncreamentAffiliateSignups::class,
);
Event::listen(
InvoicePaid::class,
RewardAffiliate::class,
);
Event::listen(
OrderCreated::class,
AssociateOrderWithAffiliate::class,
);
Event::listen('api.permissions', function () {
return [
'admin.affiliates.view' => 'View Affiliates',
'admin.affiliates.create' => 'Create Affiliates',
'admin.affiliates.update' => 'Update Affiliates',
'admin.affiliates.delete' => 'Delete Affiliates',
];
});
// Event::listen('navigation.dashboard', function ($routes) {
// dd($routes);
// return [
// 'name' => __('affiliates::affiliate.affiliate'),
// 'route' => 'affiliate.index',
// 'icon' => 'heroicon-o-banknotes',
// 'group' => 'Administration',
// ];
// });
// Hook onto account navigation
Event::listen('navigation.account', function () {
return [
'name' => __('affiliates::affiliate.affiliate'),
'route' => 'affiliate.index',
];
});
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Controllers;
use App\Http\Controllers\Api\ApiController;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\QueryParameter;
use Paymenter\Extensions\Others\Affiliates\Http\Requests\CreateAffiliateRequest;
use Paymenter\Extensions\Others\Affiliates\Http\Requests\DeleteAffiliateRequest;
use Paymenter\Extensions\Others\Affiliates\Http\Requests\GetAffiliateRequest;
use Paymenter\Extensions\Others\Affiliates\Http\Requests\GetAffiliatesRequest;
use Paymenter\Extensions\Others\Affiliates\Http\Requests\UpdateAffiliateRequest;
use Paymenter\Extensions\Others\Affiliates\Http\Resources\AffiliateResource;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
use Spatie\QueryBuilder\QueryBuilder;
#[Group(name: 'Affiliates', weight: 1)]
class AffiliateController extends ApiController
{
protected const INCLUDES = [
'user',
'orders',
];
/**
* List Affiliates
*/
#[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)]
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
public function index(GetAffiliatesRequest $request)
{
// Fetch affiliates with pagination
$affiliates = QueryBuilder::for(Affiliate::class)
->allowedFilters(['affiliate_id', 'code', 'visitors', 'reward', 'discount'])
->allowedIncludes($this->allowedIncludes(self::INCLUDES))
->allowedSorts(['id', 'code', 'visitors', 'reward', 'discount', 'created_at'])
->simplePaginate(request('per_page', 15));
// Return the affiliates as a JSON response
return AffiliateResource::collection($affiliates);
}
/**
* Create a new affiliate
*/
public function store(CreateAffiliateRequest $request)
{
// Validate and create the affiliate
$affiliate = Affiliate::create($request->validated());
// Return the created affiliate as a JSON response
return new AffiliateResource($affiliate);
}
/**
* Show a specific affiliate
*/
public function show(GetAffiliateRequest $request, Affiliate $affiliate)
{
$affiliate = QueryBuilder::for(Affiliate::class)
->allowedIncludes($this->allowedIncludes(self::INCLUDES))
->findOrFail($affiliate->id);
// Return the affiliate as a JSON response
return new AffiliateResource($affiliate);
}
/**
* Update a specific affiliate
*/
public function update(UpdateAffiliateRequest $request, Affiliate $affiliate)
{
// Validate and update the affiliate
$affiliate->update($request->validated());
// Return the updated affiliate as a JSON response
return new AffiliateResource($affiliate);
}
/**
* Delete a specific affiliate
*/
public function destroy(DeleteAffiliateRequest $request, Affiliate $affiliate)
{
// Delete the affiliate
$affiliate->delete();
return $this->returnNoContent();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Requests;
use App\Http\Requests\Api\Admin\AdminApiRequest;
class CreateAffiliateRequest extends AdminApiRequest
{
protected $permission = 'affiliates.create';
public function rules(): array
{
return [
'user_id' => 'required|exists:users,id',
'code' => 'required|string|max:255|unique:affiliates,code',
'enabled' => 'nullable|boolean',
'reward' => 'nullable|numeric|min:0|max:100',
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Requests;
use App\Http\Requests\Api\Admin\AdminApiRequest;
class DeleteAffiliateRequest extends AdminApiRequest
{
protected $permission = 'affiliates.delete';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Requests;
use App\Http\Requests\Api\Admin\AdminApiRequest;
class GetAffiliateRequest extends AdminApiRequest
{
protected $permission = 'affiliates.view';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Requests;
use App\Http\Requests\Api\Admin\AdminApiRequest;
class GetAffiliatesRequest extends AdminApiRequest
{
protected $permission = 'affiliates.view';
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Requests;
use App\Http\Requests\Api\Admin\AdminApiRequest;
class UpdateAffiliateRequest extends AdminApiRequest
{
protected $permission = 'affiliates.update';
public function rules(): array
{
return [
'user_id' => 'nullable|exists:users,id',
'code' => 'nullable|string|max:255|unique:affiliates,code,' . $this->route()->parameter('affiliate')->id,
'enabled' => 'nullable|boolean',
'reward' => 'nullable|numeric|min:0|max:100',
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Resources;
use TiMacDonald\JsonApi\JsonApiResource;
/** @mixin \App\Models\AffiliateOrder */
class AffiliateOrderResource extends JsonApiResource
{
public $attributes = [
'id',
'order_id',
];
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Http\Resources;
use App\Http\Resources\UserResource;
use TiMacDonald\JsonApi\JsonApiResource;
/** @mixin \App\Models\Affiliate */
class AffiliateResource extends JsonApiResource
{
public $attributes = [
'id',
'code',
'enabled',
'visitors',
'reward',
'discount',
'earnings',
'updated_at',
'created_at',
];
public $relationships = [
'user' => UserResource::class,
'orders' => AffiliateOrderResource::class,
];
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Listeners;
use App\Events\Order\Created;
use Illuminate\Support\Facades\Cookie;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
use Paymenter\Extensions\Others\Affiliates\Models\AffiliateOrder;
class AssociateOrderWithAffiliate
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(Created $event): void
{
$referral_code = Cookie::get('referred_by');
/** @var Affiliate */
$affiliate = Affiliate::where('code', $referral_code)->first();
if (!$affiliate || $affiliate->user->id === $event->order->user_id) {
return;
}
AffiliateOrder::create([
'order_id' => $event->order->id,
'affiliate_id' => $affiliate->id,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Listeners;
use App\Events\User\Created;
use Illuminate\Support\Facades\Cookie;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
class IncreamentAffiliateSignups
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(Created $event): void
{
$referral_code = Cookie::get('referred_by');
/** @var Affiliate */
$affiliate = Affiliate::where('code', $referral_code)->first();
if (!$affiliate) {
return;
}
$affiliate->increment('signups');
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Listeners;
use App\Helpers\ExtensionHelper;
use App\Models\Invoice;
use App\Models\Service;
use Illuminate\Support\Collection;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
use Paymenter\Extensions\Others\Affiliates\Models\AffiliateOrder;
class RewardAffiliate
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(object $event): void
{
/**
* @var Invoice $invoice
*/
$invoice = $event->invoice;
if ($invoice->items()->first()->reference_type !== Service::class) {
return;
}
$order = $invoice->items()->first()->reference->order;
$referral = AffiliateOrder::where('order_id', $order->id)->first();
if (!$referral) {
return;
}
/**
* @var Affiliate $affiliate
*/
$affiliate = $referral->affiliate;
$extension = ExtensionHelper::getExtension('other', 'Affiliates');
$reward_percentage = $affiliate->reward ?: $extension->config('default_reward');
$reward_amount = $invoice->total * $reward_percentage / 100;
/**
* @var Collection
*/
$user_credits = $affiliate->user->credits;
$affiliate_credits = $user_credits->filter(function ($credit) use ($invoice) {
return $credit->currency_code === $invoice->currency_code;
})->first();
if ($affiliate_credits) {
// Add reward to credits
$affiliate->user->credits()->where('currency_code', $invoice->currency_code)->update([
'amount' => $affiliate_credits->amount + $reward_amount,
]);
} else {
// Create new credits with the invoice's currency code
$affiliate->user->credits()->create([
'amount' => $reward_amount,
'currency_code' => $invoice->currency_code,
]);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Livewire\Affiliates;
use App\Helpers\ExtensionHelper;
use App\Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate as AffiliateModel;
class Affiliate extends Component
{
public string $signup_type = 'custom';
public ?AffiliateModel $affiliate;
public string $referral_code = '';
public function mount()
{
$this->affiliate = Auth::user()->affiliate;
$this->signup_type = ExtensionHelper::getExtension('other', 'Affiliates')->config('type');
}
public function render()
{
return view('affiliates::index')->layoutData([
'sidebar' => true,
]);
}
public function signup()
{
$this->validate();
/**
* @var App\Models\User
*/
$user = Auth::user();
if ($user->affiliate) {
$this->notify(__('affiliates::affiliate.you-are-already-affiliated'));
return;
}
$this->affiliate = $user->affiliate()->create([
'code' => $this->signup_type === 'custom' ? $this->referral_code : Str::random(10),
'visitors' => 0,
'reward' => null,
'discount' => null,
])->refresh();
$this->notify(__('affiliates::affiliate.signup-success'));
}
public function rules()
{
return [
'referral_code' => [Rule::requiredIf($this->signup_type === 'custom'), 'alpha_num:ascii', 'unique:ext_affiliates,code', 'min:5', 'max:25'],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Middleware;
use App\Helpers\ExtensionHelper;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
use Symfony\Component\HttpFoundation\Response;
class AffiliatesMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Continue if request doesn't contain `ref` query parameter
if (!request()->has('ref')) {
return $next($request);
}
$affiliate = Affiliate::where('code', request('ref'))->first();
if (!$affiliate || $affiliate->user->id === auth()->id()) {
return $next($request);
}
// Increase the visitor count if a cookie is not present already, or is not the same one
if (!Cookie::has('referred_by') || Cookie::get('referred_by') !== $affiliate->code) {
$affiliate->increment('visitors');
}
$extension = ExtensionHelper::getExtension('other', 'Affiliates');
$cookie_max_age = (int) $extension->config('cookie_max_age');
if ($cookie_max_age > 0) {
// Set affiliate cookie (and set it to expire after `$cookie_max_age` days)
Cookie::queue('referred_by', $affiliate->code, minutes: 60 * 24 * $cookie_max_age);
} else {
// Never expire
Cookie::queue(Cookie::forever('referred_by', $affiliate->code));
}
return $next($request);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Affiliate extends Model
{
protected $table = 'ext_affiliates';
protected $fillable = [
'user_id',
'code',
'visitors',
'reward',
'discount',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function orders(): HasMany
{
return $this->hasMany(AffiliateOrder::class);
}
/**
* Get the earnings made by this affiliate
*
* @return string
*/
public function earnings(): Attribute
{
return Attribute::make(
get: function (): array {
$earnings = [];
$this->orders->each(function ($order) use (&$earnings) {
foreach ($order->earnings as $currency => $total) {
if (!isset($earnings[$currency])) {
$earnings[$currency] = 0;
}
$earnings[$currency] += $total;
}
});
return $earnings;
},
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Paymenter\Extensions\Others\Affiliates\Models;
use App\Helpers\ExtensionHelper;
use App\Models\Order;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AffiliateOrder extends Model
{
protected $table = 'ext_affiliate_orders';
public $timestamps = false;
protected $fillable = [
'order_id',
'affiliate_id',
];
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* Get the earnings made from this order
*
* @return string
*/
public function earnings(): Attribute
{
return Attribute::make(
get: function (): array {
$earnings = [];
/** @var Collection */
$invoices = $this->order->invoices;
$extension = ExtensionHelper::getExtension('other', 'Affiliates');
$reward_percentage = $this->affiliate->reward ?: $extension->config('default_reward');
$invoices->each(function ($invoice) use (&$earnings, $reward_percentage) {
if ($invoice->status !== 'paid') {
return;
}
if (!isset($earnings[$invoice->currency_code])) {
$earnings[$invoice->currency_code] = 0;
}
$earnings[$invoice->currency_code] += $invoice->total * $reward_percentage / 100;
});
foreach ($earnings as $currency => $total) {
// Round to 2 decimal places
$earnings[$currency] = round($total, 2);
}
return $earnings;
},
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ext_affiliates', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->string('code')->unique();
$table->boolean('enabled')->default(true);
$table->integer('reward')->unsigned()->nullable();
$table->integer('discount')->unsigned()->nullable();
$table->integer('visitors')->unsigned()->default(0);
$table->integer('signups')->unsigned()->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('affiliates');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use App\Models\Order;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Paymenter\Extensions\Others\Affiliates\Models\Affiliate;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ext_affiliate_orders', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Affiliate::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Order::class)->unique()->constrained()->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('affiliate_orders');
}
};

View File

@@ -0,0 +1,19 @@
<?php
return [
'affiliate' => 'Affiliate',
'signup-for-affiliate' => 'Signup for Affiliate',
'code' => 'Code',
'visitors' => 'Visitors',
'total-visitors' => 'Total Visitors',
'signups' => 'Signups',
'total-signups' => 'Total Signups',
'earnings' => 'Earnings',
'total-earnings' => 'Total Earnings',
'your-affiliate-link' => 'Your affiliate link',
'copy' => 'Copy',
'you-havent-signed-up-yet' => 'You have not signed up for affiliates program yet!',
'you-are-already-affiliated' => 'You have already signed up for affiliate program.',
'signup-success' => 'Successfully signed up for affiliate program!',
];

View File

@@ -0,0 +1,73 @@
<div>
<x-navigation.breadcrumb />
<div class="px-2">
@isset($affiliate)
<div class="mt-4 md:grid-cols-3 grid">
<div class="flex flex-col gap-2">
<span class="text-xl font-semibold">{{ __('affiliates::affiliate.visitors') }}</span>
<span class="text-gray-500">{{ __('affiliates::affiliate.total-visitors') }}</span>
<span class="text-2xl font-semibold">{{ Number::format($affiliate->visitors) }}</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-xl font-semibold">{{ __('affiliates::affiliate.signups') }}</span>
<span class="text-gray-500">{{ __('affiliates::affiliate.total-signups') }}</span>
<span class="text-2xl font-semibold">
{{ Number::format($affiliate->signups) }}
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-xl font-semibold">{{ __('affiliates::affiliate.earnings') }}</span>
<span class="text-gray-500">{{ __('affiliates::affiliate.total-earnings') }}</span>
<span class="text-2xl font-semibold">
<ul>
@foreach ($affiliate->earnings as $currency => $amount)
<li>
{{ $currency }}: {{ $amount }}
</li>
@endforeach
</ul>
</span>
</div>
<div class="col-span-3 flex flex-col mt-4">
<span class="text-xl font-semibold">{{ __('affiliates::affiliate.affiliate') }}</span>
<span class="text-gray-500">{{ __('affiliates::affiliate.your-affiliate-link') }}</span>
<div class="flex flex-row gap-2 mt-2">
<x-form.input value="{{ url('/') }}?ref={{ $affiliate->code }}" name="ref"
divClass="!mt-0" type="text" readonly />
<x-button.primary class="!w-fit" type="button"
onclick="copyToClipboard('{{ url('/?ref=' . $affiliate->code) }}')">{{ __('affiliates::affiliate.copy') }}</x-button.primary>
</div>
</div>
</div>
<script>
function copyToClipboard(textToCopy) {
const temp = document.createElement("input")
temp.type = "text"
temp.value = textToCopy
document.body.appendChild(temp)
temp.select()
document.execCommand("Copy")
document.body.removeChild(temp)
}
</script>
@else
<p class="mb-4">{{ __('affiliates::affiliate.you-havent-signed-up-yet') }}</p>
<h3 class="text-lg font-bold mb-4">{{ __('affiliates::affiliate.signup-for-affiliate') }}</h3>
<form wire:submit.prevent="signup" method="POST">
@if ($signup_type === 'custom')
<x-form.input name="referral_code" type="text" :label="__('affiliates::affiliate.code')" wire:model="referral_code"
required />
@endif
<x-button.primary type="submit" class="text-sm !w-fit mt-4">
{{ __('auth.sign_up') }}
</x-button.primary>
</form>
@endisset
</div>
</div>

View File

@@ -0,0 +1,15 @@
<?php
use Illuminate\Support\Facades\Route;
use Paymenter\Extensions\Others\Affiliates\Http\Controllers\AffiliateController;
use Paymenter\Extensions\Others\Affiliates\Livewire\Affiliates\Affiliate;
Route::group(['middleware' => ['web', 'auth']], function () {
Route::get('/account/affiliate', Affiliate::class)->name('affiliate.index');
});
Route::group(['middleware' => ['api', 'api.admin'], 'prefix' => '/api/v1/admin'], function () {
Route::apiResources([
'affiliates' => AffiliateController::class,
]);
});

View File

@@ -0,0 +1,81 @@
<?php
// /var/www/paymenter/extensions/Others/Credit/Credit.php
namespace Paymenter\Extensions\Others\Credit;
use App\Classes\Extension\Extension;
use App\Events\User\Created as UserCreated;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
class Credit extends Extension
{
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
return [
[
'name' => 'start_credit',
'type' => 'number',
'label' => 'Initial Credits',
'required' => true,
'validation' => 'min:0',
'description' => 'Amount of credit given to new users'
],
[
'name' => 'currency_code',
'type' => 'text',
'label' => 'Currency Code',
'required' => true,
'default' => 'USD',
'description' => '3-letter currency code for credits'
]
];
}
public function boot()
{
Log::info('Credit extension initialized');
Event::listen(UserCreated::class, function ($event) {
try {
$credits = (float) $this->config('start_credit');
$currency = $this->config('currency_code', 'USD');
$userId = $event->user->id;
Log::info('Processing new user credits', [
'user_id' => $userId,
'amount' => $credits,
'currency' => $currency
]);
if ($credits > 0) {
DB::table('credits')->insert([
'user_id' => $userId,
'currency_code' => $currency,
'amount' => $credits,
'created_at' => now(),
'updated_at' => now()
]);
Log::info('Credits added successfully', [
'user_id' => $userId,
'amount' => $credits,
'currency' => $currency
]);
}
} catch (\Exception $e) {
Log::error('Credit extension error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
});
}
}

View File

@@ -0,0 +1,165 @@
<?php
/**
* DiscordPlus Extension for Paymenter
*
* Created by: Exp (Discord: exp_yt)
* Company: ExpHost - https://www.exphost.net/
*
* This code is the intellectual property of Exp and ExpHost.
* Unauthorized copying, modification, or distribution is prohibited.
*/
namespace Paymenter\Extensions\Others\DiscordPlus;
use App\Classes\Extension\Extension;
use App\Events\Invoice\Finalized as InvoiceCreated;
use App\Events\Invoice\Paid as InvoicePaid;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
class DiscordPlus extends Extension
{
public function getName(): string
{
return 'DiscordPlus';
}
public function getDescription(): string
{
return 'Sends Discord DMs to users when invoices are created or paid.';
}
public function getVersion(): string
{
return '1.0.0';
}
public function getAuthor(): string
{
return 'Exp_YT';
}
public function boot(): void
{
$botToken = $this->config('discord_bot_token');
if (!$botToken) return;
app()->bind(\App\Http\Controllers\SocialLoginController::class, function () {
return new \Paymenter\Extensions\Others\DiscordPlus\SocialLoginController();
});
Event::listen(InvoiceCreated::class, function ($event) use ($botToken) {
$this->sendDm($event->invoice, 'invoice_created', $botToken);
});
Event::listen(InvoicePaid::class, function ($event) use ($botToken) {
$this->sendDm($event->invoice, 'invoice_paid', $botToken);
});
}
public function getConfig($values = [])
{
return [
[
'name' => 'discord_bot_token',
'type' => 'text',
'label' => 'Discord Bot Token',
'required' => true,
'encrypted' => true,
],
];
}
private function sendDm($invoice, $type, $botToken)
{
if (!$invoice || !$invoice->user) {
return;
}
$user = $invoice->user;
$discordId = $user->discord_id;
$botToken = trim($botToken);
if (empty($discordId) || !is_string($discordId) || !ctype_digit((string)$discordId)) {
return;
}
$company = \Config::get('app.name', 'Paymenter');
$invoiceUrl = \URL::to("/invoices/{$invoice->id}");
$amount = (string)$invoice->formattedTotal;
$dueDate = $invoice->due_at ? $invoice->due_at->format('d M Y') : 'N/A';
if ($type === 'invoice_created') {
$embed = [
'embeds' => [[
'title' => '🧾 New Invoice Created',
'color' => 0x7289DA,
'description' => "A new invoice has been generated for your account.\n[View & Pay Invoice]({$invoiceUrl})",
'fields' => [
[ 'name' => 'Invoice #', 'value' => "#{$invoice->number}", 'inline' => true ],
[ 'name' => 'Amount Due', 'value' => $amount, 'inline' => true ],
[ 'name' => 'Due Date', 'value' => ($dueDate !== 'N/A' ? "`{$dueDate}`" : '*N/A*'), 'inline' => false ],
],
'footer' => [ 'text' => $company ],
]]
];
} else {
$embed = [
'embeds' => [[
'title' => '✅ Invoice Paid',
'color' => 0x43B581,
'description' => "Thank you for your payment!\n[View Invoice]({$invoiceUrl})",
'fields' => [
[ 'name' => 'Invoice #', 'value' => "#{$invoice->number}", 'inline' => true ],
[ 'name' => 'Amount', 'value' => $amount, 'inline' => true ],
],
'footer' => [ 'text' => $company ],
]]
];
}
$dmResponse = \Http::withHeaders([
'Authorization' => 'Bot ' . $botToken,
'Content-Type' => 'application/json',
])
->post('https://discord.com/api/v10/users/@me/channels', [
'recipient_id' => $discordId,
]);
if ($dmResponse->failed()) {
\Log::error('[DiscordPlus] Failed to create DM channel for discord_id: ' . $discordId . '. Response: ' . $dmResponse->body());
return;
}
$channelId = $dmResponse->json('id');
if (!$channelId) {
\Log::error('[DiscordPlus] No channel ID returned for discord_id: ' . $discordId . '. Response: ' . $dmResponse->body());
return;
}
$msgResponse = \Http::withHeaders([
'Authorization' => 'Bot ' . $botToken,
'Content-Type' => 'application/json',
])
->post("https://discord.com/api/v10/channels/{$channelId}/messages", $embed);
if ($msgResponse->failed()) {
\Log::error('[DiscordPlus] Failed to send DM to channel: ' . $channelId . '. Response: ' . $msgResponse->body());
}
}
public function enabled(): void
{
if (!\Schema::hasColumn('users', 'discord_id')) {
\Artisan::call('migrate', [
'--path' => 'extensions/Others/DiscordPlus/migrations/2025_08_27_000000_add_discord_id_to_users_table.php',
'--force' => true,
]);
}
}
public function disabled(): void {}
public function updated(): void {}
public function install(): void {}
}

View File

@@ -0,0 +1,111 @@
<?php
/**
* DiscordPlus Extension for Paymenter
*
* Created by: Exp (Discord: exp_yt)
* Company: ExpHost - https://www.exphost.net/
*
* This code is the intellectual property of Exp and ExpHost.
* Unauthorized copying, modification, or distribution is prohibited.
*/
namespace Paymenter\Extensions\Others\DiscordPlus;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\GithubProvider;
use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Discord\Provider as DiscordProvider;
class SocialLoginController extends \App\Http\Controllers\Controller
{
protected $discord_driver;
protected $github_driver;
protected $google_driver;
public function __construct()
{
$this->discord_driver = Socialite::buildProvider(DiscordProvider::class, [
'client_id' => config('settings.oauth_discord_client_id'),
'client_secret' => config('settings.oauth_discord_client_secret'),
'redirect' => '/oauth/discord/callback',
]);
$this->github_driver = Socialite::buildProvider(GithubProvider::class, [
'client_id' => config('settings.oauth_github_client_id'),
'client_secret' => config('settings.oauth_github_client_secret'),
'redirect' => '/oauth/github/callback',
]);
$this->google_driver = Socialite::buildProvider(GoogleProvider::class, [
'client_id' => config('settings.oauth_google_client_id'),
'client_secret' => config('settings.oauth_google_client_secret'),
'redirect' => '/oauth/google/callback',
]);
}
public function redirect($provider)
{
if (!config("settings.oauth_$provider")) {
abort(404);
}
return match ($provider) {
'discord' => $this->discord_driver->scopes(['email'])->redirect(),
'github' => $this->github_driver->scopes(['user:email'])->redirect(),
'google' => $this->google_driver->scopes(['email'])->redirect(),
default => abort(404)
};
}
public function handle($provider)
{
if ($provider == 'discord') {
$oauth_user = $this->discord_driver->user();
if (!($oauth_user->user['verified'] ?? true)) {
return redirect()->route('login')->with('error', __('auth.oauth.unverified_discord_account'));
}
$user = User::where('email', $oauth_user->email)->first();
if (!$user) {
return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered'));
}
// Only set discord_id if it exists and is not already set
$discordId = $oauth_user->getId() ?? ($oauth_user->id ?? $oauth_user->user['id'] ?? null);
if ($discordId && (!$user->discord_id || $user->discord_id != $discordId)) {
$user->discord_id = $discordId;
$user->save();
}
Auth::login($user, true);
return redirect()->route('home');
} elseif ($provider == 'google') {
$oauth_user = $this->google_driver->user();
$user = User::where('email', $oauth_user->email)->first();
if (!$user) {
return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered'));
}
Auth::login($user, true);
return redirect()->route('home');
} elseif ($provider == 'github') {
$oauth_user = $this->github_driver->user();
$user = User::where('email', $oauth_user->email)->first();
if (!$user) {
return redirect()->route('register')->with('error', __('auth.oauth.account_not_registered'));
}
Auth::login($user, true);
return redirect()->route('home');
} else {
return redirect()->route('login');
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
if (!Schema::hasColumn('users', 'discord_id')) {
$table->string('discord_id')->nullable()->unique()->after('email');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'discord_id')) {
$table->dropColumn('discord_id');
}
});
}
};

View File

@@ -0,0 +1,61 @@
<?php
namespace Paymenter\Extensions\Gateways\FreeTrial;
use App\Classes\Extension\Gateway;
use App\Helpers\ExtensionHelper;
use App\Models\Invoice;
class FreeTrial extends Gateway
{
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
return [
[
'name' => 'max_order_value',
'label' => 'Max Order Value',
'type' => 'number',
'required' => true,
]
];
}
/**
* Return a view or a url to redirect to
*
* @param Invoice $invoice
* @param float $total
* @return string
*/
public function pay(Invoice $invoice, $total)
{
$items = $invoice->items;
$maxOrderValue = $this->config('max_order_value');
// Make sure that the invoice is not for adding credits
foreach ($items as $item) {
if ($item->reference_type == 'App\\Models\\Credit') {
return redirect()->route('invoices.show', ['invoice' => $invoice->id])
->with('notification', ['message' => 'Cannot be used on Credits', 'type' => 'error']);
}
}
if ($total > $maxOrderValue) {
return redirect()->route('invoices.show', ['invoice' => $invoice->id])
->with('notification', ['message' => 'Item not eligible', 'type' => 'error']);
}
ExtensionHelper::addPayment($invoice->id, 'FreeTrial', $total);
return redirect()->route('invoices.show', ['invoice' => $invoice->id])->with('notification', [
'message' => 'Trial Activated',
'type' => 'success'
]);
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Vaibhav Dhiman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,60 @@
# Free Trial Payment Gateway for Paymenter
A simple extension for [Paymenter](https://paymenter.org) that allows automatic invoice approval for orders under a configurable maximum value. Ideal for offering **free trials** or promotional services with zero friction.
## Supported Versions
> The last version which supported Paymenter v0.9.5 is [v1.0.0](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/releases/tag/v1.0.0)
- **Supported Version:** Paymenter v1.0.4
- **Extension Version:** v2.0.0
## Features
- Automatically approves invoices if the total is under a configured maximum value.
- Seamless free trial support — no real payment is processed.
- Lightweight and easy to configure.
> ⚠️ **Note:** This gateway cannot be used to credit user accounts. It only applies to invoices which do not add credits to a user account.
## How It Works
Once enabled, if a user attempts to pay an invoice and the total amount is **less than or equal to** the configured **Max Order Value**, the payment is **automatically marked as paid**. If the amount exceeds the limit, the payment is **rejected**.
This gives you full flexibility to:
- Create promotional plans priced under the trial limit.
- Use one-time coupons and products to offer free trials.
- Test services internally without actual transactions.
## Getting Started
1. **Install the extension**
- Replace `/var/www/paymenter` with your actual Paymenter installation path.
- **One-liner Install:**
```sh
git clone https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension.git /var/www/paymenter/extensions/Gateways/FreeTrial
```
- **Manual Install:**
[Download](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/releases/latest/download/FreeTrial.zip) the extension and extract it to:
```
/var/www/paymenter/extensions/Gateways
```
1. **Enable the extension** from the Paymenter admin panel.
1. **Configure the Max Order Value** in the admin panel (e.g., `0.5` to allow automatic approval for invoices ≤ $0.5).
That’s it — you’re ready to offer free trials!
## Configuration
- **Max Order Value (number):** The maximum invoice amount (in your invoice currency) that can be auto-approved. Any invoice above this value will be rejected.
![Settings](Settings.png)
> 💡 If you want to use this with a one-time promotional coupon/product, ensure the final payable invoice amount (after discount) is within the Max Order Value.
## Support
For questions, suggestions, or issues:
- **Discord:** [@vaibhavd](https://discord.com/users/914452175839723550)
- **GitHub Issues:** [FreeTrial-Paymenter Issues](https://github.com/VaibhavSys/FreeTrial-Paymenter-Extension/issues)
## Additional Information
- **License:** MIT — see [LICENSE](LICENSE) for details.

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
Disabled/FreeTrial/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,83 @@
<?php
namespace Paymenter\Extensions\Gateways\Mollie;
use App\Classes\Extension\Gateway;
use App\Helpers\ExtensionHelper;
use App\Models\Invoice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class Mollie extends Gateway
{
public function boot()
{
require __DIR__ . '/routes.php';
// Register webhook route
}
private function request($url, $method = 'get', $data = [])
{
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->config('api_key'),
'Content-Type' => 'application/json',
])->$method('https://api.mollie.com' . $url, $data);
if (!$response->successful()) {
throw new \Exception('Mollie API error: ' . $response->json()['detail']);
}
return $response->json();
}
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
return [
[
'name' => 'api_key',
'label' => 'API Key',
'type' => 'text',
'required' => true,
],
];
}
/**
* Return a view or a url to redirect to
*
* @param float $total
* @return string
*/
public function pay(Invoice $invoice, $total)
{
$response = $this->request('/v2/payments', 'post', [
'amount' => [
'currency' => $invoice->currency_code,
'value' => number_format($total, 2, '.', ''),
],
'description' => 'Invoice #' . $invoice->id,
'redirectUrl' => route('invoices.show', $invoice) . '?checkPayment=true',
'webhookUrl' => route('extensions.gateways.mollie.webhook', $invoice),
'metadata' => [
'invoice_id' => $invoice->id,
],
]);
return $response['_links']['checkout']['href'];
}
public function webhook(Request $request)
{
$payment = $this->request('/v2/payments/' . $request->input('id'));
if ($payment['status'] == 'paid') {
ExtensionHelper::addPayment($payment['metadata']['invoice_id'], 'Mollie', $payment['amount']['value'], transactionId: $payment['id']);
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Paymenter\Extensions\Gateways\Mollie\Mollie;
Route::post('/extensions/gateways/mollie/webhook', [Mollie::class, 'webhook'])->withoutMiddleware([VerifyCsrfToken::class])->name('extensions.gateways.mollie.webhook');

View File

@@ -0,0 +1,115 @@
<?php
namespace Paymenter\Extensions\Others\Pages\Admin\Resources;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource\Pages;
use Paymenter\Extensions\Others\Pages\Models\Page;
class PageResource extends Resource
{
protected static ?string $model = Page::class;
protected static ?string $navigationIcon = 'ri-file-text-line';
protected static ?string $navigationGroup = 'Configuration';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
if (($get('slug') ?? '') !== Str::slug($old)) {
return;
}
$set('slug', Str::slug($state));
}),
Forms\Components\TextInput::make('slug')->rule('regex:/^[a-z0-9-]+$/')
->required()->unique(ignoreRecord: true),
Forms\Components\Textarea::make('description'),
Forms\Components\RichEditor::make('content')->enableToolbarButtons()->hidden(fn (Get $get) => $get('as_html')),
Forms\Components\MarkdownEditor::make('content')->disableAllToolbarButtons()->hidden(fn (Get $get) => !$get('as_html')),
Forms\Components\Toggle::make('visible')
->label('Visible'),
Forms\Components\Toggle::make('as_html')
->live()
->label('Show content as raw HTML (also removes title from the page)'),
Forms\Components\Select::make('visibility')
->options([
'public' => __('Public'),
'client' => __('Customers Only'),
'admin' => __('Admin Only'),
])
->default('public'),
Forms\Components\Select::make('navigation')
->options([
'none' => __('None (hidden)'),
'top' => __('Top Navigation'),
'account_dropdown' => __('Account Dropdown (requires login)'),
'dashboard' => __('Client area/Dashboard (requires login)'),
])
->default('none')
->label('Show in navigation'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->label('Title')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->label('Slug')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('visible')
->label('Visible')
->boolean(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])
->reorderable('sort')
->defaultSort('sort', 'asc');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPages::route('/'),
'create' => Pages\CreatePage::route('/create'),
'edit' => Pages\EditPage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource;
class CreatePage extends CreateRecord
{
protected static string $resource = PageResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource\Pages;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource;
class EditPage extends EditRecord
{
protected static string $resource = PageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -0,0 +1,38 @@
<?php
namespace Paymenter\Extensions\Others\Pages\Livewire;
use App\Livewire\Component;
use Paymenter\Extensions\Others\Pages\Models\Page as ModelsPage;
class Page extends Component
{
public ModelsPage $page;
public function mount($fallbackPlaceholder)
{
// Validate if page exists
$this->page = ModelsPage::where('slug', $fallbackPlaceholder)->firstOrFail();
if (!$this->page->visible) {
abort(404);
}
// Validate if user is logged in
if ($this->page->visibility == 'client' && !auth()->check()) {
abort(404);
}
if ($this->page->visibility == 'admin' && (!auth()->check() || is_null(auth()->user()->role))) {
abort(404);
}
}
public function render()
{
return view('others.pages::page', [
'page' => $this->page,
])->layout('layouts.app', [
'title' => $this->page->title,
'description' => $this->page->description,
'sidebar' => $this->page->navigation == 'dashboard' ? true : false,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Paymenter\Extensions\Others\Pages\Models;
use Illuminate\Database\Eloquent\Model;
class Page extends Model
{
protected $table = 'ext_pages';
protected $fillable = [
'slug',
'title',
'description',
'content',
'visible',
'as_html',
'visibility',
'navigation',
'sort',
];
protected $casts = [
'visible' => 'boolean',
'html' => 'boolean',
'visibility' => 'string',
];
public function getRouteKeyName()
{
return 'slug';
}
}

108
Disabled/Pages/Pages.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace Paymenter\Extensions\Others\Pages;
use App\Classes\Extension\Extension;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\HtmlString;
use Paymenter\Extensions\Others\Pages\Admin\Resources\PageResource;
use Paymenter\Extensions\Others\Pages\Livewire\Page;
use Paymenter\Extensions\Others\Pages\Models\Page as ModelsPage;
class Pages extends Extension
{
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
try {
return [
[
'name' => 'Notice',
'type' => 'placeholder',
'label' => new HtmlString('You can use this extension to create custom frontend pages, go to <a class="text-primary-600" href="' . PageResource::getUrl() . '">Pages</a> to get started.'),
],
];
} catch (\Exception $e) {
return [
[
'name' => 'Notice',
'type' => 'placeholder',
'label' => new HtmlString('You can use this extension to create custom frontend pages, you\'ll need to enable this extension above to get started.'),
],
];
}
}
public function enabled()
{
// Run migrations
Artisan::call('migrate', ['--path' => 'extensions/Others/Pages/database/migrations', '--force' => true]);
}
public function boot()
{
// Register routes
require __DIR__ . '/routes.php';
View::addNamespace('others.pages', __DIR__ . '/resources/views');
// Register livewire
\Livewire\Livewire::component('extensions.others.pages.show', Page::class);
Event::listen('navigation', function () {
if (ModelsPage::where('visible', true)->where('navigation', 'top')->count() == 0) {
return;
}
$pages = ModelsPage::where('visible', true)->where('navigation', 'top')->orderBy('sort')->get();
return $this->toNavigation($pages);
});
Event::listen('navigation.account-dropdown', function () {
if (ModelsPage::where('visible', true)->where('navigation', 'account_dropdown')->count() == 0) {
return;
}
$pages = ModelsPage::where('visible', true)->where('navigation', 'account_dropdown')->orderBy('sort')->get();
return $this->toNavigation($pages);
});
Event::listen('navigation.dashboard', function () {
if (ModelsPage::where('visible', true)->where('navigation', 'dashboard')->count() == 0) {
return;
}
$pages = ModelsPage::where('visible', true)->where('navigation', 'dashboard')->orderBy('sort')->get();
return $this->toNavigation($pages);
});
}
private function toNavigation($pages)
{
$navigation = [];
foreach ($pages as $page) {
if ($page->visibility == 'client' && !auth()->check()) {
continue;
}
if ($page->visibility == 'admin' && (!auth()->check() || is_null(auth()->user()->role))) {
continue;
}
$navigation[] = [
'name' => $page->title,
'route' => 'extensions.others.pages',
'params' => ['fallbackPlaceholder' => $page->slug],
];
}
return $navigation;
}
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ext_pages', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->string('title');
$table->text('description')->nullable();
$table->longText('content')->nullable();
$table->boolean('visible')->default(true);
$table->boolean('as_html')->default(false);
$table->enum('visibility', ['public', 'client', 'admin'])->default('public');
$table->enum('navigation', ['none', 'top', 'account_dropdown', 'dashboard'])->default('none');
$table->unsignedTinyInteger('sort')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ext_pages');
}
};

8
Disabled/Pages/readme.md Normal file
View File

@@ -0,0 +1,8 @@
Thanks for buying this extension!
Buying this extension means you are supporting the development of Paymenter and helping us to keep it free for everyone.
To install this extension just drop it in the `extensions/Others` folder of your Paymenter installation. You can find the `extensions/Others` folder in the root directory of your Paymenter installation.
If you require assistance with this extension, please go to the [Paymenter Discord](https://discord.gg/kJWDZ86NUZ) and ask for help in the 3rd party support channel.

View File

@@ -0,0 +1,8 @@
<div class="prose dark:prose-invert max-w-full bg-background-secondary border border-neutral p-6 rounded-lg">
@if($page->as_html)
{!! $page->content !!}
@else
<h1 class="mb-2">{{ $page->title }}</h1>
<div>{!! $page->content !!}</div>
@endif
</div>

View File

@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Paymenter\Extensions\Others\Pages\Livewire\Page;
Route::fallback(Page::class)->middleware('web')->name('extensions.others.pages');

View File

@@ -0,0 +1,87 @@
<?php
namespace Paymenter\Extensions\Gateways\PayPal_IPN;
use App\Classes\Extension\Gateway;
use App\Helpers\ExtensionHelper;
use App\Models\Invoice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class PayPal_IPN extends Gateway
{
public function boot()
{
require __DIR__ . '/routes.php';
}
/**
* Get all the configuration for the extension
*
* @param array $values
* @return array
*/
public function getConfig($values = [])
{
return [
[
'name' => 'email',
'label' => 'PayPal Email',
'type' => 'text',
'required' => true,
],
[
'name' => 'test_mode',
'label' => 'Test Mode',
'type' => 'checkbox',
'required' => false,
],
];
}
/**
* Return a view or a url to redirect to
*
* @param float $total
* @return string
*/
public function pay(Invoice $invoice, $total)
{
$paypal_url = $this->config('test_mode') ? 'https://www.sandbox.paypal.com/cgi-bin/webscr' : 'https://www.paypal.com/cgi-bin/webscr';
$paypal_email = $this->config('email');
$return_url = route('invoices.show', $invoice);
$cancel_url = route('invoices.show', $invoice);
$notify_url = route('extensions.gateways.paypal_ipn.notify');
$query = '?cmd=_xclick';
$query .= '&item_name=' . urlencode('Invoice #' . $invoice->id);
$query .= '&item_number=' . $invoice->id;
$query .= '&amount=' . number_format($total, 2, '.', '');
$query .= '&currency_code=' . $invoice->currency_code;
$query .= '&business=' . $paypal_email;
$query .= '&notify_url=' . $notify_url;
$query .= '&return=' . $return_url;
$query .= '&cancel_return=' . $cancel_url;
return $paypal_url . $query;
}
/**
* Handle the IPN request
*
* @return void
*/
public function notify(Request $request)
{
// Send the request to PayPal
$response = Http::asForm()->post($this->config('test_mode') ? 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr' : 'https://ipnpb.paypal.com/cgi-bin/webscr', [
'cmd' => '_notify-validate',
] + $request->all());
// Check if the response is verified
if ($response->body() == 'VERIFIED') {
ExtensionHelper::addPayment($request->item_number, 'PayPal', $request->mc_gross, $request->mc_fee, transactionId: $request->txn_id);
}
}
}

View File

@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use Paymenter\Extensions\Gateways\PayPal_IPN\PayPal_IPN;
Route::post('/extensions/paypal_ipn/notify', [PayPal_IPN::class, 'notify'])->name('extensions.gateways.paypal_ipn.notify');

View File

@@ -0,0 +1,11 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\Pages;
use Filament\Resources\Pages\CreateRecord;
use Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\ProductPageResource;
class CreateProductPage extends CreateRecord
{
protected static string $resource = ProductPageResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\Pages;
use Filament\Resources\Pages\EditRecord;
use Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\ProductPageResource;
class EditProductPage extends EditRecord
{
protected static string $resource = ProductPageResource::class;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources\ProductPageResource;
class ListProductPages extends ListRecords
{
protected static string $resource = ProductPageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
protected function getTableActions(): array
{
return [
Actions\EditAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,272 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Admin\Resources;
use App\Models\Category;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Paymenter\Extensions\Others\ProductPageBuilder\Models\ProductPageSettings;
class ProductPageResource extends Resource
{
protected static ?string $model = ProductPageSettings::class;
protected static ?string $navigationIcon = 'ri-pages-line';
protected static ?string $navigationGroup = 'Content';
protected static ?string $navigationLabel = 'Product Pages';
protected static ?int $navigationSort = 16;
protected static ?string $pluralModelLabel = 'Product Page';
protected static ?string $modelLabel = 'Product Page';
public static function form(Form $form): Form
{
return $form
->schema([
Section::make('Basic Information')
->schema([
FileUpload::make('hero_section.background')
->image()
->disk('public')
->directory('product-pages/hero')
->required()
->label('Background Image'),
TextInput::make('hero_section.title')
->required()
->live(onBlur: true)
->label('Title')
->afterStateUpdated(function ($state, callable $set) {
$set('hero_section.slug', \Str::slug($state));
$set('seo.title', $state);
}),
TextInput::make('hero_section.slug')
->required()
->unique(
table: 'ext_product_page_builder',
column: 'hero_section->slug',
ignorable: fn ($record) => $record
)
->label('URL Slug'),
RichEditor::make('hero_section.description')
->required()
->label('Description')
->afterStateUpdated(function ($state, callable $set) {
$set('seo.description', strip_tags($state));
}),
Select::make('category_id')
->label('Category')
->options(Category::all()->pluck('name', 'id'))
->required(),
])
->collapsible(),
Section::make('SEO Settings')
->schema([
TextInput::make('seo.title')
->label('SEO Title')
->placeholder('Will use Basic Information title if left empty'),
TextInput::make('seo.url')
->label('Canonical URL')
->placeholder('Auto-generated from slug')
->dehydrated(false)
->disabled()
->formatStateUsing(fn ($record) => $record ? url('/product/' . ($record->hero_section['slug'] ?? '')) : ''
),
FileUpload::make('seo.banner')
->image()
->disk('public')
->directory('product-pages/seo')
->label('Social Media Banner'),
Textarea::make('seo.description')
->label('SEO Description')
->placeholder('Will use Basic Information description if left empty')
->rows(3),
/*
==================================================================================
This only work if you inside themes/default/views/layouts/app.blade.php
{{--
@isset($keywords)
<meta name="keywords" content="{{ $keywords }}">
@endisset
--}}
delete {{-- and --}}
and also in extension/Others/ProductPageBuilder/resources/views/show.blade.php
{{--
<x-slot name="keywords">{{ is_array($page->seo['keywords'] ?? null) ? implode(', ', $page->seo['keywords']) : ($page->seo['keywords'] ?? '') }}</x-slot>
--}}
delete {{-- and --}}
TagsInput::make('seo.keywords')
->label('SEO Keywords')
->placeholder('Add keywords and press enter')
->separator(','),
==================================================================================
*/
])
->collapsible(),
Section::make('Features')
->schema([
Repeater::make('features')
->collapsible()
->schema([
Select::make('icon_type')
->label('Icon Type')
->options([
'image' => 'Image',
'svg' => 'SVG',
])
->default('image')
->required()
->reactive()
->formatStateUsing(function ($state, $record) {
if (!empty($state)) {
return $state;
}
if (!empty($record['icon_svg'])) {
return 'svg';
}
if (!empty($record['icon'])) {
return 'image';
}
return 'image';
}),
Textarea::make('icon_svg')
->label('SVG Code')
->rows(5)
->placeholder('<svg ...>...</svg>')
->visible(fn ($get) => $get('icon_type') === 'svg'),
FileUpload::make('icon')
->directory('product-pages/features')
->visible(fn ($get) => $get('icon_type') === 'image'),
TextInput::make('title'),
// ->required(),
RichEditor::make('description'),
// ->required(),
Toggle::make('is_active')
->default(true),
]),
// ->required()
])
->collapsible(),
Section::make('Game Preview')
->schema([
Repeater::make('game_preview')
->maxItems(1)
->schema([
TextInput::make('title'),
// ->required(),
RichEditor::make('description'),
// ->required(),
TextInput::make('youtube_embed')
// ->required()
->placeholder('e.g. https://www.youtube.com/embed/xxxxx'),
Toggle::make('is_active')
->default(true),
]),
// ->required()
])
->collapsible(),
Section::make('Game Panel')
->schema([
Repeater::make('game_panel')
->maxItems(1)
->schema([
TextInput::make('title'),
// ->required(),
RichEditor::make('description'),
// ->required(),
FileUpload::make('gallery')
// ->required()
->multiple()
->directory('product-pages/gallery'),
TagsInput::make('features')
->label('Panel Features')
->placeholder('Add feature and press enter')
->separator(','),
Toggle::make('is_active')
->default(true),
]),
// ->required()
])
->collapsible(),
Section::make('FAQs')
->schema([
Repeater::make('faqs')
->collapsible()
->schema([
TextInput::make('question'),
// ->required(),
RichEditor::make('answer'),
// ->required(),
Toggle::make('is_active')
->default(true),
]),
// ->required()
])
->collapsible(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('updated_at')
->label('Last Updated')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('hero_section.title')
->label('Title'),
Tables\Columns\TextColumn::make('hero_section.slug')
->label('Slug'),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\ViewAction::make()
->url(fn ($record) => "/games/{$record->hero_section['slug']}")
->openUrlInNewTab(),
Tables\Actions\DeleteAction::make(),
])
/*
->headerActions([
Tables\Actions\CreateAction::make()
->label('Create Product Page')
])*/
->defaultSort('updated_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListProductPages::route('/'),
'create' => Pages\CreateProductPage::route('/create'),
'edit' => Pages\EditProductPage::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Http\Controllers;
use App\Http\Controllers\Controller;
use Paymenter\Extensions\Others\ProductPageBuilder\Models\ProductPageSettings;
class ProductPageController extends Controller
{
public function index()
{
$pages = ProductPageSettings::all();
return view('product_page_builder::index', compact('pages'));
}
public function show($slug)
{
$page = ProductPageSettings::whereJsonContains('hero_section->slug', $slug)->firstOrFail();
return view('product_page_builder::show', compact('page'));
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder\Models;
use App\Models\Category;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class ProductPageSettings extends Model
{
protected $table = 'ext_product_page_builder';
protected $fillable = [
'hero_section',
'features',
'pricing_tables',
'seo',
'category_id',
'game_preview',
'game_panel',
'faqs',
];
protected $casts = [
'hero_section' => 'array',
'features' => 'array',
'pricing_tables' => 'array',
'seo' => 'array',
'game_preview' => 'array',
'game_panel' => 'array',
'faqs' => 'array',
];
protected function heroSection(): Attribute
{
return Attribute::make(
get: fn ($value) => json_decode($value, true) ?? [],
set: fn ($value) => json_encode($value),
);
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function getMinPriceDetailsAttribute()
{
if (!$this->category) {
return null;
}
$minPrice = $this->category->products()
->join('plans', function ($join) {
$join->on('plans.priceable_id', '=', 'products.id')
->where('plans.priceable_type', '=', 'App\\Models\\Product');
})
->join('prices', function ($join) {
$join->on('prices.plan_id', '=', 'plans.id');
})
->select('prices.price', 'prices.currency_code')
->orderBy('prices.price', 'asc')
->first();
return $minPrice ? [
'price' => $minPrice->price,
'currency_code' => $minPrice->currency_code,
] : null;
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder;
use App\Classes\Extension\Extension;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Paymenter\Extensions\Others\ProductPageBuilder\Models\ProductPageSettings;
class ProductPageBuilder extends Extension
{
public function getConfig($values = [])
{
return [
[
'name' => 'notice',
'type' => 'placeholder',
'label' => 'Use this extension to customize your product pages with custom sections.',
],
];
}
public function enabled()
{
Artisan::call('migrate', [
'--path' => 'extensions/Others/ProductPageBuilder/database/migrations',
'--force' => true,
]);
}
public function boot()
{
// Add category_id column if it doesn't exist
if (!$this->columnExists('ext_product_page_builder', 'category_id')) {
DB::statement('ALTER TABLE ext_product_page_builder ADD COLUMN category_id bigint unsigned NULL');
DB::statement('ALTER TABLE ext_product_page_builder ADD CONSTRAINT product_page_builder_category_id_foreign FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL');
}
// Register views
View::addNamespace('product_page_builder', __DIR__ . '/resources/views');
// Register routes
Route::middleware('web')->group(__DIR__ . '/routes/web.php');
// Register Filament resources
if (class_exists('\Filament\Facades\Filament')) {
Filament::registerResources([
Admin\Resources\ProductPageResource::class,
]);
}
Event::listen('product.page', function ($product) {
$settings = ProductPageSettings::first();
if (!$settings) {
return [];
}
return [
'view' => view('product_page_builder::product', [
'settings' => $settings,
'product' => $product,
]),
];
});
Event::listen('navigation', function () {
if (ProductPageSettings::count() == 0) {
return;
}
return [
'name' => 'Games',
'route' => 'product-page.index',
// 'icon' => '',
'separator' => true,
'children' => [],
];
});
}
private function columnExists($table, $column)
{
return DB::getSchemaBuilder()->hasColumn($table, $column);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Paymenter\Extensions\Others\ProductPageBuilder;
use Illuminate\Support\ServiceProvider;
class ProductPageBuilderServiceProvider extends ServiceProvider
{
public function boot()
{
$this->loadRoutesFrom(__DIR__ . '/routes/web.php');
$this->loadMigrationsFrom(__DIR__ . '/database/migrations');
$this->loadViewsFrom(__DIR__ . '/resources/views', 'product-page-builder');
}
public function register()
{
//
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "paymenter/product-page-builder",
"extra": {
"laravel": {
"providers": [
"Paymenter\\Extensions\\Others\\ProductPageBuilder\\ProductPageBuilderServiceProvider"
]
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::dropIfExists('ext_product_page_builder');
Schema::create('ext_product_page_builder', function (Blueprint $table) {
$table->id();
$table->json('hero_section')->nullable();
$table->json('features')->nullable();
$table->json('seo')->nullable();
$table->json('game_preview')->nullable();
$table->json('game_panel')->nullable();
$table->json('faqs')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->onDelete('set null');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('ext_product_page_builder');
}
};

View File

@@ -0,0 +1,119 @@
@props([
'options' => [],
'placeholder' => 'Select option'
])
<div
x-data="{
selectOpen: false,
selectedValue: @entangle($attributes->wire('model')),
selectableItems: {{ json_encode($options) }},
selectableItemActive: null,
selectId: $id('select'),
selectDropdownPosition: 'bottom',
init() {
this.$watch('selectOpen', () => {
if (this.selectOpen) {
this.selectableItemActive = this.selectableItems.find(item => item.value === this.selectedValue)
|| this.selectableItems[0];
this.$nextTick(() => this.selectScrollToActiveItem());
}
this.$nextTick(() => this.selectPositionUpdate());
});
window.addEventListener('resize', () => this.selectPositionUpdate());
},
selectableItemIsActive(item) {
return this.selectableItemActive && this.selectableItemActive.value === item.value;
},
selectableItemActiveNext() {
let index = this.selectableItems.indexOf(this.selectableItemActive);
if (index < this.selectableItems.length - 1) {
this.selectableItemActive = this.selectableItems[index + 1];
this.selectScrollToActiveItem();
}
},
selectableItemActivePrevious() {
let index = this.selectableItems.indexOf(this.selectableItemActive);
if (index > 0) {
this.selectableItemActive = this.selectableItems[index - 1];
this.selectScrollToActiveItem();
}
},
selectScrollToActiveItem() {
if (this.selectableItemActive) {
const activeElement = document.getElementById(this.selectableItemActive.value + '-' + this.selectId);
if (activeElement) {
activeElement.scrollIntoView({ block: 'nearest' });
}
}
},
selectPositionUpdate() {
if (!this.$refs.selectButton || !this.$refs.selectableItemsList) return;
const selectDropdownBottomPos = this.$refs.selectButton.getBoundingClientRect().top +
this.$refs.selectButton.offsetHeight +
this.$refs.selectableItemsList.offsetHeight;
this.selectDropdownPosition = window.innerHeight < selectDropdownBottomPos ? 'top' : 'bottom';
}
}"
@keydown.escape="selectOpen = false"
@keydown.down.prevent="if(selectOpen) { selectableItemActiveNext() } else { selectOpen = true }"
@keydown.up.prevent="if(selectOpen) { selectableItemActivePrevious() } else { selectOpen = true }"
@keydown.enter.prevent="selectedValue = selectableItemActive.value; selectOpen = false"
class="relative w-full"
>
<button
x-ref="selectButton"
@click="selectOpen = !selectOpen"
:class="{ 'ring-2 ring-offset-2 ring-neutral-800': selectOpen }"
class="relative w-full min-h-[38px] py-2 pl-3 pr-10 text-left bg-background-secondary border border-neutral rounded-md shadow-sm cursor-pointer text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-800"
type="button"
>
<span x-text="selectableItems.find(item => item.value === selectedValue)?.label ?? '{{ $placeholder }}'" class="block truncate"></span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<x-ri-expand-up-down-line class="size-4" />
</span>
</button>
<ul
x-show="selectOpen"
x-ref="selectableItemsList"
@click.away="selectOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
:class="{ 'bottom-full mb-1': selectDropdownPosition === 'top', 'top-full mt-1': selectDropdownPosition === 'bottom' }"
class="absolute z-50 w-full py-1 overflow-auto bg-background-secondary border border-neutral rounded-lg shadow-lg max-h-60 focus:outline-none text-sm"
x-cloak
>
<template x-for="item in selectableItems" :key="item.value">
<li
:id="item.value + '-' + selectId"
@click="selectedValue = item.value; selectOpen = false"
@mousemove="selectableItemActive = item"
:class="{ 'bg-neutral-700': selectableItemIsActive(item) }"
class="relative py-2 pl-8 pr-4 cursor-pointer select-none hover:bg-neutral-700"
>
<span
class="block truncate"
:class="{ 'font-semibold': item.value === selectedValue }"
x-text="item.label"
></span>
<span
x-show="item.value === selectedValue"
class="absolute inset-y-0 left-0 flex items-center pl-2 text-neutral-400"
>
<x-ri-check-fill class="size-4" />
</span>
</li>
</template>
</ul>
</div>

View File

@@ -0,0 +1,47 @@
<x-app-layout>
<x-slot name="title">Games</x-slot>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
@foreach($pages as $page)
<div class="group relative bg-background-secondary hover:bg-background-secondary/80 border border-neutral rounded-lg overflow-hidden transition-all duration-300 hover:shadow-xl p-4">
@if(isset($page->hero_section['background']))
<div class="w-full h-36 overflow-hidden rounded-lg mb-4">
<img src="{{ Storage::url($page->hero_section['background']) }}"
alt="{{ $page->hero_section['title'] }}"
class="w-full h-full object-cover">
</div>
@endif
<div class="relative">
<h2 class="text-2xl font-bold text-base mb-4">{{ $page->hero_section['title'] }}</h2>
@if(isset($page->hero_section['description']))
<div class="prose dark:prose-invert line-clamp-2 mb-4">
{!! strip_tags($page->hero_section['description']) !!}
</div>
@endif
<div class="flex justify-between items-start mb-4">
@if($page->min_price_details)
<div class="text-base text-white font-semibold">
@php
$currency = \App\Models\Currency::firstWhere('code', $page->min_price_details['currency_code']);
$formattedPrice = $currency ?
($currency->prefix ?? '') . number_format($page->min_price_details['price'], 2) . ($currency->suffix ?? '') :
number_format($page->min_price_details['price'], 2);
@endphp
Starting from {{ $formattedPrice }}
</div>
@endif
</div>
<div class="mt-4 flex items-center justify-end">
<a href="{{ route('product-page.show', $page->hero_section['slug']) }}" wire:navigate class="flex items-center gap-2 justify-center bg-primary text-white text-sm font-semibold hover:bg-primary/80 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50">
View Details
</a>
</div>
</div>
</div>
@endforeach
</div>
</x-app-layout>

View File

@@ -0,0 +1,273 @@
<x-app-layout>
<x-slot name="title">{{ $page->seo['title'] ?? $page->hero_section['title'] }}</x-slot>
<x-slot name="description">{!! $page->seo['description'] ?? strip_tags($page->hero_section['description']) !!}</x-slot>
{{--
<x-slot name="keywords">{{ is_array($page->seo['keywords'] ?? null) ? implode(', ', $page->seo['keywords']) : ($page->seo['keywords'] ?? '') }}</x-slot>
--}}
@if(isset($page->seo['banner']))
<x-slot name="image">{{ Storage::url($page->seo['banner']) }}</x-slot>
@endif
<!-- Main content -->
@if(isset($page->hero_section))
<div class="relative bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg flex items-center overflow-hidden">
@if(isset($page->hero_section['background']))
<div class="absolute inset-0 transform">
<img src="{{ Storage::url($page->hero_section['background']) }}"
alt="{{ $page->hero_section['title'] }}"
class="h-full w-full object-cover">
<div class="absolute inset-0 bg-background-secondary/50 dark:bg-background-secondary/60"></div>
</div>
@endif
<div class="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-28">
<h1 class="text-4xl lg:text-5xl font-extrabold text-base mb-4 leading-tight">
{{ $page->hero_section['title'] }}
</h1>
<div class="text-xl prose dark:prose-invert max-w-5xl">
{!! $page->hero_section['description'] !!}
</div>
</div>
</div>
@endif
@if($page->category)
<div class="py-24">
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
@foreach($page->category->products()->with(['plans.prices'])->get() as $product)
@php
$lowestPlan = $product->plans
->flatMap(function($plan) {
return $plan->prices->map(function($price) use ($plan) {
return ['plan' => $plan, 'price' => $price];
});
})
->sortBy('price.price')
->first();
@endphp
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 lg:p-6 rounded-lg overflow-hidden transition-all duration-300 hover:shadow-xl">
{{-- Prdocut Image Banner
@if($product->image)
<div class="mb-4 rounded-lg overflow-hidden">
<img src="{{ Storage::url($product->image) }}"
alt="{{ $product->name }}"
class="w-full h-48 object-cover">
</div>
@endif
--}}
{{-- Product Image Icon --}}
@if($product->image)
<div class="mb-4 rounded-lg overflow-hidden">
<img src="{{ Storage::url($product->image) }}"
alt="{{ $product->name }}"
class="w-full h-24 object-contain">
</div>
@endif
<h3 class="text-3xl font-bold text-base my-4 text-center">
{{ $product->name }}
</h3>
<div class="prose dark:prose-invert text-base mb-4">{!! $product->description !!}</div>
@if($lowestPlan)
@php
$currency = \App\Models\Currency::firstWhere('code', $lowestPlan['price']->currency_code);
$formattedPrice = $currency ?
($currency->prefix ?? '') . number_format($lowestPlan['price']->price, 2) . ($currency->suffix ?? '') :
number_format($lowestPlan['price']->price, 2);
@endphp
<div class="prose dark:prose-invert mb-4 text-center flex items-center gap-2 justify-center">
<span class="font-medium text-lg">Starting from</span>
<span class="text-base text-2xl font-bold">{{ $formattedPrice }}</span>
</div>
@endif
<a href="{{ route('products.show', ['category' => $page->category->slug, 'product' => $product->slug]) }}"
wire:navigate
class="flex items-center gap-2 justify-center bg-primary text-base text-sm font-semibold hover:bg-primary/80 py-2.5 lg:py-2 px-4.5 rounded-md w-full duration-300 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50">
{{ __('general.view') }}
</a>
</div>
@endforeach
</div>
</div>
@endif
@php
$activeFeatures = collect($page->features ?? [])->filter(fn($feature) => $feature['is_active'] ?? true);
@endphp
@if($activeFeatures->isNotEmpty())
<div class="py-12 lg:py-24">
<h2 class="text-3xl font-bold text-center text-base mb-12">Features</h2>
<div class="grid grid-cols-1 gap-12 lg:grid-cols-3">
@foreach($activeFeatures as $feature)
<div class="relative group">
<div class="relative bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 lg:p-6 rounded-lg shadow-xl">
@if(!empty($feature['icon_svg']))
<div class="h-12 w-12 mb-4 flex items-center justify-center">
{!! $feature['icon_svg'] !!}
</div>
@elseif(isset($feature['icon']))
<div class="h-12 w-12 mb-4">
<img src="{{ Storage::url($feature['icon']) }}"
alt="{{ $feature['title'] }}"
class="h-full w-full object-contain">
</div>
@endif
<h3 class="text-xl font-semibold text-base mb-3">{{ $feature['title'] }}</h3>
<div class="prose prose dark:prose-invert">{!! $feature['description'] !!}</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
@if(!empty($page->game_preview))
@foreach($page->game_preview as $preview)
@if($preview['is_active'] ?? true)
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center py-12 lg:py-24">
<div class="max-w-xl me-auto lg:text-left text-center">
<h3 class="text-3xl font-bold text-base mb-6">{{ $preview['title'] }}</h3>
<div class="prose dark:prose-invert text-base">{!! $preview['description'] !!}</div>
</div>
@if(isset($preview['youtube_embed']))
<div class="h-48 lg:h-96 rounded-lg overflow-hidden">
<iframe src="{{ $preview['youtube_embed'] }}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="w-full h-full"></iframe>
</div>
@endif
</div>
@endif
@endforeach
@endif
@php
$activeGamePanels = collect($page->game_panel ?? [])->filter(fn($panel) => $panel['is_active'] ?? true);
$activeGamePreviews = collect($page->game_preview ?? [])->filter(fn($preview) => $preview['is_active'] ?? true);
$swapOrder = $activeGamePanels->isNotEmpty() && $activeGamePreviews->isNotEmpty();
@endphp
@if(!empty($page->game_panel))
@foreach($page->game_panel as $panel)
@if($panel['is_active'] ?? true)
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center py-12 lg:py-24">
<div class="max-w-xl ms-auto lg:text-left text-center {{ $swapOrder ? 'order-2' : 'order-1' }}">
<h3 class="text-3xl font-bold text-base mb-6">{{ $panel['title'] }}</h3>
<div class="prose dark:prose-invert text-base">{!! $panel['description'] !!}</div>
@php
$features = is_array($panel['features'] ?? null)
? $panel['features']
: (empty($panel['features']) ? [] : explode(',', $panel['features']));
@endphp
@if(!empty($features))
<div class="flex flex-wrap justify-center lg:justify-start gap-2 my-4">
@foreach($features as $feature)
<span class="flex items-center gap-1 bg-background-secondary border border-neutral text-base px-3 py-1 rounded-lg text-sm font-semibold">
<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-primary">
<path d="M9.9997 15.1709L19.1921 5.97852L20.6063 7.39273L9.9997 17.9993L3.63574 11.6354L5.04996 10.2212L9.9997 15.1709Z"></path>
</svg>
{{ trim($feature) }}
</span>
@endforeach
</div>
@endif
</div>
@if(!empty($panel['gallery']))
<div
x-data="{
slides: {{ json_encode($panel['gallery']) }},
current: 0,
goTo(idx) { this.current = idx },
next() { this.current = (this.current + 1) % this.slides.length },
prev() { this.current = (this.current - 1 + this.slides.length) % this.slides.length }
}"
class="relative rounded-lg overflow-hidden shadow-lg"
>
<div class="relative h-48 md:h-96">
<template x-for="(image, idx) in slides" :key="idx">
<div
x-show="current === idx"
x-transition:enter="transition-opacity duration-700"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-700"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0"
>
<img :src="'{{ Storage::url('') }}' + image" class="object-cover w-full h-full" alt="Slide" />
</div>
</template>
</div>
<!-- Slider indicators -->
<div class="flex absolute bottom-5 left-1/2 z-30 -translate-x-1/2 space-x-2">
<template x-for="(image, idx) in slides" :key="idx">
<button
type="button"
:class="{'bg-primary': current === idx, 'bg-gray-300': current !== idx}"
class="w-4 h-1 rounded-lg hover:bg-gray-400 focus:outline-none transition"
@click="goTo(idx)"
></button>
</template>
</div>
<!-- Slider controls -->
<button type="button"
class="flex absolute top-1/2 -translate-y-1/2 left-3 z-40 items-center justify-center w-8 h-8 bg-gray-200/50 rounded-full hover:bg-gray-300 focus:outline-none transition cursor-pointer"
@click="prev"
>
<svg class="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<button type="button"
class="flex absolute top-1/2 -translate-y-1/2 right-3 z-40 items-center justify-center w-8 h-8 bg-gray-200/50 rounded-full hover:bg-gray-300 focus:outline-none transition cursor-pointer"
@click="next"
>
<svg class="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
@endif
</div>
@endif
@endforeach
@endif
@php
$activeFaqs = collect($page->faqs ?? [])->filter(fn($faq) => $faq['is_active'] ?? true);
@endphp
@if($activeFaqs->isNotEmpty())
<div class="max-w-7xl mx-auto py-12 lg:py-24">
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-center text-base mb-12">Frequently Asked Questions</h2>
<div class="space-y-8">
@foreach($activeFaqs as $faq)
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg" x-data="{ open: false }">
<dt>
<button @click="open = !open" class="w-full flex justify-between items-center text-left cursor-pointer">
<span class="text-base lg:text-lg font-semibold text-base">{{ $faq['question'] }}</span>
<span class="ml-6 flex-shrink-0">
<svg class="h-4 w-4 transform transition-transform duration-200" :class="{'rotate-180': open}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
</dt>
<dd class="mt-4" x-show="open" x-transition>
<div class="prose prose-sm max-w-none prose dark:prose-invert">
{!! $faq['answer'] !!}
</div>
</dd>
</div>
@endforeach
</div>
</div>
</div>
@endif
</x-app-layout>

View File

@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
use Paymenter\Extensions\Others\ProductPageBuilder\Http\Controllers\ProductPageController;
Route::get('/games', [ProductPageController::class, 'index'])->name('product-page.index');
Route::get('/games/{slug}', [ProductPageController::class, 'show'])->name('product-page.show');

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Stage 0:
# Build the assets that are needed for the frontend. This build stage is then discarded
# since we won't need NodeJS anymore in the future. This Docker image ships a final production
# level distribution of Paymenter.
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS build
WORKDIR /app
COPY . ./
RUN npm install \
&& npm run build
# Stage 1:
# Build the actual container with all of the needed PHP dependencies that will run the application.
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
WORKDIR /app
COPY . ./
COPY --from=0 /app/public/default /app/public/default
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev autoconf make g++ gcc libc-dev linux-headers gmp-dev \
&& docker-php-ext-configure zip \
&& docker-php-ext-install bcmath gd pdo_mysql zip intl sockets gmp \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del autoconf make g++ gcc libc-dev \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& cp .env.example .env \
&& chmod 777 -R bootstrap storage/* \
&& composer install --no-dev --optimize-autoloader \
&& rm -rf .env bootstrap/cache/*.php \
&& chown -R nginx:nginx .
RUN rm /usr/local/etc/php-fpm.conf \
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& mkdir -p /var/run/php /var/run/nginx
COPY .github/docker/default.conf /etc/nginx/http.d/default.conf
COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf
COPY .github/docker/supervisord.conf /etc/supervisord.conf
EXPOSE 80
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

36
Dockerfile.dev Normal file
View File

@@ -0,0 +1,36 @@
# Use PHP 8.2 FPM Debian as base image
FROM serversideup/php:8.3-fpm-nginx AS base
# Switch to root so we can do root things
USER root
# Install the intl extension with root permissions
RUN install-php-extensions pdo_mysql mbstring exif pcntl bcmath gd intl zip xml curl redis
# Install JavaScript dependencies
ARG NODE_VERSION=20.18.0
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
corepack enable && \
rm -rf /tmp/node-build-master
# Drop back to our unprivileged user
USER www-data
FROM base
# # Get latest Composer
# COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# # Set working directory
# WORKDIR /var/www/html
COPY --chown=www-data:www-data ./ /var/www/html
ENV PHP_OPCACHE_ENABLE=1
RUN /usr/bin/composer install --no-interaction --prefer-dist --optimize-autoloader
RUN npm install --immutable && \
npm run build && \
rm -rf node_modules

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Admin\Actions;
use Filament\Actions\Action;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
class AuditAction extends Action
{
public $auditChildren = [];
public function auditChildren($children): self
{
$this->auditChildren = $children;
// Array of children of the model to also show audits for
return $this;
}
public static function getDefaultName(): ?string
{
return 'audits';
}
protected function setUp(): void
{
parent::setUp();
$this
->label('Audits')
->color('gray')
->slideOver()
->modalContent(fn (Model $record): View => view(
'admin.actions.audits',
['record' => $record, 'children' => $this->auditChildren]
))
->modalHeading('test')
->modalWidth(MaxWidth::Large)
->modalSubmitAction(false)
->modalCancelAction(false);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Admin\Clusters;
use Filament\Clusters\Cluster;
class Services extends Cluster
{
protected static ?string $navigationIcon = 'ri-archive-stack-line';
protected static ?string $activeNavigationIcon = 'ri-archive-stack-fill';
public static ?string $navigationGroup = 'Administration';
protected static ?int $navigationSort = 0;
protected static ?string $slug = 'services';
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Admin\Components;
use App\Admin\Resources\UserResource;
use App\Models\User;
use Filament\Forms\Components\Select;
class UserComponent extends Select
{
public static function make(string $name): static
{
return parent::make($name)
->label('User')
->relationship('user', 'id')
->searchable()
->preload()
->getOptionLabelFromRecordUsing(fn ($record) => $record->name . ' (' . $record->email . ')')
->getSearchResultsUsing(fn (string $search): array => User::where('first_name', 'like', "%$search%")
->orWhere('last_name', 'like', "%$search%")
->limit(50)
->get()
->mapWithKeys(fn ($user) => [$user->id => $user->name . ' (' . $user->email . ')'])
->toArray())
->hint(fn ($get) => $get('user_id') ? new \Illuminate\Support\HtmlString('<a href="' . UserResource::getUrl('edit', ['record' => $get('user_id')]) . '" target="_blank">Go to User</a>') : null)
->live()
->required();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Admin\Pages;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
protected static ?string $navigationIcon = 'ri-function-line';
protected static ?string $activeNavigationIcon = 'ri-function-fill';
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Admin\Pages;
use App\Classes\FilamentInput;
use App\Classes\Settings as ClassesSettings;
use App\Models\Setting;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Gate;
class Settings extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationGroup = 'System';
protected static ?string $title = 'Settings';
protected static ?string $navigationIcon = 'ri-settings-3-line';
protected static ?string $activeNavigationIcon = 'ri-settings-3-fill';
protected static string $view = 'admin.pages.settings';
public ?array $data = [];
public function mount(): void
{
$setting_values = [];
foreach (\App\Classes\Settings::settings() as $group => $settings) {
foreach ($settings as $setting) {
$setting_values[$setting['name']] = config("settings.{$setting['name']}", $setting['default'] ?? null);
}
}
$this->form->fill($setting_values);
}
public function form(Form $form): Form
{
$tabs = [];
foreach (ClassesSettings::settings() as $key => $categories) {
$tab = Tabs\Tab::make($key)
->label(ucwords(str_replace('-', ' ', $key)))
->schema(function () use ($categories, $key) {
$inputs = [];
foreach ($categories as $setting) {
$inputs[] = FilamentInput::convert($setting);
}
if ($key === 'theme') {
// Add a reset colors button if there are color settings
array_unshift($inputs, Actions::make([
Actions\Action::make('resetColors')
->label('Reset Colors')
->color('danger')
->requiresConfirmation()
->action(fn () => $this->resetColors()),
]));
// Wrap the first two inputs in a group if there are more than one
if (count($inputs) > 1) {
$inputs[0] = Group::make([
$inputs[1]->columnSpan(3),
$inputs[0],
])->columns(4)->columnSpanFull();
unset($inputs[1]);
}
}
return $inputs;
});
$tabs[] = $tab;
}
return $form
->schema([
Tabs::make('Tabs')
->tabs($tabs)
->persistTabInQueryString(),
])
->statePath('data');
}
public function save(): void
{
Gate::authorize('has-permission', 'admin.settings.update');
$data = $this->form->getState();
$settings = Setting::where('settingable_type', null)
->whereIn('key', array_keys($data))
->get()
->keyBy('key');
foreach ($data as $key => $value) {
// Get only the settings that have changed
$avSetting = (object) collect(\App\Classes\Settings::settings())->flatten(1)->firstWhere('name', $key);
$avSetting->value = $settings[$key]->value ?? $avSetting->default ?? null;
if ($value !== $avSetting->value || (($avSetting->database_type ?? 'string') === 'boolean' && (bool) $value !== (bool) $avSetting->value)) {
if ($setting = $settings[$key] ?? null) {
$setting->update([
'value' => $value,
'type' => $avSetting->database_type ?? 'string',
]);
} else {
Setting::create([
'key' => $key,
'value' => $value,
'settingable_type' => null,
'type' => $avSetting->database_type ?? 'string',
'encrypted' => $avSetting->encrypted ?? false,
]);
}
}
}
Notification::make()
->title('Saved successfully!')
->success()
->send();
}
public function resetColors(): void
{
Gate::authorize('has-permission', 'admin.settings.update');
$colorSettings = [];
foreach (\App\Classes\Settings::settings() as $group => $settings) {
foreach ($settings as $setting) {
if (($setting['type'] ?? '') === 'color') {
$colorSettings[$setting['name']] = $setting['default'] ?? '';
}
}
}
$currentData = $this->form->getState();
foreach ($colorSettings as $key => $defaultValue) {
$currentData[$key] = $defaultValue;
}
$this->form->fill($currentData);
Notification::make()
->title('Colors has been reset!')
->success()
->send();
}
public static function getFormActions(): array
{
return [
Actions\Action::make('save')
->submit('save')
->keyBindings(['mod+s']),
];
}
public static function canAccess(): bool
{
/** @var \App\Models\User */
$user = auth()->user();
return $user && $user->hasPermission('admin.settings.view');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Admin\Pages;
use App\Console\Commands\CheckForUpdates;
use App\Console\Commands\Upgrade;
use Filament\Actions\Action;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Output\BufferedOutput;
class Updates extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'ri-loop-left-line';
protected static ?string $activeNavigationIcon = 'ri-loop-left-fill';
protected static string $view = 'admin.pages.updates';
protected static ?string $navigationGroup = 'System';
protected function getHeaderActions(): array
{
return [
Action::make('checkUpdates')
->action(function () {
Artisan::call(CheckForUpdates::class);
})
->label('Check for updates'),
];
}
public function update(): Action
{
return Action::make('update')
->requiresConfirmation()
->action(function () {
$output = new BufferedOutput;
// Check if current chdir is the root of the project
if (getcwd() !== base_path()) {
chdir(base_path());
}
if (config('app.version') == 'beta') {
Artisan::call(Upgrade::class, ['--no-interaction' => true, '--url' => 'https://api.paymenter.org/beta'], $output);
} else {
Artisan::call(Upgrade::class, ['--no-interaction' => true], $output);
}
$this->dispatch('update-completed', [
'output' => $output->fetch(),
]);
})
->label('Update');
}
public static function canAccess(): bool
{
return auth()->check() && auth()->user()->hasPermission('admin.updates.update');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\ApiResource\Pages;
use App\Models\ApiKey;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
class ApiResource extends Resource
{
protected static ?string $model = ApiKey::class;
protected static ?string $navigationIcon = 'ri-key-2-line';
protected static ?string $navigationGroup = 'Other';
public static function form(Form $form): Form
{
$extensionApiPermissions = once(fn () => Arr::dot(
array_merge_recursive(...Event::dispatch('api.permissions', []))
));
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->label('API Name'),
Forms\Components\TagsInput::make('ip_addresses')
->label('Allowed IP Addresses')
->placeholder('Enter IP addresses'),
Forms\Components\Toggle::make('enabled')
->default(true)
->visibleOn('edit')
->label('Active Status'),
Forms\Components\CheckboxList::make('permissions')
->options(array_merge(Arr::dot(config('permissions.api')), $extensionApiPermissions))
->columns(4)
->bulkToggleable()
->searchable()
->noSearchResultsMessage('Permission could not be found'),
])
->columns(1);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('ip_addresses')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : $state)
->label('Allowed IP Addresses'),
Tables\Columns\IconColumn::make('enabled')
->boolean(),
Tables\Columns\TextColumn::make('last_used_at')
->dateTime()
->label('Last Used At')
->sortable()
->toggleable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageApis::route('/'),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Admin\Resources\ApiResource\Pages;
use App\Admin\Resources\ApiResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ManageRecords;
use Illuminate\Support\Str;
class ManageApis extends ManageRecords
{
protected static string $resource = ApiResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()->mutateFormDataUsing(function (array $data): array {
$token = 'PAYM' . bin2hex(random_bytes(32));
$data['token'] = hash('sha256', $token);
$data['type'] = 'admin';
Notification::make()
->title('API Token Created')
->body(Str::markdown("Here is your new API token. Please copy it now, as it will not be shown again.\nToken: ```" . $token . '```'))
->icon('heroicon-o-key')
->success()
->persistent()
->send();
return $data;
}),
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\CategoryResource\Pages;
use App\Models\Category;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static ?string $navigationGroup = 'Administration';
protected static ?string $navigationIcon = 'ri-folder-6-line';
protected static ?string $activeNavigationIcon = 'ri-folder-6-fill';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
if (($get('slug') ?? '') !== Str::slug($old)) {
return;
}
$set('slug', Str::slug($state));
}),
Forms\Components\TextInput::make('slug')
->required(),
Forms\Components\RichEditor::make('description')
->required(),
Forms\Components\Select::make('parent_id')
->relationship('parent', 'name')
->searchable()
->preload()
->label('Parent Category')
// Disallow having same category as it's own parent
->disableOptionWhen(fn (string $value, ?Category $record): bool => $record && (int) $value === $record->id),
Forms\Components\FileUpload::make('image')->label('Image')->nullable()->acceptedFileTypes(['image/*'])->columnSpanFull(),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make()->before(function (DeleteBulkAction $action) {
foreach ($action->getRecords() as $record) {
if ($record->products()->exists() || $record->children()->exists()) {
Notification::make()
->title('Cannot delete category')
->body('The category has products or children categories.')
->duration(5000)
->icon('ri-error-warning-line')
->danger()
->send();
$action->cancel();
}
}
}),
]),
])
->defaultSort(function (Builder $query): Builder {
return $query
->orderBy('sort', 'asc');
})
->reorderable('sort');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Admin\Resources\CategoryResource\Pages;
use App\Admin\Resources\CategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Admin\Resources\CategoryResource\Pages;
use App\Admin\Resources\CategoryResource;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()->before(function (Actions\DeleteAction $action) {
if ($this->record->products()->exists() || $this->record->children()->exists()) {
Notification::make()
->title('Cannot delete category')
->body('The category has products or children categories.')
->duration(5000)
->icon('ri-error-warning-line')
->danger()
->send();
$action->cancel();
}
}),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CategoryResource\Pages;
use App\Admin\Resources\CategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Admin\Resources\Common\RelationManagers;
use App\Models\CustomProperty;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class PropertiesRelationManager extends RelationManager
{
protected static string $relationship = 'properties';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Select::make('custom_property_id')->label('Custom Property')
->required()
->options(function ($livewire): array {
return CustomProperty::where('model', get_class($livewire->ownerRecord))->pluck('name', 'id')->toArray();
})->nullable(),
Forms\Components\TextInput::make('name')->translateLabel()->nullable(),
Forms\Components\TextInput::make('key')->translateLabel()->required(),
Forms\Components\TextInput::make('value')->translateLabel()->required(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('parent_property.name'),
Tables\Columns\TextColumn::make('key'),
Tables\Columns\TextInputColumn::make('value'),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\ConfigOptionResource\Pages;
use App\Models\ConfigOption;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ConfigOptionResource extends Resource
{
protected static ?string $model = ConfigOption::class;
protected static ?string $navigationGroup = 'Configuration';
protected static ?string $navigationIcon = 'ri-equalizer-2-line';
protected static ?string $activeNavigationIcon = 'ri-equalizer-2-fill';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Tabs::make()
->columnSpanFull()
->schema([
Forms\Components\Tabs\Tab::make('General')->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255)
->placeholder('Enter the name of the configuration option'),
Forms\Components\TextInput::make('env_variable')
->label('Environment Variable')
->maxLength(255)
->placeholder('Enter the environment variable name'),
Forms\Components\Select::make('type')
->label('Type')
->native(false)
->required()
->reactive()
->options([
'text' => 'Text',
'number' => 'Number',
'select' => 'Select',
'radio' => 'Radio',
'checkbox' => 'Checkbox',
'slider' => 'Slider',
]),
Forms\Components\Checkbox::make('hidden')
->label('Hidden'),
Forms\Components\Checkbox::make('upgradable')
->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'radio', 'slider']))
->label('Upgradable')
->helperText('If enabled, this configuration option can be upgraded in the future.'),
Forms\Components\Select::make('products')
->label('Products')
->relationship('products', 'name')
->multiple()
->preload()
->placeholder('Select the products that this configuration option belongs to'),
]),
Forms\Components\Tabs\Tab::make('Options')
->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'radio', 'slider', 'checkbox']))
->schema([
Forms\Components\Repeater::make('Options')
->relationship('children')
->label('Options')
->addActionLabel('Add Option')
->columnSpanFull()
->itemLabel(fn (array $state) => $state['name'])
->collapsible()
->collapsed()
->cloneable()
->reorderable()
->orderColumn('sort')
->columns(2)
// When the type is checkbox only allow 1 child
->maxItems(function (Get $get): ?int {
if (in_array($get('type'), ['select', 'radio', 'slider'])) {
return null; // unlimited children
}
return 1; // checkbox
})
->minItems(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->live()
->maxLength(255)
->placeholder('Enter the name of the configuration option'),
Forms\Components\TextInput::make('env_variable')
->label('Environment Variable')
->required()
->maxLength(255)
->placeholder('Enter the environment variable name'),
// if the type is select, radio or checkbox then allow unlimited children (otherwise only allow 1)
ProductResource::plan()->columnSpanFull()->label('Pricing')->reorderable(false)->deleteAction(null),
]),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('env_variable')
->label('Environment Variable')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('hidden')
->badge()
->label('Hidden')
->sortable(),
])
->modifyQueryUsing(fn (Builder $query) => $query->where('parent_id', null))
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->defaultSort(function (Builder $query): Builder {
return $query
->orderBy('sort', 'asc');
})
->reorderable('sort');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListConfigOptions::route('/'),
'create' => Pages\CreateConfigOption::route('/create'),
'edit' => Pages\EditConfigOption::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Admin\Resources\ConfigOptionResource\Pages;
use App\Admin\Resources\ConfigOptionResource;
use Filament\Resources\Pages\CreateRecord;
class CreateConfigOption extends CreateRecord
{
protected static string $resource = ConfigOptionResource::class;
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Admin\Resources\ConfigOptionResource\Pages;
use App\Admin\Resources\ConfigOptionResource;
use App\Models\ConfigOption;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditConfigOption extends EditRecord
{
protected static string $resource = ConfigOptionResource::class;
protected function getHeaderActions(): array
{
return [
// Show warning when there are child config options
Actions\DeleteAction::make('Delete')
->requiresConfirmation()
->modalDescription(
fn (ConfigOption $record) => $record->children()->exists()
? 'This config option has services connected to it. Deleting it will also delete it from the services it is associated with. Are you sure you want to delete this config option?'
: 'Are you sure you want to delete this config option?',
)
->action(function () {
$this->record->serviceConfigs()->delete();
$this->record->delete();
return redirect()->to(ConfigOptionResource::getUrl());
}),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\ConfigOptionResource\Pages;
use App\Admin\Resources\ConfigOptionResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListConfigOptions extends ListRecords
{
protected static string $resource = ConfigOptionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\CouponResource\Pages;
use App\Admin\Resources\CouponResource\RelationManagers;
use App\Models\Coupon;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Support\RawJs;
use Filament\Tables;
use Filament\Tables\Table;
class CouponResource extends Resource
{
protected static ?string $model = Coupon::class;
protected static ?string $navigationIcon = 'ri-coupon-line';
protected static ?string $activeNavigationIcon = 'ri-coupon-fill';
protected static ?string $navigationGroup = 'Configuration';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('code')
->label('Code')
->required()
->maxLength(255)
->unique(static::getModel(), 'code', ignoreRecord: true)
->placeholder('Enter the code of the coupon'),
Forms\Components\TextInput::make('value')
->label('Value')
->required()
->numeric()
->minValue(0)
->maxValue(fn (Get $get) => $get('type') === 'percentage' ? 100 : null)
->mask(RawJs::make(
<<<'JS'
$money($input, '.', '', 2)
JS
))
->hidden(fn (Get $get) => $get('type') === 'free_setup')
->suffix(fn (Get $get) => $get('type') === 'percentage' ? '%' : config('settings.default_currency'))
->placeholder('Enter the value of the coupon'),
Forms\Components\Select::make('type')
->label('Type')
->required()
->default('percentage')
->live()
->options([
'percentage' => 'Percentage',
'fixed' => 'Fixed amount',
'free_setup' => 'Free setup',
])
->placeholder('Select the type of the coupon'),
Forms\Components\TextInput::make('recurring')
->label('Recurring')
->numeric()
->nullable()
->minValue(0)
->hidden(fn (Get $get) => $get('type') === 'free_setup')
->placeholder('How many billing cycles the discount will be applied')
->helperText('Enter 0 to apply it to all billing cycles, 1 to apply it only to the first billing cycle, etc.'),
Forms\Components\TextInput::make('max_uses')
->label('Max Uses')
->numeric()
->minValue(0)
->placeholder('Enter the maximum number of total uses of the coupon'),
Forms\Components\TextInput::make('max_uses_per_user')
->label('Max Uses Per User')
->numeric()
->minValue(0)
->placeholder('Enter the maximum number of uses per user'),
Forms\Components\DatePicker::make('starts_at')
->label('Starts At'),
Forms\Components\DatePicker::make('expires_at')
->label('Expires At'),
Forms\Components\Select::make('products')
->label('Products')
->relationship('products', 'name')
->multiple()
->preload()
->placeholder('Select the products that this coupon applies to')
->hint('Leave empty to apply the coupon to all products'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code')->searchable(),
Tables\Columns\TextColumn::make('value')->searchable()->formatStateUsing(fn ($record) => $record->value . ($record->type === 'percentage' ? '%' : config('settings.default_currency'))),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\ServicesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCoupons::route('/'),
'create' => Pages\CreateCoupon::route('/create'),
'edit' => Pages\EditCoupon::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Admin\Resources\CouponResource\Pages;
use App\Admin\Resources\CouponResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCoupon extends CreateRecord
{
protected static string $resource = CouponResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CouponResource\Pages;
use App\Admin\Resources\CouponResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCoupon extends EditRecord
{
protected static string $resource = CouponResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CouponResource\Pages;
use App\Admin\Resources\CouponResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCoupons extends ListRecords
{
protected static string $resource = CouponResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Admin\Resources\CouponResource\RelationManagers;
use App\Admin\Resources\ServiceResource;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class ServicesRelationManager extends RelationManager
{
protected static string $relationship = 'services';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('id')
->columns([
Tables\Columns\TextColumn::make('id'),
Tables\Columns\TextColumn::make('order.user.name')->label('User'),
Tables\Columns\TextColumn::make('product.name')->label('Product'),
])
->filters([
//
])
->headerActions([])
->actions([
Tables\Actions\Action::make('view')
->label('View')
->url(fn ($record) => ServiceResource::getUrl('edit', ['record' => $record])),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\CurrencyResource\Pages;
use App\Models\Currency;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CurrencyResource extends Resource
{
protected static ?string $model = Currency::class;
protected static ?string $navigationGroup = 'Configuration';
protected static ?string $navigationIcon = 'ri-money-dollar-circle-line';
protected static ?string $activeNavigationIcon = 'ri-money-dollar-circle-fill';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('code')
->label('Code')
->required()
->maxLength(3)
->disabledOn('edit')
->unique(static::getModel(), 'code', ignoreRecord: true)
->placeholder('Enter the currency code'),
Forms\Components\TextInput::make('prefix')
->label('Prefix')
->maxLength(10)
->placeholder('Enter the currency prefix'),
Forms\Components\TextInput::make('suffix')
->label('Suffix')
->maxLength(10)
->placeholder('Enter the currency suffix'),
Forms\Components\Select::make('format')
->label('Format')
->options([
'1.000,00' => '1.000,00',
'1,000.00' => '1,000.00',
'1 000,00' => '1 000,00',
'1 000.00' => '1 000.00',
])
->default('1.000,00'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('code')
->label('Code')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('prefix')
->label('Prefix')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('suffix')
->label('Suffix')
->searchable()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCurrencies::route('/'),
'create' => Pages\CreateCurrency::route('/create'),
'edit' => Pages\EditCurrency::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Admin\Resources\CurrencyResource\Pages;
use App\Admin\Resources\CurrencyResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCurrency extends CreateRecord
{
protected static string $resource = CurrencyResource::class;
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Admin\Resources\CurrencyResource\Pages;
use App\Admin\Resources\CurrencyResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCurrency extends EditRecord
{
protected static string $resource = CurrencyResource::class;
protected function getHeaderActions(): array
{
if (config('settings.default_currency') == $this->record->code) {
return [];
}
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CurrencyResource\Pages;
use App\Admin\Resources\CurrencyResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCurrencies extends ListRecords
{
protected static string $resource = CurrencyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\CustomPropertyResource\Pages;
use App\Models\CustomProperty;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class CustomPropertyResource extends Resource
{
protected static ?string $model = CustomProperty::class;
protected static ?string $navigationGroup = 'Configuration';
protected static ?string $navigationIcon = 'ri-list-settings-line';
protected static ?string $activeNavigationIcon = 'ri-list-settings-fill';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('key')->required(),
Forms\Components\Select::make('model')->options([
'App\Models\User' => 'User',
])->required(),
Forms\Components\Select::make('type')->options([
'string' => 'Short Text',
'text' => 'Long Text',
'number' => 'Number',
'select' => 'Select',
'checkbox' => 'Checkbox',
'radio' => 'Radio',
'date' => 'Date',
])->required(),
Forms\Components\Textarea::make('description')->nullable()->columnSpanFull()->rows(2),
Forms\Components\TagsInput::make('allowed_values')->nullable(),
Forms\Components\TextInput::make('validation')->nullable(),
Forms\Components\Section::make()
->columns([
'sm' => 1,
'md' => 3,
])
->schema([
Forms\Components\Toggle::make('non_editable')->default(false),
Forms\Components\Toggle::make('required')->default(false),
Forms\Components\Toggle::make('show_on_invoice')->default(false),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->label('Property')
->description(fn (CustomProperty $record): string => mb_strimwidth(($record->description ?? ''),
0,
75,
'...'
)),
Tables\Columns\TextColumn::make('key'),
Tables\Columns\TextColumn::make('type')->formatStateUsing(fn ($state) => str($state)->title()),
Tables\Columns\ToggleColumn::make('non_editable'),
Tables\Columns\ToggleColumn::make('required'),
Tables\Columns\ToggleColumn::make('show_on_invoice'),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
])->defaultGroup('model');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListCustomProperty::route('/'),
'create' => Pages\CreateCustomProperty::route('/create'),
'edit' => Pages\EditCustomProperty::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Admin\Resources\CustomPropertyResource\Pages;
use App\Admin\Resources\CustomPropertyResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCustomProperty extends CreateRecord
{
protected static string $resource = CustomPropertyResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CustomPropertyResource\Pages;
use App\Admin\Resources\CustomPropertyResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCustomProperty extends EditRecord
{
protected static string $resource = CustomPropertyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Admin\Resources\CustomPropertyResource\Pages;
use App\Admin\Resources\CustomPropertyResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListCustomProperty extends ListRecords
{
protected static string $resource = CustomPropertyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Admin\Resources;
use App\Admin\Resources\EmailLogResource\Pages;
use App\Models\EmailLog;
use Filament\Infolists;
use Filament\Infolists\Components;
use Filament\Infolists\Components\View;
use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class EmailLogResource extends Resource
{
protected static ?string $model = EmailLog::class;
protected static ?string $navigationIcon = 'ri-mail-send-line';
protected static ?string $activeNavigationIcon = 'ri-mail-send-fill';
protected static ?string $navigationGroup = 'Other';
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\TextEntry::make('subject')
->size(Components\TextEntry\TextEntrySize::Medium)
->label('Subject'),
Infolists\Components\TextEntry::make('to')
->size(Components\TextEntry\TextEntrySize::Medium)
->label('To'),
Infolists\Components\TextEntry::make('sent_at')
->size(Components\TextEntry\TextEntrySize::Medium)
->date()
->hidden(fn ($state) => $state === null)
->label('Sent At'),
Infolists\Components\TextEntry::make('status')
->badge()
->color(fn (EmailLog $record) => match ($record->status) {
'pending' => 'gray',
'sent' => 'success',
'failed' => 'danger',
})
->formatStateUsing(fn (string $state) => ucfirst($state))
->label('Status'),
Infolists\Components\TextEntry::make('error')
->columnSpanFull()
->size(Components\TextEntry\TextEntrySize::Medium)
->hidden(fn ($state) => $state === null)
->label('Error'),
View::make('admin.infolists.components.iframe')->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('subject')
->searchable(),
Tables\Columns\TextColumn::make('to')
->searchable(),
Tables\Columns\TextColumn::make('sent_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (EmailLog $record) => match ($record->status) {
'pending' => 'gray',
'sent' => 'success',
'failed' => 'danger',
})
->formatStateUsing(fn (string $state) => ucfirst($state))
->searchable(),
])
->defaultSort(function (Builder $query): Builder {
return $query
->orderBy('id', 'desc');
})
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListEmailLogs::route('/'),
'view' => Pages\ViewEmailLog::route('/{record}'),
];
}
}

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