Commit d3113b08 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(registration): ask a product's own questions before selling it, and hold names to رباعي

Two things the desk could not previously be made to capture.

A product now carries the details it cannot be sold without — a size, a
colour, the name printed on the back. The admin declares them per product
as either free text or a list of predefined answers; the registration
wizard renders them inline on the cart row and refuses to move to payment
while a required one is blank.

None of this is a separate product, and none of it is reconstructable
after the fact from an invoice line that says "قميص تدريب" — which is why
the answers freeze onto invoice_items.metadata and into the line
description at sale time. Renaming "لارج" to "L" next season must not
rewrite what a player ordered last season, exactly as prices freeze.

hotbuyCustomizations is browser-writable by necessity — it is what the
receptionist is typing — so nothing downstream trusts it. Both the step
guard and confirm() re-read the questions through Eloquent (Product
carries BranchScope, so another branch's product resolves to nothing) and
check every answer against ProductCustomization::accepts(). A select must
match its own list: the dropdown is a convenience, not the check.

Separately, a person entered into the system must now carry a four-part
name. Egyptian records are keyed on it — national ID, birth certificate,
federation card, school file — and a player registered as "محمد أحمد"
matches none of them; two players sharing a first and father's name are
common enough that parts three and four are what tell them apart. Applied
where people are created at the desk and in the portal: the registration
wizard (player and guardian), retroactive enrolment, the participant form,
and portal sign-up. Four is a floor, not a target.

FullName splits on Unicode whitespace explicitly. \s under /u still only
means ASCII whitespace, and an Arabic keyboard produces U+00A0 — glued
together, a perfectly valid four-part name would have been rejected.

Deliberately left alone:
- ParticipantImport is exempt. Enforcing the rule on a bulk import would
  block loading an existing roster, which is the one case where the short
  names are already a fact.
- Records already in the database are untouched. This validates at entry.
- The POS terminal, settlement wizard, group screen and essential-
  deliveries screen sell these same products and do not yet ask for the
  customizations.
- Kits carry no customizations, only products.

Migration is additive and guarded; CHECK constraint matches the enum
character for character. 382 passed / 99 skipped on the restored Postgres
tenant, 303 passed / 178 skipped on SQLite.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 28fc2e02
<?php
namespace App\Domain\Identity\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* A person's name must be at least four parts — رباعي.
*
* Egyptian records are keyed on the four-part name: the national ID, the birth
* certificate, the federation card and the school file all carry it, and a
* player registered as "محمد أحمد" cannot be matched against any of them. Two
* players sharing a first and father's name are common enough that the third
* and fourth parts are what actually tell them apart, which is why the desk is
* held to it at entry rather than asked to repair it later.
*
* Four is a floor, not a target: five parts or more pass untouched.
*/
class FullName implements ValidationRule
{
public const MINIMUM_PARTS = 4;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value)) {
$fail('الاسم غير صالح');
return;
}
if (count(self::parts($value)) < self::MINIMUM_PARTS) {
$fail('يجب كتابة الاسم رباعياً على الأقل (٤ أسماء)');
}
}
/**
* The name broken into its parts.
*
* Splits on Unicode whitespace explicitly — `\s` under /u still only means
* ASCII whitespace, and an Arabic keyboard produces non-breaking spaces and
* direction marks that would otherwise glue two names into one token and
* fail a name that is perfectly valid. Tatweel (ـ) is decoration and is
* stripped rather than treated as a separator.
*
* @return array<int, string>
*/
public static function parts(string $value): array
{
$normalised = preg_replace('/\x{0640}+/u', '', $value) ?? $value;
$tokens = preg_split('/[\s\x{00A0}\x{200E}\x{200F}\x{202A}-\x{202E}]+/u', trim($normalised)) ?: [];
return array_values(array_filter($tokens, fn ($t) => $t !== ''));
}
public static function isSatisfiedBy(?string $value): bool
{
return count(self::parts((string) $value)) >= self::MINIMUM_PARTS;
}
}
<?php
namespace App\Domain\Inventory\Enums;
/**
* Values match product_customizations_type_check character for character.
*/
enum ProductCustomizationType: string
{
case Text = 'text';
case Select = 'select';
public function labelAr(): string
{
return match ($this) {
self::Text => 'إدخال نص',
self::Select => 'اختيار من قائمة',
};
}
/** @return array<string, string> value => Arabic label, for pickers. */
public static function options(): array
{
$out = [];
foreach (self::cases() as $case) {
$out[$case->value] = $case->labelAr();
}
return $out;
}
}
......@@ -148,6 +148,22 @@ public function installmentPlans(): HasMany
return $this->hasMany(ProductInstallmentPlan::class);
}
/**
* The details the desk must capture before this product can be sold —
* size, colour, the name printed on the back.
*/
public function customizations(): HasMany
{
return $this->hasMany(ProductCustomization::class)
->orderBy('sort_order')
->orderBy('id');
}
public function activeCustomizations(): HasMany
{
return $this->customizations()->where('is_active', true);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
......
<?php
namespace App\Domain\Inventory\Models;
use App\Domain\Inventory\Enums\ProductCustomizationType;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Shared\Traits\ScopedThroughBranch;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductCustomization extends Model
{
use BelongsToAcademy, HasUuid, ScopedThroughBranch;
/**
* The parent whose branch this row inherits — a question about a product
* belongs wherever the product belongs, and must not grow a branch of its
* own to drift from it.
*/
protected static string $branchScopeRelation = 'product';
protected $fillable = [
'academy_id',
'product_id',
'name_ar',
'name',
'type',
'options',
'is_required',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'type' => ProductCustomizationType::class,
'options' => 'array',
'is_required' => 'boolean',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function isSelect(): bool
{
return $this->type === ProductCustomizationType::Select;
}
/**
* The answers the desk is allowed to give — empty for a text field, which
* accepts anything.
*
* @return array<int, string>
*/
public function allowedValues(): array
{
if (!$this->isSelect()) {
return [];
}
return array_values(array_filter(
array_map(fn ($o) => is_string($o) ? trim($o) : '', $this->options ?? []),
fn ($o) => $o !== ''
));
}
/**
* Whether an answer the browser sent is one this question actually accepts.
*
* A select must match its own list exactly: the cart is a browser-settable
* array, so "the dropdown only offered these" is not a check.
*/
public function accepts(?string $value): bool
{
$value = trim((string) $value);
if ($value === '') {
return !$this->is_required;
}
if (!$this->isSelect()) {
return mb_strlen($value) <= 255;
}
return in_array($value, $this->allowedValues(), true);
}
}
......@@ -5,6 +5,8 @@
use App\Domain\Inventory\Enums\ProductType;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductCategory;
use App\Domain\Inventory\Enums\ProductCustomizationType;
use App\Domain\Inventory\Models\ProductCustomization;
use App\Domain\Inventory\Models\ProductInstallmentPlan;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Validation\Rule;
......@@ -45,6 +47,10 @@ class ProductForm extends Component
// Installment plan rows [['tier','label_ar','installments','frequency','down_payment_pct']]
public array $planRows = [];
// Customization rows — the questions the desk must answer to sell this product.
// [['id','name_ar','type','options_text','is_required','is_active']]
public array $customizationRows = [];
public function mount(?Product $product = null): void
{
$this->authorize('inventory.create');
......@@ -89,7 +95,59 @@ public function mount(?Product $product = null): void
'is_active' => $p->is_active,
])
->toArray();
$this->customizationRows = $product->customizations()
->get()
->map(fn ($c) => [
'id' => $c->id,
'name_ar' => $c->name_ar,
'type' => $c->type->value,
'options_text' => implode('، ', $c->allowedValues()),
'is_required' => $c->is_required,
'is_active' => $c->is_active,
])
->toArray();
}
}
public function addCustomizationRow(): void
{
$this->customizationRows[] = [
'id' => null,
'name_ar' => '',
'type' => ProductCustomizationType::Text->value,
'options_text' => '',
'is_required' => true,
'is_active' => true,
];
}
public function removeCustomizationRow(int $index): void
{
array_splice($this->customizationRows, $index, 1);
}
/**
* Split what the admin typed into the option list.
*
* Accepts a newline, a Latin comma or an Arabic one — the desk types in
* Arabic and the keyboard gives "،", so refusing it would be a trap.
*
* @return array<int, string>
*/
public static function parseOptions(?string $raw): array
{
$parts = preg_split('/[\r\n,،]+/u', (string) $raw) ?: [];
$clean = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '' && !in_array($part, $clean, true)) {
$clean[] = $part;
}
}
return $clean;
}
public function addPlanRow(): void
......@@ -174,6 +232,22 @@ public function rules(): array
$rules["planRows.{$i}.down_payment_pct"] = 'required|integer|min:0|max:100';
}
foreach ($this->customizationRows as $i => $row) {
$rules["customizationRows.{$i}.name_ar"] = 'required|string|max:255';
$rules["customizationRows.{$i}.type"] = 'required|in:text,select';
// A list with nothing in it is not a list — it would render as an
// empty dropdown the desk can never satisfy, blocking the sale of
// the product outright.
if (($row['type'] ?? '') === ProductCustomizationType::Select->value) {
$rules["customizationRows.{$i}.options_text"] = ['required', 'string', function ($attr, $value, $fail) {
if (count(self::parseOptions($value)) < 1) {
$fail('يجب إدخال خيار واحد على الأقل');
}
}];
}
}
return $rules;
}
......@@ -189,6 +263,10 @@ public function messages(): array
'planRows.*.label_ar.required' => 'اسم الخطة مطلوب',
'planRows.*.installments.required' => 'عدد الأقساط مطلوب',
'planRows.*.installments.min' => 'يجب أن يكون عدد الأقساط 2 على الأقل',
'customizationRows.*.name_ar.required' => 'اسم التخصيص مطلوب',
'customizationRows.*.type.required' => 'نوع التخصيص مطلوب',
'customizationRows.*.type.in' => 'نوع التخصيص غير صالح',
'customizationRows.*.options_text.required' => 'يجب إدخال الخيارات المتاحة',
];
}
......@@ -236,6 +314,7 @@ public function save(): void
// Sync installment plan rows
$this->syncPlanRows($product);
$this->syncCustomizationRows($product);
$this->redirect(route('inventory.products'), navigate: true);
} catch (DomainException $e) {
......@@ -300,6 +379,44 @@ private function syncPlanRows(Product $product): void
->delete();
}
private function syncCustomizationRows(Product $product): void
{
$keepIds = [];
foreach ($this->customizationRows as $i => $row) {
$isSelect = ($row['type'] ?? '') === ProductCustomizationType::Select->value;
$data = [
'academy_id' => $product->academy_id,
'product_id' => $product->id,
'name_ar' => trim($row['name_ar']),
'type' => $row['type'],
'options' => $isSelect ? self::parseOptions($row['options_text'] ?? '') : [],
'is_required' => (bool) ($row['is_required'] ?? true),
'is_active' => (bool) ($row['is_active'] ?? true),
'sort_order' => $i,
];
if (!empty($row['id'])) {
// Resolved through the relation for the same reason the plan
// rows are: $customizationRows is a browser-controlled array,
// and a bare find() would let it rewrite another product's
// question — product_id included.
$existing = $product->customizations()->find($row['id']);
if ($existing) {
$existing->update($data);
$keepIds[] = $existing->id;
}
} else {
$keepIds[] = ProductCustomization::create($data)->id;
}
}
$product->customizations()
->when(!empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds))
->delete();
}
private function generateSku(): string
{
$prefix = match ($this->type) {
......@@ -323,6 +440,7 @@ public function render()
'digital' => 'منتج رقمي',
'service' => 'خدمة',
],
'customizationTypes' => ProductCustomizationType::options(),
]);
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Rules\FullName;
use App\Domain\Participant\Models\Participant;
use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Shared\Exceptions\DomainException;
......@@ -147,7 +148,7 @@ public function rules(): array
];
if ($this->editing) {
$rules['name_ar'] = 'required|string|max:255';
$rules['name_ar'] = ['required', 'string', 'max:255', new FullName];
$rules['name'] = 'nullable|string|max:255';
$rules['gender'] = 'required|in:male,female';
$rules['phone'] = 'nullable|string|max:20';
......@@ -156,11 +157,11 @@ public function rules(): array
$rules['date_of_birth'] = 'nullable|date|before:today';
$rules['guardian_phone'] = 'nullable|string|max:20';
if ($this->createNewGuardian) {
$rules['new_guardian_name'] = 'required|string|max:255';
$rules['new_guardian_name'] = ['required', 'string', 'max:255', new FullName];
$rules['new_guardian_phone'] = 'required|string|max:20';
}
} elseif (!$this->person_id) {
$rules['name_ar'] = 'required|string|max:255';
$rules['name_ar'] = ['required', 'string', 'max:255', new FullName];
$rules['name'] = 'required|string|max:255';
$rules['gender'] = 'required|in:male,female';
$rules['phone'] = 'nullable|string|max:20';
......
......@@ -5,6 +5,7 @@
use App\Domain\Compliance\Enums\ConsentType;
use App\Domain\Compliance\Services\ConsentService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Rules\FullName;
use App\Domain\Identity\Services\PhoneVerificationService;
use App\Domain\Portal\Services\PortalRegistrationService;
use App\Domain\Shared\Exceptions\DomainException;
......@@ -74,7 +75,7 @@ public function mount(): void
public function sendCode(PhoneVerificationService $verification): void
{
$this->validate([
'guardianName' => ['required', 'string', 'min:3', 'max:120'],
'guardianName' => ['required', 'string', 'min:3', 'max:120', new FullName],
'phone' => ['required', 'string', 'min:10', 'max:20'],
'email' => ['nullable', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'max:200', 'same:passwordConfirmation'],
......@@ -134,7 +135,7 @@ public function verifyCode(PhoneVerificationService $verification): void
private function memberRules(): array
{
return [
'childName' => ['required', 'string', 'min:3', 'max:120'],
'childName' => ['required', 'string', 'min:3', 'max:120', new FullName],
'childDateOfBirth' => ['required', 'date', 'before:today'],
'childGender' => ['required', 'in:male,female'],
'branchId' => [
......
......@@ -7,6 +7,7 @@
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Rules\FullName;
use App\Domain\Identity\Services\EgyptianNidDecoder;
use App\Domain\Identity\Services\PersonService;
use App\Domain\Participant\Services\ParticipantService;
......@@ -108,14 +109,14 @@ public function rules(): array
{
return match ($this->currentStep) {
1 => [
'participant_name_ar' => 'required|string|min:3|max:100',
'participant_name_ar' => ['required', 'string', 'min:3', 'max:100', new FullName],
'participant_date_of_birth' => 'nullable|date|before:today',
'participant_gender' => 'required|in:male,female',
'participant_phone' => 'nullable|string|max:20',
'participant_national_id' => 'nullable|string|size:14',
],
2 => [
'guardian_name_ar' => 'required|string|min:3|max:100',
'guardian_name_ar' => ['required', 'string', 'min:3', 'max:100', new FullName],
'guardian_phone' => 'required|string|min:10|max:20',
'guardian_relation' => 'required|in:father,mother,brother,sister,uncle,aunt,grandfather,grandmother,other',
],
......@@ -274,8 +275,8 @@ public function goToStep(int $step): void
public function confirm(): void
{
$this->validate([
'participant_name_ar' => 'required|string|min:3|max:100',
'guardian_name_ar' => 'required|string|min:3|max:100',
'participant_name_ar' => ['required', 'string', 'min:3', 'max:100', new FullName],
'guardian_name_ar' => ['required', 'string', 'min:3', 'max:100', new FullName],
'guardian_phone' => 'required|string|min:10|max:20',
'selected_program_id' => ['required', $this->programExistsRule()],
'actual_start_date' => 'required|date|before_or_equal:today',
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The questions a product cannot be sold without answering.
*
* A kit shirt has a size, a tracksuit has a colour, a printed jersey has a name
* on the back. None of that is a separate product — it is the same product with
* a detail the desk has to capture at the moment of sale, because nobody can
* reconstruct it afterwards from an invoice line that says "قميص تدريب".
*
* Two shapes cover every case we have:
* text — the desk types the answer (a name, a note)
* select — the admin fixed the answers in advance and the desk picks one
* (sizes, colours, anything enumerable)
*
* `options` is only meaningful for `select`. It holds a flat list of strings in
* display order — the values are what gets printed on the invoice item and on
* the delivery sheet, so they are stored exactly as the admin typed them.
*
* The answers do NOT live here. They are frozen onto invoice_items.metadata at
* sale time, for the same reason invoice prices freeze: renaming "لارج" to "L"
* next season must not rewrite what a player ordered last season.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('product_customizations')) {
return;
}
Schema::create('product_customizations', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('name_ar');
$table->string('name')->nullable();
$table->string('type', 20)->default('text');
$table->jsonb('options')->default('[]');
$table->boolean('is_required')->default(true);
$table->boolean('is_active')->default(true);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
// Tenant-scoped uniqueness, per the academy_id rule: one product
// cannot ask the same question twice.
$table->unique(['academy_id', 'product_id', 'name_ar'], 'product_customizations_unique');
$table->index(['product_id', 'is_active', 'sort_order']);
});
DB::statement("ALTER TABLE product_customizations ADD CONSTRAINT product_customizations_type_check CHECK (type IN ('text', 'select'))");
}
public function down(): void
{
Schema::dropIfExists('product_customizations');
}
};
......@@ -356,6 +356,80 @@ class="w-24 text-sm px-2 py-1.5 border border-gray-300 rounded-lg focus:ring-2 f
@endif
</div>
{{-- Customizations --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-base sm:text-lg font-semibold text-gray-700">{{ __('تخصيصات المنتج') }}</h2>
<button type="button" wire:click="addCustomizationRow"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-teal-600 text-white text-xs font-medium rounded-lg hover:bg-teal-700">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إضافة تخصيص') }}
</button>
</div>
<p class="text-xs text-gray-400 mb-4">{{ __('بيانات يجب على الموظف إدخالها قبل بيع المنتج — المقاس، اللون، الاسم المطبوع وما شابه') }}</p>
@if(count($customizationRows) === 0)
<div class="py-6 text-center border-2 border-dashed border-gray-200 rounded-xl">
<p class="text-sm text-gray-400">{{ __('لا توجد تخصيصات — يُباع المنتج مباشرة') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($customizationRows as $i => $row)
<div class="p-4 bg-gray-50 border border-gray-200 rounded-xl">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-semibold text-gray-500 uppercase">{{ __('تخصيص') }} {{ $i + 1 }}</span>
<button type="button" wire:click="removeCustomizationRow({{ $i }})"
class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('اسم التخصيص') }} *</label>
<input type="text" wire:model="customizationRows.{{ $i }}.name_ar"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 @error("customizationRows.{$i}.name_ar") border-red-500 @enderror"
placeholder="{{ __('مثال: المقاس') }}">
@error("customizationRows.{$i}.name_ar") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('النوع') }} *</label>
<select wire:model.live="customizationRows.{{ $i }}.type"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500">
@foreach($customizationTypes as $value => $label)
<option value="{{ $value }}">{{ __($label) }}</option>
@endforeach
</select>
</div>
</div>
@if(($row['type'] ?? 'text') === 'select')
<div class="mt-3">
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('الخيارات المتاحة') }} *</label>
<textarea wire:model="customizationRows.{{ $i }}.options_text" rows="2"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 @error("customizationRows.{$i}.options_text") border-red-500 @enderror"
placeholder="{{ __('سمول، ميديم، لارج، إكس لارج') }}"></textarea>
<p class="text-xs text-gray-400 mt-0.5">{{ __('افصل بين الخيارات بفاصلة أو بسطر جديد') }}</p>
@error("customizationRows.{$i}.options_text") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div class="mt-3 flex flex-wrap items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="customizationRows.{{ $i }}.is_required"
class="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-xs text-gray-700">{{ __('إجباري قبل البيع') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="customizationRows.{{ $i }}.is_active"
class="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-xs text-gray-700">{{ __('نشط') }}</span>
</label>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Actions --}}
<div class="flex flex-col-reverse sm:flex-row items-stretch sm:items-center justify-end gap-3">
<a href="{{ route('inventory.products') }}" wire:navigate
......
......@@ -431,7 +431,10 @@ class="w-full flex items-center justify-between p-3 bg-gray-50 rounded-lg border
</div>
<input type="text" wire:model="guardian_name_ar"
class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
placeholder="{{ __('اسم ولي الأمر') }}">
placeholder="{{ __('الاسم رباعي') }}">
@if($guardian_name_ar && !\App\Domain\Identity\Rules\FullName::isSatisfiedBy($guardian_name_ar))
<p class="mt-1 text-xs text-amber-600">{{ __('الاسم المأخوذ من اسم اللاعب ناقص — أكمله رباعياً') }}</p>
@endif
@error('guardian_name_ar') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
......@@ -878,6 +881,43 @@ class="w-8 h-8 rounded-full bg-red-100 text-red-600 flex items-center justify-ce
</div>
</div>
{{-- Required product details (size, colour, printed name…) --}}
@php $itemCustomizations = $cartItem['customizations'] ?? []; @endphp
@if(count($itemCustomizations) > 0)
<div class="px-3 pb-3 border-t border-teal-100 pt-2 bg-teal-50 space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-teal-700">{{ __('بيانات المنتج المطلوبة') }}</p>
@if(!empty($this->customizationGaps[$key]))
<span class="px-1.5 py-0.5 text-xs rounded bg-amber-100 text-amber-800 font-medium">{{ __('غير مكتملة') }}</span>
@endif
</div>
@foreach($itemCustomizations as $cz)
<div>
<label class="block text-xs text-teal-800 mb-1">
{{ $cz['name_ar'] }}@if($cz['is_required']) <span class="text-red-600">*</span>@endif
</label>
@if($cz['type'] === 'select')
<select wire:model.live="hotbuyCustomizations.{{ $key }}.{{ $cz['id'] }}"
class="w-full text-sm px-3 py-2 border border-teal-300 rounded-lg focus:ring-2 focus:ring-teal-500 bg-white">
<option value="">{{ __('اختر') }}</option>
@foreach($cz['options'] as $opt)
<option value="{{ $opt }}">{{ $opt }}</option>
@endforeach
</select>
@else
<input type="text" wire:model.blur="hotbuyCustomizations.{{ $key }}.{{ $cz['id'] }}"
maxlength="255"
class="w-full text-sm px-3 py-2 border border-teal-300 rounded-lg focus:ring-2 focus:ring-teal-500 bg-white"
placeholder="{{ $cz['name_ar'] }}">
@endif
</div>
@endforeach
@error("hotbuyCustomizations.{$key}")
<p class="text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
@endif
{{-- Installment plan picker for annual products --}}
@if($isAnnual)
<div class="px-3 pb-3 border-t border-purple-100 pt-2 bg-purple-50">
......@@ -1070,6 +1110,10 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
@endif
</div>
@error('hotbuyCustomizations')
<p class="mt-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ $message }}</p>
@enderror
<div class="fixed bottom-0 start-0 end-0 bg-white border-t border-gray-200 p-4 safe-bottom sm:static sm:border-0 sm:p-0 sm:mt-8 flex justify-between gap-3 z-30">
<button wire:click="previousStep"
class="inline-flex items-center justify-center gap-2 px-5 py-3.5 sm:py-3 text-gray-600 bg-gray-100 rounded-xl sm:rounded-lg hover:bg-gray-200 text-base font-medium transition-colors">
......
<?php
namespace Tests\Feature;
use App\Domain\Identity\Rules\FullName;
use App\Livewire\Receptionist\NewRegistrationWizard;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Everybody entered into the system carries a four-part name.
*
* The rule itself is exercised directly — including the Arabic typing that
* naive splitting gets wrong — and then at the one screen that creates the most
* people, to prove the rule is actually wired to the step and not merely
* defined.
*/
class FullNameRuleTest extends TestCase
{
/** @return array<string, array{0: string, 1: bool}> */
public static function names(): array
{
return [
'two parts' => ['محمد أحمد', false],
'three parts' => ['محمد أحمد علي', false],
'four parts' => ['محمد أحمد علي حسن', true],
'five parts' => ['محمد أحمد علي حسن إبراهيم', true],
'four parts, padded' => [' محمد أحمد علي حسن ', true],
'latin four parts' => ['Mohamed Ahmed Ali Hassan', true],
'empty' => ['', false],
'one long part' => ['محمدأحمدعليحسن', false],
];
}
#[DataProvider('names')]
public function test_the_rule_counts_name_parts(string $name, bool $expected): void
{
$this->assertSame($expected, FullName::isSatisfiedBy($name), $name);
}
public function test_a_non_breaking_space_still_separates_two_names(): void
{
// An Arabic keyboard produces U+00A0, and \s under /u does not match it.
// Glued together, a perfectly valid four-part name would be rejected.
$name = "محمد\u{00A0}أحمد علي حسن";
$this->assertCount(4, FullName::parts($name));
$this->assertTrue(FullName::isSatisfiedBy($name));
}
public function test_tatweel_is_decoration_not_a_separator(): void
{
$this->assertCount(4, FullName::parts('محمـــد أحمد علي حسن'));
}
public function test_the_rule_fails_with_an_arabic_message(): void
{
$validator = Validator::make(
['name' => 'محمد أحمد'],
['name' => [new FullName]]
);
$this->assertTrue($validator->fails());
$this->assertStringContainsString('رباعي', $validator->errors()->first('name'));
}
public function test_the_registration_wizard_refuses_a_three_part_player_name(): void
{
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant.');
}
$academy = \App\Domain\Shared\Models\Academy::query()->first();
if (! $academy) {
$this->markTestSkipped('No academy in the restored tenant.');
}
app()->instance('current_academy', $academy);
$owner = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $owner) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
Livewire::actingAs($owner)
->test(NewRegistrationWizard::class)
->set('participant_name_ar', 'محمد أحمد علي')
->set('participant_date_of_birth', '2015-01-01')
->set('participant_gender', 'male')
->set('membership_type', 'non_member')
->call('nextStep')
->assertHasErrors('participant_name_ar')
->assertSet('currentStep', 1);
}
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment