Compare commits

..

10 Commits

99 changed files with 4598 additions and 89 deletions

View File

@@ -6,7 +6,7 @@ APP_URL=http://localhost
SMS_API_URL=https://sms.daragt.com/api/sms
APP_LOCALE=en
APP_LOCALE=tk
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -69,3 +69,4 @@ VITE_APP_NAME="${APP_NAME}"
SMS_API_URL=http://216.250.14.144:3000/api/data
SMS_API_TIMEOUT=10
SMS_API_CONNECT_TIMEOUT=5
SMS_BULK_DELAY_MS=300

View File

@@ -1,58 +1 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
# App

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum SmsSendStatus: string
{
case Pending = 'pending';
case Sent = 'sent';
case Failed = 'failed';
case SkippedInvalid = 'skipped_invalid';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class CouponPoolExhaustedException extends RuntimeException
{
public const MESSAGE = 'Aksiýa tamamlandy. Gyzyklanmagyňyz üçin sag boluň.';
public function __construct()
{
parent::__construct(self::MESSAGE);
}
}

View File

@@ -0,0 +1,273 @@
<?php
namespace App\Filament\Pages;
use App\Models\Coupon;
use App\Services\SmsBroadcastService;
use App\Services\SmsMessageAnalyzer;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/**
* @property-read Schema $form
*/
class SendSms extends Page
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
protected static ?string $navigationLabel = null;
protected static ?string $title = null;
protected static ?int $navigationSort = 2;
protected string $view = 'filament.pages.send-sms';
/**
* @var array<string, mixed>|null
*/
public ?array $data = [];
public static function getNavigationLabel(): string
{
return __('filament.send_sms.navigation');
}
public function getTitle(): string
{
return __('filament.send_sms.title');
}
public function mount(): void
{
$this->form->fill([
'message' => '',
'send_to_all' => true,
'coupon_ids' => [],
'excluded_coupon_ids' => [],
]);
}
public function defaultForm(Schema $schema): Schema
{
return $schema
->statePath('data')
->columns(1);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('filament.send_sms.sections.message'))
->schema([
Textarea::make('message')
->label(__('filament.send_sms.fields.message'))
->required()
->rows(8)
->maxLength(1000)
->live(debounce: 300)
->helperText(fn (Get $get): string => app(SmsMessageAnalyzer::class)->summary((string) ($get('message') ?? ''))),
]),
Section::make(__('filament.send_sms.sections.recipients'))
->description(__('filament.send_sms.sections.recipients_description'))
->schema([
Checkbox::make('send_to_all')
->label(__('filament.send_sms.fields.send_to_all'))
->helperText(__('filament.send_sms.fields.send_to_all_helper'))
->live(),
Select::make('coupon_ids')
->label(__('filament.send_sms.fields.coupon_ids'))
->multiple()
->searchable()
->disabled(fn (Get $get): bool => (bool) $get('send_to_all'))
->dehydrated(fn (Get $get): bool => ! (bool) $get('send_to_all'))
->helperText(__('filament.send_sms.fields.coupon_ids_helper'))
->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search))
->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)),
Select::make('excluded_coupon_ids')
->label(__('filament.send_sms.fields.excluded_coupon_ids'))
->multiple()
->searchable()
->helperText(__('filament.send_sms.fields.excluded_coupon_ids_helper'))
->getSearchResultsUsing(fn (string $search): array => $this->searchCoupons($search))
->getOptionLabelsUsing(fn (array $values): array => $this->couponLabels($values)),
]),
]);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
Form::make([EmbeddedSchema::make('form')])
->id('send-sms-form')
->livewireSubmitHandler('sendCampaign')
->footer([
Actions::make([
Action::make('send')
->label(__('filament.send_sms.actions.send'))
->icon(Heroicon::OutlinedPaperAirplane)
->requiresConfirmation()
->modalHeading(__('filament.send_sms.actions.send_confirm_heading'))
->modalDescription(fn (): string => $this->confirmationDescription())
->modalSubmitActionLabel(__('filament.send_sms.actions.send_confirm_submit'))
->action('sendCampaign'),
]),
]),
]);
}
public function sendCampaign(): void
{
$data = $this->form->getState();
$validator = Validator::make($data, [
'message' => ['required', 'string', 'max:1000'],
'send_to_all' => ['required', 'boolean'],
'coupon_ids' => [
Rule::requiredIf(fn (): bool => ! ($data['send_to_all'] ?? false)),
'array',
],
'coupon_ids.*' => ['integer', 'exists:coupons,id'],
'excluded_coupon_ids' => ['nullable', 'array'],
'excluded_coupon_ids.*' => ['integer', 'exists:coupons,id'],
]);
$validator->validate();
$sendToAll = (bool) $data['send_to_all'];
$selectedIds = array_map('intval', $data['coupon_ids'] ?? []);
$excludedIds = array_map('intval', $data['excluded_coupon_ids'] ?? []);
$service = app(SmsBroadcastService::class);
$recipients = $service->resolveRecipients($sendToAll, $selectedIds, $excludedIds)
->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone));
$willReceive = $recipients->count();
if ($willReceive === 0) {
Notification::make()
->title(__('filament.send_sms.notifications.no_valid_recipients'))
->body(__('filament.send_sms.notifications.no_valid_recipients_body'))
->danger()
->send();
return;
}
$messages = $recipients->map(fn (Coupon $coupon): array => [
'phone' => '+993'.$coupon->phone,
'message' => $data['message'],
])->values()->all();
$success = sendBulkSMS($messages);
if ($success) {
Notification::make()
->title(__('filament.send_sms.notifications.submitted'))
->body(__('filament.send_sms.notifications.submitted_body', ['count' => $willReceive]))
->success()
->send();
} else {
Notification::make()
->title(__('filament.send_sms.notifications.failed'))
->body(__('filament.send_sms.notifications.failed_body'))
->danger()
->send();
}
}
protected function confirmationDescription(): string
{
$message = (string) ($this->data['message'] ?? '');
$analyzer = app(SmsMessageAnalyzer::class);
$willReceive = $this->getWillReceiveCount();
return __('filament.send_sms.confirmation', [
'count' => $willReceive,
'summary' => $analyzer->summary($message),
]);
}
public function getWillReceiveCount(): int
{
$data = $this->data ?? [];
return app(SmsBroadcastService::class)
->resolveRecipients(
(bool) ($data['send_to_all'] ?? false),
array_map('intval', $data['coupon_ids'] ?? []),
array_map('intval', $data['excluded_coupon_ids'] ?? []),
)
->filter(fn (Coupon $coupon): bool => is_valid_coupon_phone($coupon->phone))
->count();
}
/**
* @return array<int, string>
*/
protected function searchCoupons(string $search): array
{
$search = trim($search);
if ($search === '') {
return [];
}
return Coupon::query()
->where(function ($query) use ($search): void {
$query->where('phone', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
if (ctype_digit($search)) {
$query->orWhere('id', (int) $search);
}
})
->orderBy('id')
->limit(50)
->get()
->mapWithKeys(fn (Coupon $coupon): array => [
$coupon->id => $this->couponLabel($coupon),
])
->all();
}
/**
* @param array<int, int|string> $values
* @return array<int, string>
*/
protected function couponLabels(array $values): array
{
if ($values === []) {
return [];
}
return Coupon::query()
->whereIn('id', $values)
->get()
->mapWithKeys(fn (Coupon $coupon): array => [
$coupon->id => $this->couponLabel($coupon),
])
->all();
}
protected function couponLabel(Coupon $coupon): string
{
return format_phone($coupon->phone);
}
}

View File

@@ -19,12 +19,17 @@ class CouponResource extends Resource
protected static ?string $recordTitleAttribute = 'phone';
protected static ?string $navigationLabel = 'Coupons';
protected static ?string $navigationLabel = null;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
protected static ?int $navigationSort = 1;
public static function getNavigationLabel(): string
{
return __('filament.coupons.navigation');
}
public static function infolist(Schema $schema): Schema
{
return CouponInfolist::configure($schema);

View File

@@ -12,13 +12,17 @@ class CouponInfolist
return $schema
->components([
TextEntry::make('phone')
->label(__('filament.fields.phone'))
->formatStateUsing(fn (string $state): string => format_phone($state))
->copyable(),
TextEntry::make('code')
->label(__('filament.fields.code'))
->copyable(),
TextEntry::make('created_at')
->label(__('filament.fields.created_at'))
->dateTime(),
TextEntry::make('updated_at')
->label(__('filament.fields.updated_at'))
->dateTime(),
]);
}

View File

