From 936f789ac8aa3f52f7ebd6b01132c587a10d422d Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Mon, 7 Sep 2026 01:00:09 +0330 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013P66ea1dC5CVM9t29496t5 --- .../AiProviders/Tables/AiProvidersTable.php | 2 +- .../Categories/Tables/CategoriesTable.php | 2 +- .../Tables/NotificationsTable.php | 2 +- .../Permissions/Tables/PermissionsTable.php | 2 +- .../Resources/Roles/Tables/RolesTable.php | 2 +- .../Schemas/TemplateGenerationInfolist.php | 6 +- .../Tables/TemplateGenerationsTable.php | 11 +- .../Templates/Pages/EditTemplate.php | 4 + .../GenerationsRelationManager.php | 9 +- .../Templates/Schemas/TemplateForm.php | 4 +- .../Templates/Schemas/TemplateInfolist.php | 58 ++++++ .../Templates/Tables/TemplatesTable.php | 15 +- .../Resources/Templates/TemplateResource.php | 14 ++ .../Resources/Users/Schemas/UserForm.php | 8 + .../Resources/Users/Tables/UsersTable.php | 2 +- .../Tables/WalletTransactionsTable.php | 2 +- .../TransactionsRelationManager.php | 2 +- .../Resources/Wallets/Tables/WalletsTable.php | 20 +- .../Admin/GenerationDownloadController.php | 2 +- .../Controllers/Api/ProfileController.php | 19 +- .../Api/TemplateGenerationController.php | 11 +- .../UpdateNotificationPreferencesRequest.php | 46 ++++- app/Http/Requests/Auth/RegisterRequest.php | 12 +- .../Requests/Profile/DeleteAccountRequest.php | 34 ++++ .../Requests/Profile/UpdateProfileRequest.php | 19 +- app/Http/Resources/UserResource.php | 2 + app/Models/Template.php | 3 + app/Models/TemplateGeneration.php | 29 ++- app/Models/User.php | 3 +- app/Providers/AppServiceProvider.php | 32 +++ app/Support/Jalali.php | 119 +++++++++++ app/Support/Mobile.php | 41 ++++ ...00_add_soft_deletes_to_templates_table.php | 22 ++ ...000001_add_social_links_to_users_table.php | 22 ++ lang/fa/auth.php | 7 + lang/fa/pagination.php | 6 + lang/fa/passwords.php | 9 + lang/fa/validation.php | 192 ++++++++++++++++++ 38 files changed, 756 insertions(+), 39 deletions(-) create mode 100644 app/Filament/Resources/Templates/Schemas/TemplateInfolist.php create mode 100644 app/Http/Requests/Profile/DeleteAccountRequest.php create mode 100644 app/Support/Jalali.php create mode 100644 app/Support/Mobile.php create mode 100644 database/migrations/2026_09_07_000000_add_soft_deletes_to_templates_table.php create mode 100644 database/migrations/2026_09_07_000001_add_social_links_to_users_table.php create mode 100644 lang/fa/auth.php create mode 100644 lang/fa/pagination.php create mode 100644 lang/fa/passwords.php create mode 100644 lang/fa/validation.php diff --git a/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php b/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php index bd1ff76..7b628d7 100644 --- a/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php +++ b/app/Filament/Resources/AiProviders/Tables/AiProvidersTable.php @@ -40,7 +40,7 @@ class AiProvidersTable ->boolean(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php index 673953d..52450ab 100644 --- a/app/Filament/Resources/Categories/Tables/CategoriesTable.php +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -32,7 +32,7 @@ class CategoriesTable ->boolean(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) diff --git a/app/Filament/Resources/Notifications/Tables/NotificationsTable.php b/app/Filament/Resources/Notifications/Tables/NotificationsTable.php index eeab885..4fc4c76 100644 --- a/app/Filament/Resources/Notifications/Tables/NotificationsTable.php +++ b/app/Filament/Resources/Notifications/Tables/NotificationsTable.php @@ -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), ]) diff --git a/app/Filament/Resources/Permissions/Tables/PermissionsTable.php b/app/Filament/Resources/Permissions/Tables/PermissionsTable.php index ef9216f..0775e1a 100644 --- a/app/Filament/Resources/Permissions/Tables/PermissionsTable.php +++ b/app/Filament/Resources/Permissions/Tables/PermissionsTable.php @@ -28,7 +28,7 @@ class PermissionsTable ->searchable(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) diff --git a/app/Filament/Resources/Roles/Tables/RolesTable.php b/app/Filament/Resources/Roles/Tables/RolesTable.php index 14f39e2..cee1fbb 100644 --- a/app/Filament/Resources/Roles/Tables/RolesTable.php +++ b/app/Filament/Resources/Roles/Tables/RolesTable.php @@ -28,7 +28,7 @@ class RolesTable ->searchable(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) diff --git a/app/Filament/Resources/TemplateGenerations/Schemas/TemplateGenerationInfolist.php b/app/Filament/Resources/TemplateGenerations/Schemas/TemplateGenerationInfolist.php index c75a093..a187440 100644 --- a/app/Filament/Resources/TemplateGenerations/Schemas/TemplateGenerationInfolist.php +++ b/app/Filament/Resources/TemplateGenerations/Schemas/TemplateGenerationInfolist.php @@ -13,6 +13,10 @@ class TemplateGenerationInfolist { return $schema ->components([ + TextEntry::make('title') + ->label('نام فایل') + ->placeholder('—') + ->columnSpanFull(), TextEntry::make('uuid') ->label('شناسه') ->copyable(), @@ -42,7 +46,7 @@ class TemplateGenerationInfolist ->columnSpanFull(), TextEntry::make('created_at') ->label('تاریخ ایجاد') - ->dateTime(), + ->jalaliDateTime(), ]); } } diff --git a/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php b/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php index c0d46f7..6cb4697 100644 --- a/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php +++ b/app/Filament/Resources/TemplateGenerations/Tables/TemplateGenerationsTable.php @@ -16,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() @@ -44,7 +51,7 @@ class TemplateGenerationsTable ->placeholder('—'), TextColumn::make('created_at') ->label('تاریخ') - ->dateTime() + ->jalaliDateTime() ->sortable(), ]) ->defaultSort('created_at', 'desc') diff --git a/app/Filament/Resources/Templates/Pages/EditTemplate.php b/app/Filament/Resources/Templates/Pages/EditTemplate.php index cf52dcd..fc5ce4b 100644 --- a/app/Filament/Resources/Templates/Pages/EditTemplate.php +++ b/app/Filament/Resources/Templates/Pages/EditTemplate.php @@ -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(), ]; } } diff --git a/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php b/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php index 06c6eb1..299bed1 100644 --- a/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php +++ b/app/Filament/Resources/Templates/RelationManagers/GenerationsRelationManager.php @@ -25,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() @@ -48,7 +53,7 @@ class GenerationsRelationManager extends RelationManager ->placeholder('—'), TextColumn::make('created_at') ->label('تاریخ') - ->dateTime() + ->jalaliDateTime() ->sortable(), ]) ->defaultSort('created_at', 'desc') diff --git a/app/Filament/Resources/Templates/Schemas/TemplateForm.php b/app/Filament/Resources/Templates/Schemas/TemplateForm.php index a44a187..9713faf 100644 --- a/app/Filament/Resources/Templates/Schemas/TemplateForm.php +++ b/app/Filament/Resources/Templates/Schemas/TemplateForm.php @@ -46,7 +46,9 @@ class TemplateForm ->label('تصویر بندانگشتی') ->disk('public') ->directory('templates/thumbnails') - ->image(), + ->visibility('public') + ->image() + ->imageEditor(), Toggle::make('is_active') ->label('فعال') ->default(true), diff --git a/app/Filament/Resources/Templates/Schemas/TemplateInfolist.php b/app/Filament/Resources/Templates/Schemas/TemplateInfolist.php new file mode 100644 index 0000000..e287520 --- /dev/null +++ b/app/Filament/Resources/Templates/Schemas/TemplateInfolist.php @@ -0,0 +1,58 @@ +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(), + ]); + } +} diff --git a/app/Filament/Resources/Templates/Tables/TemplatesTable.php b/app/Filament/Resources/Templates/Tables/TemplatesTable.php index 1500f87..af7b7c9 100644 --- a/app/Filament/Resources/Templates/Tables/TemplatesTable.php +++ b/app/Filament/Resources/Templates/Tables/TemplatesTable.php @@ -4,13 +4,19 @@ 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 @@ -35,7 +41,7 @@ class TemplatesTable ->boolean(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) @@ -48,15 +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(), ]), ]); } diff --git a/app/Filament/Resources/Templates/TemplateResource.php b/app/Filament/Resources/Templates/TemplateResource.php index 45d4176..af99b6a 100644 --- a/app/Filament/Resources/Templates/TemplateResource.php +++ b/app/Filament/Resources/Templates/TemplateResource.php @@ -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 [ diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php index 2aec1b4..d3c40d0 100644 --- a/app/Filament/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -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,13 @@ class UserForm TextInput::make('mobile') ->label('شماره موبایل') ->tel() + ->helperText('مثال درست: 09123456789') + ->dehydrateStateUsing(fn (?string $state): ?string => Mobile::normalize($state)) + ->rules([static 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') diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index 6c7ccef..439f46c 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -33,7 +33,7 @@ class UsersTable ->searchable(), TextColumn::make('created_at') ->label('تاریخ ایجاد') - ->dateTime() + ->jalaliDateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) diff --git a/app/Filament/Resources/WalletTransactions/Tables/WalletTransactionsTable.php b/app/Filament/Resources/WalletTransactions/Tables/WalletTransactionsTable.php index 945b88f..32adf81 100644 --- a/app/Filament/Resources/WalletTransactions/Tables/WalletTransactionsTable.php +++ b/app/Filament/Resources/WalletTransactions/Tables/WalletTransactionsTable.php @@ -46,7 +46,7 @@ class WalletTransactionsTable ->placeholder('سیستم'), TextColumn::make('created_at') ->label('تاریخ') - ->dateTime() + ->jalaliDateTime() ->sortable(), ]) ->defaultSort('created_at', 'desc') diff --git a/app/Filament/Resources/Wallets/RelationManagers/TransactionsRelationManager.php b/app/Filament/Resources/Wallets/RelationManagers/TransactionsRelationManager.php index a051916..ff37f5f 100644 --- a/app/Filament/Resources/Wallets/RelationManagers/TransactionsRelationManager.php +++ b/app/Filament/Resources/Wallets/RelationManagers/TransactionsRelationManager.php @@ -44,7 +44,7 @@ class TransactionsRelationManager extends RelationManager ->placeholder('سیستم'), TextColumn::make('created_at') ->label('تاریخ') - ->dateTime() + ->jalaliDateTime() ->sortable(), ]) ->defaultSort('created_at', 'desc') diff --git a/app/Filament/Resources/Wallets/Tables/WalletsTable.php b/app/Filament/Resources/Wallets/Tables/WalletsTable.php index 4d0eb1c..4d2ae30 100644 --- a/app/Filament/Resources/Wallets/Tables/WalletsTable.php +++ b/app/Filament/Resources/Wallets/Tables/WalletsTable.php @@ -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), ]) @@ -84,21 +84,27 @@ class WalletsTable ->rows(2), ]) ->action(function (Wallet $record, array $data, WalletService $walletService): 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(); } }); diff --git a/app/Http/Controllers/Admin/GenerationDownloadController.php b/app/Http/Controllers/Admin/GenerationDownloadController.php index 238dbab..47596f9 100644 --- a/app/Http/Controllers/Admin/GenerationDownloadController.php +++ b/app/Http/Controllers/Admin/GenerationDownloadController.php @@ -18,6 +18,6 @@ class GenerationDownloadController extends Controller 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->template->slug.'.pptx'); + return Storage::disk('local')->download($generation->output_path, $generation->downloadFilename().'.pptx'); } } diff --git a/app/Http/Controllers/Api/ProfileController.php b/app/Http/Controllers/Api/ProfileController.php index 0e0afa2..1349b23 100644 --- a/app/Http/Controllers/Api/ProfileController.php +++ b/app/Http/Controllers/Api/ProfileController.php @@ -4,11 +4,11 @@ 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\Http\Request; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; @@ -158,10 +158,19 @@ class ProfileController extends Controller #[OA\Delete( path: '/profile', - description: 'Delete the authenticated user account', + 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, @@ -176,10 +185,14 @@ class ProfileController extends Controller new OA\Response(response: 401, description: 'Unauthenticated'), ] )] - public function destroy(Request $request): JsonResponse + 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(); diff --git a/app/Http/Controllers/Api/TemplateGenerationController.php b/app/Http/Controllers/Api/TemplateGenerationController.php index 9b0ca6b..dfd7f5b 100644 --- a/app/Http/Controllers/Api/TemplateGenerationController.php +++ b/app/Http/Controllers/Api/TemplateGenerationController.php @@ -12,6 +12,7 @@ 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; @@ -80,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, @@ -264,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())); } } diff --git a/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php b/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php index 87a5f72..0ed2a70 100644 --- a/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php +++ b/app/Http/Requests/Api/UpdateNotificationPreferencesRequest.php @@ -6,23 +6,57 @@ use Illuminate\Foundation\Http\FormRequest; class UpdateNotificationPreferencesRequest extends FormRequest { + /** + * @var list + */ + 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 */ public function rules(): array + { + return array_fill_keys(self::FIELDS, ['sometimes', 'boolean']); + } + + /** + * @return array + */ + public function messages(): array { return [ - 'comment_on_presentation' => ['sometimes', 'boolean'], - 'form_reply' => ['sometimes', 'boolean'], - 'new_follower' => ['sometimes', 'boolean'], - 'news_announcements' => ['sometimes', 'boolean'], - 'weekly_product_updates' => ['sometimes', 'boolean'], - 'weekly_blog_digest' => ['sometimes', 'boolean'], + '*.boolean' => 'مقدار این تنظیم باید بله یا خیر باشد.', ]; } } diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index f808e55..c1baee0 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -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 */ @@ -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' => 'فرمت ایمیل صحیح نیست.', diff --git a/app/Http/Requests/Profile/DeleteAccountRequest.php b/app/Http/Requests/Profile/DeleteAccountRequest.php new file mode 100644 index 0000000..eed7fc4 --- /dev/null +++ b/app/Http/Requests/Profile/DeleteAccountRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules(): array + { + return [ + 'password' => ['required', 'string', 'current_password'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'password.required' => 'برای حذف حساب کاربری، وارد کردن رمز عبور الزامی است.', + 'password.current_password' => 'رمز عبور واردشده صحیح نیست.', + ]; + } +} diff --git a/app/Http/Requests/Profile/UpdateProfileRequest.php b/app/Http/Requests/Profile/UpdateProfileRequest.php index 02e5608..c481245 100644 --- a/app/Http/Requests/Profile/UpdateProfileRequest.php +++ b/app/Http/Requests/Profile/UpdateProfileRequest.php @@ -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 */ @@ -23,10 +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'], ]; } @@ -43,12 +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' => 'ساختار شبکه‌های اجتماعی نامعتبر است.', ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 126be0f..ea8031d 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -18,6 +18,7 @@ use OpenApi\Attributes as OA; 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'), ] )] @@ -37,6 +38,7 @@ class UserResource extends JsonResource '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(), ]; } diff --git a/app/Models/Template.php b/app/Models/Template.php index da6352a..7715c88 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -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', @@ -21,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; ])] class Template extends Model { + use SoftDeletes; + /** * @return BelongsTo */ diff --git a/app/Models/TemplateGeneration.php b/app/Models/TemplateGeneration.php index e8982b7..2649829 100644 --- a/app/Models/TemplateGeneration.php +++ b/app/Models/TemplateGeneration.php @@ -29,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(); } /** @@ -40,6 +42,31 @@ 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 */ diff --git a/app/Models/User.php b/app/Models/User.php index 064c586..588f491 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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', 'avatar_path', 'is_public'])] +#[Fillable(['name', 'username', 'email', 'mobile', 'password', 'avatar_path', 'is_public', 'social_links'])] #[Hidden(['password', 'remember_token'])] class User extends Authenticatable implements FilamentUser { @@ -104,6 +104,7 @@ class User extends Authenticatable implements FilamentUser 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_public' => 'boolean', + 'social_links' => 'array', ]; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 266f2d9..87dce03 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,9 @@ 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; @@ -41,6 +44,8 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + $this->registerJalaliMacros(); + User::observe(UserObserver::class); Gate::policy(User::class, UserPolicy::class); @@ -50,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)); + }); + } } diff --git a/app/Support/Jalali.php b/app/Support/Jalali.php new file mode 100644 index 0000000..6e4ca3e --- /dev/null +++ b/app/Support/Jalali.php @@ -0,0 +1,119 @@ + 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' => '۹', + ]); + } +} diff --git a/app/Support/Mobile.php b/app/Support/Mobile.php new file mode 100644 index 0000000..3ea54c1 --- /dev/null +++ b/app/Support/Mobile.php @@ -0,0 +1,41 @@ + '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; + } +} diff --git a/database/migrations/2026_09_07_000000_add_soft_deletes_to_templates_table.php b/database/migrations/2026_09_07_000000_add_soft_deletes_to_templates_table.php new file mode 100644 index 0000000..b057497 --- /dev/null +++ b/database/migrations/2026_09_07_000000_add_soft_deletes_to_templates_table.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('templates', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_09_07_000001_add_social_links_to_users_table.php b/database/migrations/2026_09_07_000001_add_social_links_to_users_table.php new file mode 100644 index 0000000..14634dd --- /dev/null +++ b/database/migrations/2026_09_07_000001_add_social_links_to_users_table.php @@ -0,0 +1,22 @@ +json('social_links')->nullable()->after('is_public'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('social_links'); + }); + } +}; diff --git a/lang/fa/auth.php b/lang/fa/auth.php new file mode 100644 index 0000000..c439fc5 --- /dev/null +++ b/lang/fa/auth.php @@ -0,0 +1,7 @@ + 'اطلاعات ورود نادرست است.', + 'password' => 'رمز عبور واردشده صحیح نیست.', + 'throttle' => 'تلاش‌های زیادی برای ورود انجام شده است. لطفاً بعد از :seconds ثانیه دوباره تلاش کنید.', +]; diff --git a/lang/fa/pagination.php b/lang/fa/pagination.php new file mode 100644 index 0000000..c406ab0 --- /dev/null +++ b/lang/fa/pagination.php @@ -0,0 +1,6 @@ + '« قبلی', + 'next' => 'بعدی »', +]; diff --git a/lang/fa/passwords.php b/lang/fa/passwords.php new file mode 100644 index 0000000..bedc147 --- /dev/null +++ b/lang/fa/passwords.php @@ -0,0 +1,9 @@ + 'رمز عبور شما بازنشانی شد.', + 'sent' => 'لینک بازنشانی رمز عبور برای شما ایمیل شد.', + 'throttled' => 'لطفاً کمی صبر کنید و دوباره تلاش کنید.', + 'token' => 'این توکن بازنشانی رمز عبور نامعتبر است.', + 'user' => 'کاربری با این نشانی ایمیل یافت نشد.', +]; diff --git a/lang/fa/validation.php b/lang/fa/validation.php new file mode 100644 index 0000000..a5f4b6e --- /dev/null +++ b/lang/fa/validation.php @@ -0,0 +1,192 @@ + 'فیلد :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' => 'تصویر بندانگشتی', + ], + +]; -- 2.49.1