Compare commits

..

10 Commits

Author SHA1 Message Date
f31f87aaf6 fix(users): pass mobile rule as a nested closure Filament can evaluate
Filament evaluates each entry of ->rules() as its own closure and tries
to inject dependencies, so a bare Laravel validation closure blew up with
"[$attribute] was unresolvable" when opening/saving the user form. Wrap it
so the outer closure returns the Laravel rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 00:38:22 +03:30
ac11fbeaa6 Merge pull request 'fix(wallets): capture $type in the deposit/withdraw action closure' (#2) from feat/persian-panel-fixes-and-model-tweaks into main
Reviewed-on: #2
2026-09-08 20:45:31 +00:00
d2873ebcc1 fix(wallets): capture $type in the deposit/withdraw action closure
The action closure referenced $type without importing it via use(), so
every credit/debit attempt in the admin panel threw "Undefined variable
$type" instead of adjusting the wallet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 23:59:07 +03:30
84fe0e5d18 Merge pull request 'feat: Persian panel/API fixes and structural-model tweaks' (#1) from feat/persian-panel-fixes-and-model-tweaks into main
Reviewed-on: #1
2026-09-08 20:10:50 +00:00
936f789ac8 feat: Persian panel/API fixes and structural-model tweaks
- lang/fa: add validation/auth/passwords/pagination translations so admin
  and API validation errors are in Persian
- templates: soft-delete instead of cascade-deleting their generations;
  keep resolving trashed template on generations (labels/downloads)
- templates: add TemplateInfolist with a real thumbnail ImageEntry
- generations: auto-name template-based generations and use that name
  for the download file and as a searchable column
- admin: show all dates as Jalali via ->jalaliDate()/->jalaliDateTime()
  Filament macros (App\Support\Jalali)
- wallets: harden the deposit/withdraw action to surface the exact error
  instead of a 500
- profile: normalize Iranian mobile numbers (App\Support\Mobile),
  add social_links, coerce notification-preference booleans, require the
  current password to delete an account

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013P66ea1dC5CVM9t29496t5
2026-09-07 01:00:09 +03:30
46ae711a20 feat(api): add AI text-to-deck generation, wallet deposit structure, and profile/notification endpoints
Fills the gaps between the admin panel screens and the backend: a full
free-text + slide-count + language -> AI-generated presentation flow with
new slide-cloning logic in TemplateFillService, a "my presentations" list
endpoint, a swappable payment-gateway structure for wallet top-ups (fake
driver for now), and avatar upload / public-profile toggle / account
deletion / password change / notification preference endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:36:17 +03:30
fe2090a5af feat(generation): add retry action for failed template generations and update related schemas 2026-08-21 14:53:19 +03:30
e58dcd8581 fix(templates): fill pptx via direct zip/XML editing instead of PhpPresentation
Round-tripping real-world .pptx files through PhpPresentation's object model
was silently corrupting/losing content it doesn't fully model (themes, masters,
layouts, fonts), so placeholder replacement wasn't reliable. Now the service
edits the .pptx as a zip archive directly: only the touched slide XML text runs
and swapped image bytes change, everything else stays byte-identical to the
source file. Drops the now-unused phpoffice/phppresentation dependency.
2026-07-23 00:32:47 +03:30
0a7b7fea17 Merge branch 'main' of https://git.geekgrove.ir/soheilkhaledabadi/hoshpoint-backend 2026-07-22 08:10:39 +03:30
4a3c109bf3 feat(AI): add ai manager admin and create powerpoint with ai 2026-07-22 08:06:48 +03:30
88 changed files with 3465 additions and 624 deletions

View File

@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
FRONTEND_URL=http://localhost:3000
APP_LOCALE=fa
APP_FALLBACK_LOCALE=en

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Contracts;
use App\Models\WalletDeposit;
interface PaymentGateway
{
/**
* Start a payment for the given deposit and return the URL the user should be
* redirected to in order to complete it.
*/
public function initiate(WalletDeposit $deposit): string;
/**
* Verify a gateway callback for the given deposit.
*
* @param array<string, mixed> $callbackPayload
*/
public function verify(WalletDeposit $deposit, array $callbackPayload): bool;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum AiProviderDriver: string
{
case OpenAi = 'openai';
case GapGpt = 'gapgpt';
public function label(): string
{
return match ($this) {
self::OpenAi => 'OpenAI (GPT)',
self::GapGpt => 'GapGPT',
};
}
public function defaultBaseUrl(): string
{
return match ($this) {
self::OpenAi => 'https://api.openai.com/v1',
self::GapGpt => 'https://api.gapgpt.app/v1',
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum PaymentGatewayDriver: string
{
case Fake = 'fake';
public function label(): string
{
return match ($this) {
self::Fake => 'درگاه آزمایشی',
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum TemplateGenerationSource: string
{
case Template = 'template';
case AiText = 'ai_text';
public function label(): string
{
return match ($this) {
self::Template => 'قالب آماده',
self::AiText => 'ساخت با هوش مصنوعی از متن',
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum WalletDepositStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Failed = 'failed';
case Expired = 'expired';
public function label(): string
{
return match ($this) {
self::Pending => 'در انتظار پرداخت',
self::Paid => 'پرداخت‌شده',
self::Failed => 'ناموفق',
self::Expired => 'منقضی‌شده',
};
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\AiProviders;
use App\Filament\Resources\AiProviders\Pages\CreateAiProvider;
use App\Filament\Resources\AiProviders\Pages\EditAiProvider;
use App\Filament\Resources\AiProviders\Pages\ListAiProviders;
use App\Filament\Resources\AiProviders\Schemas\AiProviderForm;
use App\Filament\Resources\AiProviders\Tables\AiProvidersTable;
use App\Models\AiProvider;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class AiProviderResource extends Resource
{
protected static ?string $model = AiProvider::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
protected static string|UnitEnum|null $navigationGroup = 'هوش مصنوعی';
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $modelLabel = 'سرویس هوش مصنوعی';
protected static ?string $pluralModelLabel = 'سرویس‌های هوش مصنوعی';
protected static ?string $navigationLabel = 'سرویس‌های AI';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return AiProviderForm::configure($schema);
}
public static function table(Table $table): Table
{
return AiProvidersTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListAiProviders::route('/'),
'create' => CreateAiProvider::route('/create'),
'edit' => EditAiProvider::route('/{record}/edit'),
];
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\AiProviders\Schemas;
use App\Enums\AiProviderDriver;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class AiProviderForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('نام')
->required()
->maxLength(255),
Select::make('driver')
->label('سرویس‌دهنده')
->options([
AiProviderDriver::OpenAi->value => AiProviderDriver::OpenAi->label(),
AiProviderDriver::GapGpt->value => AiProviderDriver::GapGpt->label(),
])
->default(AiProviderDriver::OpenAi->value)
->live()
->required(),
TextInput::make('model')
->label('مدل')
->helperText('مثال: gpt-4o-mini')
->required()
->maxLength(255),
TextInput::make('base_url')
->label('آدرس API (Base URL)')
->url()
->placeholder(fn (Get $get): string => AiProviderDriver::from($get('driver') ?? AiProviderDriver::OpenAi->value)->defaultBaseUrl())
->helperText('در صورت خالی بودن، آدرس پیش‌فرض همان سرویس‌دهنده استفاده می‌شود.')
->maxLength(255),
TextInput::make('api_key')
->label('API Key')
->password()
->revealable()
->autocomplete('new-password')
->required(fn (string $operation): bool => $operation === 'create')
->saved(fn (?string $state): bool => filled($state))
->maxLength(1000),
Toggle::make('is_active')
->label('فعال')
->default(true),
Toggle::make('is_default')
->label('پیش‌فرض')
->helperText('هنگام تولید محتوا در صورت وجود چند سرویس فعال، این سرویس اولویت دارد.'),
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources\AiProviders\Tables;
use App\Enums\AiProviderDriver;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class AiProvidersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('نام')
->searchable()
->sortable(),
TextColumn::make('driver')
->label('سرویس‌دهنده')
->badge()
->formatStateUsing(fn (AiProviderDriver $state): string => $state->label()),
TextColumn::make('model')
->label('مدل')
->searchable(),
TextColumn::make('base_url')
->label('آدرس API')
->formatStateUsing(fn (?string $state, $record): string => $state ?: $record->driver->defaultBaseUrl())
->toggleable(),
IconColumn::make('is_active')
->label('فعال')
->boolean(),
IconColumn::make('is_default')
->label('پیش‌فرض')
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_active')
->label('وضعیت')
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -32,7 +32,7 @@ class CategoriesTable
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -37,7 +37,7 @@ class NotificationsTable
->getStateUsing(fn ($record): bool => $record->read_at !== null),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -28,7 +28,7 @@ class PermissionsTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -28,7 +28,7 @@ class RolesTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Models\TemplateGeneration;
use Filament\Actions\Action;
use Filament\Support\Icons\Heroicon;
class DownloadGenerationAction
{
public static function make(): Action
{
return Action::make('download')
->label('دانلود')
->icon(Heroicon::OutlinedArrowDownTray)
->color('success')
->visible(fn (TemplateGeneration $record): bool => $record->status === TemplateGenerationStatus::Completed && filled($record->output_path))
->url(fn (TemplateGeneration $record): string => route('admin.generations.download', $record->uuid))
->openUrlInNewTab();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Jobs\GenerateTemplateJob;
use App\Models\TemplateGeneration;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
/**
* Re-runs the pptx fill step for a failed generation using its already-stored
* values (including any AI-generated text), without calling the AI service again.
*/
class RetryGenerationAction
{
public static function make(): Action
{
return Action::make('retry')
->label('تلاش دوباره')
->icon(Heroicon::OutlinedArrowPath)
->color('warning')
->requiresConfirmation()
->modalDescription('فایل پاورپوینت با همان محتوایی که قبلاً (از جمله متن‌های تولیدشده با هوش مصنوعی) ذخیره شده، دوباره ساخته می‌شود. نیازی به تماس مجدد با هوش مصنوعی نیست.')
->visible(fn (TemplateGeneration $record): bool => $record->status === TemplateGenerationStatus::Failed)
->action(function (TemplateGeneration $record): void {
$record->update([
'status' => TemplateGenerationStatus::Pending,
'error_message' => null,
]);
GenerateTemplateJob::dispatch($record);
Notification::make()
->title('درخواست دوباره ارسال شد')
->success()
->send();
});
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\TemplateGenerations\Schemas;
use App\Enums\TemplateGenerationStatus;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class TemplateGenerationInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('title')
->label('نام فایل')
->placeholder('—')
->columnSpanFull(),
TextEntry::make('uuid')
->label('شناسه')
->copyable(),
TextEntry::make('template.title')
->label('قالب'),
TextEntry::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (TemplateGenerationStatus $state): string => $state->label())
->color(fn (TemplateGenerationStatus $state): string => match ($state) {
TemplateGenerationStatus::Pending => 'gray',
TemplateGenerationStatus::Processing => 'warning',
TemplateGenerationStatus::Completed => 'success',
TemplateGenerationStatus::Failed => 'danger',
}),
TextEntry::make('requester.name')
->label('درخواست‌دهنده')
->placeholder('—'),
TextEntry::make('error_message')
->label('پیام خطا')
->placeholder('—')
->columnSpanFull(),
KeyValueEntry::make('values')
->label('محتوای ذخیره‌شده (از جمله متن‌های تولیدشده با هوش مصنوعی)')
->keyLabel('کلید')
->valueLabel('مقدار')
->columnSpanFull(),
TextEntry::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime(),
]);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Filament\Resources\TemplateGenerations\Tables;
use App\Enums\TemplateGenerationStatus;
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
use App\Filament\Resources\TemplateGenerations\Actions\RetryGenerationAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -14,10 +16,17 @@ class TemplateGenerationsTable
{
return $table
->columns([
TextColumn::make('title')
->label('نام فایل')
->searchable()
->sortable()
->description(fn ($record): string => $record->uuid)
->wrap(),
TextColumn::make('uuid')
->label('شناسه')
->searchable()
->copyable(),
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('template.title')
->label('قالب')
->searchable()
@@ -42,7 +51,7 @@ class TemplateGenerationsTable
->placeholder('—'),
TextColumn::make('created_at')
->label('تاریخ')
->dateTime()
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
@@ -58,6 +67,8 @@ class TemplateGenerationsTable
])
->recordActions([
ViewAction::make(),
DownloadGenerationAction::make(),
RetryGenerationAction::make(),
]);
}
}

View File

@@ -4,10 +4,12 @@ namespace App\Filament\Resources\TemplateGenerations;
use App\Filament\Resources\TemplateGenerations\Pages\ListTemplateGenerations;
use App\Filament\Resources\TemplateGenerations\Pages\ViewTemplateGeneration;
use App\Filament\Resources\TemplateGenerations\Schemas\TemplateGenerationInfolist;
use App\Filament\Resources\TemplateGenerations\Tables\TemplateGenerationsTable;
use App\Models\TemplateGeneration;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
@@ -35,6 +37,11 @@ class TemplateGenerationResource extends Resource
return TemplateGenerationsTable::configure($table);
}
public static function infolist(Schema $schema): Schema
{
return TemplateGenerationInfolist::configure($schema);
}
public static function getPages(): array
{
return [

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Filament\Resources\Templates\Actions;
use App\Enums\TemplateGenerationStatus;
use App\Enums\TemplateKeyType;
use App\Jobs\GenerateTemplateJob;
use App\Models\AiProvider;
use App\Models\Template;
use App\Models\TemplateGeneration;
use App\Services\Ai\OpenAiCompatibleTextGenerator;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Str;
use Throwable;
class GenerateWithAiAction
{
public static function make(): Action
{
return Action::make('generateWithAi')
->label('تولید با هوش مصنوعی')
->icon(Heroicon::OutlinedSparkles)
->color('info')
->schema(function (Template $record): array {
$components = [
Textarea::make('topic')
->label('موضوع / محتوای خام')
->helperText('هوش مصنوعی بر اساس این متن، کلیدهای متنی قالب را تکمیل می‌کند.')
->required()
->rows(4),
];
foreach ($record->keys->where('type', TemplateKeyType::Image) as $key) {
$components[] = FileUpload::make("images.{$key->key}")
->label($key->label)
->image()
->disk('local')
->directory('templates/generations/inputs')
->required($key->is_required);
}
return $components;
})
->action(function (array $data, Template $record): void {
$provider = AiProvider::query()
->where('is_active', true)
->orderByDesc('is_default')
->first();
if (! $provider) {
Notification::make()
->title('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.')
->body('ابتدا از بخش «سرویس‌های AI» یک سرویس فعال تعریف کنید.')
->danger()
->send();
return;
}
try {
$values = app(OpenAiCompatibleTextGenerator::class)->generateForTemplate($record, $data['topic'], $provider);
} catch (Throwable $exception) {
Notification::make()
->title('خطا در تولید محتوا با هوش مصنوعی')
->body($exception->getMessage())
->danger()
->send();
return;
}
foreach ($data['images'] ?? [] as $key => $path) {
if ($path) {
$values[$key] = $path;
}
}
$generation = TemplateGeneration::query()->create([
'template_id' => $record->id,
'uuid' => (string) Str::uuid(),
'status' => TemplateGenerationStatus::Pending,
'values' => $values,
'requested_by' => auth()->id(),
]);
GenerateTemplateJob::dispatch($generation);
Notification::make()
->title('درخواست تولید ثبت شد')
->body('نتیجه را می‌توانید در تب «تولیدها» مشاهده کنید.')
->success()
->send();
});
}
}

View File

@@ -4,6 +4,8 @@ namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
@@ -16,6 +18,8 @@ class EditTemplate extends EditRecord
return [
ViewAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\Templates\Pages;
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
use App\Filament\Resources\Templates\TemplateResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -13,6 +14,7 @@ class ViewTemplate extends ViewRecord
protected function getHeaderActions(): array
{
return [
GenerateWithAiAction::make(),
EditAction::make(),
];
}

View File

@@ -3,6 +3,8 @@
namespace App\Filament\Resources\Templates\RelationManagers;
use App\Enums\TemplateGenerationStatus;
use App\Filament\Resources\TemplateGenerations\Actions\DownloadGenerationAction;
use App\Filament\Resources\TemplateGenerations\Actions\RetryGenerationAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
@@ -23,10 +25,15 @@ class GenerationsRelationManager extends RelationManager
return $table
->recordTitleAttribute('uuid')
->columns([
TextColumn::make('title')
->label('نام فایل')
->searchable()
->wrap(),
TextColumn::make('uuid')
->label('شناسه')
->searchable()
->copyable(),
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('status')
->label('وضعیت')
->badge()
@@ -46,7 +53,7 @@ class GenerationsRelationManager extends RelationManager
->placeholder('—'),
TextColumn::make('created_at')
->label('تاریخ')
->dateTime()
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
@@ -60,7 +67,10 @@ class GenerationsRelationManager extends RelationManager
TemplateGenerationStatus::Failed->value => TemplateGenerationStatus::Failed->label(),
]),
])
->recordActions([])
->recordActions([
DownloadGenerationAction::make(),
RetryGenerationAction::make(),
])
->toolbarActions([]);
}
}

View File

@@ -32,7 +32,7 @@ class KeysRelationManager extends RelationManager
->components([
TextInput::make('key')
->label('کلید')
->helperText('در فایل پاورپوینت برای متن به‌صورت {{key}} و برای عکس/چارت به‌صورت نام شکل (shape name) استفاده می‌شود.')
->helperText('فقط نام کلید را وارد کنید، بدون آکولاد. مثلاً اگر داخل پاورپوینت نوشته‌اید {Main Title}، اینجا فقط Main Title را وارد کنید؛ آکولاد را خود سیستم موقع جایگزینی اضافه می‌کند. برای عکس/چارت به‌جای این، نام شکل (shape name) را در فیلد پایین وارد کنید.')
->required()
->maxLength(255),
TextInput::make('label')
@@ -60,6 +60,11 @@ class KeysRelationManager extends RelationManager
Toggle::make('is_required')
->label('اجباری')
->default(true),
Toggle::make('is_repeatable')
->label('تکرارشونده (برای ساخت با هوش مصنوعی)')
->helperText('اگر فعال باشد، این کلید در اسلاید تکرارشونده قرار دارد و برای هر اسلاید تولیدشده با هوش مصنوعی مقدار جداگانه دریافت می‌کند.')
->default(false)
->visible(fn (Get $get): bool => $get('type') === TemplateKeyType::Text->value),
TextInput::make('sort_order')
->label('ترتیب نمایش')
->numeric()
@@ -90,6 +95,9 @@ class KeysRelationManager extends RelationManager
IconColumn::make('is_required')
->label('اجباری')
->boolean(),
IconColumn::make('is_repeatable')
->label('تکرارشونده')
->boolean(),
TextColumn::make('sort_order')
->label('ترتیب')
->sortable(),

View File

@@ -8,6 +8,7 @@ use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class TemplateForm
@@ -45,10 +46,25 @@ class TemplateForm
->label('تصویر بندانگشتی')
->disk('public')
->directory('templates/thumbnails')
->image(),
->visibility('public')
->image()
->imageEditor(),
Toggle::make('is_active')
->label('فعال')
->default(true),
Toggle::make('is_ai_enabled')
->label('امکان ساخت با هوش مصنوعی از متن')
->helperText('در صورت فعال بودن، این قالب در فرم «ساخت پاورپوینت از متن» به کاربران پیشنهاد داده می‌شود.')
->default(false)
->live(),
TextInput::make('ai_repeatable_slide_number')
->label('شماره اسلاید تکرارشونده')
->helperText('شماره اسلایدی از فایل پاورپوینت که برای هر بخش محتوای تولیدشده با هوش مصنوعی شبیه‌سازی می‌شود (مثلاً 2).')
->numeric()
->integer()
->minValue(1)
->visible(fn (Get $get): bool => (bool) $get('is_ai_enabled'))
->required(fn (Get $get): bool => (bool) $get('is_ai_enabled')),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Templates\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class TemplateInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
ImageEntry::make('thumbnail_path')
->label('تصویر بندانگشتی')
->disk('public')
->visibility('public')
->height(180)
->placeholder('بدون تصویر')
->columnSpanFull(),
TextEntry::make('title')
->label('عنوان'),
TextEntry::make('slug')
->label('شناسه')
->copyable(),
TextEntry::make('category.name')
->label('دسته‌بندی')
->placeholder('—'),
TextEntry::make('description')
->label('توضیحات')
->placeholder('—')
->columnSpanFull(),
IconEntry::make('is_active')
->label('فعال')
->boolean(),
IconEntry::make('is_ai_enabled')
->label('ساخت با هوش مصنوعی')
->boolean(),
TextEntry::make('ai_repeatable_slide_number')
->label('شماره اسلاید تکرارشونده')
->placeholder('—'),
TextEntry::make('generations_count')
->label('تعداد تولیدها')
->state(fn ($record): int => $record->generations()->count()),
TextEntry::make('creator.name')
->label('ایجادکننده')
->placeholder('—'),
TextEntry::make('created_at')
->label('تاریخ ایجاد')
->jalaliDateTime(),
TextEntry::make('updated_at')
->label('آخرین بروزرسانی')
->jalaliDateTime(),
]);
}
}

View File

@@ -2,14 +2,21 @@
namespace App\Filament\Resources\Templates\Tables;
use App\Filament\Resources\Templates\Actions\GenerateWithAiAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class TemplatesTable
@@ -34,7 +41,7 @@ class TemplatesTable
->boolean(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
@@ -47,14 +54,22 @@ class TemplatesTable
->placeholder('همه')
->trueLabel('فعال')
->falseLabel('غیرفعال'),
TrashedFilter::make()
->label('وضعیت حذف'),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
GenerateWithAiAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
RestoreBulkAction::make(),
ForceDeleteBulkAction::make(),
]),
]);
}

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\Templates\Pages\ViewTemplate;
use App\Filament\Resources\Templates\RelationManagers\GenerationsRelationManager;
use App\Filament\Resources\Templates\RelationManagers\KeysRelationManager;
use App\Filament\Resources\Templates\Schemas\TemplateForm;
use App\Filament\Resources\Templates\Schemas\TemplateInfolist;
use App\Filament\Resources\Templates\Tables\TemplatesTable;
use App\Models\Template;
use BackedEnum;
@@ -16,6 +17,8 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class TemplateResource extends Resource
@@ -41,11 +44,22 @@ class TemplateResource extends Resource
return TemplateForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return TemplateInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return TemplatesTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([SoftDeletingScope::class]);
}
public static function getRelations(): array
{
return [

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\Users\Schemas;
use App\Support\Mobile;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
@@ -25,6 +26,15 @@ class UserForm
TextInput::make('mobile')
->label('شماره موبایل')
->tel()
->helperText('مثال درست: 09123456789')
->dehydrateStateUsing(fn (?string $state): ?string => Mobile::normalize($state))
->rules([
fn (): \Closure => function (string $attribute, mixed $value, \Closure $fail): void {
if (filled($value) && ! preg_match(Mobile::REGEX, (string) Mobile::normalize((string) $value))) {
$fail('فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789');
}
},
])
->unique(ignoreRecord: true)
->maxLength(20),
TextInput::make('password')

View File

@@ -33,7 +33,7 @@ class UsersTable
->searchable(),
TextColumn::make('created_at')
->label('تاریخ ایجاد')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])

View File

@@ -46,7 +46,7 @@ class WalletTransactionsTable
->placeholder('سیستم'),
TextColumn::make('created_at')
->label('تاریخ')
->dateTime()
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')

View File

@@ -44,7 +44,7 @@ class TransactionsRelationManager extends RelationManager
->placeholder('سیستم'),
TextColumn::make('created_at')
->label('تاریخ')
->dateTime()
->jalaliDateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')

View File

@@ -15,7 +15,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use RuntimeException;
use Throwable;
class WalletsTable
{
@@ -45,7 +45,7 @@ class WalletsTable
->boolean(),
TextColumn::make('updated_at')
->label('آخرین بروزرسانی')
->dateTime()
->jalaliDateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
@@ -83,22 +83,28 @@ class WalletsTable
->maxLength(500)
->rows(2),
])
->action(function (Wallet $record, array $data, WalletService $walletService): void {
->action(function (Wallet $record, array $data, WalletService $walletService) use ($type): void {
$amount = (int) $data['amount'];
$description = filled($data['description'] ?? null) ? $data['description'] : null;
try {
$transaction = $type === 'credit'
? $walletService->credit($record, (int) $data['amount'], $data['description'] ?? null, auth()->user())
: $walletService->debit($record, (int) $data['amount'], $data['description'] ?? null, auth()->user());
? $walletService->credit($record, $amount, $description, auth()->user())
: $walletService->debit($record, $amount, $description, auth()->user());
Notification::make()
->title($type === 'credit' ? 'واریز انجام شد' : 'برداشت انجام شد')
->body('موجودی جدید: '.number_format($transaction->balance_after).' تومان')
->success()
->send();
} catch (RuntimeException $exception) {
} catch (Throwable $exception) {
report($exception);
Notification::make()
->title('خطا')
->body($exception->getMessage())
->title($type === 'credit' ? 'واریز ناموفق بود' : 'برداشت ناموفق بود')
->body($exception->getMessage() ?: 'خطای نامشخص هنگام ثبت تراکنش.')
->danger()
->persistent()
->send();
}
});

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\TemplateGenerationStatus;
use App\Http\Controllers\Controller;
use App\Models\TemplateGeneration;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GenerationDownloadController extends Controller
{
public function __invoke(Request $request, TemplateGeneration $generation): StreamedResponse
{
$user = $request->user();
abort_unless($user && ($user->hasRole('admin') || $user->hasPermission('templates.manage')), 403);
abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, 404);
return Storage::disk('local')->download($generation->output_path, $generation->downloadFilename().'.pptx');
}
}

View File

@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\UpdateNotificationPreferencesRequest;
use App\Http\Resources\NotificationPreferenceResource;
use App\Http\Resources\NotificationResource;
use App\Models\NotificationPreference;
use App\Services\NotificationService;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
@@ -86,4 +89,93 @@ class NotificationController extends Controller
return $this->success('همه نوتیفیکیشن‌ها خوانده شد.');
}
#[OA\Get(
path: '/notifications/preferences',
description: 'Get the authenticated user notification preferences',
summary: 'Show notification preferences',
security: [['sanctum' => []]],
tags: ['Notifications'],
responses: [
new OA\Response(
response: 200,
description: 'Notification preferences fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function preferences(Request $request): JsonResponse
{
$preferences = $this->firstOrCreatePreferences($request);
return $this->success('تنظیمات اعلان‌ها با موفقیت دریافت شد.', [
'preferences' => new NotificationPreferenceResource($preferences),
]);
}
#[OA\Put(
path: '/notifications/preferences',
description: 'Update the authenticated user notification preferences',
summary: 'Update notification preferences',
security: [['sanctum' => []]],
tags: ['Notifications'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: '#/components/schemas/NotificationPreference')
),
responses: [
new OA\Response(
response: 200,
description: 'Notification preferences updated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'preferences', ref: '#/components/schemas/NotificationPreference'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function updatePreferences(UpdateNotificationPreferencesRequest $request): JsonResponse
{
$preferences = $this->firstOrCreatePreferences($request);
$preferences->update($request->validated());
return $this->success('تنظیمات اعلان‌ها با موفقیت به‌روزرسانی شد.', [
'preferences' => new NotificationPreferenceResource($preferences->fresh()),
]);
}
private function firstOrCreatePreferences(Request $request): NotificationPreference
{
return $request->user()->notificationPreference()->firstOrCreate([], [
'comment_on_presentation' => true,
'form_reply' => false,
'new_follower' => false,
'news_announcements' => false,
'weekly_product_updates' => true,
'weekly_blog_digest' => false,
]);
}
}

View File

@@ -3,10 +3,13 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Profile\ChangePasswordRequest;
use App\Http\Requests\Profile\DeleteAccountRequest;
use App\Http\Requests\Profile\UpdateProfileRequest;
use App\Http\Resources\UserResource;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
class ProfileController extends Controller
@@ -89,10 +92,110 @@ class ProfileController extends Controller
{
$user = $request->user();
$user->update($request->validated());
$data = $request->safe()->except('avatar');
if ($request->hasFile('avatar')) {
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$data['avatar_path'] = $request->file('avatar')->store('avatars', 'public');
}
$user->update($data);
return $this->success('پروفایل با موفقیت به‌روزرسانی شد.', [
'user' => new UserResource($user->fresh()),
]);
}
#[OA\Put(
path: '/profile/password',
description: 'Change the authenticated user password',
summary: 'Change password',
security: [['sanctum' => []]],
tags: ['Profile'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['current_password', 'password', 'password_confirmation'],
properties: [
new OA\Property(property: 'current_password', type: 'string', format: 'password'),
new OA\Property(property: 'password', type: 'string', format: 'password'),
new OA\Property(property: 'password_confirmation', type: 'string', format: 'password'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Password changed successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'رمز عبور با موفقیت تغییر کرد.'),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
$user = $request->user();
$user->update(['password' => $request->validated('password')]);
$currentTokenId = $user->currentAccessToken()?->id;
if ($currentTokenId) {
$user->tokens()->where('id', '!=', $currentTokenId)->delete();
}
return $this->success('رمز عبور با موفقیت تغییر کرد.');
}
#[OA\Delete(
path: '/profile',
description: 'Delete the authenticated user account. Requires the current password for confirmation.',
summary: 'Delete account',
security: [['sanctum' => []]],
tags: ['Profile'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['password'],
properties: [
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'password123'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Account deleted successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'حساب کاربری با موفقیت حذف شد.'),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function destroy(DeleteAccountRequest $request): JsonResponse
{
$user = $request->user();
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$user->tokens()->delete();
$user->delete();
return $this->success('حساب کاربری با موفقیت حذف شد.');
}
}

View File

@@ -2,16 +2,20 @@
namespace App\Http\Controllers\Api;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use App\Enums\TemplateKeyType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\GenerateAiPresentationRequest;
use App\Http\Requests\Api\GenerateTemplateRequest;
use App\Http\Resources\TemplateGenerationResource;
use App\Jobs\GenerateTemplateJob;
use App\Models\Template;
use App\Models\TemplateGeneration;
use App\Support\Jalali;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -77,6 +81,7 @@ class TemplateGenerationController extends Controller
$generation = TemplateGeneration::query()->create([
'template_id' => $template->id,
'uuid' => $uuid,
'title' => $this->buildGenerationTitle($template, $request),
'status' => TemplateGenerationStatus::Pending,
'values' => $storedValues,
'requested_by' => $request->user()->id,
@@ -89,6 +94,117 @@ class TemplateGenerationController extends Controller
], 202);
}
#[OA\Post(
path: '/templates/{slug}/generate-from-text',
description: 'Start generating a presentation from free-form text using AI',
summary: 'Generate presentation from text (AI)',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'slug', description: 'Template slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['title', 'language', 'slide_count', 'text'],
properties: [
new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'),
new OA\Property(property: 'language', type: 'string', example: 'fa'),
new OA\Property(property: 'slide_count', type: 'integer', example: 10),
new OA\Property(property: 'text', type: 'string', example: 'متن، عناوین، بولت‌ها و قوانین موردنظر...'),
]
)
),
responses: [
new OA\Response(
response: 202,
description: 'Generation started',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'درخواست تولید پاورپوینت ثبت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'generation', ref: '#/components/schemas/TemplateGeneration'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function storeFromText(GenerateAiPresentationRequest $request, Template $template): JsonResponse
{
$generation = TemplateGeneration::query()->create([
'template_id' => $template->id,
'uuid' => (string) Str::uuid(),
'source' => TemplateGenerationSource::AiText,
'title' => $request->validated('title'),
'language' => $request->validated('language'),
'slide_count' => $request->validated('slide_count'),
'source_text' => $request->validated('text'),
'status' => TemplateGenerationStatus::Pending,
'requested_by' => $request->user()->id,
]);
GenerateTemplateJob::dispatch($generation);
return $this->success('درخواست تولید پاورپوینت ثبت شد.', [
'generation' => new TemplateGenerationResource($generation),
], 202);
}
#[OA\Get(
path: '/generations',
description: 'List the authenticated user presentation generations (both template-based and AI-generated)',
summary: 'List my presentations',
security: [['sanctum' => []]],
tags: ['Templates'],
parameters: [
new OA\Parameter(name: 'per_page', description: 'Items per page', in: 'query', schema: new OA\Schema(type: 'integer', example: 15)),
],
responses: [
new OA\Response(
response: 200,
description: 'Generations fetched successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'پاورپوینت‌های شما با موفقیت دریافت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'generations',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/TemplateGeneration')
),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
public function index(Request $request): JsonResponse
{
$perPage = min(max((int) $request->integer('per_page', 15), 1), 100);
$generations = TemplateGeneration::query()
->with('template')
->where('requested_by', $request->user()->id)
->latest()
->paginate($perPage);
return $this->success('پاورپوینت‌های شما با موفقیت دریافت شد.', [
'generations' => TemplateGenerationResource::collection($generations)->response()->getData(true),
]);
}
#[OA\Get(
path: '/generations/{uuid}',
description: 'Get the status of a presentation generation request',
@@ -150,6 +266,13 @@ class TemplateGenerationController extends Controller
abort_if($generation->requested_by !== request()->user()->id, Response::HTTP_FORBIDDEN);
abort_if($generation->status !== TemplateGenerationStatus::Completed || ! $generation->output_path, Response::HTTP_NOT_FOUND);
return Storage::disk('local')->download($generation->output_path, $generation->template->slug.'.pptx');
return Storage::disk('local')->download($generation->output_path, $generation->downloadFilename().'.pptx');
}
private function buildGenerationTitle(Template $template, Request $request): string
{
$label = $request->user()?->name ?: 'کاربر';
return sprintf('%s - %s - %s', $template->title, $label, Jalali::date(now()));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\InitiateDepositRequest;
use App\Http\Resources\WalletDepositResource;
use App\Models\WalletDeposit;
use App\Services\WalletDepositService;
use App\Traits\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class WalletDepositController extends Controller
{
use ApiResponse;
public function __construct(private readonly WalletDepositService $walletDepositService) {}
#[OA\Post(
path: '/wallet/deposits',
description: 'Initiate a wallet top-up and get a payment redirect URL',
summary: 'Initiate wallet deposit',
security: [['sanctum' => []]],
tags: ['Wallet'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['amount'],
properties: [
new OA\Property(property: 'amount', type: 'integer', example: 500000),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Deposit initiated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'درخواست شارژ کیف پول ثبت شد.'),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'deposit', ref: '#/components/schemas/WalletDeposit'),
new OA\Property(property: 'payment_url', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function store(InitiateDepositRequest $request): JsonResponse
{
$result = $this->walletDepositService->initiate($request->user(), $request->validated('amount'));
return $this->success('درخواست شارژ کیف پول ثبت شد.', [
'deposit' => new WalletDepositResource($result['deposit']),
'payment_url' => $result['payment_url'],
], 201);
}
#[OA\Get(
path: '/wallet/deposits/{deposit}/callback',
description: 'Public callback the payment gateway redirects to after a payment attempt',
summary: 'Wallet deposit callback',
tags: ['Wallet'],
parameters: [
new OA\Parameter(name: 'deposit', description: 'Deposit id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 302, description: 'Redirects to the frontend wallet page with a status query param'),
]
)]
public function callback(WalletDeposit $deposit, Request $request): RedirectResponse
{
$success = $this->walletDepositService->handleCallback($deposit, $request->query());
return redirect()->away(
rtrim((string) config('app.frontend_url'), '/').'/wallet?status='.($success ? 'success' : 'failed')
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\Api;
use App\Models\Template;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class GenerateAiPresentationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'language' => ['required', 'string', 'in:fa,en,ar'],
'slide_count' => ['required', 'integer', 'min:3', 'max:30'],
'text' => ['required', 'string', 'min:20'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
/** @var Template $template */
$template = $this->route('template');
if (! $template->is_ai_enabled) {
$validator->errors()->add('template', 'این قالب برای ساخت پاورپوینت با هوش مصنوعی فعال نیست.');
}
});
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'title.required' => 'عنوان پاورپوینت الزامی است.',
'language.required' => 'زبان ارائه الزامی است.',
'language.in' => 'زبان انتخاب‌شده پشتیبانی نمی‌شود.',
'slide_count.required' => 'تعداد اسلاید الزامی است.',
'slide_count.min' => 'تعداد اسلاید باید حداقل :min باشد.',
'slide_count.max' => 'تعداد اسلاید باید حداکثر :max باشد.',
'text.required' => 'متن ورودی الزامی است.',
'text.min' => 'متن ورودی باید حداقل :min کاراکتر باشد.',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class InitiateDepositRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'amount.required' => 'مبلغ شارژ الزامی است.',
'amount.integer' => 'مبلغ باید عدد باشد.',
'amount.min' => 'حداقل مبلغ شارژ :min تومان است.',
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* @var list<string>
*/
private const FIELDS = [
'comment_on_presentation',
'form_reply',
'new_follower',
'news_announcements',
'weekly_product_updates',
'weekly_blog_digest',
];
public function authorize(): bool
{
return true;
}
/**
* Accept "true"/"false"/"1"/"0"/1/0 strings from the frontend and coerce them to
* real booleans before validation so the toggles actually persist.
*/
protected function prepareForValidation(): void
{
$normalized = [];
foreach (self::FIELDS as $field) {
if ($this->has($field)) {
$normalized[$field] = filter_var($this->input($field), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false;
}
}
if ($normalized !== []) {
$this->merge($normalized);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return array_fill_keys(self::FIELDS, ['sometimes', 'boolean']);
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'*.boolean' => 'مقدار این تنظیم باید بله یا خیر باشد.',
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Support\Mobile;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRequest extends FormRequest
@@ -11,6 +12,13 @@ class RegisterRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('mobile')) {
$this->merge(['mobile' => Mobile::normalize($this->input('mobile'))]);
}
}
/**
* @return array<string, mixed>
*/
@@ -18,7 +26,7 @@ class RegisterRequest extends FormRequest
{
return [
'name' => ['required', 'string', 'max:255'],
'mobile' => ['required', 'string', 'regex:/^09\d{9}$/', 'unique:users,mobile'],
'mobile' => ['required', 'string', 'regex:'.Mobile::REGEX, 'unique:users,mobile'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
];
@@ -32,7 +40,7 @@ class RegisterRequest extends FormRequest
return [
'name.required' => 'نام و نام خانوادگی الزامی است.',
'mobile.required' => 'شماره موبایل الزامی است.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789',
'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.',
'email.required' => 'ایمیل الزامی است.',
'email.email' => 'فرمت ایمیل صحیح نیست.',

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Profile;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'current_password' => ['required', 'string', 'current_password'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'current_password.required' => 'رمز عبور فعلی الزامی است.',
'current_password.current_password' => 'رمز عبور فعلی صحیح نیست.',
'password.required' => 'رمز عبور جدید الزامی است.',
'password.min' => 'رمز عبور جدید باید حداقل ۸ کاراکتر باشد.',
'password.confirmed' => 'تکرار رمز عبور جدید مطابقت ندارد.',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Profile;
use Illuminate\Foundation\Http\FormRequest;
class DeleteAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'password' => ['required', 'string', 'current_password'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'password.required' => 'برای حذف حساب کاربری، وارد کردن رمز عبور الزامی است.',
'password.current_password' => 'رمز عبور واردشده صحیح نیست.',
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Profile;
use App\Support\Mobile;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('mobile')) {
$this->merge(['mobile' => Mobile::normalize($this->input('mobile'))]);
}
}
/**
* @return array<string, mixed>
*/
@@ -23,8 +31,16 @@ class UpdateProfileRequest extends FormRequest
'name' => ['sometimes', 'required', 'string', 'max:255'],
'username' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('users', 'username')->ignore($userId)],
'email' => ['sometimes', 'required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
'mobile' => ['sometimes', 'required', 'string', 'regex:/^09\d{9}$/', Rule::unique('users', 'mobile')->ignore($userId)],
'mobile' => ['sometimes', 'required', 'string', 'regex:'.Mobile::REGEX, Rule::unique('users', 'mobile')->ignore($userId)],
'password' => ['sometimes', 'required', 'string', 'min:8'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:3072'],
'is_public' => ['sometimes', 'nullable', 'boolean'],
'social_links' => ['sometimes', 'nullable', 'array'],
'social_links.instagram' => ['nullable', 'string', 'max:255'],
'social_links.telegram' => ['nullable', 'string', 'max:255'],
'social_links.linkedin' => ['nullable', 'string', 'max:255'],
'social_links.twitter' => ['nullable', 'string', 'max:255'],
'social_links.website' => ['nullable', 'string', 'max:255'],
];
}
@@ -41,10 +57,13 @@ class UpdateProfileRequest extends FormRequest
'email.email' => 'فرمت ایمیل صحیح نیست.',
'email.unique' => 'این ایمیل قبلاً ثبت شده است.',
'mobile.required' => 'شماره موبایل الزامی است.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست.',
'mobile.regex' => 'فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789',
'mobile.unique' => 'این شماره موبایل قبلاً ثبت شده است.',
'password.required' => 'رمز عبور الزامی است.',
'password.min' => 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'avatar.image' => 'فایل ارسالی باید تصویر باشد.',
'avatar.max' => 'حداکثر حجم تصویر ۳ مگابایت است.',
'social_links.array' => 'ساختار شبکه‌های اجتماعی نامعتبر است.',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\NotificationPreference;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotificationPreference',
properties: [
new OA\Property(property: 'comment_on_presentation', type: 'boolean', example: true),
new OA\Property(property: 'form_reply', type: 'boolean', example: false),
new OA\Property(property: 'new_follower', type: 'boolean', example: false),
new OA\Property(property: 'news_announcements', type: 'boolean', example: false),
new OA\Property(property: 'weekly_product_updates', type: 'boolean', example: true),
new OA\Property(property: 'weekly_blog_digest', type: 'boolean', example: false),
]
)]
/** @mixin NotificationPreference */
class NotificationPreferenceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'comment_on_presentation' => $this->comment_on_presentation,
'form_reply' => $this->form_reply,
'new_follower' => $this->new_follower,
'news_announcements' => $this->news_announcements,
'weekly_product_updates' => $this->weekly_product_updates,
'weekly_blog_digest' => $this->weekly_blog_digest,
];
}
}

View File

@@ -11,6 +11,20 @@ use OpenApi\Attributes as OA;
schema: 'TemplateGeneration',
properties: [
new OA\Property(property: 'uuid', type: 'string', example: '9d6f0b1e-9a3a-4f0a-8a1a-6c9a2e5b3d10'),
new OA\Property(property: 'title', type: 'string', example: 'گزارش فروش سال ۱۴۰۴'),
new OA\Property(property: 'source', type: 'string', example: 'ai_text'),
new OA\Property(property: 'source_label', type: 'string', example: 'ساخت با هوش مصنوعی از متن'),
new OA\Property(property: 'language', type: 'string', example: 'fa', nullable: true),
new OA\Property(property: 'slide_count', type: 'integer', example: 10, nullable: true),
new OA\Property(
property: 'template',
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'slug', type: 'string'),
],
type: 'object',
nullable: true
),
new OA\Property(property: 'status', type: 'string', example: 'completed'),
new OA\Property(property: 'status_label', type: 'string', example: 'تکمیل شده'),
new OA\Property(property: 'error_message', type: 'string', example: null, nullable: true),
@@ -28,6 +42,15 @@ class TemplateGenerationResource extends JsonResource
{
return [
'uuid' => $this->uuid,
'title' => $this->title ?: $this->template?->title,
'source' => $this->source->value,
'source_label' => $this->source->label(),
'language' => $this->language,
'slide_count' => $this->slide_count,
'template' => $this->template ? [
'title' => $this->template->title,
'slug' => $this->template->slug,
] : null,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'error_message' => $this->error_message,

View File

@@ -5,6 +5,7 @@ namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -15,6 +16,9 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'username', type: 'string', example: 'ali'),
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ali@example.com'),
new OA\Property(property: 'mobile', type: 'string', example: '09123456789'),
new OA\Property(property: 'avatar_url', type: 'string', example: 'https://example.com/storage/avatars/1.png', nullable: true),
new OA\Property(property: 'is_public', type: 'boolean', example: false),
new OA\Property(property: 'social_links', type: 'object', example: ['instagram' => 'https://instagram.com/handle'], nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
@@ -32,6 +36,9 @@ class UserResource extends JsonResource
'username' => $this->username,
'email' => $this->email,
'mobile' => $this->mobile,
'avatar_url' => $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null,
'is_public' => $this->is_public,
'social_links' => $this->social_links ?: null,
'created_at' => $this->created_at?->toIso8601String(),
];
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\WalletDeposit;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'WalletDeposit',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'amount', type: 'integer', example: 500000),
new OA\Property(property: 'status', type: 'string', example: 'pending'),
new OA\Property(property: 'status_label', type: 'string', example: 'در انتظار پرداخت'),
new OA\Property(property: 'gateway', type: 'string', example: 'fake'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)]
/** @mixin WalletDeposit */
class WalletDepositResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'gateway' => $this->gateway,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}

View File

@@ -2,14 +2,18 @@
namespace App\Jobs;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use App\Models\AiProvider;
use App\Models\TemplateGeneration;
use App\Services\Ai\OpenAiCompatibleTextGenerator;
use App\Services\TemplateFillService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
class GenerateTemplateJob implements ShouldQueue
@@ -18,12 +22,14 @@ class GenerateTemplateJob implements ShouldQueue
public function __construct(private readonly TemplateGeneration $generation) {}
public function handle(TemplateFillService $templateFillService): void
public function handle(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): void
{
$this->generation->update(['status' => TemplateGenerationStatus::Processing]);
try {
$outputPath = $templateFillService->fill($this->generation);
$outputPath = $this->generation->source === TemplateGenerationSource::AiText
? $this->fillFromAiText($templateFillService, $outlineGenerator)
: $templateFillService->fill($this->generation);
$this->generation->update([
'status' => TemplateGenerationStatus::Completed,
@@ -36,4 +42,24 @@ class GenerateTemplateJob implements ShouldQueue
]);
}
}
private function fillFromAiText(TemplateFillService $templateFillService, OpenAiCompatibleTextGenerator $outlineGenerator): string
{
$provider = AiProvider::resolveActiveDefault();
if (! $provider) {
throw new RuntimeException('هیچ سرویس هوش مصنوعی فعالی تنظیم نشده است.');
}
$outline = $outlineGenerator->generateOutline(
$this->generation->template,
(string) $this->generation->title,
(string) $this->generation->source_text,
(int) $this->generation->slide_count,
(string) $this->generation->language,
$provider,
);
return $templateFillService->fillAiGeneration($this->generation, $outline);
}
}

39
app/Models/AiProvider.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\AiProviderDriver;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'driver', 'model', 'base_url', 'api_key', 'is_active', 'is_default'])]
#[Hidden(['api_key'])]
class AiProvider extends Model
{
public static function resolveActiveDefault(): ?self
{
return static::query()
->where('is_active', true)
->orderByDesc('is_default')
->first();
}
public function resolvedBaseUrl(): string
{
return $this->base_url ?: $this->driver->defaultBaseUrl();
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'driver' => AiProviderDriver::class,
'api_key' => 'encrypted',
'is_active' => 'boolean',
'is_default' => 'boolean',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'comment_on_presentation',
'form_reply',
'new_follower',
'news_announcements',
'weekly_product_updates',
'weekly_blog_digest',
])]
class NotificationPreference extends Model
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'comment_on_presentation' => 'boolean',
'form_reply' => 'boolean',
'new_follower' => 'boolean',
'news_announcements' => 'boolean',
'weekly_product_updates' => 'boolean',
'weekly_blog_digest' => 'boolean',
];
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Fillable([
'category_id',
@@ -15,10 +16,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'file_path',
'thumbnail_path',
'is_active',
'is_ai_enabled',
'ai_repeatable_slide_number',
'created_by',
])]
class Template extends Model
{
use SoftDeletes;
/**
* @return BelongsTo<Category, $this>
*/
@@ -58,6 +63,8 @@ class Template extends Model
{
return [
'is_active' => 'boolean',
'is_ai_enabled' => 'boolean',
'ai_repeatable_slide_number' => 'integer',
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\TemplateGenerationSource;
use App\Enums\TemplateGenerationStatus;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
@@ -10,6 +11,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'template_id',
'uuid',
'source',
'title',
'language',
'slide_count',
'source_text',
'status',
'values',
'output_path',
@@ -23,7 +29,9 @@ class TemplateGeneration extends Model
*/
public function template(): BelongsTo
{
return $this->belongsTo(Template::class);
// Keep resolving the template even after it has been soft-deleted, so past
// generations stay downloadable and keep their labels.
return $this->belongsTo(Template::class)->withTrashed();
}
/**
@@ -34,14 +42,41 @@ class TemplateGeneration extends Model
return $this->belongsTo(User::class, 'requested_by');
}
/**
* A human-friendly name for this generation, used in the admin panel and as the
* downloaded file name. Falls back to the template title plus a short id.
*/
public function displayName(): string
{
$base = $this->title ?: $this->template?->title ?: 'presentation';
return trim($base).' - '.$this->uuid;
}
/**
* Sanitized file name (without extension) for downloads. Keeps Persian characters
* but drops anything a filesystem or HTTP header would choke on.
*/
public function downloadFilename(): string
{
$base = $this->title ?: $this->template?->title ?: $this->template?->slug ?: 'presentation';
$clean = preg_replace('/[\/\\\\?%*:|"<>\r\n]+/u', ' ', (string) $base) ?? '';
$clean = trim(preg_replace('/\s+/u', ' ', $clean) ?? '');
return ($clean !== '' ? $clean : 'presentation').' - '.substr($this->uuid, 0, 8);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'source' => TemplateGenerationSource::class,
'status' => TemplateGenerationStatus::class,
'values' => 'array',
'slide_count' => 'integer',
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\TemplateKeyType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'type',
'shape_name',
'is_required',
'is_repeatable',
'sort_order',
'description',
])]
@@ -27,6 +29,17 @@ class TemplateKey extends Model
return $this->belongsTo(Template::class);
}
/**
* Strip any literal curly braces the admin may have copied from the pptx placeholder
* (e.g. "{Main Title}" -> "Main Title"), since braces are added automatically at match time.
*/
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => trim($value, "{} \t\n\r\0\x0B"),
);
}
/**
* @return array<string, string>
*/
@@ -35,6 +48,7 @@ class TemplateKey extends Model
return [
'type' => TemplateKeyType::class,
'is_required' => 'boolean',
'is_repeatable' => 'boolean',
'sort_order' => 'integer',
];
}

