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
This commit was merged in pull request #1.
This commit is contained in:
2026-09-08 20:10:50 +00:00
38 changed files with 756 additions and 39 deletions

View File

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

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

@@ -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(),
]);
}
}

View File

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

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

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

View File

@@ -46,7 +46,9 @@ class TemplateForm
->label('تصویر بندانگشتی')
->disk('public')
->directory('templates/thumbnails')
->image(),
->visibility('public')
->image()
->imageEditor(),
Toggle::make('is_active')
->label('فعال')
->default(true),

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

@@ -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(),
]),
]);
}

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

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),
])
@@ -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();
}
});

View File

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

View File

@@ -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();

View File

@@ -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()));
}
}

View File

@@ -6,23 +6,57 @@ 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 [
'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' => 'مقدار این تنظیم باید بله یا خیر باشد.',
];
}
}

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,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,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' => 'ساختار شبکه‌های اجتماعی نامعتبر است.',
];
}
}

View File

@@ -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(),
];
}

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',
@@ -21,6 +22,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
])]
class Template extends Model
{
use SoftDeletes;
/**
* @return BelongsTo<Category, $this>
*/

View File

@@ -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<string, string>
*/

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', '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',
];
}
}

View File

@@ -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));
});
}
}

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

@@ -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' => 'تصویر بندانگشتی',
],
];