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

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');