View File

@@ -17,7 +17,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'username', 'email', 'mobile', 'password'])]
#[Fillable(['name', 'username', 'email', 'mobile', 'password', 'avatar_path', 'is_public', 'social_links'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
@@ -56,6 +56,22 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(Notification::class)->latest();
}
/**
* @return HasOne<NotificationPreference, $this>
*/
public function notificationPreference(): HasOne
{
return $this->hasOne(NotificationPreference::class);
}
/**
* @return HasMany<TemplateGeneration, $this>
*/
public function templateGenerations(): HasMany
{
return $this->hasMany(TemplateGeneration::class, 'requested_by')->latest();
}
public function hasRole(string|array $roles): bool
{
$roles = (array) $roles;
@@ -87,6 +103,8 @@ class User extends Authenticatable implements FilamentUser
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_public' => 'boolean',
'social_links' => 'array',
];
}
}

View File

@@ -26,6 +26,14 @@ class Wallet extends Model
return $this->hasMany(WalletTransaction::class)->latest();
}
/**
* @return HasMany<WalletDeposit, $this>
*/
public function deposits(): HasMany
{
return $this->hasMany(WalletDeposit::class)->latest();
}
/**
* @return array<string, string>
*/

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Enums\WalletDepositStatus;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'wallet_id',
'amount',
'status',
'gateway',
'reference_id',
'meta',
])]
class WalletDeposit extends Model
{
/**
* @return BelongsTo<Wallet, $this>
*/
public function wallet(): BelongsTo
{
return $this->belongsTo(Wallet::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'amount' => 'integer',
'status' => WalletDepositStatus::class,
'meta' => 'array',
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\AiProvider;
use App\Models\User;
class AiProviderPolicy
{
public function viewAny(User $user): bool
{
return $this->canManage($user);
}
public function view(User $user, AiProvider $aiProvider): bool
{
return $this->canManage($user);
}
public function create(User $user): bool
{
return $this->canManage($user);
}
public function update(User $user, AiProvider $aiProvider): bool
{
return $this->canManage($user);
}
public function delete(User $user, AiProvider $aiProvider): bool
{
return $this->canManage($user);
}
private function canManage(User $user): bool
{
return $user->hasRole('admin') || $user->hasPermission('ai.manage');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Models\Notification;
use App\Models\Permission;
use App\Models\Role;
@@ -15,6 +16,10 @@ use App\Policies\RolePolicy;
use App\Policies\UserPolicy;
use App\Policies\WalletPolicy;
use App\Policies\WalletTransactionPolicy;
use App\Services\Payments\FakePaymentGateway;
use App\Support\Jalali;
use Filament\Infolists\Components\TextEntry;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@@ -26,7 +31,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Swap this binding for a real gateway (ZarinPal, Zibal, ...) once one is integrated.
$this->app->bind(PaymentGateway::class, FakePaymentGateway::class);
}
/**
@@ -38,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
}
$this->registerJalaliMacros();
User::observe(UserObserver::class);
Gate::policy(User::class, UserPolicy::class);
@@ -47,4 +55,31 @@ class AppServiceProvider extends ServiceProvider
Gate::policy(WalletTransaction::class, WalletTransactionPolicy::class);
Gate::policy(Notification::class, NotificationPolicy::class);
}
/**
* Adds ->jalaliDate() / ->jalaliDateTime() helpers to Filament table columns and
* infolist entries so every date shown in the admin panel is Shamsi.
*/
private function registerJalaliMacros(): void
{
TextColumn::macro('jalaliDate', function () {
/** @var TextColumn $this */
return $this->formatStateUsing(fn ($state): ?string => Jalali::date($state));
});
TextColumn::macro('jalaliDateTime', function () {
/** @var TextColumn $this */
return $this->formatStateUsing(fn ($state): ?string => Jalali::dateTime($state));
});
TextEntry::macro('jalaliDate', function () {
/** @var TextEntry $this */
return $this->formatStateUsing(fn ($state): ?string => Jalali::date($state));
});
TextEntry::macro('jalaliDateTime', function () {
/** @var TextEntry $this */
return $this->formatStateUsing(fn ($state): ?string => Jalali::dateTime($state));
});
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Services\Ai;
use App\Enums\TemplateKeyType;
use App\Models\AiProvider;
use App\Models\Template;
use App\Models\TemplateKey;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiCompatibleTextGenerator
{
/**
* @return array<string, string>
*/
public function generateForTemplate(Template $template, string $topic, AiProvider $provider): array
{
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
if ($textKeys->isEmpty()) {
return [];
}
$keysDescription = $textKeys
->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : ''))
->implode("\n");
$systemPrompt = <<<PROMPT
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس موضوعی که کاربر می‌دهد، برای هرکدام از کلیدهای زیر یک متن مناسب فارسی تولید کن.
فقط یک JSON معتبر برگردان که کلیدهای آن دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نام کلیدهای زیر باشد. هیچ توضیح اضافه‌ای برنگردان.
کلیدها:
{$keysDescription}
PROMPT;
$response = Http::withToken($provider->api_key)
->timeout(60)
->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [
'model' => $provider->model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $topic],
],
]);
if ($response->failed()) {
throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body());
}
$decoded = json_decode((string) $response->json('choices.0.message.content'), true);
if (! is_array($decoded)) {
throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.');
}
$normalizedDecoded = [];
foreach ($decoded as $decodedKey => $decodedValue) {
$normalizedDecoded[$this->normalizeKey((string) $decodedKey)] = $decodedValue;
}
$values = [];
foreach ($textKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedDecoded)) {
$values[$key->key] = (string) $normalizedDecoded[$normalizedKey];
}
}
return $values;
}
/**
* Generates a full slide outline from free-form text: a set of fixed "title_values"
* (filled once for the whole deck) plus exactly $slideCount "slides", each carrying
* its own values for the template's repeatable keys.
*
* @return array{title_values: array<string, string>, slides: array<int, array<string, string>>}
*/
public function generateOutline(Template $template, string $title, string $sourceText, int $slideCount, string $language, AiProvider $provider): array
{
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
$fixedKeys = $textKeys->where('is_repeatable', false);
$repeatableKeys = $textKeys->where('is_repeatable', true);
if ($repeatableKeys->isEmpty()) {
throw new RuntimeException('کلید تکرارشونده‌ای برای این قالب تعریف نشده است.');
}
$describeKeys = fn ($keys): string => $keys
->map(fn (TemplateKey $key): string => sprintf('- %s (%s)%s', $key->key, $key->label, $key->description ? ': '.$key->description : ''))
->implode("\n");
$fixedKeysDescription = $fixedKeys->isEmpty() ? 'هیچ' : $describeKeys($fixedKeys);
$repeatableKeysDescription = $describeKeys($repeatableKeys);
$systemPrompt = <<<PROMPT
شما دستیار تولید محتوای پاورپوینت هستید. بر اساس عنوان و متن خامی که کاربر می‌دهد، یک ارائه به زبان "{$language}" شامل دقیقاً {$slideCount} اسلاید محتوایی طراحی کن.
فقط یک JSON معتبر با دقیقاً این ساختار برگردان، بدون هیچ توضیح اضافه:
{"title_values": {...}, "slides": [{...}, ...]}
"slides" باید دقیقاً {$slideCount} عضو داشته باشد.
کلیدهای ثابت (یک بار برای کل ارائه، در title_values):
{$fixedKeysDescription}
کلیدهای تکرارشونده (برای هر اسلاید، در هر عضو آرایه slides):
{$repeatableKeysDescription}
نام کلیدها باید دقیقاً و بدون هیچ تغییری (بدون تغییر حروف بزرگ/کوچک، بدون ترجمه، بدون حذف فاصله) برابر با نام‌های بالا باشند.
PROMPT;
$userPrompt = "عنوان: {$title}\n\nمتن خام:\n{$sourceText}";
$response = Http::withToken($provider->api_key)
->timeout(120)
->post(rtrim($provider->resolvedBaseUrl(), '/').'/chat/completions', [
'model' => $provider->model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $userPrompt],
],
]);
if ($response->failed()) {
throw new RuntimeException('خطا در ارتباط با سرویس هوش مصنوعی: '.$response->body());
}
$decoded = json_decode((string) $response->json('choices.0.message.content'), true);
if (! is_array($decoded) || ! is_array($decoded['slides'] ?? null)) {
throw new RuntimeException('پاسخ هوش مصنوعی قابل تفسیر نبود.');
}
$normalizedTitleValues = $this->normalizeValueMap(is_array($decoded['title_values'] ?? null) ? $decoded['title_values'] : []);
$titleValues = [];
foreach ($fixedKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedTitleValues)) {
$titleValues[$key->key] = (string) $normalizedTitleValues[$normalizedKey];
}
}
$slides = [];
foreach ($decoded['slides'] as $slideData) {
if (! is_array($slideData)) {
continue;
}
$normalizedSlideData = $this->normalizeValueMap($slideData);
$slideValues = [];
foreach ($repeatableKeys as $key) {
$normalizedKey = $this->normalizeKey($key->key);
if (array_key_exists($normalizedKey, $normalizedSlideData)) {
$slideValues[$key->key] = (string) $normalizedSlideData[$normalizedKey];
}
}
$slides[] = $slideValues;
}
if ($slides === []) {
throw new RuntimeException('هوش مصنوعی محتوایی برای اسلایدها تولید نکرد.');
}
return [
'title_values' => $titleValues,
'slides' => array_slice($slides, 0, $slideCount),
];
}
/**
* @param array<mixed, mixed> $values
* @return array<string, mixed>
*/
private function normalizeValueMap(array $values): array
{
$normalized = [];
foreach ($values as $key => $value) {
$normalized[$this->normalizeKey((string) $key)] = $value;
}
return $normalized;
}
private function normalizeKey(string $key): string
{
return mb_strtolower(trim($key, "{} \t\n\r\0\x0B"));
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services\Payments;
use App\Contracts\PaymentGateway;
use App\Models\WalletDeposit;
use Illuminate\Support\Str;
/**
* Stand-in gateway used until a real provider (e.g. ZarinPal, Zibal) is wired up.
* It simulates an instant-success redirect straight to the callback route, so the
* full initiate -> redirect -> callback -> credit flow is testable end-to-end
* without any external credentials. Swap the PaymentGateway binding in
* AppServiceProvider to replace it with a real driver.
*/
class FakePaymentGateway implements PaymentGateway
{
public function initiate(WalletDeposit $deposit): string
{
$deposit->update(['reference_id' => (string) Str::uuid()]);
return route('api.wallet.deposits.callback', [
'deposit' => $deposit->id,
'reference_id' => $deposit->reference_id,
'status' => 'success',
]);
}
public function verify(WalletDeposit $deposit, array $callbackPayload): bool
{
return $deposit->reference_id !== null
&& ($callbackPayload['reference_id'] ?? null) === $deposit->reference_id
&& ($callbackPayload['status'] ?? null) === 'success';
}
}

View File

@@ -4,101 +4,584 @@ namespace App\Services;
use App\Enums\TemplateKeyType;
use App\Models\TemplateGeneration;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpPresentation\IOFactory;
use PhpOffice\PhpPresentation\PhpPresentation;
use PhpOffice\PhpPresentation\Shape\Drawing\File as FileDrawing;
use PhpOffice\PhpPresentation\Shape\Drawing\Gd as GdDrawing;
use PhpOffice\PhpPresentation\Shape\RichText;
use RuntimeException;
use ZipArchive;
/**
* Fills a .pptx template by editing it directly as a zip/XML archive, instead of
* round-tripping it through a full presentation object model. Real-world .pptx files
* (themes, masters, layouts, animations, embedded fonts) can be lost or corrupted by
* libraries that fully re-serialize the document; this way only the exact text runs
* and image bytes we touch are changed, everything else stays byte-identical.
*/
class TemplateFillService
{
private const NS_PRESENTATION = 'http://schemas.openxmlformats.org/presentationml/2006/main';
private const NS_DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main';
private const NS_RELATIONSHIPS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
private const NS_CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types';
private const NS_PACKAGE_RELATIONSHIPS = 'http://schemas.openxmlformats.org/package/2006/relationships';
private const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
public function fill(TemplateGeneration $generation): string
{
$template = $generation->template;
$sourcePath = Storage::disk('local')->path($template->file_path);
$presentation = IOFactory::load($sourcePath);
$sourceAbsolutePath = Storage::disk('local')->path($template->file_path);
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
Storage::disk('local')->makeDirectory('templates/generations');
if (! copy($sourceAbsolutePath, $outputAbsolutePath)) {
throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.');
}
$values = $generation->values ?? [];
$textValues = [];
$imageValues = [];
foreach ($template->keys as $key) {
if (! array_key_exists($key->key, $values)) {
continue;
}
match ($key->type) {
TemplateKeyType::Text => $this->fillText($presentation, $key->key, (string) $values[$key->key]),
TemplateKeyType::Image => $this->fillImage($presentation, $key->shape_name, Storage::disk('local')->path($values[$key->key])),
TemplateKeyType::Text => $textValues[$key->key] = (string) $values[$key->key],
TemplateKeyType::Image => $imageValues[$key->shape_name] = Storage::disk('local')->path($values[$key->key]),
TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمی‌شود.'),
};
}
$zip = new ZipArchive;
if ($zip->open($outputAbsolutePath) !== true) {
throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.');
}
try {
foreach ($this->findSlideEntries($zip) as $slideEntry) {
$this->applyValuesToSlide($zip, $slideEntry, $textValues, $imageValues);
}
} finally {
$zip->close();
}
return $outputRelativePath;
}
/**
* Generates a presentation from AI-produced content by cloning the template's
* designated repeatable slide once per outline slide, then filling each slide
* (repeatable and fixed alike) with its corresponding text values. Unlike fill(),
* this can change the slide count, since the outline's slide count is user-chosen.
*
* @param array{title_values: array<string, string>, slides: array<int, array<string, string>>} $outline
*/
public function fillAiGeneration(TemplateGeneration $generation, array $outline): string
{
$template = $generation->template;
if ($template->ai_repeatable_slide_number === null) {
throw new RuntimeException('اسلاید تکرارشونده برای این قالب تنظیم نشده است.');
}
$sourceAbsolutePath = Storage::disk('local')->path($template->file_path);
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
Storage::disk('local')->makeDirectory('templates/generations');
$writer = IOFactory::createWriter($presentation, 'PowerPoint2007');
$writer->save($outputAbsolutePath);
if (! copy($sourceAbsolutePath, $outputAbsolutePath)) {
throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.');
}
$textKeys = $template->keys->where('type', TemplateKeyType::Text);
$repeatableKeys = $textKeys->where('is_repeatable', true);
$fixedKeys = $textKeys->where('is_repeatable', false);
$titleValues = [];
foreach ($fixedKeys as $key) {
if (array_key_exists($key->key, $outline['title_values'] ?? [])) {
$titleValues[$key->key] = (string) $outline['title_values'][$key->key];
}
}
$slides = array_values($outline['slides'] ?? []);
$repeatCount = count($slides);
$zip = new ZipArchive;
if ($zip->open($outputAbsolutePath) !== true) {
throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.');
}
try {
$repeatableSlideEntry = sprintf('ppt/slides/slide%d.xml', $template->ai_repeatable_slide_number);
$repeatableEntries = [$repeatableSlideEntry];
if ($repeatCount > 1) {
$repeatableEntries = [...$repeatableEntries, ...$this->cloneSlide($zip, $repeatableSlideEntry, $repeatCount - 1)];
}
foreach ($this->findSlideEntries($zip) as $slideEntry) {
$repeatableIndex = array_search($slideEntry, $repeatableEntries, true);
if ($repeatableIndex === false) {
$this->applyValuesToSlide($zip, $slideEntry, $titleValues, []);
continue;
}
$slideValues = [];
foreach ($repeatableKeys as $key) {
if (array_key_exists($key->key, $slides[$repeatableIndex] ?? [])) {
$slideValues[$key->key] = (string) $slides[$repeatableIndex][$key->key];
}
}
$this->applyValuesToSlide($zip, $slideEntry, $slideValues, []);
}
} finally {
$zip->close();
}
return $outputRelativePath;
}
private function fillText(PhpPresentation $presentation, string $key, string $value): void
/**
* @param array<string, string> $textValues
* @param array<string, string> $imageValues
*/
private function applyValuesToSlide(ZipArchive $zip, string $slideEntry, array $textValues, array $imageValues): void
{
$placeholder = '{{'.$key.'}}';
$xml = $zip->getFromName($slideEntry);
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
if (! $shape instanceof RichText) {
continue;
}
foreach ($shape->getParagraphs() as $paragraph) {
foreach ($paragraph->getRichTextElements() as $element) {
if (str_contains($element->getText(), $placeholder)) {
$element->setText(str_replace($placeholder, $value, $element->getText()));
}
}
}
}
}
}
private function fillImage(PhpPresentation $presentation, ?string $shapeName, string $imagePath): void
{
if ($shapeName === null || $shapeName === '') {
if ($xml === false) {
return;
}
foreach ($presentation->getAllSlides() as $slide) {
foreach ($slide->getShapeCollection() as $shape) {
if ($shape->getName() !== $shapeName) {
continue;
}
if ($textValues !== []) {
$newXml = $this->replaceTextInSlideXml($xml, $textValues);
if ($shape instanceof GdDrawing) {
$this->replaceGdImage($shape, $imagePath);
} elseif ($shape instanceof FileDrawing) {
$shape->setPath($imagePath);
}
if ($newXml !== null) {
$zip->addFromString($slideEntry, $newXml);
$xml = $newXml;
}
}
foreach ($imageValues as $shapeName => $imagePath) {
$this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath);
}
}
private function replaceGdImage(GdDrawing $shape, string $imagePath): void
/**
* Clones the given slide entry $times additional times, registering each clone in
* presentation.xml, presentation.xml.rels and [Content_Types].xml so PowerPoint
* recognizes them as real slides, positioned immediately after the source slide in
* deck order. Internal shape/paragraph ids inside the cloned XML are left as-is —
* they only need to be unique within their own slide part, not deck-wide.
*
* @return array<int, string> the newly created slide entry names, in order
*/
private function cloneSlide(ZipArchive $zip, string $sourceSlideEntry, int $times): array
{
$info = getimagesize($imagePath);
$image = imagecreatefromstring(file_get_contents($imagePath));
if ($times <= 0) {
return [];
}
if ($info === false || $image === false) {
$sourceSlideXml = $zip->getFromName($sourceSlideEntry);
if ($sourceSlideXml === false) {
throw new RuntimeException('اسلاید الگو برای شبیه‌سازی یافت نشد.');
}
$sourceRelsEntry = 'ppt/slides/_rels/'.basename($sourceSlideEntry).'.rels';
$sourceRelsXml = $zip->getFromName($sourceRelsEntry);
$presentationXml = $zip->getFromName('ppt/presentation.xml');
$presentationRelsXml = $zip->getFromName('ppt/_rels/presentation.xml.rels');
$contentTypesXml = $zip->getFromName('[Content_Types].xml');
if ($presentationXml === false || $presentationRelsXml === false || $contentTypesXml === false) {
throw new RuntimeException('ساختار فایل پاورپوینت نامعتبر است.');
}
$presentationDom = $this->loadXml($presentationXml);
$relsDom = $this->loadXml($presentationRelsXml);
$contentTypesDom = $this->loadXml($contentTypesXml);
if ($presentationDom === null || $relsDom === null || $contentTypesDom === null) {
throw new RuntimeException('خواندن ساختار فایل پاورپوینت با خطا مواجه شد.');
}
$sourceRelId = $this->findSlideRelationshipId($relsDom, $sourceSlideEntry);
if ($sourceRelId === null) {
throw new RuntimeException('ارتباط اسلاید الگو با فایل ارائه یافت نشد.');
}
$presentationXpath = new DOMXPath($presentationDom);
$presentationXpath->registerNamespace('p', self::NS_PRESENTATION);
$presentationXpath->registerNamespace('r', self::NS_RELATIONSHIPS);
$sourceSldIdEl = null;
foreach ($presentationXpath->query('//p:sldIdLst/p:sldId') as $sldIdEl) {
if ($sldIdEl->getAttributeNS(self::NS_RELATIONSHIPS, 'id') === $sourceRelId) {
$sourceSldIdEl = $sldIdEl;
break;
}
}
if ($sourceSldIdEl === null) {
throw new RuntimeException('اسلاید الگو در فهرست اسلایدها یافت نشد.');
}
$nextSlideNumber = $this->nextSlideNumber($zip);
$nextRelIndex = $this->maxRelationshipId($relsDom) + 1;
$nextSlideId = $this->maxSlideId($presentationDom) + 1;
$newEntries = [];
$insertAfter = $sourceSldIdEl;
for ($i = 0; $i < $times; $i++) {
$newSlideNumber = $nextSlideNumber++;
$newSlideEntry = "ppt/slides/slide{$newSlideNumber}.xml";
$newRelsEntry = "ppt/slides/_rels/slide{$newSlideNumber}.xml.rels";
$newRelId = 'rId'.$nextRelIndex++;
$newSlideId = (string) $nextSlideId++;
$zip->addFromString($newSlideEntry, $sourceSlideXml);
if ($sourceRelsXml !== false) {
$zip->addFromString($newRelsEntry, $sourceRelsXml);
}
$override = $contentTypesDom->createElementNS(self::NS_CONTENT_TYPES, 'Override');
$override->setAttribute('PartName', '/'.$newSlideEntry);
$override->setAttribute('ContentType', self::SLIDE_CONTENT_TYPE);
$contentTypesDom->documentElement->appendChild($override);
$relationship = $relsDom->createElementNS(self::NS_PACKAGE_RELATIONSHIPS, 'Relationship');
$relationship->setAttribute('Id', $newRelId);
$relationship->setAttribute('Type', self::NS_RELATIONSHIPS.'/slide');
$relationship->setAttribute('Target', 'slides/'.basename($newSlideEntry));
$relsDom->documentElement->appendChild($relationship);
$sldId = $presentationDom->createElementNS(self::NS_PRESENTATION, 'p:sldId');
$sldId->setAttribute('id', $newSlideId);
$sldId->setAttributeNS(self::NS_RELATIONSHIPS, 'r:id', $newRelId);
$insertAfter->parentNode->insertBefore($sldId, $insertAfter->nextSibling);
$insertAfter = $sldId;
$newEntries[] = $newSlideEntry;
}
$zip->addFromString('[Content_Types].xml', $contentTypesDom->saveXML());
$zip->addFromString('ppt/_rels/presentation.xml.rels', $relsDom->saveXML());
$zip->addFromString('ppt/presentation.xml', $presentationDom->saveXML());
return $newEntries;
}
private function findSlideRelationshipId(DOMDocument $relsDom, string $slideEntry): ?string
{
$target = 'slides/'.basename($slideEntry);
foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) {
if ($relationship->getAttribute('Target') === $target) {
return $relationship->getAttribute('Id');
}
}
return null;
}
private function maxRelationshipId(DOMDocument $relsDom): int
{
$max = 0;
foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) {
if (preg_match('/^rId(\d+)$/', $relationship->getAttribute('Id'), $matches)) {
$max = max($max, (int) $matches[1]);
}
}
return $max;
}
private function maxSlideId(DOMDocument $presentationDom): int
{
$max = 0;
foreach ($presentationDom->getElementsByTagNameNS(self::NS_PRESENTATION, 'sldId') as $sldId) {
$max = max($max, (int) $sldId->getAttribute('id'));
}
return $max;
}
private function nextSlideNumber(ZipArchive $zip): int
{
$max = 0;
foreach ($this->findSlideEntries($zip) as $entry) {
if (preg_match('#slide(\d+)\.xml$#', $entry, $matches)) {
$max = max($max, (int) $matches[1]);
}
}
return $max + 1;
}
private function loadXml(string $xml): ?DOMDocument
{
$dom = new DOMDocument;
$previousSetting = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml);
libxml_clear_errors();
libxml_use_internal_errors($previousSetting);
return $loaded ? $dom : null;
}
/**
* @return array<int, string>
*/
private function findSlideEntries(ZipArchive $zip): array
{
$entries = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name !== false && preg_match('#^ppt/slides/slide\d+\.xml$#', $name)) {
$entries[] = $name;
}
}
return $entries;
}
/**
* @param array<string, string> $textValues
*/
private function replaceTextInSlideXml(string $xml, array $textValues): ?string
{
if (! str_contains($xml, '{')) {
return null;
}
$lookup = [];
foreach ($textValues as $key => $value) {
$lookup[$this->normalizeKey($key)] = $value;
}
$dom = $this->loadXml($xml);
if ($dom === null) {
return null;
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('a', self::NS_DRAWING);
$changed = false;
foreach ($xpath->query('//a:p') as $paragraph) {
$textNodes = iterator_to_array($xpath->query('.//a:r/a:t', $paragraph));
if ($textNodes === []) {
continue;
}
$originalText = '';
foreach ($textNodes as $textNode) {
$originalText .= $textNode->textContent;
}
if (! str_contains($originalText, '{')) {
continue;
}
$newText = preg_replace_callback(
'/\{\{?\s*([^{}]+?)\s*\}?\}/u',
function (array $matches) use ($lookup): string {
$normalizedKey = $this->normalizeKey($matches[1]);
return array_key_exists($normalizedKey, $lookup) ? $lookup[$normalizedKey] : $matches[0];
},
$originalText
);
if ($newText === null || $newText === $originalText) {
continue;
}
$textNodes[0]->textContent = $newText;
for ($i = 1; $i < count($textNodes); $i++) {
$textNodes[$i]->textContent = '';
}
$changed = true;
}
return $changed ? $dom->saveXML() : null;
}
private function normalizeKey(string $key): string
{
return mb_strtolower(trim($key, "{} \t\n\r\0\x0B"));
}
private function replaceShapeImage(ZipArchive $zip, string $slideEntry, string $slideXml, string $shapeName, string $newImagePath): void
{
$relationshipId = $this->findImageRelationshipId($slideXml, $shapeName);
if ($relationshipId === null) {
return;
}
$mediaPath = $this->resolveMediaPath($zip, $slideEntry, $relationshipId);
if ($mediaPath === null) {
return;
}
$zip->addFromString($mediaPath, $this->reencodeImage($mediaPath, $newImagePath));
}
private function findImageRelationshipId(string $slideXml, string $shapeName): ?string
{
$dom = $this->loadXml($slideXml);
if ($dom === null) {
return null;
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('p', self::NS_PRESENTATION);
$xpath->registerNamespace('a', self::NS_DRAWING);
$xpath->registerNamespace('r', self::NS_RELATIONSHIPS);
foreach ($xpath->query('//p:cNvPr') as $cNvPr) {
if ($cNvPr->getAttribute('name') !== $shapeName) {
continue;
}
$picNode = $cNvPr->parentNode;
while ($picNode !== null && $picNode->nodeName !== 'p:pic') {
$picNode = $picNode->parentNode;
}
if ($picNode === null) {
continue;
}
$blips = $xpath->query('.//a:blip', $picNode);
if ($blips->length === 0) {
continue;
}
$embedId = $blips->item(0)->getAttributeNS(self::NS_RELATIONSHIPS, 'embed');
if ($embedId !== '') {
return $embedId;
}
}
return null;
}
private function resolveMediaPath(ZipArchive $zip, string $slideEntry, string $relationshipId): ?string
{
$relsEntry = 'ppt/slides/_rels/'.basename($slideEntry).'.rels';
$relsXml = $zip->getFromName($relsEntry);
if ($relsXml === false) {
return null;
}
$dom = $this->loadXml($relsXml);
if ($dom === null) {
return null;
}
foreach ($dom->getElementsByTagName('Relationship') as $relationship) {
if ($relationship->getAttribute('Id') !== $relationshipId) {
continue;
}
return $this->normalizeZipPath('ppt/slides/'.$relationship->getAttribute('Target'));
}
return null;
}
private function normalizeZipPath(string $path): string
{
$segments = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
return implode('/', $segments);
}
private function reencodeImage(string $mediaPath, string $newImagePath): string
{
$resource = imagecreatefromstring(file_get_contents($newImagePath));
if ($resource === false) {
throw new RuntimeException('فایل تصویر ارسالی معتبر نیست.');
}
$shape->setImageResource($image);
$shape->setMimeType($info['mime']);
$shape->setRenderingFunction(str_replace('/', '', $info['mime']));
$extension = strtolower(pathinfo($mediaPath, PATHINFO_EXTENSION));
ob_start();
match ($extension) {
'jpg', 'jpeg' => imagejpeg($resource, quality: 92),
'gif' => imagegif($resource),
'bmp' => imagebmp($resource),
default => imagepng($resource),
};
$bytes = ob_get_clean();
return $bytes;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services;
use App\Contracts\PaymentGateway;
use App\Enums\PaymentGatewayDriver;
use App\Enums\WalletDepositStatus;
use App\Models\User;
use App\Models\WalletDeposit;
use Illuminate\Support\Facades\DB;
class WalletDepositService
{
public function __construct(
private readonly WalletService $walletService,
private readonly PaymentGateway $gateway,
) {}
/**
* @return array{deposit: WalletDeposit, payment_url: string}
*/
public function initiate(User $user, int $amount): array
{
$wallet = $this->walletService->createForUser($user);
$deposit = WalletDeposit::query()->create([
'wallet_id' => $wallet->id,
'amount' => $amount,
'status' => WalletDepositStatus::Pending,
'gateway' => PaymentGatewayDriver::Fake->value,
]);
$paymentUrl = $this->gateway->initiate($deposit);
return ['deposit' => $deposit, 'payment_url' => $paymentUrl];
}
/**
* @param array<string, mixed> $callbackPayload
*/
public function handleCallback(WalletDeposit $deposit, array $callbackPayload): bool
{
if ($deposit->status !== WalletDepositStatus::Pending) {
return $deposit->status === WalletDepositStatus::Paid;
}
if (! $this->gateway->verify($deposit, $callbackPayload)) {
$deposit->update(['status' => WalletDepositStatus::Failed]);
return false;
}
DB::transaction(function () use ($deposit): void {
$this->walletService->credit($deposit->wallet, $deposit->amount, 'شارژ کیف پول');
$deposit->update(['status' => WalletDepositStatus::Paid]);
});
return true;
}
}

119
app/Support/Jalali.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace App\Support;
use Carbon\CarbonInterface;
use DateTimeInterface;
/**
* Lightweight Gregorian -> Jalali (Shamsi) converter, so the admin panel can show
* Persian dates without pulling in an extra dependency. Only the conversion and a
* couple of display formatters are implemented — enough for table columns and infolists.
*/
class Jalali
{
private const MONTHS = [
1 => 'فروردین', 2 => 'اردیبهشت', 3 => 'خرداد', 4 => 'تیر', 5 => 'مرداد', 6 => 'شهریور',
7 => 'مهر', 8 => 'آبان', 9 => 'آذر', 10 => 'دی', 11 => 'بهمن', 12 => 'اسفند',
];
/**
* @return array{0: int, 1: int, 2: int} [year, month, day]
*/
public static function fromGregorian(int $gy, int $gm, int $gd): array
{
$g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
$gy2 = ($gm > 2) ? ($gy + 1) : $gy;
$days = 355666 + (365 * $gy) + intdiv($gy2 + 3, 4) - intdiv($gy2 + 99, 100)
+ intdiv($gy2 + 399, 400) + $gd + $g_d_m[$gm - 1];
$jy = -1595 + (33 * intdiv($days, 12053));
$days %= 12053;
$jy += 4 * intdiv($days, 1461);
$days %= 1461;
if ($days > 365) {
$jy += intdiv($days - 1, 365);
$days = ($days - 1) % 365;
}
if ($days < 186) {
$jm = 1 + intdiv($days, 31);
$jd = 1 + ($days % 31);
} else {
$jm = 7 + intdiv($days - 186, 30);
$jd = 1 + (($days - 186) % 30);
}
return [$jy, $jm, $jd];
}
public static function date(DateTimeInterface|CarbonInterface|string|null $value): ?string
{
$date = self::normalize($value);
if ($date === null) {
return null;
}
[$jy, $jm, $jd] = self::fromGregorian((int) $date->format('Y'), (int) $date->format('n'), (int) $date->format('j'));
return self::toPersianDigits(sprintf('%04d/%02d/%02d', $jy, $jm, $jd));
}
public static function dateTime(DateTimeInterface|CarbonInterface|string|null $value): ?string
{
$date = self::normalize($value);
if ($date === null) {
return null;
}
[$jy, $jm, $jd] = self::fromGregorian((int) $date->format('Y'), (int) $date->format('n'), (int) $date->format('j'));
return self::toPersianDigits(sprintf(
'%04d/%02d/%02d - %02d:%02d',
$jy, $jm, $jd, (int) $date->format('H'), (int) $date->format('i')
));
}
public static function longDate(DateTimeInterface|CarbonInterface|string|null $value): ?string
{
$date = self::normalize($value);
if ($date === null) {
return null;
}
[$jy, $jm, $jd] = self::fromGregorian((int) $date->format('Y'), (int) $date->format('n'), (int) $date->format('j'));
return self::toPersianDigits($jd.' '.self::MONTHS[$jm].' '.$jy);
}
private static function normalize(DateTimeInterface|CarbonInterface|string|null $value): ?DateTimeInterface
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof DateTimeInterface) {
return $value;
}
try {
return new \DateTimeImmutable((string) $value);
} catch (\Throwable) {
return null;
}
}
private static function toPersianDigits(string $value): string
{
return strtr($value, [
'0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴',
'5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹',
]);
}
}

41
app/Support/Mobile.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Support;
/**
* Normalizes Iranian mobile numbers to the canonical "09XXXXXXXXX" form so that the
* frontend can send numbers with Persian/Arabic digits, spaces, dashes or a country
* code (+98 / 0098 / 98) without tripping the validation regex.
*/
class Mobile
{
public const REGEX = '/^09\d{9}$/';
public static function normalize(?string $value): ?string
{
if ($value === null) {
return null;
}
$digits = strtr(trim($value), [
'۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
'۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
'٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
'٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
]);
$digits = preg_replace('/[^\d+]/', '', $digits) ?? '';
if (str_starts_with($digits, '+98')) {
$digits = '0'.substr($digits, 3);
} elseif (str_starts_with($digits, '0098')) {
$digits = '0'.substr($digits, 4);
} elseif (str_starts_with($digits, '98') && strlen($digits) === 12) {
$digits = '0'.substr($digits, 2);
} elseif (strlen($digits) === 10 && str_starts_with($digits, '9')) {
$digits = '0'.$digits;
}
return $digits === '' ? null : $digits;
}
}

View File

@@ -14,8 +14,7 @@
"filament/filament": "^4.0",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"phpoffice/phppresentation": "^1.2"
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",

618
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "228c4071ac2afaa94e16b892dc1fbdc4",
"content-hash": "c479d2908801be9c7f146d6a1c4662b8",
"packages": [
{
"name": "blade-ui-kit/blade-heroicons",
@@ -447,85 +447,6 @@
],
"time": "2026-03-20T21:10:52+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "danharrin/date-format-converter",
"version": "v0.3.1",
@@ -3465,191 +3386,6 @@
],
"time": "2026-04-30T23:56:43+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4397,279 +4133,6 @@
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "pclzip/pclzip",
"version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/ivanlanin/pclzip.git",
"reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ivanlanin/pclzip/zipball/19dd1de9d3f5fc4d7d70175b4c344dee329f45fd",
"reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd",
"shasum": ""
},
"type": "library",
"autoload": {
"classmap": [
"pclzip.lib.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "Vincent Blavet"
}
],
"description": "A PHP library that offers compression and extraction functions for Zip formatted archives",
"homepage": "http://www.phpconcept.net/pclzip",
"keywords": [
"php",
"zip"
],
"support": {
"issues": "https://github.com/ivanlanin/pclzip/issues",
"source": "https://github.com/ivanlanin/pclzip/tree/master"
},
"time": "2014-06-05T11:42:24+00:00"
},
{
"name": "phpoffice/common",
"version": "1.0.5",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/Common.git",
"reference": "5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/Common/zipball/5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91",
"reference": "5a2eeb82d4dfce4ce2163819063ba6f5a80c3e91",
"shasum": ""
},
"require": {
"pclzip/pclzip": "^2.8",
"php": ">=7.1"
},
"require-dev": {
"phpmd/phpmd": "2.*",
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
"phpunit/phpunit": ">=7"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\Common\\": "src/Common/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-only"
],
"authors": [
{
"name": "Mark Baker"
},
{
"name": "Franck Lefevre",
"homepage": "http://rootslabs.net"
}
],
"description": "PHPOffice Common",
"homepage": "http://phpoffice.github.io",
"keywords": [
"common",
"component",
"office",
"php"
],
"support": {
"issues": "https://github.com/PHPOffice/Common/issues",
"source": "https://github.com/PHPOffice/Common/tree/1.0.5"
},
"time": "2025-02-28T12:39:45+00:00"
},
{
"name": "phpoffice/phppresentation",
"version": "1.2.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PHPPresentation.git",
"reference": "b9cdcd413e2d463ba3198f912f32716adc88f340"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PHPPresentation/zipball/b9cdcd413e2d463ba3198f912f32716adc88f340",
"reference": "b9cdcd413e2d463ba3198f912f32716adc88f340",
"shasum": ""
},
"require": {
"ext-xml": "*",
"ext-zip": "*",
"php": "^7.1|^8.0",
"phpoffice/common": "^1",
"phpoffice/phpspreadsheet": "^1.9 || ^2.0 || ^3.0 || ^4.0"
},
"require-dev": {
"dompdf/dompdf": "^3.1",
"phpmd/phpmd": "2.*",
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
"phpunit/phpunit": ">=7.0"
},
"suggest": {
"ext-gd": "Required to add images"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpPresentation\\": "src/PhpPresentation/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-only"
],
"authors": [
{
"name": "Mark Baker"
},
{
"name": "Franck Lefevre",
"homepage": "http://rootslabs.net"
},
{
"name": "Ivan Lanin",
"homepage": "http://ivan.lanin.org"
}
],
"description": "PHPPresentation - Read, Create and Write Presentations documents in PHP",
"homepage": "http://phpoffice.github.io",
"keywords": [
"LibreOffice",
"PowerPoint",
"odp",
"php",
"ppt",
"pptx",
"presentations"
],
"support": {
"issues": "https://github.com/PHPOffice/PHPPresentation/issues",
"source": "https://github.com/PHPOffice/PHPPresentation/tree/1.2.0"
},
"time": "2025-06-06T10:27:25+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "4.5.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2ea9786632e6fac1aee601b6e426bcc723d8ce13",
"reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/4.5.0"
},
"time": "2025-07-24T05:15:59+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -9224,6 +8687,85 @@
],
"time": "2026-03-29T15:46:14+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/xdebug-handler",
"version": "3.0.5",