@@ -3,8 +3,8 @@
namespace App\Filament\Resources\Coupons\Tables;
use App\Filament\Tables\Filters\CreatedAtDateFilter;
use Filament\Actions\ViewAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -16,19 +16,24 @@ class CouponsTable
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('id')
->label(__('filament.fields.id'))
->sortable(),
TextColumn::make('phone')
->label(__('filament.fields.phone'))
->formatStateUsing(fn (string $state): string => format_phone($state))
->searchable()
->sortable()
->copyable(),
TextColumn::make('code')
->label(__('filament.fields.code'))
->sortable()
->copyable(),
TextColumn::make('created_at')
->label(__('filament.fields.created_at'))
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->label(__('filament.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),

View File

@@ -19,16 +19,31 @@ class PhoneVerificationResource extends Resource
protected static ?string $recordTitleAttribute = 'phone';
protected static ?string $navigationLabel = 'OTP Verifications';
protected static ?string $navigationLabel = null;
protected static ?string $modelLabel = 'OTP verification';
protected static ?string $modelLabel = null;
protected static ?string $pluralModelLabel = 'OTP verifications';
protected static ?string $pluralModelLabel = null;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey;
protected static ?int $navigationSort = 2;
public static function getNavigationLabel(): string
{
return __('filament.phone_verifications.navigation');
}
public static function getModelLabel(): string
{
return __('filament.phone_verifications.model');
}
public static function getPluralModelLabel(): string
{
return __('filament.phone_verifications.plural');
}
public static function infolist(Schema $schema): Schema
{
return PhoneVerificationInfolist::configure($schema);

View File

@@ -13,15 +13,20 @@ class PhoneVerificationInfolist
return $schema
->components([
TextEntry::make('phone')
->label(__('filament.fields.phone'))
->formatStateUsing(fn (string $state): string => format_phone($state))
->copyable(),
TextEntry::make('expires_at')
->label(__('filament.fields.expires_at'))
->dateTime(),
IconEntry::make('verified')
->label(__('filament.fields.verified'))
->boolean(),
TextEntry::make('created_at')
->label(__('filament.fields.created_at'))
->dateTime(),
TextEntry::make('updated_at')
->label(__('filament.fields.updated_at'))
->dateTime(),
]);
}

View File

@@ -16,22 +16,28 @@ class PhoneVerificationsTable
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('id')
->label(__('filament.fields.id'))
->sortable(),
TextColumn::make('phone')
->label(__('filament.fields.phone'))
->formatStateUsing(fn (string $state): string => format_phone($state))
->searchable()
->sortable()
->copyable(),
TextColumn::make('expires_at')
->label(__('filament.fields.expires_at'))
->dateTime()
->sortable(),
IconColumn::make('verified')
->label(__('filament.fields.verified'))
->boolean()
->sortable(),
TextColumn::make('created_at')
->label(__('filament.fields.created_at'))
->dateTime()
->sortable(),
TextColumn::make('updated_at')
->label(__('filament.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),

View File

@@ -13,12 +13,12 @@ class CreatedAtDateFilter
public static function make(): Filter
{
return Filter::make('created_at')
->label('Date')
->label(__('filament.filters.date'))
->schema([
DatePicker::make('from')
->label('From'),
->label(__('filament.filters.from')),
DatePicker::make('until')
->label('Until'),
->label(__('filament.filters.until')),
])
->query(function (Builder $query, array $data): Builder {
return $query
@@ -35,12 +35,16 @@ class CreatedAtDateFilter
$indicators = [];
if ($data['from'] ?? null) {
$indicators[] = Indicator::make('From '.Carbon::parse($data['from'])->toFormattedDateString())
$indicators[] = Indicator::make(__('filament.filters.from_indicator', [
'date' => Carbon::parse($data['from'])->toFormattedDateString(),
]))
->removeField('from');
}
if ($data['until'] ?? null) {
$indicators[] = Indicator::make('Until '.Carbon::parse($data['until'])->toFormattedDateString())
$indicators[] = Indicator::make(__('filament.filters.until_indicator', [
'date' => Carbon::parse($data['until'])->toFormattedDateString(),
]))
->removeField('until');
}

View File

@@ -56,6 +56,57 @@ if (! function_exists('sendSMS')) {
}
}
if (! function_exists('sendBulkSMS')) {
/**
* Send bulk SMS
*
* @param array<int, array{phone: string, message: string}> $messages
*/
function sendBulkSMS(array $messages): bool
{
try {
$response = Http::timeout(config('services.sms.timeout'))
->connectTimeout(config('services.sms.connect_timeout'))
->retry(
times: 3,
sleepMilliseconds: 50,
throw: false,
when: function (Throwable $exception, PendingRequest $request): bool {
if ($exception instanceof ConnectionException) {
return true;
}
if ($exception instanceof RequestException) {
return $exception->response->serverError();
}
return false;
}
)
->post(config('services.sms.url').'/bulk', [
'messages' => $messages,
]);
if (! $response->successful()) {
Log::error('Bulk SMS API request failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false;
}
return true;
} catch (Throwable $exception) {
Log::error('Bulk SMS API exception', [
'message' => $exception->getMessage(),
]);
return false;
}
}
}
if (! function_exists('unmask_phone')) {
/**
* Unmask Turkmenistan phone number from TM code +993 6X XX XX XX to 6xxxxxxx
@@ -76,6 +127,16 @@ if (! function_exists('unmask_phone')) {
}
}
if (! function_exists('is_valid_coupon_phone')) {
/**
* Whether an 8-digit stored coupon phone can receive SMS.
*/
function is_valid_coupon_phone(string $phone): bool
{
return (bool) preg_match('/^6\d{7}$/', $phone);
}
}
if (! function_exists('format_phone')) {
/**
* Format an 8-digit TM mobile number for display (+993 6X XX XX XX).

View File

@@ -3,9 +3,11 @@
namespace App\Http\Controllers;
use App\Enums\OtpVerificationResult;
use App\Exceptions\CouponPoolExhaustedException;
use App\Http\Requests\SendOtpRequest;
use App\Http\Requests\VerifyOtpRequest;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use App\Services\CouponService;
use App\Services\OtpService;
use Illuminate\Contracts\View\View;
@@ -20,6 +22,7 @@ class VerificationController extends Controller
public function __construct(
private OtpService $otpService,
private CouponService $couponService,
private CouponCodePool $couponCodePool,
) {}
public function index(): View|RedirectResponse
@@ -28,6 +31,10 @@ class VerificationController extends Controller
return $redirect;
}
if (! $this->couponCodePool->hasAvailable()) {
return view('verification.promotion-ended');
}
return view('verification.index');
}
@@ -44,6 +51,10 @@ class VerificationController extends Controller
return back()->withErrors(['phone' => 'Bu telefon belgisi eýýäm ulanyldy.'])->withInput();
}
if (! $this->couponCodePool->hasAvailable()) {
return back()->withErrors(['phone' => CouponPoolExhaustedException::MESSAGE])->withInput();
}
if (! $this->otpService->issue($phone)) {
return back()->withErrors(['phone' => 'SMS iberilmedi. Soňrak synanyşyň.'])->withInput();
}
@@ -63,6 +74,12 @@ class VerificationController extends Controller
return redirect()->route('verification.index');
}
$phone = session()->get('verify_phone');
if (! $this->couponCodePool->hasAvailable() && ! Coupon::query()->where('phone', $phone)->exists()) {
return redirect()->route('verification.index');
}
return view('verification.verify', [
'phone' => session()->get('verify_phone'),
]);
@@ -84,6 +101,10 @@ class VerificationController extends Controller
return redirect()->route('verification.congratulations');
}
if (! $this->couponCodePool->hasAvailable()) {
return redirect()->route('verification.index');
}
if (! $this->otpService->issue($phone)) {
return back()->withErrors(['otp' => 'SMS iberilmedi. Soňrak synanyşyň.']);
}
@@ -109,7 +130,11 @@ class VerificationController extends Controller
return back()->withErrors(['otp' => 'Nädogry kod.']);
}
try {
$coupon = $this->couponService->findOrCreateForPhone($phone);
} catch (CouponPoolExhaustedException) {
return back()->withErrors(['otp' => CouponPoolExhaustedException::MESSAGE]);
}
session()->put('coupon_code', $coupon->code);

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Database\Factories\SmsCampaignFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SmsCampaign extends Model
{
/** @use HasFactory<SmsCampaignFactory> */
use HasFactory;
protected $fillable = [
'message',
'mode',
'recipient_count',
'sent_count',
'failed_count',
'skipped_count',
'laravel_batch_id',
'created_by',
'completed_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'completed_at' => 'datetime',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function sendLogs(): HasMany
{
return $this->hasMany(SmsSendLog::class);
}
public function isComplete(): bool
{
return $this->completed_at !== null;
}
public function processedCount(): int
{
return $this->sent_count + $this->failed_count + $this->skipped_count;
}
public function progressPercent(): int
{
if ($this->recipient_count === 0) {
return 100;
}
return (int) min(100, round(($this->processedCount() / $this->recipient_count) * 100));
}
}

46
app/Models/SmsSendLog.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\SmsSendStatus;
use Database\Factories\SmsSendLogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SmsSendLog extends Model
{
/** @use HasFactory<SmsSendLogFactory> */
use HasFactory;
protected $fillable = [
'sms_campaign_id',
'coupon_id',
'phone',
'message',
'status',
'error_message',
'attempted_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => SmsSendStatus::class,
'attempted_at' => 'datetime',
];
}
public function campaign(): BelongsTo
{
return $this->belongsTo(SmsCampaign::class, 'sms_campaign_id');
}
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\Coupon;
class CouponCodePool
{
/** @var list<string>|null */
private ?array $allCodesCache = null;
public function __construct(
private ?string $codesPath = null,
) {}
/**
* @return list<string>
*/
public function allCodes(): array
{
if ($this->allCodesCache !== null) {
return $this->allCodesCache;
}
$path = $this->codesPath ?? resource_path('codes/codes.txt');
if (! is_readable($path)) {
throw new \RuntimeException("Coupon codes file is not readable: {$path}");
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
throw new \RuntimeException("Unable to read coupon codes file: {$path}");
}
$codes = array_values(array_filter(array_map(trim(...), $lines)));
if ($codes === []) {
throw new \RuntimeException("Coupon codes file is empty: {$path}");
}
return $this->allCodesCache = $codes;
}
/**
* @return list<string>
*/
public function usedCodes(): array
{
return Coupon::query()->pluck('code')->all();
}
/**
* @return list<string>
*/
public function availableCodes(): array
{
return array_values(array_diff($this->allCodes(), $this->usedCodes()));
}
public function hasAvailable(): bool
{
return $this->availableCodes() !== [];
}
public function pickRandom(): ?string
{
$available = $this->availableCodes();
if ($available === []) {
return null;
}
return $available[array_rand($available)];
}
}

View File

@@ -2,12 +2,16 @@
namespace App\Services;
use App\Exceptions\CouponPoolExhaustedException;
use App\Models\Coupon;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Str;
class CouponService
{
public function __construct(
private CouponCodePool $couponCodePool,
) {}
public function findOrCreateForPhone(string $phone): Coupon
{
$existing = Coupon::query()->where('phone', $phone)->first();
@@ -22,24 +26,22 @@ class CouponService
private function createWithUniqueCode(string $phone): Coupon
{
for ($attempt = 0; $attempt < 10; $attempt++) {
$code = $this->couponCodePool->pickRandom();
if ($code === null) {
throw new CouponPoolExhaustedException;
}
try {
return Coupon::query()->create([
'phone' => $phone,
'code' => $this->generateCouponCode(),
'code' => $code,
]);
} catch (UniqueConstraintViolationException) {
continue;
}
}
throw new \RuntimeException('Unable to generate a unique coupon code.');
}
private function generateCouponCode(): string
{
$x = random_int(1, 9);
$yyyy = Str::upper(Str::random(4));
return "{$x}_{$yyyy}";
throw new CouponPoolExhaustedException;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Services;
use App\Models\Coupon;
use Illuminate\Database\Eloquent\Collection;
class SmsBroadcastService
{
/**
* @param array<int, int|string> $selectedIds
* @param array<int, int|string> $excludedIds
* @return Collection<int, Coupon>
*/
public function resolveRecipients(bool $sendToAll, array $selectedIds = [], array $excludedIds = []): Collection
{
$excludedIds = array_values(array_unique(array_map('intval', $excludedIds)));
if ($sendToAll) {
$query = Coupon::query();
} else {
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
$query = Coupon::query()->whereIn('id', $selectedIds);
}
if ($excludedIds !== []) {
$query->whereNotIn('id', $excludedIds);
}
return $query->orderBy('id')->get();
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Services;
class SmsMessageAnalyzer
{
private const GSM_SINGLE_LIMIT = 160;
private const GSM_MULTI_LIMIT = 153;
private const UCS2_SINGLE_LIMIT = 70;
private const UCS2_MULTI_LIMIT = 67;
public function characterCount(string $message): int
{
return mb_strlen($message);
}
public function isGsm7(string $message): bool
{
if ($message === '') {
return true;
}
return ! preg_match('/[^\x00-\x7F]/', $message);
}
public function estimatedParts(string $message): int
{
$length = $this->characterCount($message);
if ($length === 0) {
return 0;
}
if ($this->isGsm7($message)) {
if ($length <= self::GSM_SINGLE_LIMIT) {
return 1;
}
return (int) ceil($length / self::GSM_MULTI_LIMIT);
}
if ($length <= self::UCS2_SINGLE_LIMIT) {
return 1;
}
return (int) ceil($length / self::UCS2_MULTI_LIMIT);
}
public function summary(string $message): string
{
$characters = $this->characterCount($message);
$parts = $this->estimatedParts($message);
if ($parts === 0) {
return __('filament.sms_analyzer.zero_characters');
}
$smsLabel = $parts === 1
? __('filament.sms_analyzer.one_sms')
: __('filament.sms_analyzer.multiple_sms', ['count' => $parts]);
return __('filament.sms_analyzer.characters_sms', [
'characters' => $characters,
'sms' => $smsLabel,
]);
}
}

View File

@@ -39,6 +39,7 @@ return [
'url' => env('SMS_API_URL', 'https://sms.daragt.com/api/sms'),
'timeout' => (int) env('SMS_API_TIMEOUT', 10),
'connect_timeout' => (int) env('SMS_API_CONNECT_TIMEOUT', 5),
'bulk_delay_ms' => (int) env('SMS_BULK_DELAY_MS', 300),
],
];

View File

@@ -20,7 +20,7 @@ class CouponFactory extends Factory
{
return [
'phone' => '6'.fake()->unique()->numerify('#######'),
'code' => random_int(1, 9).'_'.Str::upper(Str::random(4)),
'code' => 'TEST_'.Str::upper(Str::random(8)),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Database\Factories;
use App\Models\SmsCampaign;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SmsCampaign>
*/
class SmsCampaignFactory extends Factory
{
protected $model = SmsCampaign::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'message' => fake()->sentence(),
'mode' => 'all',
'recipient_count' => 0,
'sent_count' => 0,
'failed_count' => 0,
'skipped_count' => 0,
'created_by' => User::factory(),
'completed_at' => null,
];
}
public function completed(): static
{
return $this->state(fn (array $attributes): array => [
'completed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\SmsSendStatus;
use App\Models\Coupon;
use App\Models\SmsCampaign;
use App\Models\SmsSendLog;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SmsSendLog>
*/
class SmsSendLogFactory extends Factory
{
protected $model = SmsSendLog::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$campaign = SmsCampaign::factory()->create();
$coupon = Coupon::factory()->create();
return [
'sms_campaign_id' => $campaign->id,
'coupon_id' => $coupon->id,
'phone' => $coupon->phone,
'message' => $campaign->message,
'status' => SmsSendStatus::Pending,
'error_message' => null,
'attempted_at' => null,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sms_campaigns', function (Blueprint $table) {
$table->id();
$table->text('message');
$table->string('mode');
$table->unsignedInteger('recipient_count')->default(0);
$table->unsignedInteger('sent_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->unsignedInteger('skipped_count')->default(0);
$table->string('laravel_batch_id')->nullable();
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sms_campaigns');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sms_send_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('sms_campaign_id')->constrained()->cascadeOnDelete();
$table->foreignId('coupon_id')->nullable()->constrained()->nullOnDelete();
$table->string('phone');
$table->text('message');
$table->string('status');
$table->text('error_message')->nullable();
$table->timestamp('attempted_at')->nullable();
$table->timestamps();
$table->index(['sms_campaign_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('sms_send_logs');
}
};

9
lang/tk/auth.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
return [
'failed' => 'Bu maglumatlar biziň ýazgylarymyza gabat gelenok.',
'password' => 'Berlen parol nädogry.',
'throttle' => 'Giriş synanyşyklary köp. :seconds sekuntdan soň gaýtadan synanyşyň.',
];

74
lang/tk/filament.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
return [
'coupons' => [
'navigation' => 'Kuponlar',
],
'phone_verifications' => [
'navigation' => 'OTP tassyklamalary',
'model' => 'OTP tassyklama',
'plural' => 'OTP tassyklamalary',
],
'send_sms' => [
'navigation' => 'SMS ibermek',
'title' => 'SMS ibermek',
'sections' => [
'message' => 'Hat',
'message_description' => 'SMS-ni alýanlar görmeli bolşy ýaly takyk ýazyň.',
'recipients' => 'Alyjylar',
'recipients_description' => 'Hemmä ibermek, belli kupon eýelerini saýlamak ýa-da sanlary aýyrmak.',
],
'fields' => [
'message' => 'SMS habary',
'send_to_all' => 'Ähli kupon eýelerine ibermek',
'send_to_all_helper' => 'Açyk bolsa, ähli kupon eýeleri girýär. El bilen saýlaw öçürilýär.',
'coupon_ids' => 'Alyjylary saýlaň',
'coupon_ids_helper' => 'Telefon, kupon kody ýa-da ID boýunça gözleg.',
'excluded_coupon_ids' => 'Alyjylary aýyrmak',
'excluded_coupon_ids_helper' => 'Aýrylan sanlar bu habary almaz, hatda ähläne iberilende hem.',
],
'actions' => [
'send' => 'SMS ibermek',
'send_confirm_heading' => 'SMS häzir ibermeli?',
'send_confirm_submit' => 'Iber',
],
'notifications' => [
'no_valid_recipients' => 'Dogry alyjy ýok',
'no_valid_recipients_body' => 'Saýlawy ýa-da aýyrmalary üýtgediň — dogry telefon belgili kupon eýesi bu habary almaz.',
'submitted' => 'SMS kampaniýasy iberildi',
'submitted_body' => ':count alyja iberildi.',
'failed' => 'SMS ibermek başa barmady',
'failed_body' => 'Näsazlyk ýüze çykdy. Täzeden synanyşyň.',
],
'confirmation' => ':count alyja ibermeli? :summary',
],
'filters' => [
'date' => 'Sene',
'from' => 'Başlanýan',
'until' => 'Gutarýan',
'from_indicator' => 'Başlanýan :date',
'until_indicator' => 'Gutarýan :date',
],
'sms_analyzer' => [
'zero_characters' => '0 harp',
'characters_sms' => ':characters harp · :sms',
'one_sms' => '1 SMS',
'multiple_sms' => ':count SMS',
],
'fields' => [
'id' => 'ID',
'phone' => 'Telefon',
'code' => 'Kupon kody',
'created_at' => 'Döredilen',
'updated_at' => 'Üýtgedilen',
'expires_at' => 'Gutarýan wagty',
'verified' => 'Tassyklanan',
],
];

34
lang/tk/validation.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
return [
'accepted' => ':attribute kabul edilmeli.',
'array' => ':attribute massiw bolmaly.',
'boolean' => ':attribute dogry ýa-da ýalňyş bolmaly.',
'confirmed' => ':attribute tassyklamasy gabat gelenok.',
'email' => ':attribute dogry e-poçta salgysy bolmaly.',
'exists' => 'Saýlanan :attribute nädogry.',
'integer' => ':attribute san bolmaly.',
'max' => [
'array' => ':attribute iň köp :max elementden ybarat bolmaly.',
'numeric' => ':attribute iň köp :max bolmaly.',
'string' => ':attribute iň köp :max harp bolmaly.',
],
'min' => [
'array' => ':attribute iň az :min elementden ybarat bolmaly.',
'numeric' => ':attribute iň az :min bolmaly.',
'string' => ':attribute iň az :min harp bolmaly.',
],
'required' => ':attribute meýdany hökmany.',
'string' => ':attribute setir bolmaly.',
'attributes' => [
'email' => 'e-poçta salgysy',
'password' => 'parol',
'message' => 'SMS habary',
'send_to_all' => 'ähli kupon eýelerine ibermek',
'coupon_ids' => 'alyjylar',
'excluded_coupon_ids' => 'aýrylan alyjylar',
],
];

View File

@@ -0,0 +1,28 @@
<?php
return [
'single' => [
'label' => 'İlişkilendir',
'modal' => [
'heading' => ':label İlişkilendir',
'fields' => [
'record_id' => [
'label' => 'Kayıt',
],
],
'actions' => [
'associate' => [
'label' => 'İlişkilendir',
],
'associate_another' => [
'label' => 'İlişkilendir ve başka bir taneye başla',
],
],
],
'notifications' => [
'associated' => [
'title' => 'İlişkilendirildi',
],
],
],
];

View File

@@ -0,0 +1,28 @@
<?php
return [
'single' => [
'label' => 'İliştir',
'modal' => [
'heading' => ':label iliştir',
'fields' => [
'record_id' => [
'label' => 'Kayıt',
],
],
'actions' => [
'attach' => [
'label' => 'İliştir',
],
'attach_another' => [
'label' => 'İliştir ve başka bir taneye başla',
],
],
],
'notifications' => [
'attached' => [
'title' => 'İliştirildi',
],
],
],
];

View File

@@ -0,0 +1,23 @@
<?php
return [
'single' => [
'label' => ':label Oluştur',
'modal' => [
'heading' => ':label oluştur',
'actions' => [
'create' => [
'label' => 'Döret',
],
'create_another' => [
'label' => 'Oluştur & yeni oluştur',
],
],
],
'notifications' => [
'created' => [
'title' => 'Oluşturuldu',
],
],
],
];

View File

@@ -0,0 +1,73 @@
<?php
return [
'single' => [
'label' => 'Poz',
'modal' => [
'heading' => ':label poz',
'actions' => [
'delete' => [
'label' => 'Poz',
],
],
],
'notifications' => [
'deleted' => [
'title' => 'Pozuldy',
],
],
],
'multiple' => [
'label' => 'Saýlananlary poz',
'modal' => [
'heading' => 'Saýlananlary poz',
'actions' => [
'delete' => [
'label' => 'Poz',
],
],
],
'notifications' => [
'deleted' => [
'title' => 'Pozuldy',
],
'deleted_partial' => [
'title' => ':total ýazgydan :count pozuldy',
'missing_authorization_failure_message' => ':count ýazgy pozmak üçin ygtyýaryňyz ýok.',
'missing_processing_failure_message' => ':count ýazgy pozulmady.',
],
'deleted_none' => [
'title' => 'Ýazgylar pozulmady',
'missing_authorization_failure_message' => ':count ýazgy pozmak üçin ygtyýaryňyz ýok.',
'missing_processing_failure_message' => ':count ýazgy pozulmady.',
],
],
],
];

View File

@@ -0,0 +1,36 @@
<?php
return [
'single' => [
'label' => 'Ayır',
'modal' => [
'heading' => ':label ayır',
'actions' => [
'detach' => [
'label' => 'Ayır',
],
],
],
'notifications' => [
'detached' => [
'title' => 'Ayrıldı',
],
],
],
'multiple' => [
'label' => 'Seçiliyi ayır',
'modal' => [
'heading' => ':label seçiliyi ayır ',
'actions' => [
'detach' => [
'label' => 'Seçiliyi ayır',
],
],
],
'notifications' => [
'detached' => [
'title' => 'Ayrıldı',
],
],
],
];

View File

@@ -0,0 +1,36 @@
<?php
return [
'single' => [
'label' => 'Ayrıştır',
'modal' => [
'heading' => ':label ayrıştır',
'actions' => [
'dissociate' => [
'label' => 'Ayrıştır',
],
],
],
'notifications' => [
'dissociated' => [
'title' => 'Ayrıştırıldı',
],
],
],
'multiple' => [
'label' => 'Seçiliyi ayrıştır',
'modal' => [
'heading' => ':label seçiliyi ayrıştır',
'actions' => [
'dissociate' => [
'label' => 'Seçiliyi ayrıştır',
],
],
],
'notifications' => [
'dissociated' => [
'title' => 'Ayrıştırıldı',
],
],
],
];

View File

@@ -0,0 +1,20 @@
<?php
return [
'single' => [
'label' => 'Üýtget',
'modal' => [
'heading' => ':label üýtget',
'actions' => [
'save' => [
'label' => 'Sakla',
],
],
],
'notifications' => [
'saved' => [
'title' => 'Kaydedildi',
],
],
],
];

View File

@@ -0,0 +1,48 @@
<?php
return [
'label' => 'Dışa Aktar :label',
'modal' => [
'heading' => 'Dışa Aktar :label',
'form' => [
'columns' => [
'label' => 'Sütünler',
'form' => [
'is_enabled' => [
'label' => ':column etkin',
],
'label' => [
'label' => ':column etiketi',
],
],
],
],
'actions' => [
'export' => [
'label' => 'Dışa Aktar',
],
],
],
'notifications' => [
'completed' => [
'title' => 'Dışa Aktarım Tamamlandı',
'actions' => [
'download_csv' => [
'label' => '.csv Olarak İndir',
],
'download_xlsx' => [
'label' => '.xlsx Olarak İndir',
],
],
],
'max_rows' => [
'title' => 'Maksimum Satır Sayısııldı',
'body' => 'Birden fazla satırı dışa aktaramazsınız.|:count satırı dışa aktaramazsınız.',
],
'started' => [
'title' => 'Dışa Aktarım Başladı',
'body' => 'Dışa aktarım başladı ve 1 satır arka planda işlenecek.|Dışa aktarım başladı ve :count satır arka planda işlenecek.',
],
],
'file_name' => 'export-:export_id-:model',
];

View File

@@ -0,0 +1,46 @@
<?php
return [
'single' => [
'label' => 'Kalıcı olarak sil',
'modal' => [
'heading' => ':label kalıcı olarak sil',
'actions' => [
'delete' => [
'label' => 'Kalıcı olarak sil',
],
],
],
'notifications' => [
'deleted' => [
'title' => 'Kayıt kalıcı olarak silindi',
],
],
],
'multiple' => [
'label' => 'Seçiliyi kalıcı olarak sil',
'modal' => [
'heading' => ':label seçiliyi kalıcı olarak sil',
'actions' => [
'delete' => [
'label' => 'Kalıcı olarak sil',
],
],
],
'notifications' => [
'deleted' => [
'title' => 'Kayıtlar kalıcı olarak silindi',
],
'deleted_partial' => [
'title' => ':total kayıttan :count kayıt kalıcı olarak silindi',
'missing_authorization_failure_message' => ':count kayıtı kalıcı olarak silmek için gereken izniniz yok.',
'missing_processing_failure_message' => ':count kayıt kalıcı olarak silinemedi.',
],
'deleted_none' => [
'title' => 'Kayıtlar kalıcı olarak silinemedi',
'missing_authorization_failure_message' => ':count kayıtı kalıcı olarak silmek için gereken izniniz yok.',
'missing_processing_failure_message' => ':count kayıt kalıcı olarak silinemedi.',
],
],
],
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'trigger' => [
'label' => 'Eylemler',
],
];

View File

@@ -0,0 +1,56 @@
<?php
return [
'label' => 'İçe Aktar: :label',
'modal' => [
'heading' => 'İçe Aktar: :label',
'form' => [
'file' => [
'label' => 'Dosya',
'placeholder' => 'Bir CSV dosyası seçin',
'rules' => [
'duplicate_columns' => '{0} Dosya birden fazla boş sütun başlığı içeremez.|{1,*} Dosya tekrar eden sütun başlığı içeremez: :columns.',
],
],
'columns' => [
'label' => 'Sütünler',
'placeholder' => 'Sütunları eşleştirin',
],
],
'actions' => [
'download_example' => [
'label' => 'Örnek CSV Dosyasını İndir',
],
'import' => [
'label' => 'İçe Aktar',
],
],
],
'notifications' => [
'completed' => [
'title' => 'İçe Aktarım Tamamlandı',
'actions' => [
'download_failed_rows_csv' => [
'label' => 'Başarısız satır hakkında bilgileri indir|Başarısız satırlar hakkında bilgileri indir',
],
],
],
'max_rows' => [
'title' => 'Yüklenen Dosya Çok Büyük',
'body' => 'Aynı anda 1\'den fazla satır içeren dosyaları içe aktaramazsınız.|Aynı anda :count\'den fazla satır içeren dosyaları içe aktaramazsınız.',
],
'started' => [
'title' => 'İçe Aktarım Başladı',
'body' => 'İçe aktarım başladı ve 1 satır arka planda işlenecek.|İçe aktarım başladı ve :count satır arka planda işlenecek.',
],
],
'example_csv' => [
'file_name' => ':importer-example',
],
'failure_csv' => [
'file_name' => 'import-:import_id-:csv_name-failed-rows',
'error_header' => 'error',
'system_error' => 'Sistem Hatası',
'column_mapping_required_for_new_record' => ':attribute sütunu dosyadaki bir sütun ile eşleştirilmedi, fakat bu sütun yeni kayıt oluşturmak için gerekli bir sütun.',
],
];

View File

@@ -0,0 +1,16 @@
<?php
return [
'confirmation' => 'Bunu yapmak istediğinizden emin misiniz?',
'actions' => [
'cancel' => [
'label' => 'Ýatyr',
],
'confirm' => [
'label' => 'Tassykla',
],
'submit' => [
'label' => 'Iber',
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'throttled' => [
'title' => 'Çok Fazla Deneme Yapıldı',
'body' => 'Çok fazla deneme yapıldı, lütfen :seconds saniye sonra tekrar deneyin.',
],
];

View File

@@ -0,0 +1,20 @@
<?php
return [
'single' => [
'label' => 'Çoğalt',
'modal' => [
'heading' => ':label çoğalt',
'actions' => [
'replicate' => [
'label' => 'Çoğalt',
],
],
],
'notifications' => [
'replicated' => [
'title' => 'Kayıt çoğaltıldı',
],
],
],
];

View File

@@ -0,0 +1,46 @@
<?php
return [
'single' => [
'label' => 'Geri yükle',
'modal' => [
'heading' => ':label geri yükle',
'actions' => [
'restore' => [
'label' => 'Geri yükle',
],
],
],
'notifications' => [
'restored' => [
'title' => 'Kayıt geri yüklendi',
],
],
],
'multiple' => [
'label' => 'Seçileni geri yükle',
'modal' => [
'heading' => ':label seçileni geri yükle',
'actions' => [
'restore' => [
'label' => 'Geri yükle',
],
],
],
'notifications' => [
'restored' => [
'title' => 'Kayıtlar geri yüklendi',
],
'restored_partial' => [
'title' => ':total kayıttan :count kayıt geri yüklendi',
'missing_authorization_failure_message' => ':count kayıt geri yüklemek için gereken izniniz yok.',
'missing_processing_failure_message' => ':count geri yüklenemedi.',
],
'restored_none' => [
'title' => 'Kayıtlar geri yüklenemedi',
'missing_authorization_failure_message' => ':count kayıt geri yüklemek için gereken izniniz yok.',
'missing_processing_failure_message' => ':count geri yüklenemedi.',
],
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
return [
'single' => [
'label' => 'Gör',
'modal' => [
'heading' => ':label gör',
'actions' => [
'close' => [
'label' => 'Ýap',
],
],
],
],
];

View File

@@ -0,0 +1,498 @@
<?php
return [
'builder' => [
'actions' => [
'clone' => [
'label' => 'Klonla',
],
'add' => [
'label' => ':label\'e Ekle',
'modal' => [
'heading' => ':label\'e Ekle',
'actions' => [
'add' => [
'label' => 'Goş',
],
],
],
],
'add_between' => [
'label' => 'Bloklar arasına ekle',
'modal' => [
'heading' => ':label\'e Ekle',
'actions' => [
'add' => [
'label' => 'Goş',
],
],
],
],
'delete' => [
'label' => 'Poz',
],
'edit' => [
'label' => 'Üýtget',
'modal' => [
'heading' => 'Bloğu Düzenle',
'actions' => [
'save' => [
'label' => 'Değişiklikleri Kaydet',
],
],
],
],
'reorder' => [
'label' => 'Taşı',
],
'move_down' => [
'label' => 'Aşağı taşı',
],
'move_up' => [
'label' => 'Yukarı taşı',
],
'collapse' => [
'label' => 'Daralt',
],
'expand' => [
'label' => 'Genişlet',
],
'collapse_all' => [
'label' => 'Tümünü daralt',
],
'expand_all' => [
'label' => 'Tümünü genişlet',
],
],
],
'checkbox_list' => [
'actions' => [
'deselect_all' => [
'label' => 'Tüm seçimi kaldır',
],
'select_all' => [
'label' => 'Hemmesini saýla',
],
],
],
'file_upload' => [
'editor' => [
'actions' => [
'cancel' => [
'label' => 'Ýatyr',
],
'drag_crop' => [
'label' => 'Sürükleme modu "kırpma"',
],
'drag_move' => [
'label' => 'Sürükleme modu "taşıma"',
],
'flip_horizontal' => [
'label' => 'Görüntüyü yatay olarak çevir',
],
'flip_vertical' => [
'label' => 'Görüntüyü dikey olarak çevir',
],
'move_down' => [
'label' => 'Görüntüyü aşağı taşı',
],
'move_left' => [
'label' => 'Görüntüyü sola taşı',
],
'move_right' => [
'label' => 'Görüntüyü sağa taşı',
],
'move_up' => [
'label' => 'Görüntüyü yukarı taşı',
],
'reset' => [
'label' => 'Täzeden',
],
'rotate_left' => [
'label' => 'Görüntüyü sola döndür',
],
'rotate_right' => [
'label' => 'Görüntüyü sağa döndür',
],
'set_aspect_ratio' => [
'label' => 'En boy oranını :ratio olarak ayarla',
],
'save' => [
'label' => 'Sakla',
],
'zoom_100' => [
'label' => 'Görüntüyü %100 yakınlaştır',
],
'zoom_in' => [
'label' => 'Yakınlaştır',
],
'zoom_out' => [
'label' => 'Uzaklaştır',
],
],
'fields' => [
'height' => [
'label' => 'Yükseklik',
'unit' => 'px',
],
'rotation' => [
'label' => 'Döndürme',
'unit' => '°',
],
'width' => [
'label' => 'Genişlik',
'unit' => 'px',
],
'x_position' => [
'label' => 'X',
'unit' => 'px',
],
'y_position' => [
'label' => 'Y',
'unit' => 'px',
],
],
'aspect_ratios' => [
'label' => 'En boy oranı',
'no_fixed' => [
'label' => 'Serbest',
],
],
'svg' => [
'messages' => [
'confirmation' => 'SVG dosyalarını düzenleme, ölçeklendirme yaptığınızda kalite kaybına neden olabileceği için tavsiye edilmez.\\n Devam etmek istediğinize emin misiniz?',
'disabled' => 'SVG dosyalarını düzenleme, ölçeklendirme yaptığınızda kalite kaybına neden olduğu için engellendi.',
],
],
],
],
'key_value' => [
'actions' => [
'add' => [
'label' => 'Satır ekle',
],
'delete' => [
'label' => 'Satır sil',
],
'reorder' => [
'label' => 'Satır sırala',
],
],
'fields' => [
'key' => [
'label' => 'Anahtar',
],
'value' => [
'label' => 'Değer',
],
],
],
'markdown_editor' => [
'file_attachments_accepted_file_types_message' => 'Yüklenen dosyalar şu türlerden olmalıdır: :values.',
'file_attachments_max_size_message' => 'Yüklenen dosyalar :max kilobayttan büyük olmamalıdır.',
'tools' => [
'attach_files' => 'Dosya ekle',
'blockquote' => 'Alıntı',
'bold' => 'Kalın',
'bullet_list' => 'Sanaw',
'code_block' => 'Kod bloğu',
'heading' => 'Başlık',
'italic' => 'Eğik',
'link' => 'Bağlantı',
'ordered_list' => 'Numaralı liste',
'redo' => 'Yinele',
'strike' => 'Üstü çizili',
'table' => 'Tablo',
'undo' => 'Geri al',
],
],
'modal_table_select' => [
'actions' => [
'select' => [
'label' => 'Saýla',
'actions' => [
'select' => [
'label' => 'Saýla',
],
],
],
],
],
'radio' => [
'boolean' => [
'true' => 'Hawa',
'false' => 'Ýok',
],
],
'repeater' => [
'actions' => [
'add' => [
'label' => ':label\'e ekle',
],
'add_between' => [
'label' => 'Arasına yerleştir',
],
'delete' => [
'label' => 'Poz',
],
'clone' => [
'label' => 'Klonla',
],
'reorder' => [
'label' => 'Taşı',
],
'move_down' => [
'label' => 'Aşağı taşı',
],
'move_up' => [
'label' => 'Yukarı taşı',
],
'collapse' => [
'label' => 'Daralt',
],
'expand' => [
'label' => 'Genişlet',
],
'collapse_all' => [
'label' => 'Tümünü daralt',
],
'expand_all' => [
'label' => 'Tümünü genişlet',
],
],
],
'rich_editor' => [
'actions' => [
'attach_files' => [
'label' => 'Dosya yükle',
'modal' => [
'heading' => 'Dosya yükle',
'form' => [
'file' => [
'label' => [
'new' => 'Dosya',
'existing' => 'Dosyayı değiştir',
],
],
'alt' => [
'label' => [
'new' => 'Açıklama metni',
'existing' => 'Açıklama metnini değiştir',
],
],
],
],
],
'custom_block' => [
'modal' => [
'actions' => [
'insert' => [
'label' => 'Goş',
],
'save' => [
'label' => 'Sakla',
],
],
],
],
'grid' => [
'label' => 'Grid',
'modal' => [
'heading' => 'Grid',
'form' => [
'preset' => [
'label' => 'Ön ayar',
'placeholder' => 'Yok',
'options' => [
'two' => 'İki',
'three' => 'Üç',
'four' => 'Dört',
'five' => 'Beş',
'two_start_third' => 'İki (Başta Üçte Bir)',
'two_end_third' => 'İki (Sonda Üçte Bir)',
'two_start_fourth' => 'İki (Başta Dörtte Bir)',
'two_end_fourth' => 'İki (Sonda Dörtte Bir)',
],
],
'columns' => [
'label' => 'Sütünler',
],
'from_breakpoint' => [
'label' => 'Kesme noktasından',
'options' => [
'default' => 'Hemmesi',
'sm' => 'Küçük (sm)',
'md' => 'Orta (md)',
'lg' => 'Büyük (lg)',
'xl' => 'Çok büyük (xl)',
'2xl' => 'İki kat büyük (2xl)',
],
],
'is_asymmetric' => [
'label' => 'İki asimetrik sütun',
],
'start_span' => [
'label' => 'Başlangıç aralığı',
],
'end_span' => [
'label' => 'Bitiş aralığı',
],
],
],
],
'link' => [
'label' => 'Üýtget',
'modal' => [
'heading' => 'Bağlantı',
'form' => [
'url' => [
'label' => 'URL',
],
'should_open_in_new_tab' => [
'label' => 'Yeni sekmede aç',
],
],
],
],
'text_color' => [
'label' => 'Yazı rengi',
'modal' => [
'heading' => 'Yazı rengi',
'form' => [
'color' => [
'label' => 'Renk',
],
'custom_color' => [
'label' => 'Özel renk',
],
],
],
],
],
'file_attachments_accepted_file_types_message' => 'Yüklenen dosyalar şu türlerden olmalıdır: :values.',
'file_attachments_max_size_message' => 'Yüklenen dosyalar :max kilobayttan büyük olmamalıdır.',
'no_merge_tag_search_results_message' => 'Uygun birleşme etiketi bulunamadı.',
'mentions' => [
'no_options_message' => 'Seçenek bulunamadı.',
'no_search_results_message' => 'Aramanızla eşleşen sonuç bulunamadı.',
'search_prompt' => 'Aramak için yazmaya başlayın...',
'searching_message' => 'Aranıyor...',
],
'tools' => [
'align_center' => 'Ortaya hizala',
'align_end' => 'Sona hizala',
'align_justify' => 'İki yana yasla',
'align_start' => 'Başa hizala',
'attach_files' => 'Dosya ekle',
'blockquote' => 'Alıntı',
'bold' => 'Kalın',
'bullet_list' => 'Sırasız liste',
'clear_formatting' => 'Biçimlendirmeyi temizle',
'code' => 'Kod',
'code_block' => 'Kod bloğu',
'custom_blocks' => 'Bloklar',
'details' => 'Jikme-jik',
'h1' => 'Başlık',
'h2' => 'Başlık 2',
'h3' => 'Alt başlık',
'grid' => 'Grid',
'grid_delete' => 'Grid\'i sil',
'highlight' => 'Vurgula',
'horizontal_rule' => 'Yatay çizgi',
'italic' => 'Eğik',
'lead' => 'Öne çıkan metin',
'link' => 'Bağlantı',
'merge_tags' => 'Birleşme etiketleri',
'ordered_list' => 'Sıralı liste',
'redo' => 'Yinele',
'small' => 'Küçük metin',
'strike' => 'Üstü çizili',
'subscript' => 'Alt simge',
'superscript' => 'Üst simge',
'table' => 'Tablo',
'table_delete' => 'Tabloyu sil',
'table_add_column_before' => 'Öncesine sütun ekle',
'table_add_column_after' => 'Sonrasına sütun ekle',
'table_delete_column' => 'Sütunu sil',
'table_add_row_before' => 'Üstüne satır ekle',
'table_add_row_after' => 'Altına satır ekle',
'table_delete_row' => 'Satırı sil',
'table_merge_cells' => 'Hücreleri birleştir',
'table_split_cell' => 'Hücreyi böl',
'table_toggle_header_row' => 'Başlık satırını aç/kapat',
'table_toggle_header_cell' => 'Başlık hücresini aç/kapat',
'text_color' => 'Yazı rengi',
'underline' => 'Altı çizili',
'undo' => 'Geri al',
],
'uploading_file_message' => 'Dosya yükleniyor...',
],
'select' => [
'actions' => [
'create_option' => [
'label' => 'Döret',
'modal' => [
'heading' => 'Döret',
'actions' => [
'create' => [
'label' => 'Döret',
],
'create_another' => [
'label' => 'Oluştur & Yeni oluştur',
],
],
],
],
'edit_option' => [
'label' => 'Üýtget',
'modal' => [
'heading' => 'Üýtget',
'actions' => [
'save' => [
'label' => 'Sakla',
],
],
],
],
],
'boolean' => [
'true' => 'Hawa',
'false' => 'Ýok',
],
'loading_message' => 'Ýüklenýär...',
'max_items_message' => 'Sadece :count adet seçilebilir.',
'no_options_message' => 'Seçenek bulunamadı.',
'no_search_results_message' => 'Arama kriterlerinize uyan seçenek yok.',
'placeholder' => 'Bir seçenek seçin',
'searching_message' => 'Aranıyor...',
'search_prompt' => 'Aramak için yazmaya başlayın...',
],
'tags_input' => [
'actions' => [
'delete' => [
'label' => 'Poz',
],
],
'placeholder' => 'Yeni etiket',
],
'text_input' => [
'actions' => [
'copy' => [
'label' => 'Göçür',
'message' => 'Göçürildi',
],
'hide_password' => [
'label' => 'Şifreyi gizle',
],
'show_password' => [
'label' => 'Şifreyi göster',
],
],
],
'toggle_buttons' => [
'boolean' => [
'true' => 'Hawa',
'false' => 'Ýok',
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'distinct' => [
'must_be_selected' => 'En az 1 adet :attribute alanı seçmelisiniz.',
'only_one_must_be_selected' => 'Sadece 1 adet :attribute alanı seçilmelidir.',
],
];

View File

@@ -0,0 +1,24 @@
<?php
return [
'entries' => [
'text' => [
'actions' => [
'collapse_list' => ':count kayıt az göster',
'expand_list' => ':count kayıt daha göster',
],
'more_list_items' => 've :count kayıt daha',
],
'key_value' => [
'columns' => [
'key' => [
'label' => 'Anahtar',
],
'value' => [
'label' => 'Değer',
],
],
'placeholder' => 'Kayıt yok',
],
],
];

View File

@@ -0,0 +1,19 @@
<?php
return [
'modal' => [
'heading' => 'Habarnamalar',
'actions' => [
'clear' => [
'label' => 'Arassala',
],
'mark_all_as_read' => [
'label' => 'Tümünü okundu işaretle',
],
],
'empty' => [
'heading' => 'Bildirim yok',
'description' => 'Lütfen sonra kontrol ediniz',
],
],
];

View File

@@ -0,0 +1,14 @@
<?php
return [
'notifications' => [
'blocked' => [
'title' => 'E-posta adresi güncelleme isteği engellendi',
'body' => 'Başarılı bir şekilde E-posta adresinizin :email olarak güncellenme isteğini engellediniz. Eğer bu isteği siz yapmadıysanız lütfen bizimle iletişime geçin.',
],
'failed' => [
'title' => 'E-posta adresi güncelleme isteği engellenirken bir hata oluştu',
'body' => 'Ne yazık ki, E-posta adresinizin :email olarak güncellenme isteğini engelleme işleminiz başarısız oldu, siz engelleyemeden E-posta adresi onaylandı. Eğer bu isteği siz yapmadıysanız lütfen bizimle iletişime geçin.',
],
],
];

View File

@@ -0,0 +1,10 @@
<?php
return [
'notifications' => [
'verified' => [
'title' => 'E-posta adresi güncellendi',
'body' => 'E-posta adresiniz başarıyla :email olarak güncellendi.',
],
],
];

View File

@@ -0,0 +1,40 @@
<?php
return [
'label' => 'Ýap',
'modal' => [
'heading' => 'Doğrulama uygulamasını devre dışı bırak',
'description' => 'Doğrulama uygulamasını devre dışı bırakmak istediğinize emin misiniz? Bunu devre dışı bırakmak hesabınızda bulunan ekstra koruma katmanını kaldıracaktır.',
'form' => [
'code' => [
'label' => 'Doğrulama uygulamanızdaki 6 haneli kodu girin',
'validation_attribute' => 'kod',
'actions' => [
'use_recovery_code' => [
'label' => 'Bunun yerine kurtarma kodu girin',
],
],
'messages' => [
'invalid' => 'Girmiş olduğunuz kod geçersiz.',
],
],
'recovery_code' => [
'label' => 'Veya, bir kurtarma kodu girin',
'validation_attribute' => 'kurtarma kodu',
'messages' => [
'invalid' => 'Girmiş olduğunuz kurtarma kodu geçersiz.',
],
],
],
'actions' => [
'submit' => [
'label' => 'Uygulamayı devre dışı bırak',
],
],
],
'notifications' => [
'disabled' => [
'title' => 'Doğrulama uygulaması devre dışı bırakıldı',
],
],
];

View File

@@ -0,0 +1,43 @@
<?php
return [
'label' => 'Kurtarma kodlarını yeniden oluştur',
'modal' => [
'heading' => 'Kurtarma kodlarını yeniden oluştur',
'description' => 'Eğer kurtarma kodlarınızı kaybederseniz buradan yeniden oluşturabilirsiniz. Eski kodlarınız devre dışı kalacaktır.',
'form' => [
'code' => [
'label' => 'Doğrulama uygulamanızdaki 6 haneli kodu girin',
'validation_attribute' => 'kod',
'messages' => [
'invalid' => 'Girmiş olduğunuz kod geçersiz.',
],
],
'password' => [
'label' => 'Veya, geçerli şifrenizi girin',
'validation_attribute' => 'şifre',
],
],
'actions' => [
'submit' => [
'label' => 'Kodları yeniden oluştur',
],
],
],
'notifications' => [
'regenerated' => [
'title' => 'Yeni kurtarma kodları oluşturuldu',
],
],
'show_new_recovery_codes' => [
'modal' => [
'heading' => 'Yeni kodlar',
'description' => 'Lütfen bu kodları güvenli bir şekilde saklayın. Bu kodlar size sadece bir kere gösterilecek ve eğer doğrulama uygulamanıza erişiminizi kaybederseniz bu kodları kullanmanız gerekecek:',
'actions' => [
'submit' => [
'label' => 'Ýap',
],
],
],
],
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'label' => 'Kur',
'modal' => [
'heading' => 'Doğrulama uygulaması kur',
'description' => 'Devam etmek için Google Authenticator gibi (<x-filament::link href="https://itunes.apple.com/us/app/google-authenticator/id388497605" target="_blank">iOS</x-filament::link>, <x-filament::link href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Android</x-filament::link>) uygulamalardan birine ihtiyacınız olacak.',
'content' => [
'qr_code' => [
'instruction' => 'Doğrulama uygulamanızla aşağıdaki QR kodunu taratın:',
'alt' => 'QR kodunu taratın',
],
'text_code' => [
'instruction' => 'Veya aşağıdaki kodu elle girin:',
'messages' => [
'copied' => 'Göçürildi',
],
],
'recovery_codes' => [
'instruction' => 'Lütfen bu kodları güvenli bir şekilde saklayın. Bu kodlar size sadece bir kere gösterilecek ve eğer doğrulama uygulamanıza erişiminizi kaybederseniz bu kodları kullanmanız gerekecek:',
],
],
'form' => [
'code' => [
'label' => 'Doğrulama uygulamanızdaki 6 haneli kodu girin',
'validation_attribute' => 'kod',
'below_content' => 'Giriş yaparken veya hassas bir işlem gerçekleştirirken doğrulama uygulamanız tarafından oluşturulan 6 haneli kodu girmeniz gerekecek.',
'messages' => [
'invalid' => 'Girmiş olduğunuz kod geçersiz.',
],
],
],
'actions' => [
'submit' => [
'label' => 'Doğrulama uygulamasını etkinleştir',
],
],
],
'notifications' => [
'enabled' => [
'title' => 'Doğrulama uygulaması etkinleştirildi',
],
],
];

View File

@@ -0,0 +1,36 @@
<?php
return [
'management_schema' => [
'actions' => [
'label' => 'Doğrulama uygulaması',
'below_content' => 'Girişinizi doğrulamak için doğrulama uygulamanız tarafından oluşturulan kodları kullanın',
'messages' => [
'enabled' => 'Etkin',
'disabled' => 'Devre Dışı',
],
],
],
'login_form' => [
'label' => 'Use a code from your authenticator app',
'code' => [
'label' => 'Girişinizi doğrulamak için doğrulama uygulamanız tarafından oluşturulan bir kod girin',
'validation_attribute' => 'kod',
'actions' => [
'use_recovery_code' => [
'label' => 'Kurtarma kodu kullan',
],
],
'messages' => [
'invalid' => 'Girmiş olduğunuz kod geçersiz.',
],
],
'recovery_code' => [
'label' => 'Veya kurtarma kodu girin',
'validation_attribute' => 'kurtarma kodu',
'messages' => [
'invalid' => 'Girmiş olduğunuz kurtarma kodu geçersiz.',
],
],
],
];

View File

@@ -0,0 +1,38 @@
<?php
return [
'label' => 'Ýap',
'modal' => [
'heading' => 'E-posta doğrulama kodlarını devre dışı bırak',
'description' => 'E-posta doğrulama kodları almayı durdurmak istediğinizden emin misiniz? Bu özelliği devre dışı bırakmak hesabınızdan ek bir güvenlik katmanını kaldıracaktır.',
'form' => [
'code' => [
'label' => 'Size e-posta ile gönderdiğimiz 6 haneli kodu girin',
'validation_attribute' => 'kod',
'actions' => [
'resend' => [
'label' => 'E-posta ile yeni kod gönder',
'notifications' => [
'resent' => [
'title' => 'Size e-posta ile yeni bir kod gönderdik',
],
],
],
],
'messages' => [
'invalid' => 'Girdiğiniz kod geçersiz.',
],
],
],
'actions' => [
'submit' => [
'label' => 'E-posta doğrulama kodlarını devre dışı bırak',
],
],
],
'notifications' => [
'disabled' => [
'title' => 'E-posta doğrulama kodları devre dışı bırakıldı',
],
],
];

View File

@@ -0,0 +1,38 @@
<?php
return [
'label' => 'Kur',
'modal' => [
'heading' => 'E-posta doğrulama kodlarını kur',
'description' => 'Her giriş yaptığınızda veya hassas işlemler gerçekleştirdiğinizde size e-posta ile gönderdiğimiz 6 haneli kodu girmeniz gerekecek. Kurulumu tamamlamak için e-postanızı kontrol edin ve 6 haneli kodu girin.',
'form' => [
'code' => [
'label' => 'Size e-posta ile gönderdiğimiz 6 haneli kodu girin',
'validation_attribute' => 'kod',
'actions' => [
'resend' => [
'label' => 'E-posta ile yeni kod gönder',
'notifications' => [
'resent' => [
'title' => 'Size e-posta ile yeni bir kod gönderdik',
],
],
],
],
'messages' => [
'invalid' => 'Girdiğiniz kod geçersiz.',
],
],
],
'actions' => [
'submit' => [
'label' => 'E-posta doğrulama kodlarını etkinleştir',
],
],
],
'notifications' => [
'enabled' => [
'title' => 'E-posta doğrulama kodları etkinleştirildi',
],
],
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'subject' => 'Giriş kodunuz',
'lines' => [
0 => 'Giriş kodunuz: :code',
1 => 'Bu kod 1 dakika içinde geçersiz olacak.|Bu kod :minutes dakika içinde geçersiz olacak.',
],
];

View File

@@ -0,0 +1,34 @@
<?php
return [
'management_schema' => [
'actions' => [
'label' => 'E-posta doğrulama kodları',
'below_content' => 'Giriş sırasında kimliğinizi doğrulamak için e-posta adresinize geçici bir kod alın.',
'messages' => [
'enabled' => 'Etkin',
'disabled' => 'Devre dışı',
],
],
],
'login_form' => [
'label' => 'E-postanıza kod gönder',
'code' => [
'label' => 'Size e-posta ile gönderdiğimiz 6 haneli kodu girin',
'validation_attribute' => 'kod',
'actions' => [
'resend' => [
'label' => 'E-posta ile yeni kod gönder',
'notifications' => [
'resent' => [
'title' => 'Size e-posta ile yeni bir kod gönderdik',
],
],
],
],
'messages' => [
'invalid' => 'Girdiğiniz kod geçersiz.',
],
],
],
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => 'İki faktörlü kimlik doğrulamayı (2FA) kur',
'heading' => 'İki faktörlü kimlik doğrulamayı kur',
'subheading' => '2FA, giriş yaparken ikinci bir doğrulama formu gerektirerek hesabınıza ek bir güvenlik katmanı ekler.',
'actions' => [
'continue' => [
'label' => 'Devam et',
],
],
];

View File

@@ -0,0 +1,18 @@
<?php
return [
'actions' => [
0 => 'Tüm kodları',
'copy' => [
'label' => 'kopyalamak',
],
1 => 'or',
'download' => [
'label' => 'indirmek',
],
2 => 'için tıklayın.',
],
'messages' => [
'copied' => 'Göçürildi',
],
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'subject' => 'E-posta adresiniz güncelleniyor',
'lines' => [
0 => 'E-posta adresinizin güncellenmesi isteğini aldık. Bu güncelleme işlemi şifreniz kullanılarak doğrulanmıştır.',
1 => 'Doğrulanınca hesabınızın E-posta adresi belirtilen adres olarak güncellenecektir: :email.',
2 => 'Aşağıdaki butonu kullanarak E-posta adresi doğrulanmadan bu güncelleme isteğini iptal edebilirsiniz.',
3 => 'Eğer bu isteği siz yapmadıysanız lütfen bizimle iletişime geçin.',
],
'action' => 'Güncelleme İsteğini İptal Et',
];

View File

@@ -0,0 +1,48 @@
<?php
return [
'label' => 'Profil',
'form' => [
'email' => [
'label' => 'E-poçta salgysy',
],
'name' => [
'label' => 'Ad',
],
'password' => [
'label' => 'Yeni şifre',
'validation_attribute' => 'şifre',
],
'password_confirmation' => [
'label' => 'Yeni şifreyi onayla',
'validation_attribute' => 'şifre onayı',
],
'current_password' => [
'label' => 'Güncel şifre',
'below_content' => 'Güvenliğiniz için lütfen güncel şifrenizi girin.',
'validation_attribute' => 'güncel şifre',
],
'actions' => [
'save' => [
'label' => 'Değişiklikleri Kaydet',
],
],
],
'multi_factor_authentication' => [
'label' => 'İki Faktörlü Doğrulama (2FA)',
],
'notifications' => [
'email_change_verification_sent' => [
'title' => 'E-posta adresi güncelleme isteği gönderildi',
'body' => 'E-posta adresi güncelleme isteği :email adresine gönderildi. Lütfen güncellemeyi tamamlamak için E-posta adresinizi doğrulayın.',
],
'saved' => [
'title' => 'Kaydedildi',
],
],
'actions' => [
'cancel' => [
'label' => 'Ýatyr',
],
],
];

View File

@@ -0,0 +1,24 @@
<?php
return [
'title' => 'E-posta adresinizi doğrulayın',
'heading' => 'E-posta adresinizi doğrulayın',
'actions' => [
'resend_notification' => [
'label' => 'Yeniden Gönder',
],
],
'messages' => [
'notification_not_received' => 'Gönderdiğimiz e-postayı almadınız mı?',
'notification_sent' => ':email adresine, e-posta adresinizi nasıl doğrulayacağınıza ilişkin talimatları içeren bir e-posta gönderdik.',
],
'notifications' => [
'notification_resent' => [
'title' => 'E-posta yeniden gönderildi.',
],
'notification_resend_throttled' => [
'title' => 'Çok fazla yeniden gönderme denemesi',
'body' => ':seconds sekuntdan soň gaýtadan synanyşyň.',
],
],
];

View File

@@ -0,0 +1,85 @@
<?php
return [
'title' => 'Giriş',
'heading' => 'Girmek',
'actions' => [
'register' => [
'before' => 'ýa-da',
'label' => 'hasap açmak',
],
'request_password_reset' => [
'label' => 'Paroly ýatdan çykardyňyzmy?',
],
],
'form' => [
'email' => [
'label' => 'E-poçta salgysy',
],
'password' => [
'label' => 'Parol',
],
'remember' => [
'label' => 'Meni ýatda sakla',
],
'actions' => [
'authenticate' => [
'label' => 'Girmek',
],
],
],
'multi_factor' => [
'heading' => 'Şahsyýetiňizi tassyklaň',
'subheading' => 'Girmegi dowam etdirmek üçin şahsyýetiňizi tassyklaň.',
'form' => [
'provider' => [
'label' => 'Nähili tassyklamak isleýärsiňiz?',
],
'actions' => [
'authenticate' => [
'label' => 'Girişi tassykla',
],
],
],
],
'messages' => [
'failed' => 'Bu maglumatlar biziň ýazgylarymyza gabat gelenok.',
],
'notifications' => [
'throttled' => [
'title' => 'Giriş synanyşyklary köp',
'body' => ':seconds sekuntdan soň gaýtadan synanyşyň.',
],
],
];

View File

@@ -0,0 +1,30 @@
<?php
return [
'title' => 'Şifrenizi sıfırlayın',
'heading' => 'Şifrenizi mi unuttunuz?',
'actions' => [
'login' => [
'label' => 'girişe geri dön',
],
],
'form' => [
'email' => [
'label' => 'E-poçta salgysy',
],
'actions' => [
'request' => [
'label' => 'E-posta gönder',
],
],
],
'notifications' => [
'sent' => [
'body' => 'Eğer hesabınız yoksa bir e-posta almayacaksınız.',
],
'throttled' => [
'title' => 'Çok fazla istek',
'body' => ':seconds sekuntdan soň gaýtadan synanyşyň.',
],
],
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'title' => 'Şifrenizi sıfırlayın',
'heading' => 'Şifrenizi sıfırlayın',
'form' => [
'email' => [
'label' => 'E-poçta salgysy',
],
'password' => [
'label' => 'Parol',
'validation_attribute' => 'password',
],
'password_confirmation' => [
'label' => 'Şifreyi onayla',
],
'actions' => [
'reset' => [
'label' => 'Şifreyi sıfırla',
],
],
],
'notifications' => [
'throttled' => [
'title' => 'Çok fazla sıfırlama denemesi',
'body' => ':seconds sekuntdan soň gaýtadan synanyşyň.',
],
],
];

View File

@@ -0,0 +1,38 @@
<?php
return [
'title' => 'Kayıt Ol',
'heading' => 'Üye Ol',
'actions' => [
'login' => [
'before' => 'ýa-da',
'label' => 'hesabınıza giriş yapın',
],
],
'form' => [
'email' => [
'label' => 'E-poçta salgysy',
],
'name' => [
'label' => 'Ad',
],
'password' => [
'label' => 'Parol',
'validation_attribute' => 'password',
],
'password_confirmation' => [
'label' => 'Şifreyi onayla',
],
'actions' => [
'register' => [
'label' => 'Üye ol',
],
],
],
'notifications' => [
'throttled' => [
'title' => 'Çok fazla kayıt denemesi',
'body' => ':seconds sekuntdan soň gaýtadan synanyşyň.',
],
],
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'title' => 'Sayfa yüklenirken hata oluştu',
'body' => 'Sayfa yüklenirken bir hata oluştu. Lütfen daha sonra tekrar deneyin.',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'field' => [
'label' => 'Genel arama',
'placeholder' => 'Gözleg',
],
'no_results_message' => 'Sonuç bulunamadı.',
];

View File

@@ -0,0 +1,72 @@
<?php
return [
'direction' => 'ltr',
'actions' => [
'billing' => [
'label' => 'Abunalygy dolandyr',
],
'logout' => [
'label' => 'Çykmak',
],
'open_database_notifications' => [
'label' => 'Habarnamalar',
],
'open_user_menu' => [
'label' => 'Ulanyjy menýusy',
],
'sidebar' => [
'collapse' => [
'label' => 'Gapdal paneli ýygnamak',
],
'expand' => [
'label' => 'Gapdal paneli giňeltmek',
],
],
'theme_switcher' => [
'dark' => [
'label' => 'Gara tema açmak',
],
'light' => [
'label' => 'Açyk tema açmak',
],
'system' => [
'label' => 'Ulgam temasyny açmak',
],
],
],
'avatar' => [
'alt' => ':name awatary',
],
'logo' => [
'alt' => ':name logotipi',
],
'tenant_menu' => [
'search_field' => [
'label' => 'Kirdeçi gözlegi',
'placeholder' => 'Gözleg',
],
],
];

View File

@@ -0,0 +1,33 @@
<?php
return [
'title' => 'Dolandyryş paneli',
'actions' => [
'filter' => [
'label' => 'Süzgüç',
'modal' => [
'heading' => 'Süzgüç',
'actions' => [
'apply' => [
'label' => 'Ulan',
],
],
],
],
],
];

View File

@@ -0,0 +1,16 @@
<?php
return [
'form' => [
'actions' => [
'save' => [
'label' => 'Değişiklikleri kaydet',
],
],
],
'notifications' => [
'saved' => [
'title' => 'Kaydedildi',
],
],
];

View File

@@ -0,0 +1,24 @@
<?php
return [
'title' => ':label oluştur',
'breadcrumb' => 'Döret',
'form' => [
'actions' => [
'cancel' => [
'label' => 'Ýatyr',
],
'create' => [
'label' => 'Döret',
],
'create_another' => [
'label' => 'Oluştur & yeni oluştur',
],
],
],
'notifications' => [
'created' => [
'title' => 'Oluşturuldu',
],
],
];

View File

@@ -0,0 +1,27 @@
<?php
return [
'title' => ':label üýtget',
'breadcrumb' => 'Üýtget',
'navigation_label' => 'Üýtget',
'form' => [
'actions' => [
'cancel' => [
'label' => 'Ýatyr',
],
'save' => [
'label' => 'Değişiklikleri kaydet',
],
],
],
'content' => [
'tab' => [
'label' => 'Üýtget',
],
],
'notifications' => [
'saved' => [
'title' => 'Kaydedildi',
],
],
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'breadcrumb' => 'Sanaw',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'title' => ':label :relationship yönet',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'title' => ':label gör',
'breadcrumb' => 'Gör',
'navigation_label' => 'Gör',
'content' => [
'tab' => [
'label' => 'Gör',
],
],
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'body' => 'Kayıt edilmemiş değişiklikleriniz mevcut. Bu sayfayı terk etmek istediğinize emin misiniz?',
];

View File

@@ -0,0 +1,10 @@
<?php
return [
'actions' => [
'logout' => [
'label' => 'Çykmak',
],
],
'welcome' => 'Hoş geldin',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'actions' => [
'open_documentation' => [
'label' => 'Dokümantasyon',
],
'open_github' => [
'label' => 'GitHub',
],
],
];

View File

@@ -0,0 +1,333 @@
<?php
return [
'label' => 'Sorgu oluşturucu',
'form' => [
'operator' => [
'label' => 'Operatör',
],
'or_groups' => [
'label' => 'Gruplar',
'block' => [
'label' => 'Veya (OR)',
'or' => 'VEYA',
],
],
'rules' => [
'label' => 'Kurallar',
'item' => [
'and' => 'VE',
],
],
],
'no_rules' => '(Kural yok)',
'item_separators' => [
'and' => 'VE',
'or' => 'VEYA',
],
'operators' => [
'is_filled' => [
'label' => [
'direct' => 'Dolu',
'inverse' => 'Boş',
],
'summary' => [
'direct' => ':attribute dolu',
'inverse' => ':attribute boş',
],
],
'boolean' => [
'is_true' => [
'label' => [
'direct' => 'Doğru',
'inverse' => 'Yanlış',
],
'summary' => [
'direct' => ':attribute doğru',
'inverse' => ':attribute yanlış',
],
],
],
'date' => [
'is_after' => [
'label' => [
'direct' => 'Sonra',
'inverse' => 'Sonra değil',
],
'summary' => [
'direct' => ':attribute :date tarihinden sonra',
'inverse' => ':attribute :date tarihinden sonra değil',
],
],
'is_before' => [
'label' => [
'direct' => 'Önce',
'inverse' => 'Önce değil',
],
'summary' => [
'direct' => ':attribute :date tarihinden önce',
'inverse' => ':attribute :date tarihinden önce değil',
],
],
'is_date' => [
'label' => [
'direct' => 'Tarihtir',
'inverse' => 'Tarih değildir',
],
'summary' => [
'direct' => ':attribute :date',
'inverse' => ':attribute :date değil',
],
],
'is_month' => [
'label' => [
'direct' => 'Aydır',
'inverse' => 'Ay değildir',
],
'summary' => [
'direct' => ':attribute :month',
'inverse' => ':attribute :month değil',
],
],
'is_year' => [
'label' => [
'direct' => 'Yıldır',
'inverse' => 'Yıl değildir',
],
'summary' => [
'direct' => ':attribute :year',
'inverse' => ':attribute :year değil',
],
],
'form' => [
'date' => [
'label' => 'Tarih',
],
'month' => [
'label' => 'Ay',
],
'year' => [
'label' => 'Yıl',
],
],
],
'number' => [
'equals' => [
'label' => [
'direct' => 'Eşittir',
'inverse' => 'Eşit değildir',
],
'summary' => [
'direct' => ':attribute :number\'a eşittir',
'inverse' => ':attribute :number\'a eşit değildir',
],
],
'is_max' => [
'label' => [
'direct' => 'Maksimum',
'inverse' => 'Büyüktür',
],
'summary' => [
'direct' => ':attribute maksimum :number',
'inverse' => ':attribute :number\'dan büyük',
],
],
'is_min' => [
'label' => [
'direct' => 'Minimum',
'inverse' => 'Küçüktür',
],
'summary' => [
'direct' => ':attribute minimum :number',
'inverse' => ':attribute :number\'dan küçük',
],
],
'aggregates' => [
'average' => [
'label' => 'Ortalama',
'summary' => 'Ortalama :attribute',
],
'max' => [
'label' => 'Maksimum',
'summary' => 'Maksimum :attribute',
],
'min' => [
'label' => 'Min',
'summary' => 'Min :attribute',
],
'sum' => [
'label' => 'Toplam',
'summary' => ':attribute toplamı',
],
],
'form' => [
'aggregate' => [
'label' => 'Toplam',
],
'number' => [
'label' => 'Sayı',
],
],
],
'relationship' => [
'equals' => [
'label' => [
'direct' => 'Sahip',
'inverse' => 'Sahip değil',
],
'summary' => [
'direct' => ':count :relationship mevcut',
'inverse' => ':count :relationship mevcut değil',
],
],
'has_max' => [
'label' => [
'direct' => 'En fazla',
'inverse' => 'Has köp',
],
'summary' => [
'direct' => 'En fazla :count :relationship',
'inverse' => ':count adetten fazla :relationship',
],
],
'has_min' => [
'label' => [
'direct' => 'En az',
'inverse' => 'Has az',
],
'summary' => [
'direct' => 'En az :count :relationship',
'inverse' => ':count adetten az :relationship',
],
],
'is_empty' => [
'label' => [
'direct' => 'Boş',
'inverse' => 'Boş değil',
],
'summary' => [
'direct' => ':relationship boş',
'inverse' => ':relationship boş değil',
],
],
'is_related_to' => [
'label' => [
'single' => [
'direct' => 'Eşittir',
'inverse' => 'Eşit değildir',
],
'multiple' => [
'direct' => 'İçerir',
'inverse' => 'İçermez',
],
],
'summary' => [
'single' => [
'direct' => ':relationship :values',
'inverse' => ':relationship :values değil',
],
'multiple' => [
'direct' => ':relationship :values içerir',
'inverse' => ':relationship :values içermez',
],
'values_glue' => [
0 => ', ',
'final' => ' veya ',
],
],
'form' => [
'value' => [
'label' => 'Değer',
],
'values' => [
'label' => 'Değerler',
],
],
],
'form' => [
'count' => [
'label' => 'Sayı',
],
],
],
'select' => [
'is' => [
'label' => [
'direct' => 'Eşittir',
'inverse' => 'Eşit değildir',
],
'summary' => [
'direct' => ':attribute :values',
'inverse' => ':attribute :values değil',
'values_glue' => [
0 => ', ',
'final' => ' veya ',
],
],
'form' => [
'value' => [
'label' => 'Değer',
],
'values' => [
'label' => 'Değerler',
],
],
],
],
'text' => [
'contains' => [
'label' => [
'direct' => 'İçerir',
'inverse' => 'İçermez',
],
'summary' => [
'direct' => ':attribute :text içerir',
'inverse' => ':attribute :text içermez',
],
],
'ends_with' => [
'label' => [
'direct' => 'Şununla biter',
'inverse' => 'Şununla bitmez',
],
'summary' => [
'direct' => ':attribute :text ile biter',
'inverse' => ':attribute :text ile bitmez',
],
],
'equals' => [
'label' => [
'direct' => 'Eşittir',
'inverse' => 'Eşit değildir',
],
'summary' => [
'direct' => ':attribute :text\'e eşittir',
'inverse' => ':attribute :text\'e eşit değildir',
],
],
'starts_with' => [
'label' => [
'direct' => 'Şununla başlar',
'inverse' => 'Şununla başlamaz',
],
'summary' => [
'direct' => ':attribute :text ile başlar',
'inverse' => ':attribute :text ile başlamaz',
],
],
'form' => [
'text' => [
'label' => 'Metin',
],
],
],
],
'actions' => [
'add_rule' => [
'label' => 'Kural ekle',
],
'add_rule_group' => [
'label' => 'Kural grubu ekle',
],
],
];

View File

@@ -0,0 +1,14 @@
<?php
return [
'wizard' => [
'actions' => [
'previous_step' => [
'label' => 'Yza',
],
'next_step' => [
'label' => 'İleri',
],
],
],
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'messages' => [
'uploading_file' => 'Faýl ýüklenýär...',
],
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'messages' => [
'copied' => 'Göçürildi',
],
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'actions' => [
'close' => [
'label' => 'Ýap',
],
],
];

View File

@@ -0,0 +1,47 @@
<?php
return [
'label' => 'Sahypalar navigasiýasy',
'overview' => '{1} 1 netije görkezilýär|[2,*] :total netijeden :first:last görkezilýär',
'fields' => [
'records_per_page' => [
'label' => 'Sahypada',
'options' => [
'all' => 'Hemmesi',
],
],
],
'actions' => [
'first' => [
'label' => 'Ilkinji',
],
'go_to_page' => [
'label' => ':page sahypa geç',
],
'last' => [
'label' => 'Soňky',
],
'next' => [
'label' => 'Indiki',
],
'previous' => [
'label' => 'Öňki',
],
],
];

267
lang/vendor/filament-tables/tk/table.php vendored Normal file
View File

@@ -0,0 +1,267 @@
<?php
return [
'column_manager' => [
'heading' => 'Sütünler',
'actions' => [
'apply' => [
'label' => 'Sütünleri ulan',
],
'reset' => [
'label' => 'Täzeden',
],
],
],
'columns' => [
'actions' => [
'label' => 'Hereket|Hereketler',
],
'select' => [
'loading_message' => 'Ýüklenýär...',
'no_options_message' => 'Saýlaw ýok.',
'no_search_results_message' => 'Gözlege laýyk saýlaw tapylmady.',
'placeholder' => 'Saýlaw saýlaň',
'searching_message' => 'Gözlenýär...',
'search_prompt' => 'Gözlemek üçin ýazyň...',
],
'text' => [
'actions' => [
'collapse_list' => ':count az görkez',
'expand_list' => ':count köp görkez',
],
'more_list_items' => 'we ýene :count',
],
],
'fields' => [
'bulk_select_page' => [
'label' => 'Köp hereketler üçin ähli elementleri saýla/saýlawy aýyr.',
],
'bulk_select_record' => [
'label' => 'Köp hereketler üçin :key elementini saýla/saýlawy aýyr.',
],
'bulk_select_group' => [
'label' => 'Köp hereketler üçin :title toparyny saýla/saýlawy aýyr.',
],
'search' => [
'label' => 'Gözleg',
'placeholder' => 'Gözleg',
'indicator' => 'Gözleg',
],
],
'summary' => [
'heading' => 'Jemi',
'subheadings' => [
'all' => 'Ähli :label',
'group' => ':group jemi',
'page' => 'Bu sahypa',
],
'summarizers' => [
'average' => [
'label' => 'Ortaça',
],
'count' => [
'label' => 'Sany',
],
'sum' => [
'label' => 'Jemi',
],
],
],
'actions' => [
'disable_reordering' => [
'label' => 'Tertiplemegi tamamla',
],
'enable_reordering' => [
'label' => 'Ýazgylary tertiple',
],
'filter' => [
'label' => 'Süzgüç',
],
'group' => [
'label' => 'Toparla',
],
'open_bulk_actions' => [
'label' => 'Köp hereketler',
],
'column_manager' => [
'label' => 'Sütün dolandyryşy',
],
],
'empty' => [
'heading' => ':model ýok',
'description' => 'Başlamak üçin :model dörediň.',
],
'filters' => [
'actions' => [
'apply' => [
'label' => 'Süzgüçleri ulan',
],
'remove' => [
'label' => 'Süzgüçi aýyr',
],
'remove_all' => [
'label' => 'Ähli süzgüçleri aýyr',
'tooltip' => 'Ähli süzgüçleri aýyr',
],
'reset' => [
'label' => 'Täzeden',
],
],
'heading' => 'Süzgüçler',
'indicator' => 'Işjeň süzgüçler',
'multi_select' => [
'placeholder' => 'Hemmesi',
],
'select' => [
'placeholder' => 'Hemmesi',
'relationship' => [
'empty_option_label' => 'Ýok',
],
],
'trashed' => [
'label' => 'Pozulan ýazgylar',
'only_trashed' => 'Diňe pozulan ýazgylar',
'with_trashed' => 'Pozulanlar bilen',
'without_trashed' => 'Pozulmadyklar',
],
],
'grouping' => [
'fields' => [
'group' => [
'label' => 'Toparla',
],
'direction' => [
'label' => 'Topar ugry',
'options' => [
'asc' => 'Artýan',
'desc' => 'Pesýän',
],
],
],
],
'reorder_indicator' => 'Ýazgylary tertiplemek üçin süýşüriň.',
'selection_indicator' => [
'selected_count' => '1 ýazgy saýlandy|:count ýazgy saýlandy',
'actions' => [
'select_all' => [
'label' => 'Ähli :count saýla',
],
'deselect_all' => [
'label' => 'Saýlawy aýyr',
],
],
],
'sorting' => [
'fields' => [
'column' => [
'label' => 'Tertiple',
],
'direction' => [
'label' => 'Tertip ugry',
'options' => [
'asc' => 'Artýan',
'desc' => 'Pesýän',
],
],
],
],
'default_model_label' => 'ýazgy',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'actions' => [
'filter' => [
'label' => 'Süzgüç',
],
],
];

500
resources/codes/codes.txt Normal file
View File

@@ -0,0 +1,500 @@
1_3VKL
2_MCPW
3_7PMX
4_TZT9
5_0C7Y
6_WIVJ
7_SGAJ
8_H4U8
9_NS9X
10_812A
11_EK7K
12_TE33
13_XWNL
14_1NTQ
15_CDLW
16_8JUV
17_JPDJ
18_U21E
19_EBRZ
20_UPF8
21_PKLI
22_21DT
23_BGR4
24_USZQ
25_8GQ3
26_CVRV
27_XNJG
28_THB6
29_PMCG
30_MFT5
31_WDP9
32_Q30T
33_VOOK
34_0BTS
35_TULN
36_4VV9
37_K2F4
38_LMBA
39_85X7
40_4PUS
41_9OLE
42_11YM
43_WRI9
44_F2M0
45_VW7L
46_LZ79
47_7PY5
48_R80B
49_M2QS
50_MNC6
51_91MD
52_TE3C
53_AF5L
54_EF1S
55_8EKV
56_KPYO
57_HSW6
58_PBEN
59_F3NM
60_GA7C
61_BBFM
62_BP3J
63_FC7K
64_K7YK
65_WIPQ
66_ZJ6A
67_U0GY
68_FSMM
69_YDEG
70_98JY
71_3UF4
72_WCLV
73_89K6
74_HZFK
75_UC84
76_M55S
77_LAG2
78_NZ5P
79_DKIG
80_DP4T
81_UYOC
82_ILY0
83_GR2J
84_KM6B
85_KVI3
86_1SSF
87_NNPW
88_I1OD
89_GDMS
90_8QUH
91_8JMX
92_KHVE
93_WM7B
94_L4O6
95_QQ49
96_4JC1
97_TDI2
98_1TM0
99_5E3R
100_KP4E
101_9O8Y
102_TXAO
103_ITQ4
104_NZAZ
105_RIWG
106_OTA9
107_1CW5
108_0IF4
109_GURI
110_JUM2
111_WDMH
112_93G4
113_HWVP
114_OZTR
115_9C6X
116_HC8N
117_293T
118_GL0T
119_N0DT
120_QZ4D
121_EG3S
122_W4TI
123_3RW6
124_KT1X
125_RKPO
126_R8ZS
127_XZS1
128_U41S
129_H8VI
130_IZD7
131_3FJA
132_EWCU
133_DIEM
134_TO2S
135_49NX
136_CEEL
137_Y9MG
138_YO87
139_3081
140_ZECX
141_NMH9
142_8W3Z
143_KNV9
144_LZU9
145_36HY
146_SF8N
147_IZGI
148_Q8HH
149_5PQK
150_BJM8
151_6LFK
152_XTBN
153_4D37
154_2DAI
155_5AZ7
156_UQ0A
157_XPRJ
158_EHRC
159_JC06
160_LA9J
161_B5SZ
162_W5W3
163_OF1U
164_GC5D
165_8WK8
166_XH10
167_R7H0
168_GWOT
169_8M0N
170_BRAA
171_PL05
172_7DCY
173_Q8E7
174_J7QE
175_RPKN
176_7UKB
177_927Y
178_M4OZ
179_VF3C
180_0LCS
181_CU8W
182_HCVL
183_EF1H
184_AOG1
185_K3QX
186_DY9P
187_X658
188_LZZW
189_959S
190_O577
191_X1NU
192_KU9L
193_QFUC
194_CB48
195_ZR2G
196_UIFK
197_BL2X
198_L896
199_QIU6
200_N1XM
201_IIZW
202_H1W8
203_KUAT
204_HM4E
205_BWLL
206_RP4T
207_LSU6
208_EXBW
209_I74Q
210_QUKQ
211_66SO
212_PSOP
213_4HJU
214_IPYE
215_I1W7
216_X8ZV
217_YEZB
218_MPMJ
219_A5GT
220_1HJT
221_8RWP
222_URNI
223_G90D
224_BXEN
225_QYEV
226_K20E
227_6X2P
228_JI0S
229_PTDE
230_LLLG
231_3IWX
232_RHNR
233_F8XQ
234_SXRC
235_Y4LK
236_1E5V
237_CO7W
238_U7Y1
239_8QXP
240_0WO7
241_BS3V
242_E5T2
243_0ETP
244_7MIU
245_25DW
246_OCPS
247_XBP9
248_QDA1
249_2CXJ
250_STYT
251_ZNLZ
252_Z0NJ
253_PE45
254_8K30
255_WK9E
256_N12C
257_NHU8
258_JM5W
259_QAP6
260_O73L
261_3TFN
262_23I2
263_6ZT4
264_AV12
265_9S0I
266_0WIB
267_MW1C
268_HL55
269_0L3E
270_JMKI
271_U4EJ
272_YLN2
273_MR75
274_8K7S
275_JPVV
276_SMQ8
277_935M
278_H7SA
279_Z0WG
280_P58E
281_RUQL
282_Q84V
283_YH6B
284_F5YL
285_S8FP
286_FW42
287_LACT
288_UG5P
289_82KV
290_MGJN
291_3BBA
292_W62R
293_2D61
294_E6Q8
295_X43E
296_OWL9
297_H79G
298_S1DH
299_JRKD
300_KG68
301_9ER6
302_1MVY
303_3ST4
304_LMTD
305_SB8C
306_J42I
307_EIUX
308_U5BL
309_RZ2E
310_20L2
311_Y2EY
312_YEUU
313_HBY3
314_SNPT
315_7W31
316_03QM
317_WTA6
318_2Z9V
319_4C2F
320_5HX6
321_X2H7
322_OHYV
323_ORN8
324_CA5O
325_AJFS
326_EGN1
327_VBR0
328_VO3N
329_8QP5
330_0A19
331_R1BO
332_FKH9
333_KKUO
334_L7UR
335_LSKK
336_ML7S
337_MA9Y
338_GGCE
339_0NL9
340_DXMF
341_8I2Z
342_KWO0
343_K2XY
344_Z8FN
345_R04V
346_IGGL
347_3YL3
348_E3W3
349_FU44
350_0X7B
351_GI6E
352_O3LC
353_WER4
354_E1WM
355_7HPZ
356_SRL0
357_C0IG
358_T47P
359_3ADP
360_MWPI
361_XKW0
362_KR6N
363_U53I
364_64LP
365_N2BN
366_16M0
367_FDS4
368_RY2M
369_PPCM
370_X7IX
371_6XUN
372_6IDF
373_YL7O
374_OTPM
375_B086
376_RLMV
377_UNQN
378_NX6Q
379_335Q
380_OB1Z
381_ULKQ
382_QHL2
383_ZFFS
384_NU7Z
385_EM9Z
386_GOM7
387_R2WL
388_J350
389_KMA3
390_8MPR
391_PFZB
392_P8W4
393_E36O
394_FA0T
395_VLC4
396_49FF
397_0RMD
398_FHCI
399_ZT6A
400_EE45
401_4ULP
402_2HHJ
403_33D8
404_QQBW
405_KM9U
406_5YQ2
407_KAN2
408_81RU
409_8H5W
410_R2FH
411_YHG1
412_W15M
413_9MGE
414_BL23
415_44MX
416_0XN1
417_N1SE
418_EV2G
419_FDMW
420_L599
421_O8I8
422_SDRS
423_5FLL
424_IKYD
425_ZGU2
426_AJM6
427_PL0Q
428_BQN7
429_AFGG
430_8XWJ
431_916E
432_QG5T
433_PU29
434_SKS9
435_BVS6
436_ROUE
437_Z772
438_KYU5
439_4KY9
440_8CMS
441_QIYG
442_ZTBY
443_PPS1
444_T5EX
445_2FH9
446_ULQX
447_BI9M
448_KSL5
449_852B
450_399J
451_0PQS
452_S75P
453_N4FI
454_KZ81
455_4LAG
456_O33N
457_C9HS
458_J28X
459_0M05
460_KIHC
461_NX86
462_F4IB
463_KGXK
464_C9LJ
465_L2VB
466_HU09
467_GAFU
468_B4AA
469_6O5G
470_8FB1
471_I9KU
472_I7MX
473_L5OM
474_OBTX
475_T6YS
476_6KSS
477_6H6A
478_W6IU
479_PR66
480_6D8V
481_8MSK
482_VV5S
483_VO57
484_EZJL
485_8H2G
486_RKBS
487_NSJ9
488_J316
489_JQCW
490_9IGH
491_5HPW
492_37JS
493_Y4M6
494_Z9UH
495_8Q2V
496_M3L4
497_LVE9
498_8FZ5
499_2NK3
500_BW2M

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->content }}
</x-filament-panels::page>

View File

@@ -78,9 +78,13 @@
<!-- Action Buttons -->
<div class="w-full max-w-sm mt-2xl flex flex-col gap-md">
<button class="w-full bg-primary text-on-primary py-md rounded-full font-label-md text-label-md shadow-[0_4px_12px_rgba(0,105,72,0.3)] hover:opacity-90 active:scale-95 transition-all">
Häzir ulan
</button>
<a
href="https://daragt.com"
target="_blank"
class="w-full bg-primary text-on-primary py-md rounded-full font-label-md text-label-md shadow-[0_4px_12px_rgba(0,105,72,0.3)] hover:opacity-90 active:scale-95 transition-all text-center"
>
Daragt harytlary görmek
</a>
</div>
<!-- Info Card -->

View File

@@ -0,0 +1,29 @@
@extends('layouts.app')
@section('header-back')
<div class="w-10"></div>
@endsection
@section('content')
<main class="flex-grow pt-2xl px-container-padding pb-xl flex flex-col mt-16 max-w-md mx-auto w-full">
<div class="flex gap-xs mb-2xl justify-center">
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
<div class="h-1.5 w-8 rounded-full bg-outline-variant"></div>
</div>
<section class="space-y-base mb-2xl text-center">
<h2 class="font-headline-xl text-headline-xl text-on-background">Aksiýa tamamlandy</h2>
<p class="font-body-md text-body-md text-on-surface-variant">
{{ \App\Exceptions\CouponPoolExhaustedException::MESSAGE }}
</p>
</section>
<div class="flex items-start gap-sm bg-secondary-container/30 p-md rounded border border-secondary-container">
<x-app-icon name="info" class="w-6 h-6 text-on-secondary-container mt-0.5 shrink-0" />
<p class="font-body-sm text-body-sm text-on-secondary-container text-left">
Ähli kuponlar paýlandy. Öň agza bolan ulanyjylar öz kodlaryny «Gutlaýarys» sahypasyndan görüp bilýärler.
</p>
</div>
</main>
@endsection

View File

@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature;
use App\Enums\SmsSendStatus;
use App\Jobs\SendSmsToCouponJob;
use App\Models\Coupon;
use App\Models\SmsCampaign;
use App\Models\SmsSendLog;
use App\Models\User;
use App\Services\SmsBroadcastService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class SmsBroadcastServiceTest extends TestCase
{
use LazilyRefreshDatabase;
private SmsBroadcastService $service;
protected function setUp(): void
{
parent::setUp();
Http::fake([
config('services.sms.url') => Http::response('ok', 200),
]);
$this->service = app(SmsBroadcastService::class);
}
public function test_resolve_recipients_for_all_excluding_ids(): void
{
$included = Coupon::factory()->count(2)->create();
$excluded = Coupon::factory()->create();
$recipients = $this->service->resolveRecipients(
sendToAll: true,
excludedIds: [$excluded->id],
);
$this->assertCount(2, $recipients);
$this->assertTrue($recipients->pluck('id')->contains($included->first()->id));
$this->assertFalse($recipients->pluck('id')->contains($excluded->id));
}
public function test_resolve_recipients_for_selected_only(): void
{
$selected = Coupon::factory()->create();
Coupon::factory()->create();
$recipients = $this->service->resolveRecipients(
sendToAll: false,
selectedIds: [$selected->id],
);
$this->assertCount(1, $recipients);
$this->assertSame($selected->id, $recipients->first()->id);
}
public function test_start_campaign_queues_chain_and_creates_logs(): void
{
Bus::fake();
$admin = User::factory()->create();
$coupons = Coupon::factory()->count(2)->create();
$campaign = $this->service->startCampaign(
admin: $admin,
message: 'Test broadcast',
sendToAll: false,
selectedIds: $coupons->pluck('id')->all(),
);
$this->assertSame(2, $campaign->recipient_count);
$this->assertDatabaseCount(SmsSendLog::class, 2);
$this->assertDatabaseHas(SmsSendLog::class, [
'sms_campaign_id' => $campaign->id,
'status' => SmsSendStatus::Pending->value,
]);
Bus::assertChained([
SendSmsToCouponJob::class,
SendSmsToCouponJob::class,
]);
}
public function test_invalid_phone_is_skipped_without_queue_job(): void
{
Bus::fake();
$admin = User::factory()->create();
$invalid = Coupon::factory()->create(['phone' => '81234567']);
$valid = Coupon::factory()->create();
$campaign = $this->service->startCampaign(
admin: $admin,
message: 'Test',
sendToAll: false,
selectedIds: [$invalid->id, $valid->id],
);
$this->assertSame(2, $campaign->recipient_count);
$this->assertSame(1, $campaign->skipped_count);
$this->assertDatabaseHas(SmsSendLog::class, [
'coupon_id' => $invalid->id,
'status' => SmsSendStatus::SkippedInvalid->value,
]);
Bus::assertChained([
SendSmsToCouponJob::class,
]);
}
public function test_send_job_marks_success_and_completes_campaign(): void
{
Queue::fake();
$admin = User::factory()->create();
$coupon = Coupon::factory()->create(['phone' => '61929248']);
$campaign = SmsCampaign::factory()->create([
'message' => 'Queued hello',
'recipient_count' => 1,
'created_by' => $admin->id,
]);
SmsSendLog::factory()->create([
'sms_campaign_id' => $campaign->id,
'coupon_id' => $coupon->id,
'phone' => $coupon->phone,
'message' => $campaign->message,
'status' => SmsSendStatus::Pending,
]);
$job = new SendSmsToCouponJob($campaign, $coupon);
$job->handle();
Http::assertSentCount(1);
$campaign->refresh();
$this->assertSame(1, $campaign->sent_count);
$this->assertNotNull($campaign->completed_at);
$this->assertDatabaseHas(SmsSendLog::class, [
'sms_campaign_id' => $campaign->id,
'status' => SmsSendStatus::Sent->value,
]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Feature;
use App\Jobs\SendSmsToCouponJob;
use App\Jobs\StartSmsCampaignJob;
use App\Models\Coupon;
use App\Models\User;
use App\Services\SmsBroadcastService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class StartSmsCampaignJobTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_job_is_dispatched_with_expected_payload(): void
{
Queue::fake();
$admin = User::factory()->create();
$coupon = Coupon::factory()->create();
StartSmsCampaignJob::dispatch(
adminId: $admin->id,
message: 'Hello',
sendToAll: false,
selectedIds: [$coupon->id],
excludedIds: [],
);
Queue::assertPushed(StartSmsCampaignJob::class, function (StartSmsCampaignJob $job) use ($admin, $coupon): bool {
return $job->adminId === $admin->id
&& $job->message === 'Hello'
&& $job->sendToAll === false
&& $job->selectedIds === [$coupon->id]
&& $job->excludedIds === [];
});
}
public function test_handle_starts_campaign_and_chains_send_jobs(): void
{
Bus::fake();
$admin = User::factory()->create();
$coupons = Coupon::factory()->count(2)->create();
$job = new StartSmsCampaignJob(
adminId: $admin->id,
message: 'Test broadcast',
sendToAll: false,
selectedIds: $coupons->pluck('id')->all(),
);
$job->handle(app(SmsBroadcastService::class));
Bus::assertChained([
SendSmsToCouponJob::class,
SendSmsToCouponJob::class,
]);
}
}

View File

@@ -2,10 +2,14 @@
namespace Tests\Feature;
use App\Exceptions\CouponPoolExhaustedException;
use App\Models\Coupon;
use App\Models\PhoneVerification;
use App\Services\CouponCodePool;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Http;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class VerificationFlowTest extends TestCase
@@ -82,15 +86,43 @@ class VerificationFlowTest extends TestCase
->assertRedirect(route('verification.congratulations'))
->assertCookie('device_registered');
$this->assertDatabaseHas(Coupon::class, [
'phone' => self::UNMASKED_PHONE,
]);
$coupon = Coupon::query()->where('phone', self::UNMASKED_PHONE)->first();
$this->assertNotNull($coupon);
$this->assertContains(
$coupon->code,
(new CouponCodePool)->allCodes(),
);
$this->get(route('verification.congratulations'))
->assertOk()
->assertViewHas('code');
}
public function test_index_shows_promotion_ended_when_pool_exhausted(): void
{
$this->mock(CouponCodePool::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasAvailable')->andReturn(false);
});
$this->get(route('verification.index'))
->assertOk()
->assertViewIs('verification.promotion-ended')
->assertSee(CouponPoolExhaustedException::MESSAGE);
}
public function test_send_otp_rejects_when_pool_exhausted(): void
{
$this->mock(CouponCodePool::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasAvailable')->andReturn(false);
});
$this->from(route('verification.index'))
->post(route('verification.send'), ['phone' => self::PHONE])
->assertRedirect(route('verification.index'))
->assertSessionHasErrors(['phone' => CouponPoolExhaustedException::MESSAGE]);
}
public function test_verify_otp_rejects_wrong_code(): void
{
PhoneVerification::factory()
@@ -164,6 +196,13 @@ class VerificationFlowTest extends TestCase
->assertStatus(429);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function extractOtpFromLastSms(): string
{
$recorded = Http::recorded()->all();
@@ -172,7 +211,7 @@ class VerificationFlowTest extends TestCase
[$request] = $recorded[array_key_last($recorded)];
preg_match('/(\d{4})/', (string) $request->data()['code'], $matches);
preg_match('/(\d{4})/', (string) $request->data()['message'], $matches);
$this->assertNotEmpty($matches[1]);

View File

@@ -0,0 +1,73 @@
<?php
namespace Tests\Unit;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Tests\TestCase;
class CouponCodePoolTest extends TestCase
{
use LazilyRefreshDatabase;
private string $codesPath;
protected function setUp(): void
{
parent::setUp();
$this->codesPath = tempnam(sys_get_temp_dir(), 'codes_');
file_put_contents($this->codesPath, "1_AAAA\n2_BBBB\n3_CCCC\n");
}
protected function tearDown(): void
{
if (is_file($this->codesPath)) {
unlink($this->codesPath);
}
parent::tearDown();
}
public function test_all_codes_loads_from_file(): void
{
$pool = new CouponCodePool($this->codesPath);
$this->assertSame(['1_AAAA', '2_BBBB', '3_CCCC'], $pool->allCodes());
}
public function test_available_codes_excludes_used_coupons(): void
{
Coupon::factory()->create(['code' => '1_AAAA', 'phone' => '61111111']);
$pool = new CouponCodePool($this->codesPath);
$this->assertEqualsCanonicalizing(['2_BBBB', '3_CCCC'], $pool->availableCodes());
}
public function test_pick_random_returns_only_unused_code(): void
{
Coupon::factory()->create(['code' => '2_BBBB', 'phone' => '61111111']);
$pool = new CouponCodePool($this->codesPath);
for ($i = 0; $i < 20; $i++) {
$picked = $pool->pickRandom();
$this->assertContains($picked, ['1_AAAA', '3_CCCC']);
}
}
public function test_has_available_is_false_when_all_codes_used(): void
{
Coupon::factory()->create(['code' => '1_AAAA', 'phone' => '61111111']);
Coupon::factory()->create(['code' => '2_BBBB', 'phone' => '62222222']);
Coupon::factory()->create(['code' => '3_CCCC', 'phone' => '63333333']);
$pool = new CouponCodePool($this->codesPath);
$this->assertFalse($pool->hasAvailable());
$this->assertNull($pool->pickRandom());
$this->assertSame([], $pool->availableCodes());
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Tests\Unit;
use App\Exceptions\CouponPoolExhaustedException;
use App\Models\Coupon;
use App\Services\CouponCodePool;
use App\Services\CouponService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class CouponServiceTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_find_or_create_assigns_code_from_pool(): void
{
$this->mock(CouponCodePool::class, function (MockInterface $mock): void {
$mock->shouldReceive('pickRandom')->once()->andReturn('1_AAAA');
});
$coupon = app(CouponService::class)->findOrCreateForPhone('61929248');
$this->assertSame('61929248', $coupon->phone);
$this->assertSame('1_AAAA', $coupon->code);
$this->assertDatabaseHas(Coupon::class, [
'phone' => '61929248',
'code' => '1_AAAA',
]);
}
public function test_find_or_create_returns_existing_coupon_without_picking(): void
{
$existing = Coupon::factory()->create(['phone' => '61929248', 'code' => '2_BBBB']);
$this->mock(CouponCodePool::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('pickRandom');
});
$coupon = app(CouponService::class)->findOrCreateForPhone('61929248');
$this->assertTrue($existing->is($coupon));
}
public function test_find_or_create_throws_when_pool_exhausted(): void
{
$this->mock(CouponCodePool::class, function (MockInterface $mock): void {
$mock->shouldReceive('pickRandom')->andReturn(null);
});
$this->expectException(CouponPoolExhaustedException::class);
app(CouponService::class)->findOrCreateForPhone('61929248');
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Tests\Unit;
use App\Services\SmsMessageAnalyzer;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class SmsMessageAnalyzerTest extends TestCase
{
private SmsMessageAnalyzer $analyzer;
protected function setUp(): void
{
parent::setUp();
$this->analyzer = new SmsMessageAnalyzer;
}
public function test_empty_message_has_zero_parts(): void
{
$this->assertSame(0, $this->analyzer->estimatedParts(''));
}
public function test_gsm_single_part_message(): void
{
$message = str_repeat('A', 160);
$this->assertTrue($this->analyzer->isGsm7($message));
$this->assertSame(1, $this->analyzer->estimatedParts($message));
}
public function test_gsm_multi_part_message(): void
{
$message = str_repeat('A', 161);
$this->assertSame(2, $this->analyzer->estimatedParts($message));
}
public function test_unicode_single_part_message(): void
{
$message = 'Привет';
$this->assertFalse($this->analyzer->isGsm7($message));
$this->assertSame(1, $this->analyzer->estimatedParts($message));
}
public function test_unicode_multi_part_message(): void
{
$message = str_repeat('Я', 71);
$this->assertSame(2, $this->analyzer->estimatedParts($message));
}
#[DataProvider('summaryProvider')]
public function test_summary(string $message, string $expectedSubstring): void
{
$this->assertStringContainsString($expectedSubstring, $this->analyzer->summary($message));
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function summaryProvider(): array
{
return [
'empty' => ['', '0 harp'],
'gsm' => ['Hello', '5 harp · 1 SMS'],
'unicode' => ['你好', '2 harp · 1 SMS'],
];
}
}