View File

@@ -54,6 +54,18 @@ return [
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Frontend URL
|--------------------------------------------------------------------------
|
| Base URL of the frontend panel. Used to build redirect targets for flows
| that hand off to a browser mid-request, such as payment gateway callbacks.
|
*/
'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'),
/*
|--------------------------------------------------------------------------
| Application Timezone

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ai_providers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('driver')->default('openai');
$table->string('model');
$table->text('api_key');
$table->boolean('is_active')->default(true);
$table->boolean('is_default')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ai_providers');
}
};

View File

@@ -0,0 +1,22 @@
<?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('ai_providers', function (Blueprint $table) {
$table->string('base_url')->nullable()->after('model');
});
}
public function down(): void
{
Schema::table('ai_providers', function (Blueprint $table) {
$table->dropColumn('base_url');
});
}
};

View File

@@ -0,0 +1,29 @@
<?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::table('templates', function (Blueprint $table) {
$table->boolean('is_ai_enabled')->default(false)->after('is_active');
$table->unsignedInteger('ai_repeatable_slide_number')->nullable()->after('is_ai_enabled');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('templates', function (Blueprint $table) {
$table->dropColumn(['is_ai_enabled', 'ai_repeatable_slide_number']);
});
}
};

View File

@@ -0,0 +1,32 @@
<?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::table('template_generations', function (Blueprint $table) {
$table->string('source')->default('template')->after('template_id');
$table->string('title')->nullable()->after('source');
$table->string('language')->nullable()->after('title');
$table->unsignedInteger('slide_count')->nullable()->after('language');
$table->longText('source_text')->nullable()->after('slide_count');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('template_generations', function (Blueprint $table) {
$table->dropColumn(['source', 'title', 'language', 'slide_count', 'source_text']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?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::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('mobile');
$table->boolean('is_public')->default(false)->after('avatar_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['avatar_path', 'is_public']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('template_keys', function (Blueprint $table) {
$table->boolean('is_repeatable')->default(false)->after('is_required');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('template_keys', function (Blueprint $table) {
$table->dropColumn('is_repeatable');
});
}
};

View File

@@ -0,0 +1,35 @@
<?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('wallet_deposits', function (Blueprint $table) {
$table->id();
$table->foreignId('wallet_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('amount');
$table->string('status')->default('pending');
$table->string('gateway');
$table->string('reference_id')->nullable();
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['wallet_id', 'status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('wallet_deposits');
}
};

View File

@@ -0,0 +1,34 @@
<?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('notification_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->boolean('comment_on_presentation')->default(true);
$table->boolean('form_reply')->default(false);
$table->boolean('new_follower')->default(false);
$table->boolean('news_announcements')->default(false);
$table->boolean('weekly_product_updates')->default(true);
$table->boolean('weekly_blog_digest')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notification_preferences');
}
};

View File

@@ -0,0 +1,22 @@
<?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('templates', function (Blueprint $table): void {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('templates', function (Blueprint $table): void {
$table->dropSoftDeletes();
});
}
};

View File

@@ -0,0 +1,22 @@
<?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): void {
$table->json('social_links')->nullable()->after('is_public');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('social_links');
});
}
};

7
lang/fa/auth.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
return [
'failed' => 'اطلاعات ورود نادرست است.',
'password' => 'رمز عبور واردشده صحیح نیست.',
'throttle' => 'تلاش‌های زیادی برای ورود انجام شده است. لطفاً بعد از :seconds ثانیه دوباره تلاش کنید.',
];

6
lang/fa/pagination.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; قبلی',
'next' => 'بعدی &raquo;',
];

9
lang/fa/passwords.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
'reset' => 'رمز عبور شما بازنشانی شد.',
'sent' => 'لینک بازنشانی رمز عبور برای شما ایمیل شد.',
'throttled' => 'لطفاً کمی صبر کنید و دوباره تلاش کنید.',
'token' => 'این توکن بازنشانی رمز عبور نامعتبر است.',
'user' => 'کاربری با این نشانی ایمیل یافت نشد.',
];

192
lang/fa/validation.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
return [
'accepted' => 'فیلد :attribute باید پذیرفته شود.',
'accepted_if' => 'فیلد :attribute وقتی :other برابر :value است باید پذیرفته شود.',
'active_url' => 'فیلد :attribute باید یک نشانی اینترنتی معتبر باشد.',
'after' => 'فیلد :attribute باید تاریخی بعد از :date باشد.',
'after_or_equal' => 'فیلد :attribute باید تاریخی بعد یا برابر با :date باشد.',
'alpha' => 'فیلد :attribute باید فقط شامل حروف باشد.',
'alpha_dash' => 'فیلد :attribute باید فقط شامل حروف، اعداد، خط تیره و زیرخط باشد.',
'alpha_num' => 'فیلد :attribute باید فقط شامل حروف و اعداد باشد.',
'any_of' => 'فیلد :attribute نامعتبر است.',
'array' => 'فیلد :attribute باید آرایه باشد.',
'ascii' => 'فیلد :attribute باید فقط شامل حروف و نمادهای تک‌بایتی باشد.',
'before' => 'فیلد :attribute باید تاریخی قبل از :date باشد.',
'before_or_equal' => 'فیلد :attribute باید تاریخی قبل یا برابر با :date باشد.',
'between' => [
'array' => 'فیلد :attribute باید بین :min تا :max آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute باید بین :min تا :max کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید بین :min تا :max باشد.',
'string' => 'فیلد :attribute باید بین :min تا :max کاراکتر باشد.',
],
'boolean' => 'فیلد :attribute باید بله یا خیر باشد.',
'can' => 'فیلد :attribute شامل مقدار غیرمجاز است.',
'confirmed' => 'تکرار فیلد :attribute مطابقت ندارد.',
'contains' => 'فیلد :attribute یک مقدار الزامی را ندارد.',
'current_password' => 'رمز عبور صحیح نیست.',
'date' => 'فیلد :attribute باید یک تاریخ معتبر باشد.',
'date_equals' => 'فیلد :attribute باید تاریخی برابر با :date باشد.',
'date_format' => 'فیلد :attribute باید با قالب :format مطابقت داشته باشد.',
'decimal' => 'فیلد :attribute باید :decimal رقم اعشار داشته باشد.',
'declined' => 'فیلد :attribute باید رد شود.',
'declined_if' => 'فیلد :attribute وقتی :other برابر :value است باید رد شود.',
'different' => 'فیلد :attribute و :other باید متفاوت باشند.',
'digits' => 'فیلد :attribute باید :digits رقم باشد.',
'digits_between' => 'فیلد :attribute باید بین :min تا :max رقم باشد.',
'dimensions' => 'ابعاد تصویر فیلد :attribute نامعتبر است.',
'distinct' => 'فیلد :attribute دارای مقدار تکراری است.',
'doesnt_contain' => 'فیلد :attribute نباید شامل موارد زیر باشد: :values.',
'doesnt_end_with' => 'فیلد :attribute نباید با یکی از موارد زیر تمام شود: :values.',
'doesnt_start_with' => 'فیلد :attribute نباید با یکی از موارد زیر شروع شود: :values.',
'email' => 'فیلد :attribute باید یک نشانی ایمیل معتبر باشد.',
'encoding' => 'فیلد :attribute باید با :encoding کدگذاری شده باشد.',
'ends_with' => 'فیلد :attribute باید با یکی از موارد زیر تمام شود: :values.',
'enum' => 'مقدار انتخاب‌شده برای :attribute نامعتبر است.',
'exists' => 'مقدار انتخاب‌شده برای :attribute نامعتبر است.',
'extensions' => 'فیلد :attribute باید یکی از پسوندهای زیر را داشته باشد: :values.',
'file' => 'فیلد :attribute باید یک فایل باشد.',
'filled' => 'فیلد :attribute باید مقدار داشته باشد.',
'gt' => [
'array' => 'فیلد :attribute باید بیش از :value آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute باید بیشتر از :value کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید بزرگ‌تر از :value باشد.',
'string' => 'فیلد :attribute باید بیشتر از :value کاراکتر باشد.',
],
'gte' => [
'array' => 'فیلد :attribute باید :value آیتم یا بیشتر داشته باشد.',
'file' => 'حجم فیلد :attribute باید بزرگ‌تر یا مساوی :value کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید بزرگ‌تر یا مساوی :value باشد.',
'string' => 'فیلد :attribute باید بزرگ‌تر یا مساوی :value کاراکتر باشد.',
],
'hex_color' => 'فیلد :attribute باید یک رنگ هگزادسیمال معتبر باشد.',
'image' => 'فیلد :attribute باید یک تصویر باشد.',
'in' => 'مقدار انتخاب‌شده برای :attribute نامعتبر است.',
'in_array' => 'فیلد :attribute باید در :other وجود داشته باشد.',
'in_array_keys' => 'فیلد :attribute باید حداقل شامل یکی از کلیدهای زیر باشد: :values.',
'integer' => 'فیلد :attribute باید عدد صحیح باشد.',
'ip' => 'فیلد :attribute باید یک نشانی IP معتبر باشد.',
'ipv4' => 'فیلد :attribute باید یک نشانی IPv4 معتبر باشد.',
'ipv6' => 'فیلد :attribute باید یک نشانی IPv6 معتبر باشد.',
'json' => 'فیلد :attribute باید یک رشته JSON معتبر باشد.',
'list' => 'فیلد :attribute باید یک فهرست باشد.',
'lowercase' => 'فیلد :attribute باید با حروف کوچک باشد.',
'lt' => [
'array' => 'فیلد :attribute باید کمتر از :value آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute باید کمتر از :value کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید کوچک‌تر از :value باشد.',
'string' => 'فیلد :attribute باید کمتر از :value کاراکتر باشد.',
],
'lte' => [
'array' => 'فیلد :attribute نباید بیش از :value آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute باید کوچک‌تر یا مساوی :value کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید کوچک‌تر یا مساوی :value باشد.',
'string' => 'فیلد :attribute باید کوچک‌تر یا مساوی :value کاراکتر باشد.',
],
'mac_address' => 'فیلد :attribute باید یک نشانی MAC معتبر باشد.',
'max' => [
'array' => 'فیلد :attribute نباید بیش از :max آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute نباید بیشتر از :max کیلوبایت باشد.',
'numeric' => 'فیلد :attribute نباید بزرگ‌تر از :max باشد.',
'string' => 'فیلد :attribute نباید بیشتر از :max کاراکتر باشد.',
],
'max_digits' => 'فیلد :attribute نباید بیش از :max رقم داشته باشد.',
'mimes' => 'فیلد :attribute باید فایلی از نوع: :values باشد.',
'mimetypes' => 'فیلد :attribute باید فایلی از نوع: :values باشد.',
'min' => [
'array' => 'فیلد :attribute باید حداقل :min آیتم داشته باشد.',
'file' => 'حجم فیلد :attribute باید حداقل :min کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید حداقل :min باشد.',
'string' => 'فیلد :attribute باید حداقل :min کاراکتر باشد.',
],
'min_digits' => 'فیلد :attribute باید حداقل :min رقم داشته باشد.',
'missing' => 'فیلد :attribute نباید وجود داشته باشد.',
'missing_if' => 'فیلد :attribute وقتی :other برابر :value است نباید وجود داشته باشد.',
'missing_unless' => 'فیلد :attribute مگر اینکه :other برابر :value باشد نباید وجود داشته باشد.',
'missing_with' => 'فیلد :attribute وقتی :values وجود دارد نباید وجود داشته باشد.',
'missing_with_all' => 'فیلد :attribute وقتی :values وجود دارند نباید وجود داشته باشد.',
'multiple_of' => 'فیلد :attribute باید مضربی از :value باشد.',
'not_in' => 'مقدار انتخاب‌شده برای :attribute نامعتبر است.',
'not_regex' => 'قالب فیلد :attribute نامعتبر است.',
'numeric' => 'فیلد :attribute باید عدد باشد.',
'password' => [
'letters' => 'فیلد :attribute باید حداقل شامل یک حرف باشد.',
'mixed' => 'فیلد :attribute باید حداقل شامل یک حرف بزرگ و یک حرف کوچک باشد.',
'numbers' => 'فیلد :attribute باید حداقل شامل یک عدد باشد.',
'symbols' => 'فیلد :attribute باید حداقل شامل یک نماد باشد.',
'uncompromised' => 'فیلد :attribute در نشت اطلاعات دیده شده است. لطفاً مقدار دیگری انتخاب کنید.',
],
'present' => 'فیلد :attribute باید موجود باشد.',
'present_if' => 'فیلد :attribute وقتی :other برابر :value است باید موجود باشد.',
'present_unless' => 'فیلد :attribute مگر اینکه :other برابر :value باشد باید موجود باشد.',
'present_with' => 'فیلد :attribute وقتی :values موجود است باید موجود باشد.',
'present_with_all' => 'فیلد :attribute وقتی :values موجود هستند باید موجود باشد.',
'prohibited' => 'فیلد :attribute ممنوع است.',
'prohibited_if' => 'فیلد :attribute وقتی :other برابر :value است ممنوع است.',
'prohibited_if_accepted' => 'فیلد :attribute وقتی :other پذیرفته شده است ممنوع است.',
'prohibited_if_declined' => 'فیلد :attribute وقتی :other رد شده است ممنوع است.',
'prohibited_unless' => 'فیلد :attribute مگر اینکه :other در :values باشد ممنوع است.',
'prohibits' => 'فیلد :attribute از موجود بودن :other جلوگیری می‌کند.',
'regex' => 'قالب فیلد :attribute نامعتبر است.',
'required' => 'فیلد :attribute الزامی است.',
'required_array_keys' => 'فیلد :attribute باید شامل مقادیری برای: :values باشد.',
'required_if' => 'فیلد :attribute وقتی :other برابر :value است الزامی است.',
'required_if_accepted' => 'فیلد :attribute وقتی :other پذیرفته شده است الزامی است.',
'required_if_declined' => 'فیلد :attribute وقتی :other رد شده است الزامی است.',
'required_unless' => 'فیلد :attribute مگر اینکه :other در :values باشد الزامی است.',
'required_with' => 'فیلد :attribute وقتی :values موجود است الزامی است.',
'required_with_all' => 'فیلد :attribute وقتی :values موجود هستند الزامی است.',
'required_without' => 'فیلد :attribute وقتی :values موجود نیست الزامی است.',
'required_without_all' => 'فیلد :attribute وقتی هیچ‌کدام از :values موجود نیستند الزامی است.',
'same' => 'فیلد :attribute باید با :other مطابقت داشته باشد.',
'size' => [
'array' => 'فیلد :attribute باید شامل :size آیتم باشد.',
'file' => 'حجم فیلد :attribute باید :size کیلوبایت باشد.',
'numeric' => 'فیلد :attribute باید برابر :size باشد.',
'string' => 'فیلد :attribute باید :size کاراکتر باشد.',
],
'starts_with' => 'فیلد :attribute باید با یکی از موارد زیر شروع شود: :values.',
'string' => 'فیلد :attribute باید رشته باشد.',
'timezone' => 'فیلد :attribute باید یک منطقه زمانی معتبر باشد.',
'unique' => 'فیلد :attribute قبلاً استفاده شده است.',
'uploaded' => 'بارگذاری فیلد :attribute ناموفق بود.',
'uppercase' => 'فیلد :attribute باید با حروف بزرگ باشد.',
'url' => 'فیلد :attribute باید یک نشانی اینترنتی معتبر باشد.',
'ulid' => 'فیلد :attribute باید یک ULID معتبر باشد.',
'uuid' => 'فیلد :attribute باید یک UUID معتبر باشد.',
'custom' => [
'mobile' => [
'regex' => 'فرمت شماره موبایل صحیح نیست. مثال درست: 09123456789',
],
],
'attributes' => [
'name' => 'نام',
'first_name' => 'نام',
'last_name' => 'نام خانوادگی',
'username' => 'نام کاربری',
'email' => 'ایمیل',
'mobile' => 'شماره موبایل',
'phone' => 'شماره تماس',
'password' => 'رمز عبور',
'password_confirmation' => 'تکرار رمز عبور',
'current_password' => 'رمز عبور فعلی',
'title' => 'عنوان',
'slug' => 'شناسه',
'description' => 'توضیحات',
'category_id' => 'دسته‌بندی',
'amount' => 'مبلغ',
'avatar' => 'تصویر پروفایل',
'is_public' => 'نمایش عمومی پروفایل',
'social_links' => 'شبکه‌های اجتماعی',
'language' => 'زبان',
'slide_count' => 'تعداد اسلایدها',
'text' => 'متن',
'roles' => 'نقش‌ها',
'file_path' => 'فایل پاورپوینت',
'thumbnail_path' => 'تصویر بندانگشتی',
],
];

View File

@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\ProfileController;
use App\Http\Controllers\Api\TemplateController;
use App\Http\Controllers\Api\TemplateGenerationController;
use App\Http\Controllers\Api\WalletController;
use App\Http\Controllers\Api\WalletDepositController;
use Illuminate\Support\Facades\Route;
Route::prefix('auth')->group(function (): void {
@@ -22,24 +23,33 @@ Route::prefix('templates')->group(function (): void {
Route::middleware('auth:sanctum')->group(function (): void {
Route::post('{template:slug}/generate', [TemplateGenerationController::class, 'store']);
Route::post('{template:slug}/generate-from-text', [TemplateGenerationController::class, 'storeFromText']);
});
});
Route::get('wallet/deposits/{deposit}/callback', [WalletDepositController::class, 'callback'])->name('api.wallet.deposits.callback');
Route::middleware('auth:sanctum')->group(function (): void {
Route::get('profile', [ProfileController::class, 'show']);
Route::put('profile', [ProfileController::class, 'update']);
Route::put('profile/password', [ProfileController::class, 'changePassword']);
Route::delete('profile', [ProfileController::class, 'destroy']);
Route::prefix('wallet')->group(function (): void {
Route::get('/', [WalletController::class, 'show']);
Route::get('transactions', [WalletController::class, 'transactions']);
Route::post('deposits', [WalletDepositController::class, 'store']);
});
Route::prefix('notifications')->group(function (): void {
Route::get('/', [NotificationController::class, 'index']);
Route::post('read-all', [NotificationController::class, 'markAllAsRead']);
Route::get('preferences', [NotificationController::class, 'preferences']);
Route::put('preferences', [NotificationController::class, 'updatePreferences']);
});
Route::prefix('generations')->group(function (): void {
Route::get('/', [TemplateGenerationController::class, 'index']);
Route::get('{generation:uuid}', [TemplateGenerationController::class, 'show']);
Route::get('{generation:uuid}/download', [TemplateGenerationController::class, 'download'])->name('api.generations.download');
});

View File

@@ -1,7 +1,11 @@
<?php
use App\Http\Controllers\Admin\GenerationDownloadController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware('auth')->get('admin/generations/{generation:uuid}/download', GenerationDownloadController::class)
->name('admin.generations.download');

View File

@@ -0,0 +1,48 @@
<?php
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Models\Role;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel(Filament::getDefaultPanel());
$admin = User::factory()->create();
$role = Role::query()->firstOrCreate(['slug' => 'admin'], ['name' => 'مدیر']);
$admin->roles()->sync([$role->id]);
actingAs($admin);
});
it('opens the create user form without the unresolvable-closure error', function (): void {
Livewire\Livewire::test(CreateUser::class)
->assertOk();
});
it('accepts a +98 mobile and normalizes it, rejects a malformed one', function (): void {
Livewire\Livewire::test(CreateUser::class)
->fillForm([
'name' => 'کاربر تست',
'email' => 'ok@example.com',
'mobile' => '+98 912 345 6789',
'password' => 'password12',
])
->call('create')
->assertHasNoFormErrors();
expect(User::query()->where('email', 'ok@example.com')->value('mobile'))->toBe('09123456789');
Livewire\Livewire::test(CreateUser::class)
->fillForm([
'name' => 'کاربر بد',
'email' => 'bad@example.com',
'mobile' => '123',
'password' => 'password12',
])
->call('create')
->assertHasFormErrors(['mobile']);
});