Commit e8def3f5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(terminology): let each branch call its people what it actually calls them

The product calls a person who belongs to the organisation عضو and
everyone else غير عضو. That is only true for a club. The same software
runs inside residential compounds (مقيم / غير مقيم), on beaches and in
resorts (مشترك / زائر), and in hotels (نزيل / زائر), where every screen
read as though it had been written for somebody else.

The data model does not move: participants.membership_type still holds
'member' and 'non_member', every query and every enum is untouched, and
nothing about pricing or membership logic changes. Only the words shown
to a human do, and they are chosen per branch — one academy can run a
club and a compound at the same time.

Eight Arabic forms are stored per branch rather than derived, because
Arabic will not let you derive them: ال prefixes the noun in العضو but
the second word in غير العضو, and the plural of عضو is أعضاء while the
plural of مقيم is مقيمين. Everything a screen needs beyond those eight —
نوع العضوية, رقم العضوية, سعر العضو — composes from them in
Identity\Support\Terminology. Plurals are stored in the ـين form because
almost every site prints them after a preposition or in an idafa.

TerminologyService takes an explicit ?int $branchId like every other
domain service, so it stays callable from a queued notification or an
artisan report; terms()/term()/membership_label() in app/Helpers are the
adapter that reads BranchContext for a Blade file. Reads go through the
query builder rather than the model, because the sidebar consults this on
every page and BranchSetting's branch scope would filter a settings
screen editing branch B, viewed from branch A, down to nothing. The
service is bound scoped, so its memo lives exactly one request.

Presets (club, compound, resort, hotel, gym) fill the settings screen and
stay editable afterwards; the fields are stored in the existing
branch_settings table, so there is no schema change. An unconfigured
branch — and the public website, and a user in all-branches mode — gets
the default preset rather than blank labels. term() throws on an unknown
key rather than echoing it back, because a typo that printed
"membershipp_type" onto a receipt would survive review.

62 call sites converted across the sidebar, participant screens, the
registration wizard, the portal, reports, messaging, imports and the
printed card. New screen at /settings/terminology behind
permission:settings.manage.

Verified: 413 tests green, no failures.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 7622238b
...@@ -108,7 +108,7 @@ public function verify(string $token): array ...@@ -108,7 +108,7 @@ public function verify(string $token): array
$currentVersion = (int) ($participant->checkin_key_version ?? 1); $currentVersion = (int) ($participant->checkin_key_version ?? 1);
if ((int) $version !== $currentVersion) { if ((int) $version !== $currentVersion) {
throw new DomainException('انتهت صلاحية بطاقة العضو — اطلب منه تحديث البطاقة'); throw new DomainException('انتهت صلاحية بطاقة ' . term('member_definite', $participant->branch_id) . ' — اطلب منه تحديث البطاقة');
} }
$now = $this->currentCounter(); $now = $this->currentCounter();
...@@ -122,7 +122,7 @@ public function verify(string $token): array ...@@ -122,7 +122,7 @@ public function verify(string $token): array
} }
} }
throw new DomainException('انتهت صلاحية الرمز — اطلب من العضو تحديث الشاشة'); throw new DomainException('انتهت صلاحية الرمز — اطلب من ' . term('member_definite', $participant->branch_id) . ' تحديث الشاشة');
} }
private function tag(Participant $participant, int $version, int $counter): string private function tag(Participant $participant, int $version, int $counter): string
......
...@@ -56,13 +56,13 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin ...@@ -56,13 +56,13 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin
: (string) $participant->status; : (string) $participant->status;
if ($status !== 'active') { if ($status !== 'active') {
throw new DomainException('العضوية غير نشطة — يرجى مراجعة الإدارة'); throw new DomainException(term('membership', $participant->branch_id) . ' غير نشطة — يرجى مراجعة الإدارة');
} }
$session = $this->resolveSession($participant); $session = $this->resolveSession($participant);
if (! $session) { if (! $session) {
throw new DomainException('لا توجد حصة مجدولة لهذا العضو الآن'); throw new DomainException('لا توجد حصة مجدولة لهذا ' . term('member_definite', $participant->branch_id) . ' الآن');
} }
// The session's own branch first: facility_id is nullable on both // The session's own branch first: facility_id is nullable on both
...@@ -102,7 +102,7 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin ...@@ -102,7 +102,7 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin
->first(); ->first();
if (! $record) { if (! $record) {
throw new DomainException('لا يوجد سجل حضور لهذا العضو في هذه الحصة'); throw new DomainException('لا يوجد سجل حضور لهذا ' . term('member_definite', $participant->branch_id) . ' في هذه الحصة');
} }
if ($inserted === 0) { if ($inserted === 0) {
......
...@@ -36,10 +36,10 @@ public function description(): string ...@@ -36,10 +36,10 @@ public function description(): string
return match ($this) { return match ($this) {
self::Terms => 'قواعد الاشتراك والحضور والدفع.', self::Terms => 'قواعد الاشتراك والحضور والدفع.',
self::Privacy => 'ما نجمعه من بيانات وكيف نستخدمه.', self::Privacy => 'ما نجمعه من بيانات وكيف نستخدمه.',
self::PhotoMedia => 'استخدام صور العضو ومقاطع الفيديو على موقع الأكاديمية وحساباتها. يمكن سحب الموافقة في أي وقت.', self::PhotoMedia => 'استخدام صور ' . term('member_definite') . ' ومقاطع الفيديو على موقع الأكاديمية وحساباتها. يمكن سحب الموافقة في أي وقت.',
self::Marketing => 'العروض والأخبار عبر واتساب. لا يشمل إشعارات الحصص والمستحقات.', self::Marketing => 'العروض والأخبار عبر واتساب. لا يشمل إشعارات الحصص والمستحقات.',
self::MedicalTreatment => 'التصرف الطبي العاجل عند وقوع إصابة أثناء التدريب.', self::MedicalTreatment => 'التصرف الطبي العاجل عند وقوع إصابة أثناء التدريب.',
self::DataProcessing => 'حفظ بيانات العضو ومعالجتها لإدارة الاشتراك.', self::DataProcessing => 'حفظ بيانات ' . term('member_definite') . ' ومعالجتها لإدارة الاشتراك.',
}; };
} }
......
...@@ -50,7 +50,7 @@ public function record( ...@@ -50,7 +50,7 @@ public function record(
->participantIdsFor($givenBy); ->participantIdsFor($givenBy);
if (! in_array((int) $participant->id, $allowed, true)) { if (! in_array((int) $participant->id, $allowed, true)) {
throw new DomainException('لا تملك صلاحية تسجيل موافقة عن هذا العضو'); throw new DomainException('لا تملك صلاحية تسجيل موافقة عن هذا ' . term('member_definite', $participant->branch_id));
} }
} }
......
...@@ -38,7 +38,11 @@ ...@@ -38,7 +38,11 @@
'فريق', 'فرق', 'نادي', 'اكاديميه', 'كره', 'مباراه', 'تدريب', 'فريق', 'فرق', 'نادي', 'اكاديميه', 'كره', 'مباراه', 'تدريب',
'شهر', 'شهري', 'سنوي', 'سنه', 'موسم', 'شهر', 'شهري', 'سنوي', 'سنه', 'موسم',
'قسط', 'اقساط', 'دفعه', 'دفعات', 'مبلغ', 'باقي', 'متبقي', 'خالص', 'قسط', 'اقساط', 'دفعه', 'دفعات', 'مبلغ', 'باقي', 'متبقي', 'خالص',
'بند', 'لاعب', 'عضو', 'بند', 'لاعب',
// Every branch's word for the person being billed, not only the club's
// one: a compound's ledger says مقيم where a club's says عضو, and
// neither identifies which product a line paid for.
'عضو', 'اعضاء', 'مقيم', 'مقيمين', 'مشترك', 'مشتركين', 'نزيل', 'زائر', 'منتسب',
]; ];
/** A line paying part of something agreed, without saying part of what. */ /** A line paying part of something agreed, without saying part of what. */
......
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\BranchSetting;
use App\Domain\Identity\Support\Terminology;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
/**
* Resolves — and stores — the words a branch uses for member / non-member.
*
* Receives an explicit ?int $branchId like every other domain service: the
* session lives in BranchContext, and the helpers in app/Helpers/terminology.php
* are the adapter between the two. That is what keeps this callable from a
* queued notification or an artisan report, both of which have a branch id and
* no session at all.
*
* Reads go through the query builder rather than the model on purpose. This is
* consulted by the sidebar, so it runs on literally every page; BranchSetting
* carries the branch global scope, which would filter a settings screen editing
* branch B while the user sits in branch A down to nothing. branch_id is unique
* per key, so filtering on it needs no other scope to be unambiguous.
*
* Bound scoped, so the per-branch memo lives exactly one request — and a
* Livewire round-trip is its own request.
*/
class TerminologyService
{
public const GROUP = 'terminology';
private const PREFIX = 'terminology.';
private const PRESET_KEY = 'terminology.preset';
/** @var array<string, array<string, string>> */
private array $stored = [];
/** @var array<string, Terminology> */
private array $resolved = [];
/**
* The vocabulary for a branch, in the given locale.
*
* A null branch — an unconfigured install, the public website, a user in
* all-branches mode — gets the default preset rather than blank labels.
* Empty strings anywhere in the app would be a worse failure than the
* wrong word.
*/
public function for(?int $branchId, ?string $locale = null): Terminology
{
$locale ??= app()->getLocale();
$cacheKey = ($branchId ?? 0) . '|' . $locale;
return $this->resolved[$cacheKey] ??= $this->build($branchId, $locale);
}
private function build(?int $branchId, string $locale): Terminology
{
$stored = $branchId ? $this->storedFor($branchId) : [];
$preset = $stored[self::PRESET_KEY] ?? null;
if (! is_string($preset) || ! isset($this->presets()[$preset])) {
$preset = $this->defaultPreset();
}
$terms = $this->presets()[$preset]['terms'] ?? [];
// A branch that picked a preset and then edited two words stores only
// those two. Everything it did not touch keeps following the preset,
// which is how a preset fix ships to every branch that uses it.
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$value = $stored[self::PREFIX . $key] ?? null;
if (is_string($value) && trim($value) !== '') {
$terms[$key] = trim($value);
}
}
return new Terminology($terms, $locale, $preset);
}
/**
* The raw terminology rows for a branch, keyed by setting key.
*
* @return array<string, string>
*/
private function storedFor(int $branchId): array
{
return $this->stored[$branchId] ??= $this->readSettings($branchId);
}
/**
* @return array<string, string>
*/
private function readSettings(int $branchId): array
{
try {
return DB::table('branch_settings')
->where('branch_id', $branchId)
->where('group', self::GROUP)
->pluck('value', 'key')
->all();
} catch (QueryException) {
// Before the table exists — a container mid-migrate, a test booting
// against an empty schema — the defaults are the right answer, not
// a 500 on every page that renders a sidebar.
return [];
}
}
// --------------------------------------------------------------- writes
/**
* Persist a branch's vocabulary.
*
* Only genuine overrides are stored. A word the caller hands back exactly
* as the preset spells it is deleted rather than written, so the settings
* screen — which always posts all thirteen fields — does not silently pin
* a branch to today's preset text and cut it off from a later correction.
* Blank fields are deleted for the same reason: a cleared word falls back
* to the preset instead of rendering nothing.
*
* @param array<string, string|null> $terms
*/
public function save(int $branchId, string $preset, array $terms, ?int $academyId = null): void
{
$academyId ??= (int) Branch::withoutGlobalScopes()
->whereKey($branchId)
->firstOrFail(['id', 'academy_id'])
->academy_id;
if (! isset($this->presets()[$preset])) {
$preset = $this->defaultPreset();
}
$presetTerms = $this->presets()[$preset]['terms'] ?? [];
DB::transaction(function () use ($branchId, $preset, $terms, $academyId, $presetTerms) {
$this->put($branchId, $academyId, self::PRESET_KEY, $preset);
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$value = $terms[$key] ?? null;
$value = is_string($value) ? trim($value) : null;
if ($value === '' || $value === ($presetTerms[$key] ?? null)) {
$value = null;
}
$this->put($branchId, $academyId, self::PREFIX . $key, $value);
}
});
unset($this->stored[$branchId]);
$this->resolved = [];
}
private function put(int $branchId, int $academyId, string $key, ?string $value): void
{
if ($value === null) {
BranchSetting::withoutGlobalScopes()
->where('branch_id', $branchId)
->where('key', $key)
->delete();
return;
}
BranchSetting::withoutGlobalScopes()->updateOrCreate(
['branch_id' => $branchId, 'key' => $key],
['academy_id' => $academyId, 'value' => $value, 'group' => self::GROUP],
);
}
/**
* Copy one branch's vocabulary onto every other branch in its academy.
* The nine-branch client configures once instead of nine times.
*
* What is copied is the source's preset plus the words it actually
* overrode — not its fully expanded vocabulary. Copying the expansion
* would leave every target pinned to today's preset text while the source
* kept following the preset, so a later correction to a preset would reach
* one branch out of nine.
*
* @return int branches written, not counting the source
*/
public function copyToAcademy(int $sourceBranchId): int
{
$source = Branch::withoutGlobalScopes()
->whereKey($sourceBranchId)
->firstOrFail(['id', 'academy_id']);
$preset = $this->presetFor($sourceBranchId);
$overrides = $this->overridesFor($sourceBranchId);
$targets = Branch::withoutGlobalScopes()
->where('academy_id', $source->academy_id)
->where('id', '!=', $sourceBranchId)
->pluck('id');
foreach ($targets as $targetId) {
$this->save((int) $targetId, $preset, $overrides, (int) $source->academy_id);
}
return $targets->count();
}
/**
* Only the words this branch stored for itself, keyed without the prefix.
*
* @return array<string, string>
*/
private function overridesFor(int $branchId): array
{
$stored = $this->storedFor($branchId);
$overrides = [];
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$value = $stored[self::PREFIX . $key] ?? null;
if (is_string($value) && trim($value) !== '') {
$overrides[$key] = trim($value);
}
}
return $overrides;
}
// --------------------------------------------------------------- presets
/**
* @return array<string, array{label: string, terms: array<string, string>}>
*/
public function presets(): array
{
return config('terminology.presets', []);
}
public function defaultPreset(): string
{
$default = config('terminology.default', 'club');
return isset($this->presets()[$default])
? $default
: (string) array_key_first($this->presets());
}
/**
* Which preset a branch is on, before any per-word edits.
*/
public function presetFor(?int $branchId): string
{
return $this->for($branchId, 'ar')->preset;
}
/**
* Drop the memo. Only the settings screen needs this — it edits the very
* rows the memo was built from and then re-renders in the same request.
*/
public function flush(): void
{
$this->stored = [];
$this->resolved = [];
}
}
<?php
namespace App\Domain\Identity\Support;
/**
* The words one branch uses for the two kinds of person it serves.
*
* `participants.membership_type` holds 'member' and 'non_member' forever —
* this object is the only place that decides what a human is shown instead.
* A club branch says عضو / غير عضو, a compound says مقيم / غير مقيم, a beach
* says مشترك / زائر يومي. Nothing downstream of this class knows which.
*
* Two families of key:
*
* base the eight Arabic forms stored per branch, plus their English
* counterparts. Stored rather than derived because Arabic does not
* derive: العضو but غير العضو, أعضاء but مقيمين.
*
* composed everything a screen actually asks for — نوع العضوية, رقم
* العضوية, سعر العضو — built from the base forms here so that a
* branch configures eight words and gets every label in the app.
*
* Composition is deliberately shallow: a prefix and a base form, in an order
* that stays grammatical for every preset. "نوع " . "الإقامة" is "نوع الإقامة";
* "سعر " . "غير المقيم" is "سعر غير المقيم". Anything that needed more than
* that belongs in a sentence at the call site, as __(':member ...', [...]).
*/
final class Terminology
{
/**
* Every key a caller may ask for. Used by the settings screen to render
* a live preview, and by term() to fail loudly on a typo.
*/
public const BASE_KEYS = [
'member',
'member_definite',
'members',
'non_member',
'non_member_definite',
'non_members',
'membership',
'membership_indefinite',
];
public const ENGLISH_KEYS = [
'member_en',
'members_en',
'non_member_en',
'non_members_en',
'membership_en',
];
public const COMPOSED_KEYS = [
'membership_type',
'membership_types',
'membership_id',
'membership_card',
'member_price',
'non_member_price',
'for_member',
'for_non_member',
'for_members',
'for_non_members',
];
/**
* @param array<string, string> $terms
*/
public function __construct(
private readonly array $terms,
private readonly string $locale = 'ar',
public readonly string $preset = 'club',
) {
}
public function get(string $key): string
{
if (in_array($key, self::BASE_KEYS, true)) {
return $this->base($key);
}
$arabic = $this->locale === 'ar';
return match ($key) {
'membership_type' => $arabic ? 'نوع ' . $this->base('membership') : $this->base('membership') . ' type',
'membership_types' => $arabic ? 'أنواع ' . $this->base('membership') : $this->base('membership') . ' types',
'membership_id' => $arabic ? 'رقم ' . $this->base('membership') : $this->base('membership') . ' number',
'membership_card' => $arabic ? 'بطاقة ' . $this->base('membership_indefinite') : $this->base('membership') . ' card',
'member_price' => $arabic ? 'سعر ' . $this->base('member_definite') : $this->base('member') . ' price',
'non_member_price' => $arabic ? 'سعر ' . $this->base('non_member_definite') : $this->base('non_member') . ' price',
'for_member' => $arabic ? $this->lam($this->base('member_definite')) : $this->base('member'),
'for_non_member' => $arabic ? $this->lam($this->base('non_member_definite')) : $this->base('non_member'),
'for_members' => $arabic ? $this->lam($this->base('members')) : $this->base('members'),
'for_non_members' => $arabic ? $this->lam($this->base('non_members')) : $this->base('non_members'),
default => throw new \InvalidArgumentException("Unknown terminology key [{$key}]."),
};
}
public function __get(string $key): string
{
return $this->get($key);
}
public function __isset(string $key): bool
{
return in_array($key, self::BASE_KEYS, true)
|| in_array($key, self::COMPOSED_KEYS, true);
}
/**
* The stored form, in the active locale.
*
* English is coarser than Arabic on purpose: it has no definite/indefinite
* distinction worth storing, so five stored words answer eight questions.
* A blank English field falls back to Arabic rather than to an empty label.
*/
private function base(string $key): string
{
if ($this->locale !== 'ar') {
$english = match ($key) {
'member', 'member_definite' => 'member_en',
'members' => 'members_en',
'non_member', 'non_member_definite' => 'non_member_en',
'non_members' => 'non_members_en',
'membership', 'membership_indefinite' => 'membership_en',
default => null,
};
if ($english !== null && filled($this->terms[$english] ?? null)) {
return (string) $this->terms[$english];
}
}
return (string) ($this->terms[$key] ?? '');
}
/**
* Attach the Arabic preposition ل to a definite noun.
*
* ل + العضو is للعضو, not لالعضو — the alif of the article elides. But the
* article is not always at the front: ل + غير العضو is لغير العضو and must
* be left alone. Testing the composed string for the لال sequence is what
* distinguishes the two without needing to know which preset is loaded.
*/
private function lam(string $definite): string
{
$joined = 'ل' . $definite;
return str_starts_with($joined, 'لال')
? 'لل' . mb_substr($joined, 3)
: $joined;
}
/**
* @return array<string, string>
*/
public function toArray(): array
{
return $this->terms;
}
/**
* Every label this vocabulary produces, for the settings preview.
*
* @return array<string, string>
*/
public function all(): array
{
$out = [];
foreach ([...self::BASE_KEYS, ...self::COMPOSED_KEYS] as $key) {
$out[$key] = $this->get($key);
}
return $out;
}
}
...@@ -159,11 +159,18 @@ public function frequencyLabel(): string ...@@ -159,11 +159,18 @@ public function frequencyLabel(): string
}; };
} }
/**
* Which tier the plan is offered to, in the branch's own words.
*
* The table has no branch of its own — a plan hangs off a product — so
* this follows the branch being worked in. That is the branch whose till
* is open, which is the only place a plan is ever offered.
*/
public function tierLabel(): string public function tierLabel(): string
{ {
return match ($this->membership_tier) { return match ($this->membership_tier) {
'member' => 'أعضاء', 'member' => term('members'),
'non_member' => 'غير أعضاء', 'non_member' => term('non_members'),
default => 'الجميع', default => 'الجميع',
}; };
} }
......
...@@ -2,16 +2,35 @@ ...@@ -2,16 +2,35 @@
namespace App\Domain\Participant\Enums; namespace App\Domain\Participant\Enums;
/**
* Whether a participant belongs to the organisation or is a guest of it.
*
* The two stored values never change. What a human is shown for them is a
* per-branch setting — عضو in a club, مقيم in a compound, مشترك on a beach —
* so label() asks the branch instead of answering itself. Pass a branch id
* when labelling a record from a branch other than the one on screen.
*/
enum MembershipType: string enum MembershipType: string
{ {
case Member = 'member'; case Member = 'member';
case NonMember = 'non_member'; case NonMember = 'non_member';
public function label(): string public function label(?int $branchId = null): string
{ {
return match ($this) { return match ($this) {
self::Member => 'عضو', self::Member => term('member', $branchId),
self::NonMember => 'غير عضو', self::NonMember => term('non_member', $branchId),
}; };
} }
/**
* @return array<string, string> stored value => branch label
*/
public static function options(?int $branchId = null): array
{
return [
self::Member->value => self::Member->label($branchId),
self::NonMember->value => self::NonMember->label($branchId),
];
}
} }
...@@ -22,7 +22,7 @@ public function label(): string ...@@ -22,7 +22,7 @@ public function label(): string
{ {
return match ($this) { return match ($this) {
self::Age => 'العمر', self::Age => 'العمر',
self::MembershipDuration => 'مدة العضوية', self::MembershipDuration => 'مدة ' . term('membership'),
self::FamilySize => 'حجم الأسرة', self::FamilySize => 'حجم الأسرة',
self::SiblingOrder => 'ترتيب الأخوة', self::SiblingOrder => 'ترتيب الأخوة',
self::Classification => 'التصنيف', self::Classification => 'التصنيف',
......
...@@ -210,11 +210,11 @@ public static function describe(PricingRuleType|string $type, array $c): string ...@@ -210,11 +210,11 @@ public static function describe(PricingRuleType|string $type, array $c): string
return match ($key) { return match ($key) {
'age' => $range('للأعمار'), 'age' => $range('للأعمار'),
'membership_duration' => $range('لمدة عضوية'), 'membership_duration' => $range('لمدة ' . term('membership_indefinite')),
'family_size' => $range('لأسرة عدد أبنائها'), 'family_size' => $range('لأسرة عدد أبنائها'),
'sibling_order' => $range('للابن رقم'), 'sibling_order' => $range('للابن رقم'),
'enrollment_volume' => $range('لعدد برامج'), 'enrollment_volume' => $range('لعدد برامج'),
'loyalty' => $range(عضوية'), 'loyalty' => $range(' . term('membership_indefinite')),
'classification' => 'لتصنيف: ' . implode('، ', array_map( 'classification' => 'لتصنيف: ' . implode('، ', array_map(
fn ($v) => self::classificationLabel($v), fn ($v) => self::classificationLabel($v),
$c['values'] ?? [] $c['values'] ?? []
......
...@@ -150,14 +150,25 @@ ...@@ -150,14 +150,25 @@
], ],
]; ];
/**
* The recipes, with the branch's own vocabulary substituted in.
*
* RECIPES is a const, so the loyalty card cannot say "مقيم قديم" from the
* array itself. Relabelling on the way out keeps the constant a constant
* and still gives a compound the right word.
*/
public static function all(): array public static function all(): array
{ {
return self::RECIPES; $recipes = self::RECIPES;
$recipes['loyalty']['name_ar'] = term('member') . ' قديم';
return $recipes;
} }
public static function find(string $key): ?array public static function find(string $key): ?array
{ {
return self::RECIPES[$key] ?? null; return self::all()[$key] ?? null;
} }
/** Recipe defaults as builder field values (percent stays human here). */ /** Recipe defaults as builder field values (percent stays human here). */
......
...@@ -58,7 +58,7 @@ public function open(array $data, User $requester): ServiceRequest ...@@ -58,7 +58,7 @@ public function open(array $data, User $requester): ServiceRequest
// The requester must actually speak for this member. // The requester must actually speak for this member.
if (! in_array($participantId, $this->guardians->participantIdsFor($requester), true)) { if (! in_array($participantId, $this->guardians->participantIdsFor($requester), true)) {
throw new DomainException('لا تملك صلاحية تقديم طلب عن هذا العضو'); throw new DomainException('لا تملك صلاحية تقديم طلب عن هذا المشترك');
} }
return DB::transaction(function () use ($data, $requester, $type, $participantId) { return DB::transaction(function () use ($data, $requester, $type, $participantId) {
...@@ -241,7 +241,7 @@ private function applyExcuse(ServiceRequest $request, User $actor): void ...@@ -241,7 +241,7 @@ private function applyExcuse(ServiceRequest $request, User $actor): void
$status = $record->status instanceof \BackedEnum ? $record->status->value : (string) $record->status; $status = $record->status instanceof \BackedEnum ? $record->status->value : (string) $record->status;
if (in_array($status, ['present', 'late', 'partial'], true)) { if (in_array($status, ['present', 'late', 'partial'], true)) {
throw new DomainException('سُجّل حضور العضو في هذه الحصة بالفعل'); throw new DomainException('سُجّل حضور المشترك في هذه الحصة بالفعل');
} }
$this->attendance->markStatus( $this->attendance->markStatus(
...@@ -277,7 +277,7 @@ private function resolveExcusableSession(int $participantId, int $sessionId): ar ...@@ -277,7 +277,7 @@ private function resolveExcusableSession(int $participantId, int $sessionId): ar
->first(); ->first();
if (! $session) { if (! $session) {
throw new DomainException('هذه الحصة ليست ضمن مجموعات العضو'); throw new DomainException('هذه الحصة ليست ضمن مجموعات ' . term('member_definite', $participant->branch_id));
} }
// An excuse is for an absence that has not happened yet, or has just // An excuse is for an absence that has not happened yet, or has just
......
<?php
use App\Domain\Identity\Services\TerminologyService;
use App\Domain\Identity\Support\Terminology;
use App\Domain\Shared\Context\BranchContext;
if (! function_exists('terms')) {
/**
* The vocabulary of the branch currently being worked in.
*
* This is the adapter TerminologyService deliberately does not contain:
* the service takes an explicit branch id so it stays callable from a
* queue, and this reads the session so a Blade file does not have to.
*
* Pass $branchId to label a record that belongs to a branch other than the
* one on screen — a cross-branch report row, a receipt reprinted from
* elsewhere. Leave it null everywhere else.
*
* $locale is for the few writes that store both languages side by side —
* a base price carries name_ar and name — and must not both come back in
* whichever language the session happens to be in.
*/
function terms(?int $branchId = null, ?string $locale = null): Terminology
{
$branchId ??= app(BranchContext::class)->branchId();
return app(TerminologyService::class)->for($branchId, $locale);
}
}
if (! function_exists('term')) {
/**
* One label from the current branch's vocabulary.
*
* term('member') عضو / مقيم / مشترك
* term('members') الأعضاء / المقيمون / المشتركون
* term('non_member') غير عضو / غير مقيم / زائر يومي
* term('membership') العضوية / الإقامة / الاشتراك
* term('membership_id') رقم العضوية / رقم الإقامة
* term('member_price') سعر العضو / سعر المقيم
*
* Unknown keys throw rather than returning the key back — a typo that
* printed "membershipp_type" onto a receipt would survive review, and an
* exception in development will not.
*/
function term(string $key, ?int $branchId = null): string
{
return terms($branchId)->get($key);
}
}
if (! function_exists('membership_label')) {
/**
* The label for a membership_type value.
*
* Takes the enum, the raw column ('member' / 'non_member') or a CSV cell,
* because callers have all three: a hydrated model, a ->pluck() of the
* column, and an uploaded file being previewed before it is imported.
*/
function membership_label(
\App\Domain\Participant\Enums\MembershipType|string|null $membershipType,
?int $branchId = null,
): string {
$value = $membershipType instanceof \App\Domain\Participant\Enums\MembershipType
? $membershipType->value
: $membershipType;
return $value === 'member'
? term('member', $branchId)
: term('non_member', $branchId);
}
}
...@@ -69,14 +69,17 @@ public function participants(Request $request): StreamedResponse ...@@ -69,14 +69,17 @@ public function participants(Request $request): StreamedResponse
} }
return $this->streamCsv('participants', [ return $this->streamCsv('participants', [
'الاسم', 'الهاتف', 'البريد', 'الحالة', 'نوع العضوية', 'رقم العضوية', 'مجاني', 'الفرع', 'النشاط', 'تاريخ التسجيل', 'الاسم', 'الهاتف', 'البريد', 'الحالة', term('membership_type'), term('membership_id'), 'مجاني', 'الفرع', 'النشاط', 'تاريخ التسجيل',
], $query, function ($p) { ], $query, function ($p) {
return [ return [
$p->person?->name_ar ?? $p->person?->name ?? '', $p->person?->name_ar ?? $p->person?->name ?? '',
$p->person?->phone ?? '', $p->person?->phone ?? '',
$p->person?->email ?? '', $p->person?->email ?? '',
$p->status?->value ?? $p->status ?? '', $p->status?->value ?? $p->status ?? '',
$p->membership_type?->value ?? $p->membership_type ?? '', // The header is Arabic prose, so this file is read by a human,
// not re-imported — it gets the branch's word rather than the
// stored 'member' / 'non_member'.
membership_label($p->membership_type?->value ?? $p->membership_type, $p->branch_id),
$p->membership_id ?? '', $p->membership_id ?? '',
$p->is_free ? 'نعم' : 'لا', $p->is_free ? 'نعم' : 'لا',
$p->branch?->name_ar ?? '', $p->branch?->name_ar ?? '',
......
...@@ -53,7 +53,7 @@ public function __invoke(BrandingService $branding, SettingsService $settings): ...@@ -53,7 +53,7 @@ public function __invoke(BrandingService $branding, SettingsService $settings):
'id' => '/app/', 'id' => '/app/',
'name' => $brand->academyName, 'name' => $brand->academyName,
'short_name' => $brand->appShortName, 'short_name' => $brand->appShortName,
'description' => __('بوابة أعضاء') . ' ' . $brand->academyName, 'description' => __('بوابة') . ' ' . term('members') . ' ' . $brand->academyName,
'start_url' => '/app/', 'start_url' => '/app/',
'scope' => '/app/', 'scope' => '/app/',
'display' => 'standalone', 'display' => 'standalone',
......
...@@ -78,7 +78,7 @@ public function issueToken(Request $request, AuthService $auth): JsonResponse ...@@ -78,7 +78,7 @@ public function issueToken(Request $request, AuthService $auth): JsonResponse
$user = $result->user; $user = $result->user;
if (! $user->can('portal.access')) { if (! $user->can('portal.access')) {
return response()->json(['error' => __('هذا الحساب ليس حساب عضو')], 403); return response()->json(['error' => __('هذا الحساب ليس حساب مشترك')], 403);
} }
foreach ($keys as $key) { foreach ($keys as $key) {
......
...@@ -116,7 +116,7 @@ public function reject(PaymentProofService $proofs): void ...@@ -116,7 +116,7 @@ public function reject(PaymentProofService $proofs): void
'rejectionReason' => ['required', 'string', 'max:40'], 'rejectionReason' => ['required', 'string', 'max:40'],
'reviewNotes' => ['nullable', 'string', 'max:1000'], 'reviewNotes' => ['nullable', 'string', 'max:1000'],
], [ ], [
'rejectionReason.required' => __('اذكر سبب الرفض — سيراه العضو'), 'rejectionReason.required' => __('اذكر سبب الرفض — سيراه') . ' ' . term('member_definite'),
]); ]);
$proof = $this->findInScope($this->reviewingId); $proof = $this->findInScope($this->reviewingId);
......
...@@ -59,7 +59,7 @@ public function messages(): array ...@@ -59,7 +59,7 @@ public function messages(): array
'messageBody.max' => __('نص الرسالة يجب أن لا يتجاوز 640 حرف'), 'messageBody.max' => __('نص الرسالة يجب أن لا يتجاوز 640 حرف'),
'selectedGroup.required_if' => __('يجب اختيار مجموعة'), 'selectedGroup.required_if' => __('يجب اختيار مجموعة'),
'selectedStatus.required_if' => __('يجب اختيار حالة'), 'selectedStatus.required_if' => __('يجب اختيار حالة'),
'selectedMembership.required_if' => __('يجب اختيار نوع العضوية'), 'selectedMembership.required_if' => __('يجب اختيار') . ' ' . term('membership_type'),
'selectedParticipants.required_if' => __('يجب اختيار مشتركين'), 'selectedParticipants.required_if' => __('يجب اختيار مشتركين'),
]; ];
} }
......
...@@ -207,7 +207,7 @@ private function product(int $id): ?Product ...@@ -207,7 +207,7 @@ private function product(int $id): ?Product
/** /**
* The instalment plans this buyer may be offered for this product. * The instalment plans this buyer may be offered for this product.
* *
* Plans are configured per tier — "أعضاء فقط", "غير أعضاء فقط", or both — * Plans are configured per tier — member only, non-member only, or both —
* and a plan is only ever a plan for an annual product. Everything here is * and a plan is only ever a plan for an annual product. Everything here is
* read from the database on every call rather than kept in the cart: the * read from the database on every call rather than kept in the cart: the
* cart is a public property, so a plan id sitting in it is a number the * cart is a public property, so a plan id sitting in it is a number the
......
...@@ -182,9 +182,9 @@ public function messages(): array ...@@ -182,9 +182,9 @@ public function messages(): array
'registration_source.required' => 'مصدر التسجيل مطلوب', 'registration_source.required' => 'مصدر التسجيل مطلوب',
'registration_source.in' => 'مصدر التسجيل غير صالح', 'registration_source.in' => 'مصدر التسجيل غير صالح',
'skill_level.in' => 'المستوى غير صالح', 'skill_level.in' => 'المستوى غير صالح',
'membership_type.in' => 'نوع العضوية غير صالح', 'membership_type.in' => term('membership_type') . ' غير صالح',
'membership_id.required' => 'رقم عضوية النادي مطلوب للأعضاء', 'membership_id.required' => term('membership_id') . ' مطلوب ' . term('for_member'),
'membership_id.max' => 'رقم العضوية يجب ألا يتجاوز 50 حرف', 'membership_id.max' => term('membership_id') . ' يجب ألا يتجاوز 50 حرف',
'dominant_hand.in' => 'اليد المسيطرة غير صالحة', 'dominant_hand.in' => 'اليد المسيطرة غير صالحة',
'dominant_foot.in' => 'القدم المسيطرة غير صالحة', 'dominant_foot.in' => 'القدم المسيطرة غير صالحة',
'height_cm.min' => 'الطول يجب أن يكون 50 سم على الأقل', 'height_cm.min' => 'الطول يجب أن يكون 50 سم على الأقل',
......
...@@ -166,8 +166,35 @@ public function import(): void ...@@ -166,8 +166,35 @@ public function import(): void
$this->done = true; $this->done = true;
} }
/**
* What each CSV column is called in this branch's own words.
*
* The column names themselves are the file format and never change —
* a spreadsheet exported last year still imports. Only the explanation
* beside them follows the branch: membership_type reads "نوع الإقامة" in
* a compound and "نوع العضوية" in a club.
*
* @return array<string, string>
*/
private function columnLabels(): array
{
return [
'name_ar' => __('الاسم بالعربية'),
'name' => __('الاسم بالإنجليزية'),
'phone' => __('الهاتف'),
'email' => __('البريد'),
'gender' => __('الجنس'),
'date_of_birth' => __('تاريخ الميلاد'),
'nationality' => __('الجنسية'),
'membership_type' => term('membership_type'),
'membership_id' => term('membership_id'),
];
}
public function render() public function render()
{ {
return view('livewire.participants.participant-import'); return view('livewire.participants.participant-import', [
'columnLabels' => $this->columnLabels(),
]);
} }
} }
...@@ -132,10 +132,7 @@ public function render() ...@@ -132,10 +132,7 @@ public function render()
'advanced' => 'متقدم', 'advanced' => 'متقدم',
'professional' => 'محترف', 'professional' => 'محترف',
], ],
'membershipTypeOptions' => [ 'membershipTypeOptions' => \App\Domain\Participant\Enums\MembershipType::options(),
'member' => 'عضو',
'non_member' => 'غير عضو',
],
'isFreeOptions' => [ 'isFreeOptions' => [
'1' => 'مجاني', '1' => 'مجاني',
'0' => 'غير مجاني', '0' => 'غير مجاني',
......
...@@ -68,7 +68,7 @@ protected function authorizePortal(?string $permission = null): void ...@@ -68,7 +68,7 @@ protected function authorizePortal(?string $permission = null): void
$this->authorize($permission ?? 'portal.access'); $this->authorize($permission ?? 'portal.access');
if (! $this->portal()->speaksForAnyone()) { if (! $this->portal()->speaksForAnyone()) {
abort(403, __('هذا الحساب غير مرتبط بأي عضو في الأكاديمية')); abort(403, __('هذا الحساب غير مرتبط بأي مشترك في الأكاديمية'));
} }
} }
...@@ -77,7 +77,7 @@ protected function activeParticipantId(): int ...@@ -77,7 +77,7 @@ protected function activeParticipantId(): int
{ {
$id = $this->portal()->participantId(); $id = $this->portal()->participantId();
abort_if($id === null, 403, __('هذا الحساب غير مرتبط بأي عضو في الأكاديمية')); abort_if($id === null, 403, __('هذا الحساب غير مرتبط بأي مشترك في الأكاديمية'));
return $id; return $id;
} }
......
...@@ -88,7 +88,7 @@ public function payInstallmentFromWallet(int $installmentId, PaymentService $pay ...@@ -88,7 +88,7 @@ public function payInstallmentFromWallet(int $installmentId, PaymentService $pay
// can_authorize_payment has existed since 2024 with zero readers. This // can_authorize_payment has existed since 2024 with zero readers. This
// is one of the two places it finally gates something. // is one of the two places it finally gates something.
if (! $this->portal()->mayPayFor((int) $invoice->billable_id)) { if (! $this->portal()->mayPayFor((int) $invoice->billable_id)) {
$this->addError('pay', __('لا تملك صلاحية الدفع عن هذا العضو')); $this->addError('pay', __('لا تملك صلاحية الدفع عن هذا المشترك'));
return; return;
} }
......
...@@ -10,7 +10,6 @@ ...@@ -10,7 +10,6 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
...@@ -40,7 +39,6 @@ ...@@ -40,7 +39,6 @@
* family — hard-filtering would leave them invitable from neither. * family — hard-filtering would leave them invitable from neither.
*/ */
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('دعوات بوابة الأعضاء')]
class PortalInvitationManager extends Component class PortalInvitationManager extends Component
{ {
use WithPagination; use WithPagination;
...@@ -193,6 +191,8 @@ public function render() ...@@ -193,6 +191,8 @@ public function render()
'invitations' => $invitations, 'invitations' => $invitations,
'activeByPerson' => $invitations->whereNull('consumed_at')->whereNull('revoked_at') 'activeByPerson' => $invitations->whereNull('consumed_at')->whereNull('revoked_at')
->where('expires_at', '>', now())->keyBy('person_id'), ->where('expires_at', '>', now())->keyBy('person_id'),
]); // #[Title] is a PHP attribute and cannot call term(), so the branch's
// word reaches the browser tab from here instead.
])->title(__('دعوات بوابة') . ' ' . term('members'));
} }
} }
...@@ -47,7 +47,7 @@ public function render() ...@@ -47,7 +47,7 @@ public function render()
try { try {
$tokens = app(CheckInTokenService::class); $tokens = app(CheckInTokenService::class);
$token = $tokens->issue($participant); $token = $tokens->issue($participant);
$svg = QrCode::svg($token, 260, 4, __('رمز دخول العضو')); $svg = QrCode::svg($token, 260, 4, __('رمز دخول') . ' ' . term('member_definite', $participant->branch_id));
// How long this code stays valid, so the screen can count down // How long this code stays valid, so the screen can count down
// rather than silently going stale in someone's hand. // rather than silently going stale in someone's hand.
......
...@@ -152,7 +152,7 @@ private function assertPayable(Invoice $invoice): void ...@@ -152,7 +152,7 @@ private function assertPayable(Invoice $invoice): void
$invoice->billable_type === Participant::class $invoice->billable_type === Participant::class
&& $this->portal()->mayPayFor((int) $invoice->billable_id), && $this->portal()->mayPayFor((int) $invoice->billable_id),
403, 403,
__('لا تملك صلاحية الدفع عن هذا العضو') __('لا تملك صلاحية الدفع عن هذا المشترك')
); );
if ($invoice->due_amount <= 0) { if ($invoice->due_amount <= 0) {
......
...@@ -152,7 +152,7 @@ private function memberRules(): array ...@@ -152,7 +152,7 @@ private function memberRules(): array
private function memberMessages(): array private function memberMessages(): array
{ {
return [ return [
'childName.required' => __('اسم العضو مطلوب'), 'childName.required' => __('اسم') . ' ' . term('member_definite') . ' ' . __('مطلوب'),
'childDateOfBirth.required' => __('تاريخ الميلاد مطلوب'), 'childDateOfBirth.required' => __('تاريخ الميلاد مطلوب'),
'childDateOfBirth.before' => __('تاريخ الميلاد يجب أن يكون في الماضي'), 'childDateOfBirth.before' => __('تاريخ الميلاد يجب أن يكون في الماضي'),
'childGender.required' => __('النوع مطلوب'), 'childGender.required' => __('النوع مطلوب'),
...@@ -216,7 +216,7 @@ public function submit(PortalRegistrationService $registration, ConsentService $ ...@@ -216,7 +216,7 @@ public function submit(PortalRegistrationService $registration, ConsentService $
$consents->recordMany($this->consents, $user, $participant, request()->ip(), request()->userAgent()); $consents->recordMany($this->consents, $user, $participant, request()->ip(), request()->userAgent());
session()->flash('success', __('تم إنشاء حسابك. تسجيل العضو قيد المراجعة من الأكاديمية.')); session()->flash('success', __('تم إنشاء حسابك. تسجيل :member قيد المراجعة من الأكاديمية.', ['member' => term('member_definite', $participant->branch_id)]));
$this->redirect(route('portal.home'), navigate: false); $this->redirect(route('portal.home'), navigate: false);
} }
......
...@@ -311,10 +311,10 @@ public function messages(): array ...@@ -311,10 +311,10 @@ public function messages(): array
'billing_day.integer' => 'يوم الفوترة يجب أن يكون رقم', 'billing_day.integer' => 'يوم الفوترة يجب أن يكون رقم',
'billing_day.min' => 'يوم الفوترة يجب أن يكون 1 على الأقل', 'billing_day.min' => 'يوم الفوترة يجب أن يكون 1 على الأقل',
'billing_day.max' => 'يوم الفوترة يجب ألا يتجاوز 28', 'billing_day.max' => 'يوم الفوترة يجب ألا يتجاوز 28',
'member_price.numeric' => 'سعر العضو يجب أن يكون رقم', 'member_price.numeric' => term('member_price') . ' يجب أن يكون رقم',
'member_price.min' => 'سعر العضو يجب ألا يكون سالب', 'member_price.min' => term('member_price') . ' يجب ألا يكون سالب',
'non_member_price.numeric' => 'سعر غير العضو يجب أن يكون رقم', 'non_member_price.numeric' => term('non_member_price') . ' يجب أن يكون رقم',
'non_member_price.min' => 'سعر غير العضو يجب ألا يكون سالب', 'non_member_price.min' => term('non_member_price') . ' يجب ألا يكون سالب',
]; ];
} }
...@@ -454,8 +454,15 @@ private function savePrices(TrainingProgram $program): void ...@@ -454,8 +454,15 @@ private function savePrices(TrainingProgram $program): void
'priceable_type' => TrainingProgram::class, 'priceable_type' => TrainingProgram::class,
'priceable_id' => $program->id, 'priceable_id' => $program->id,
'branch_id' => $program->branch_id, 'branch_id' => $program->branch_id,
'name_ar' => $membershipType === 'member' ? 'سعر العضو' : 'سعر غير العضو', // Labelled with the branch's own vocabulary at the moment
'name' => $membershipType === 'member' ? 'Member Price' : 'Non-Member Price', // the price row is created — "سعر المقيم" in a compound,
// "سعر العضو" in a club.
'name_ar' => $membershipType === 'member'
? term('member_price', $program->branch_id)
: term('non_member_price', $program->branch_id),
'name' => $membershipType === 'member'
? terms($program->branch_id, 'en')->get('member_price')
: terms($program->branch_id, 'en')->get('non_member_price'),
'amount' => $amount, 'amount' => $amount,
'currency' => 'EGP', 'currency' => 'EGP',
'effective_from' => now()->toDateString(), 'effective_from' => now()->toDateString(),
......
...@@ -394,9 +394,9 @@ public function messages(): array ...@@ -394,9 +394,9 @@ public function messages(): array
'participant_date_of_birth.before' => 'تاريخ الميلاد يجب أن يكون في الماضي', 'participant_date_of_birth.before' => 'تاريخ الميلاد يجب أن يكون في الماضي',
'participant_gender.required' => 'الجنس مطلوب', 'participant_gender.required' => 'الجنس مطلوب',
'participant_gender.in' => 'الجنس غير صالح', 'participant_gender.in' => 'الجنس غير صالح',
'membership_type.required' => 'نوع العضوية مطلوب', 'membership_type.required' => term('membership_type') . ' مطلوب',
'membership_type.in' => 'نوع العضوية غير صالح', 'membership_type.in' => term('membership_type') . ' غير صالح',
'membership_id.required_if' => 'رقم عضوية النادي مطلوب للأعضاء', 'membership_id.required_if' => term('membership_id') . ' مطلوب ' . term('for_member'),
'guardian_name_ar.required' => 'اسم ولي الأمر مطلوب', 'guardian_name_ar.required' => 'اسم ولي الأمر مطلوب',
'guardian_phone.required' => 'رقم الهاتف مطلوب', 'guardian_phone.required' => 'رقم الهاتف مطلوب',
'guardian_relation.required' => 'صلة القرابة مطلوبة', 'guardian_relation.required' => 'صلة القرابة مطلوبة',
......
...@@ -147,9 +147,9 @@ public function getReportConfig(): array ...@@ -147,9 +147,9 @@ public function getReportConfig(): array
'uses_dates' => false, 'uses_dates' => false,
], ],
'expired_memberships' => [ 'expired_memberships' => [
'name' => لعضويات المنتهية', 'name' => نتهاء ' . term('membership'),
'method' => 'expiredMemberships', 'method' => 'expiredMemberships',
'headers' => ['الاسم', 'الهاتف', 'رقم العضوية', 'تاريخ الانتهاء', 'الحالة', 'الفرع', 'الأيام'], 'headers' => ['الاسم', 'الهاتف', term('membership_id'), 'تاريخ الانتهاء', 'الحالة', 'الفرع', 'الأيام'],
'columns' => ['name', 'phone', 'membership_id', 'expires_at', 'status', 'branch', 'days_until'], 'columns' => ['name', 'phone', 'membership_id', 'expires_at', 'status', 'branch', 'days_until'],
'money_cols' => [], 'money_cols' => [],
'uses_dates' => true, 'uses_dates' => true,
......
...@@ -84,7 +84,7 @@ private function allReports(): array ...@@ -84,7 +84,7 @@ private function allReports(): array
['key' => 'by_status', 'name' => 'حسب الحالة', 'desc' => 'توزيع المشتركين (نشط، مجمد، موقوف...)'], ['key' => 'by_status', 'name' => 'حسب الحالة', 'desc' => 'توزيع المشتركين (نشط، مجمد، موقوف...)'],
['key' => 'by_age', 'name' => 'حسب الفئة العمرية', 'desc' => 'المشتركون النشطون مصنفين بالعمر'], ['key' => 'by_age', 'name' => 'حسب الفئة العمرية', 'desc' => 'المشتركون النشطون مصنفين بالعمر'],
['key' => 'by_gender', 'name' => 'حسب الجنس', 'desc' => 'عدد الذكور والإناث النشطين'], ['key' => 'by_gender', 'name' => 'حسب الجنس', 'desc' => 'عدد الذكور والإناث النشطين'],
['key' => 'expired_memberships', 'name' => لعضويات المنتهية', 'desc' => 'أعضاء تنتهي أو انتهت عضويتهم قريباً'], ['key' => 'expired_memberships', 'name' => نتهاء ' . term('membership'), 'desc' => 'مشتركون تنتهي أو انتهت ' . term('membership') . ' قريباً'],
['key' => 'without_medical_clearance', 'name' => 'بدون شهادة طبية', 'desc' => 'مشتركون نشطون بدون شهادة طبية سارية'], ['key' => 'without_medical_clearance', 'name' => 'بدون شهادة طبية', 'desc' => 'مشتركون نشطون بدون شهادة طبية سارية'],
['key' => 'frozen_suspended', 'name' => 'المجمدون والموقوفون', 'desc' => 'مشتركون متوقفون مع السبب والمدة'], ['key' => 'frozen_suspended', 'name' => 'المجمدون والموقوفون', 'desc' => 'مشتركون متوقفون مع السبب والمدة'],
], ],
......
...@@ -8,7 +8,6 @@ ...@@ -8,7 +8,6 @@
use App\Domain\Shared\Services\ServiceRequestService; use App\Domain\Shared\Services\ServiceRequestService;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
...@@ -28,7 +27,6 @@ ...@@ -28,7 +27,6 @@
* subscription was frozen when it was not is worse than a slow queue. * subscription was frozen when it was not is worse than a slow queue.
*/ */
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('طلبات الأعضاء')]
class ServiceRequestQueue extends Component class ServiceRequestQueue extends Component
{ {
use WithPagination; use WithPagination;
...@@ -96,7 +94,7 @@ public function reject(ServiceRequestService $requests): void ...@@ -96,7 +94,7 @@ public function reject(ServiceRequestService $requests): void
$this->validate( $this->validate(
['notes' => ['required', 'string', 'max:1000']], ['notes' => ['required', 'string', 'max:1000']],
['notes.required' => __('اكتب سبب الرفض — سيراه العضو')], ['notes.required' => __('اكتب سبب الرفض — سيراه') . ' ' . term('member_definite')],
); );
try { try {
...@@ -126,7 +124,9 @@ public function render() ...@@ -126,7 +124,9 @@ public function render()
? ServiceRequest::with(['participant.person', 'trainingSession.group'])->find($this->handlingId) ? ServiceRequest::with(['participant.person', 'trainingSession.group'])->find($this->handlingId)
: null, : null,
'types' => ServiceRequest::TYPES, 'types' => ServiceRequest::TYPES,
]); // #[Title] is a PHP attribute and cannot call term(), so the branch's
// word reaches the browser tab from here instead.
])->title(__('طلبات') . ' ' . term('members'));
} }
private function inScope(?int $requestId): ServiceRequest private function inScope(?int $requestId): ServiceRequest
......
<?php
namespace App\Livewire\Settings;
use App\Domain\Identity\Services\TerminologyService;
use App\Domain\Identity\Support\Terminology;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Choose what this branch calls the people it serves.
*
* A club says عضو, a compound says مقيم, a beach says مشترك. The stored value
* never changes — participants.membership_type is 'member' / 'non_member' in
* every branch — so switching the vocabulary here relabels every screen, list,
* price field, report and import hint without touching a single row.
*/
#[Layout('layouts.app')]
class TerminologySettings extends Component
{
use UsesBranchScope;
public ?int $selectedBranchId = null;
public string $preset = 'club';
/** @var array<string, string> */
public array $terms = [];
public bool $saved = false;
public ?string $copiedTo = null;
public function mount(): void
{
$this->authorize('settings.manage');
$this->selectedBranchId = $this->defaultBranchId();
$this->load();
}
public function updatedSelectedBranchId(): void
{
// A plain public property is settable from the browser, and this one
// decides whose labels get rewritten — so it is reduced to a branch
// this user may actually edit before anything is read or written.
$this->selectedBranchId = $this->allowedBranchId($this->selectedBranchId);
$this->load();
}
private function defaultBranchId(): ?int
{
$active = $this->getActiveBranchId();
if ($active !== null) {
return $active;
}
$first = $this->selectableBranches()->first();
return $first ? (int) $first->id : null;
}
private function allowedBranchId(?int $branchId): ?int
{
if ($branchId === null) {
return null;
}
return $this->selectableBranches()->contains('id', $branchId)
? $branchId
: $this->defaultBranchId();
}
private function service(): TerminologyService
{
return app(TerminologyService::class);
}
private function load(): void
{
$service = $this->service();
$vocabulary = $service->for($this->selectedBranchId, 'ar');
$this->preset = $vocabulary->preset;
$this->terms = [];
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$this->terms[$key] = (string) ($vocabulary->toArray()[$key] ?? '');
}
$this->saved = false;
$this->copiedTo = null;
$this->resetErrorBag();
}
/**
* Swap in a whole preset. The fields stay editable afterwards — picking
* "مجمع سكني" and then changing one word is the normal path, not an edge
* case, which is why nothing here locks the inputs.
*/
public function applyPreset(string $preset): void
{
$this->authorize('settings.manage');
$presets = $this->service()->presets();
if (! isset($presets[$preset])) {
return;
}
$this->preset = $preset;
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$this->terms[$key] = (string) ($presets[$preset]['terms'][$key] ?? '');
}
$this->saved = false;
$this->copiedTo = null;
$this->resetErrorBag();
}
public function rules(): array
{
$rules = [
'preset' => 'required|string|in:' . implode(',', array_keys($this->service()->presets())),
];
foreach (Terminology::BASE_KEYS as $key) {
$rules["terms.{$key}"] = 'required|string|max:60';
}
foreach (Terminology::ENGLISH_KEYS as $key) {
$rules["terms.{$key}"] = 'nullable|string|max:60';
}
return $rules;
}
/**
* The field labels, shared with the view so an error names the box the
* user is looking at rather than a second wording of it.
*
* @return array<string, array{0: string, 1: string}> key => [label, club example]
*/
public static function arabicFields(): array
{
return [
'member' => ['المنتمي — مفرد نكرة', 'عضو'],
'member_definite' => ['المنتمي — مفرد مُعرَّف', 'العضو'],
'members' => ['المنتمون — جمع مُعرَّف', 'الأعضاء'],
'non_member' => ['غير المنتمي — مفرد نكرة', 'غير عضو'],
'non_member_definite' => ['غير المنتمي — مفرد مُعرَّف', 'غير العضو'],
'non_members' => ['غير المنتمين — جمع مُعرَّف', 'غير الأعضاء'],
'membership' => ['الصفة — مُعرَّفة', 'العضوية'],
'membership_indefinite' => ['الصفة — نكرة', 'عضوية'],
];
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function englishFields(): array
{
return [
'member_en' => ['Member (singular)', 'Member'],
'members_en' => ['Members (plural)', 'Members'],
'non_member_en' => ['Non-member (singular)', 'Non-member'],
'non_members_en' => ['Non-members (plural)', 'Non-members'],
'membership_en' => ['Membership', 'Membership'],
];
}
public function messages(): array
{
$messages = ['preset.required' => 'اختر نوع الفرع'];
foreach (self::arabicFields() as $key => [$label]) {
$messages["terms.{$key}.required"] = "{$label} مطلوب";
$messages["terms.{$key}.max"] = "{$label} يجب ألا يتجاوز 60 حرفاً";
}
foreach (Terminology::ENGLISH_KEYS as $key) {
$messages["terms.{$key}.max"] = 'المسمى الإنجليزي يجب ألا يتجاوز 60 حرفاً';
}
return $messages;
}
public function save(): void
{
$this->authorize('settings.manage');
$this->selectedBranchId = $this->allowedBranchId($this->selectedBranchId);
if ($this->selectedBranchId === null) {
$this->addError('branch', __('اختر فرعاً أولاً'));
return;
}
$this->validate();
try {
$this->service()->save($this->selectedBranchId, $this->preset, $this->terms);
} catch (\Throwable $e) {
report($e);
$this->addError('branch', __('تعذر حفظ المسميات. حاول مرة أخرى.'));
return;
}
$this->saved = true;
$this->copiedTo = null;
}
/**
* Nine branches of the same compound should not be configured nine times.
* Saves first, so what gets copied is what is on screen.
*/
public function applyToAllBranches(): void
{
$this->authorize('settings.manage');
$this->save();
if ($this->getErrorBag()->isNotEmpty() || $this->selectedBranchId === null) {
return;
}
try {
$count = $this->service()->copyToAcademy($this->selectedBranchId);
} catch (\Throwable $e) {
report($e);
$this->addError('branch', __('تعذر نسخ المسميات لباقي الفروع.'));
return;
}
$this->copiedTo = trans_choice(
'{0}لا توجد فروع أخرى لنسخها|{1}تم تطبيق المسميات على فرع آخر|[2,*]تم تطبيق المسميات على :count فروع أخرى',
$count,
['count' => $count],
);
}
/**
* What the form currently says, rendered the way the app will render it.
* Built from the unsaved values so the preview answers "what will this
* look like" rather than "what did I last save".
*/
public function getPreviewProperty(): Terminology
{
return new Terminology($this->terms, 'ar', $this->preset);
}
public function render()
{
return view('livewire.settings.terminology-settings', [
'presets' => $this->service()->presets(),
'branches' => $this->selectableBranches(),
'preview' => $this->preview,
])->title(__('مسميات') . ' ' . term('members'));
}
}
...@@ -39,6 +39,11 @@ public function register(): void ...@@ -39,6 +39,11 @@ public function register(): void
// per-request, and a Livewire round-trip is its own request. // per-request, and a Livewire round-trip is its own request.
$this->app->scoped(\App\Domain\Identity\Services\GuardianResolver::class); $this->app->scoped(\App\Domain\Identity\Services\GuardianResolver::class);
$this->app->scoped(\App\Domain\Shared\Context\PortalContext::class); $this->app->scoped(\App\Domain\Shared\Context\PortalContext::class);
// The branch's word for "member". Consulted by the sidebar and by most
// list screens, so the per-branch memo inside it is what keeps a page
// render at one query instead of one per label.
$this->app->scoped(\App\Domain\Identity\Services\TerminologyService::class);
} }
/** /**
......
...@@ -27,7 +27,8 @@ ...@@ -27,7 +27,8 @@
"app/Helpers/money.php", "app/Helpers/money.php",
"app/Helpers/whatsapp.php", "app/Helpers/whatsapp.php",
"app/Helpers/video.php", "app/Helpers/video.php",
"app/Helpers/website.php" "app/Helpers/website.php",
"app/Helpers/terminology.php"
], ],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
......
<?php
/*
|--------------------------------------------------------------------------
| Branch terminology
|--------------------------------------------------------------------------
|
| The product calls a person who belongs to the organisation an "عضو" and
| everyone else "غير عضو". That is only true for a club. The same branch
| software runs inside residential compounds (مقيم / غير مقيم), on beaches
| and in resorts (مشترك / زائر), in hotels (نزيل / زائر) and in gyms.
|
| The data model does not change — `participants.membership_type` still holds
| 'member' and 'non_member'. Only the words shown to a human do, and they are
| chosen per branch, because one academy can run a club and a compound at the
| same time.
|
| Eight Arabic forms are stored rather than derived, because Arabic will not
| let you derive them: "ال" prefixes the noun in "العضو" but the second word
| in "غير العضو", and the plural of عضو is أعضاء while the plural of مقيم is
| مقيمين. Anything that tried to be clever here would be wrong for half the
| presets. Everything a screen actually needs beyond these eight — "نوع
| العضوية", "رقم العضوية", "سعر العضو" — composes cleanly from them and is
| built in App\Domain\Identity\Support\Terminology.
|
| The plurals are stored in the ـين form (المقيمين, not المقيمون) on purpose.
| Almost every place the app prints a plural puts it after a preposition or in
| an idafa — "مسميات المقيمين", "للمشتركين" — where the ـين form is the correct
| one. Broken plurals such as الأعضاء and النزلاء do not inflect and are
| unaffected either way.
|
*/
return [
// The preset a branch falls back to when it has never been configured.
'default' => 'club',
/*
|----------------------------------------------------------------------
| Presets
|----------------------------------------------------------------------
|
| Picking one fills every field in the settings screen; the fields stay
| editable afterwards, so a branch that is nearly a compound picks
| 'compound' and rewrites the one word that does not fit. There is no
| 'custom' entry because that is not a separate mode — it is any preset
| with edits on top of it.
|
*/
'presets' => [
'club' => [
'label' => 'نادٍ رياضي / اجتماعي',
'terms' => [
'member' => 'عضو',
'member_definite' => 'العضو',
'members' => 'الأعضاء',
'non_member' => 'غير عضو',
'non_member_definite' => 'غير العضو',
'non_members' => 'غير الأعضاء',
'membership' => 'العضوية',
'membership_indefinite' => 'عضوية',
'member_en' => 'Member',
'members_en' => 'Members',
'non_member_en' => 'Non-member',
'non_members_en' => 'Non-members',
'membership_en' => 'Membership',
],
],
'compound' => [
'label' => 'مجمع سكني / كمبوند',
'terms' => [
'member' => 'مقيم',
'member_definite' => 'المقيم',
'members' => 'المقيمين',
'non_member' => 'غير مقيم',
'non_member_definite' => 'غير المقيم',
'non_members' => 'غير المقيمين',
'membership' => 'الإقامة',
'membership_indefinite' => 'إقامة',
'member_en' => 'Resident',
'members_en' => 'Residents',
'non_member_en' => 'Non-resident',
'non_members_en' => 'Non-residents',
'membership_en' => 'Residency',
],
],
'beach' => [
'label' => 'شاطئ / منتجع',
'terms' => [
'member' => 'مشترك',
'member_definite' => 'المشترك',
'members' => 'المشتركين',
'non_member' => 'زائر يومي',
'non_member_definite' => 'الزائر اليومي',
'non_members' => 'الزوار اليوميين',
'membership' => 'الاشتراك',
'membership_indefinite' => 'اشتراك',
'member_en' => 'Subscriber',
'members_en' => 'Subscribers',
'non_member_en' => 'Day visitor',
'non_members_en' => 'Day visitors',
'membership_en' => 'Subscription',
],
],
'hotel' => [
'label' => 'فندق / نُزُل',
'terms' => [
'member' => 'نزيل',
'member_definite' => 'النزيل',
'members' => 'النزلاء',
'non_member' => 'زائر',
'non_member_definite' => 'الزائر',
'non_members' => 'الزوار',
'membership' => 'الإقامة',
'membership_indefinite' => 'إقامة',
'member_en' => 'Guest',
'members_en' => 'Guests',
'non_member_en' => 'Visitor',
'non_members_en' => 'Visitors',
'membership_en' => 'Stay',
],
],
'gym' => [
'label' => 'صالة رياضية / جيم',
'terms' => [
'member' => 'مشترك',
'member_definite' => 'المشترك',
'members' => 'المشتركين',
'non_member' => 'غير مشترك',
'non_member_definite' => 'غير المشترك',
'non_members' => 'غير المشتركين',
'membership' => 'الاشتراك',
'membership_indefinite' => 'اشتراك',
'member_en' => 'Subscriber',
'members_en' => 'Subscribers',
'non_member_en' => 'Non-subscriber',
'non_members_en' => 'Non-subscribers',
'membership_en' => 'Subscription',
],
],
'school' => [
'label' => 'مدرسة / جهة تعليمية',
'terms' => [
'member' => 'منتسب',
'member_definite' => 'المنتسب',
'members' => 'المنتسبين',
'non_member' => 'غير منتسب',
'non_member_definite' => 'غير المنتسب',
'non_members' => 'غير المنتسبين',
'membership' => 'الانتساب',
'membership_indefinite' => 'انتساب',
'member_en' => 'Affiliate',
'members_en' => 'Affiliates',
'non_member_en' => 'Non-affiliate',
'non_members_en' => 'Non-affiliates',
'membership_en' => 'Affiliation',
],
],
],
];
...@@ -118,6 +118,7 @@ ...@@ -118,6 +118,7 @@
['label' => 'الهوية البصرية', 'route' => 'settings.branding', 'icon' => 'swatch', 'permission' => 'settings.manage'], ['label' => 'الهوية البصرية', 'route' => 'settings.branding', 'icon' => 'swatch', 'permission' => 'settings.manage'],
['label' => 'إعدادات النظام', 'route' => 'settings.system', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'], ['label' => 'إعدادات النظام', 'route' => 'settings.system', 'icon' => 'cog-6-tooth', 'permission' => 'settings.manage'],
['label' => 'إعدادات الإيصال', 'route' => 'settings.receipts', 'icon' => 'receipt-percent', 'permission' => 'settings.manage'], ['label' => 'إعدادات الإيصال', 'route' => 'settings.receipts', 'icon' => 'receipt-percent', 'permission' => 'settings.manage'],
['label' => 'مسميات ' . term('members'), 'route' => 'settings.terminology', 'icon' => 'tag', 'permission' => 'settings.manage'],
['label' => 'معالج الإعداد', 'route' => 'setup.wizard', 'icon' => 'bolt', 'permission' => 'settings.manage'], ['label' => 'معالج الإعداد', 'route' => 'setup.wizard', 'icon' => 'bolt', 'permission' => 'settings.manage'],
['label' => 'لوحة المشرف', 'route' => 'admin.panel', 'icon' => 'shield-check', 'permission' => 'settings.manage'], ['label' => 'لوحة المشرف', 'route' => 'admin.panel', 'icon' => 'shield-check', 'permission' => 'settings.manage'],
]], ]],
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<header> <header>
<h1 class="text-xl font-bold text-gray-900">{{ __('مسح بطاقة الحضور') }}</h1> <h1 class="text-xl font-bold text-gray-900">{{ __('مسح بطاقة الحضور') }}</h1>
<p class="mt-0.5 text-sm text-gray-500"> <p class="mt-0.5 text-sm text-gray-500">
{{ __('وجّه الكاميرا إلى رمز العضو، أو استخدم قارئ الباركود المتصل.') }} {{ __('وجّه الكاميرا إلى رمز :member، أو استخدم قارئ الباركود المتصل.', ['member' => term('member_definite')]) }}
</p> </p>
</header> </header>
......
...@@ -105,7 +105,7 @@ class="text-sm text-gray-600 hover:text-gray-800 flex items-center gap-1"> ...@@ -105,7 +105,7 @@ class="text-sm text-gray-600 hover:text-gray-800 flex items-center gap-1">
<input type="text" <input type="text"
id="participantSearch" id="participantSearch"
wire:model.live.debounce.300ms="participantSearch" wire:model.live.debounce.300ms="participantSearch"
placeholder="{{ __('اكتب اسم المشترك أو رقم العضوية أو رقم الهاتف...') }}" placeholder="{{ __('اكتب اسم المشترك أو :membership_id أو رقم الهاتف...', ['membership_id' => term('membership_id')]) }}"
class="w-full ps-10 pe-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full ps-10 pe-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autocomplete="off"> autocomplete="off">
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"> <div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<div> <div>
<h1 class="text-xl font-bold text-gray-900">{{ __('إثباتات التحويل') }}</h1> <h1 class="text-xl font-bold text-gray-900">{{ __('إثباتات التحويل') }}</h1>
<p class="mt-0.5 text-sm text-gray-500"> <p class="mt-0.5 text-sm text-gray-500">
{{ __('التحويلات التي سجّلها الأعضاء وتنتظر المطابقة مع كشف الحساب') }} {{ __('التحويلات التي سجّلها :members وتنتظر المطابقة مع كشف الحساب', ['members' => term('members')]) }}
</p> </p>
</div> </div>
@if($pendingCount > 0) @if($pendingCount > 0)
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4"> <dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div> <div>
<dt class="text-xs text-gray-500">{{ __('العضو') }}</dt> <dt class="text-xs text-gray-500">{{ term('member_definite') }}</dt>
<dd class="font-semibold">{{ $reviewing->participant?->person?->name_ar ?? '—' }}</dd> <dd class="font-semibold">{{ $reviewing->participant?->person?->name_ar ?? '—' }}</dd>
</div> </div>
<div> <div>
...@@ -86,7 +86,7 @@ class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل ا ...@@ -86,7 +86,7 @@ class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل ا
class="mt-1 w-full rounded-lg border-gray-300 font-mono"> class="mt-1 w-full rounded-lg border-gray-300 font-mono">
@error('verifiedAmount') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror @error('verifiedAmount') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
{{ __('الزائد عن المستحق يُضاف إلى محفظة العضو تلقائياً') }} {{ __('الزائد عن المستحق يُضاف إلى محفظة :member تلقائياً', ['member' => term('member_definite')]) }}
</p> </p>
</div> </div>
...@@ -129,7 +129,7 @@ class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500 hover:text-gray- ...@@ -129,7 +129,7 @@ class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500 hover:text-gray-
<table class="min-w-full divide-y divide-gray-200 text-sm"> <table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-xs text-gray-500"> <thead class="bg-gray-50 text-xs text-gray-500">
<tr> <tr>
<th class="px-4 py-3 text-start font-medium">{{ __('العضو') }}</th> <th class="px-4 py-3 text-start font-medium">{{ term('member_definite') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الفاتورة') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('الفاتورة') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('المبلغ') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('المبلغ') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('رقم العملية') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('رقم العملية') }}</th>
......
...@@ -211,7 +211,7 @@ class="w-32 text-sm px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 foc ...@@ -211,7 +211,7 @@ class="w-32 text-sm px-3 py-2 border border-blue-300 rounded-lg focus:ring-2 foc
@if($billing_cycle === 'annual') @if($billing_cycle === 'annual')
{{-- Dual pricing --}} {{-- Dual pricing --}}
<div class="p-4 bg-purple-50 border border-purple-200 rounded-xl mb-6"> <div class="p-4 bg-purple-50 border border-purple-200 rounded-xl mb-6">
<h3 class="text-sm font-semibold text-purple-800 mb-3">{{ __('الأسعار حسب نوع العضوية') }}</h3> <h3 class="text-sm font-semibold text-purple-800 mb-3">{{ __('الأسعار حسب') }} {{ term('membership_type') }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('السعر الأساسي (ج.م)') }} *</label> <label class="block text-xs font-medium text-gray-600 mb-1">{{ __('السعر الأساسي (ج.م)') }} *</label>
...@@ -221,14 +221,14 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -221,14 +221,14 @@ class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<p class="text-xs text-gray-400 mt-0.5">{{ __('يُستخدم إذا لم يحدد سعر خاص') }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ __('يُستخدم إذا لم يحدد سعر خاص') }}</p>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-green-700 mb-1">{{ __('سعر الأعضاء (ج.م)') }}</label> <label class="block text-xs font-medium text-green-700 mb-1">{{ __('سعر') }} {{ term('members') }} {{ __('(ج.م)') }}</label>
<input type="number" wire:model="member_price" step="0.01" min="0" dir="ltr" <input type="number" wire:model="member_price" step="0.01" min="0" dir="ltr"
class="w-full text-sm px-3 py-2 border border-green-300 rounded-lg focus:ring-2 focus:ring-green-500 @error('member_price') border-red-500 @enderror" class="w-full text-sm px-3 py-2 border border-green-300 rounded-lg focus:ring-2 focus:ring-green-500 @error('member_price') border-red-500 @enderror"
placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}"> placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}">
@error('member_price') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror @error('member_price') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-orange-700 mb-1">{{ __('سعر غير الأعضاء (ج.م)') }}</label> <label class="block text-xs font-medium text-orange-700 mb-1">{{ __('سعر') }} {{ term('non_members') }} {{ __('(ج.م)') }}</label>
<input type="number" wire:model="non_member_price" step="0.01" min="0" dir="ltr" <input type="number" wire:model="non_member_price" step="0.01" min="0" dir="ltr"
class="w-full text-sm px-3 py-2 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 @error('non_member_price') border-red-500 @enderror" class="w-full text-sm px-3 py-2 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 @error('non_member_price') border-red-500 @enderror"
placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}"> placeholder="{{ __('اتركه فارغاً للسعر الأساسي') }}">
...@@ -269,17 +269,17 @@ class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button> ...@@ -269,17 +269,17 @@ class="text-red-500 hover:text-red-700 text-xs">{{ __('حذف') }}</button>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('اسم الخطة') }} *</label> <label class="block text-xs font-medium text-gray-600 mb-1">{{ __('اسم الخطة') }} *</label>
<input type="text" wire:model="planRows.{{ $i }}.label_ar" <input type="text" wire:model="planRows.{{ $i }}.label_ar"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="{{ __('مثال: 3 أقساط شهرية للأعضاء') }}"> placeholder="{{ __('مثال: 3 أقساط شهرية') }} {{ term('for_members') }}">
@error("planRows.{$i}.label_ar") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror @error("planRows.{$i}.label_ar") <p class="mt-0.5 text-xs text-red-600">{{ $message }}</p> @enderror
</div> </div>
{{-- Tier --}} {{-- Tier --}}
<div> <div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('للعضوية') }}</label> <label class="block text-xs font-medium text-gray-600 mb-1">{{ term('membership_type') }}</label>
<select wire:model="planRows.{{ $i }}.tier" <select wire:model="planRows.{{ $i }}.tier"
class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"> class="w-full text-sm px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500">
<option value="any">{{ __('الجميع') }}</option> <option value="any">{{ __('الجميع') }}</option>
<option value="member">{{ __('أعضاء فقط') }}</option> <option value="member">{{ term('members') }} {{ __('فقط') }}</option>
<option value="non_member">{{ __('غير أعضاء') }}</option> <option value="non_member">{{ term('non_members') }}</option>
</select> </select>
</div> </div>
{{-- Installments count --}} {{-- Installments count --}}
......
...@@ -41,7 +41,7 @@ class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors" ...@@ -41,7 +41,7 @@ class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors"
<button type="button" wire:click="$set('selectionMode', 'membership')" <button type="button" wire:click="$set('selectionMode', 'membership')"
class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors" class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors"
:class="mode === 'membership' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"> :class="mode === 'membership' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'">
{{ __('حسب العضوية') }} {{ __('حسب') }} {{ term('membership') }}
</button> </button>
<button type="button" wire:click="$set('selectionMode', 'custom')" <button type="button" wire:click="$set('selectionMode', 'custom')"
class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors" class="px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition-colors"
...@@ -83,10 +83,10 @@ class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:r ...@@ -83,10 +83,10 @@ class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:r
{{-- Membership Selection --}} {{-- Membership Selection --}}
@if($selectionMode === 'membership') @if($selectionMode === 'membership')
<div> <div>
<label for="selectedMembership" class="block text-sm font-medium text-gray-700 mb-1">{{ __('نوع العضوية') }} <span class="text-red-500">*</span></label> <label for="selectedMembership" class="block text-sm font-medium text-gray-700 mb-1">{{ term('membership_type') }} <span class="text-red-500">*</span></label>
<select wire:model.live="selectedMembership" id="selectedMembership" <select wire:model.live="selectedMembership" id="selectedMembership"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('اختر نوع العضوية') }}</option> <option value="">{{ __('اختر') }} {{ term('membership_type') }}</option>
@foreach($membershipTypes as $type) @foreach($membershipTypes as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option> <option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach @endforeach
......
...@@ -82,7 +82,7 @@ class="flex-1 py-2.5 px-3 rounded-lg text-sm font-medium transition-all min-h-[4 ...@@ -82,7 +82,7 @@ class="flex-1 py-2.5 px-3 rounded-lg text-sm font-medium transition-all min-h-[4
<span class="text-sm font-medium text-[#0F172A]" dir="ltr">{{ $participant->registration_date?->format('Y-m-d') ?? '--' }}</span> <span class="text-sm font-medium text-[#0F172A]" dir="ltr">{{ $participant->registration_date?->format('Y-m-d') ?? '--' }}</span>
</div> </div>
<div class="flex items-center justify-between px-5 py-3.5"> <div class="flex items-center justify-between px-5 py-3.5">
<span class="text-sm text-[#64748B]">{{ __('نوع العضوية') }}</span> <span class="text-sm text-[#64748B]">{{ term('membership_type') }}</span>
<span class="text-sm font-medium text-[#0F172A]">{{ $participant->membership_type ?? __('أساسي') }}</span> <span class="text-sm font-medium text-[#0F172A]">{{ $participant->membership_type ?? __('أساسي') }}</span>
</div> </div>
</div> </div>
......
...@@ -192,19 +192,19 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 ...@@ -192,19 +192,19 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نوع العضوية') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('membership_type') }} <span class="text-red-500">*</span></label>
<select wire:model.live="membership_type" <select wire:model.live="membership_type"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('— غير محدد —') }}</option> <option value="">{{ __('— غير محدد —') }}</option>
<option value="member">{{ __('عضو') }}</option> <option value="member">{{ term('member') }}</option>
<option value="non_member">{{ __('غير عضو') }}</option> <option value="non_member">{{ term('non_member') }}</option>
</select> </select>
</div> </div>
@if($membership_type === 'member') @if($membership_type === 'member')
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('رقم عضوية النادي') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('membership_id') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model.live.debounce.500ms="membership_id" dir="ltr" <input type="text" wire:model.live.debounce.500ms="membership_id" dir="ltr"
placeholder="{{ __('أدخل رقم العضوية') }}" placeholder="{{ __('أدخل') }} {{ term('membership_id') }}"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('membership_id') border-red-500 @enderror"> class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('membership_id') border-red-500 @enderror">
@error('membership_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('membership_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
...@@ -218,7 +218,7 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 ...@@ -218,7 +218,7 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg> </svg>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-semibold text-amber-800 mb-2">{{ __('رقم العضوية مسجل بالفعل للمشتركين التاليين:') }}</p> <p class="text-sm font-semibold text-amber-800 mb-2">{{ term('membership_id') }} {{ __('مسجل بالفعل للمشتركين التاليين:') }}</p>
@foreach($membershipIdSiblings as $sibling) @foreach($membershipIdSiblings as $sibling)
<div class="flex items-center gap-2 bg-white rounded-lg px-3 py-2 border border-amber-200 mb-1"> <div class="flex items-center gap-2 bg-white rounded-lg px-3 py-2 border border-amber-200 mb-1">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
......
...@@ -13,6 +13,17 @@ class="text-sm text-blue-600 hover:text-blue-800">{{ __('العودة للقائ ...@@ -13,6 +13,17 @@ class="text-sm text-blue-600 hover:text-blue-800">{{ __('العودة للقائ
<li>{{ __('الأعمدة المطلوبة: name_ar, phone') }}</li> <li>{{ __('الأعمدة المطلوبة: name_ar, phone') }}</li>
<li>{{ __('أعمدة اختيارية: name, email, gender, date_of_birth, nationality, membership_type, membership_id') }}</li> <li>{{ __('أعمدة اختيارية: name, email, gender, date_of_birth, nationality, membership_type, membership_id') }}</li>
<li>{{ __('إذا كان رقم الهاتف موجود مسبقاً، يتم تخطي السجل') }}</li> <li>{{ __('إذا كان رقم الهاتف موجود مسبقاً، يتم تخطي السجل') }}</li>
{{-- Column names are part of the file format and never translate;
what they mean in this branch does, so it is spelled out. --}}
<li>
{{ __('عمود') }} <code dir="ltr" class="font-mono">membership_type</code>
{{ __('يقبل') }} <code dir="ltr" class="font-mono">member</code> ({{ term('member') }})
{{ __('أو') }} <code dir="ltr" class="font-mono">non_member</code> ({{ term('non_member') }}) —
{{ __('والفارغ يُعتبر') }} {{ term('non_member') }}.
</li>
<li>
<code dir="ltr" class="font-mono">membership_id</code> {{ __('هو') }} {{ term('membership_id') }}.
</li>
</ul> </ul>
</div> </div>
...@@ -37,15 +48,30 @@ class="block w-full text-sm text-gray-500 file:me-4 file:py-2.5 file:px-4 file:r ...@@ -37,15 +48,30 @@ class="block w-full text-sm text-gray-500 file:me-4 file:py-2.5 file:px-4 file:r
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
@foreach(array_keys($preview[0]) as $col) @foreach(array_keys($preview[0]) as $col)
<th class="px-3 py-2 text-start text-xs font-medium text-gray-500 whitespace-nowrap">{{ $col }}</th> {{-- The CSV column name stays, because that is what the
file must literally contain; the branch's word for
it sits underneath so nobody has to guess. --}}
<th class="px-3 py-2 text-start text-xs font-medium text-gray-500 whitespace-nowrap">
<span dir="ltr" class="font-mono block">{{ $col }}</span>
@if(isset($columnLabels[$col]))
<span class="block font-normal text-gray-400">{{ $columnLabels[$col] }}</span>
@endif
</th>
@endforeach @endforeach
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach($preview as $row) @foreach($preview as $row)
<tr class="border-t"> <tr class="border-t">
@foreach($row as $cell) @foreach($row as $col => $cell)
<td class="px-3 py-2 text-gray-700 whitespace-nowrap">{{ $cell }}</td> <td class="px-3 py-2 text-gray-700 whitespace-nowrap">
@if($col === 'membership_type' && filled($cell))
{{ membership_label($cell) }}
<span class="text-xs text-gray-400" dir="ltr">({{ $cell }})</span>
@else
{{ $cell }}
@endif
</td>
@endforeach @endforeach
</tr> </tr>
@endforeach @endforeach
......
...@@ -30,7 +30,7 @@ class="inline-flex items-center gap-2 px-3 sm:px-4 py-2 bg-blue-600 text-white r ...@@ -30,7 +30,7 @@ class="inline-flex items-center gap-2 px-3 sm:px-4 py-2 bg-blue-600 text-white r
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div class="col-span-2 md:col-span-3 lg:col-span-4"> <div class="col-span-2 md:col-span-3 lg:col-span-4">
<input type="text" wire:model.live.debounce.300ms="search" <input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالاسم، الرقم القومي، رقم العضوية، أو الهاتف...') }}" placeholder="{{ __('بحث بالاسم، الرقم القومي، :membership_id، أو الهاتف...', ['membership_id' => term('membership_id')]) }}"
class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
</div> </div>
<select wire:model.live="status" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> <select wire:model.live="status" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
...@@ -46,9 +46,9 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -46,9 +46,9 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
@endforeach @endforeach
</select> </select>
<select wire:model.live="membershipType" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> <select wire:model.live="membershipType" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('كل أنواع العضوية') }}</option> <option value="">{{ __('كل') }} {{ term('membership_types') }}</option>
@foreach($membershipTypeOptions as $value => $label) @foreach($membershipTypeOptions as $value => $label)
<option value="{{ $value }}">{{ __($label) }}</option> <option value="{{ $value }}">{{ $label }}</option>
@endforeach @endforeach
</select> </select>
<select wire:model.live="isFree" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"> <select wire:model.live="isFree" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
...@@ -76,7 +76,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -76,7 +76,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<x-ui.sort-header column="participant_number" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('رقم المشترك') }}</x-ui.sort-header> <x-ui.sort-header column="participant_number" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('رقم المشترك') }}</x-ui.sort-header>
<x-ui.sort-header column="name_ar" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الاسم') }}</x-ui.sort-header> <x-ui.sort-header column="name_ar" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الاسم') }}</x-ui.sort-header>
<x-ui.sort-header column="primary_activity_id" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('النشاط') }}</x-ui.sort-header> <x-ui.sort-header column="primary_activity_id" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('النشاط') }}</x-ui.sort-header>
<x-ui.sort-header column="membership_type" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('العضوية') }}</x-ui.sort-header> <x-ui.sort-header column="membership_type" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ term('membership') }}</x-ui.sort-header>
<x-ui.sort-header column="status" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحالة') }}</x-ui.sort-header> <x-ui.sort-header column="status" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحالة') }}</x-ui.sort-header>
<x-ui.sort-header column="created_at" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('تاريخ التسجيل') }}</x-ui.sort-header> <x-ui.sort-header column="created_at" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('تاريخ التسجيل') }}</x-ui.sort-header>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('إجراءات') }}</th> <th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('إجراءات') }}</th>
...@@ -103,12 +103,12 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -103,12 +103,12 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
$membershipValue = $participant->membership_type?->value ?? $participant->membership_type; $membershipValue = $participant->membership_type?->value ?? $participant->membership_type;
@endphp @endphp
@if($membershipValue === 'member') @if($membershipValue === 'member')
<span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">{{ __('عضو') }}</span> <span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">{{ term('member') }}</span>
@if($participant->membership_id) @if($participant->membership_id)
<p class="text-xs text-gray-500 mt-0.5 font-mono" dir="ltr">{{ $participant->membership_id }}</p> <p class="text-xs text-gray-500 mt-0.5 font-mono" dir="ltr">{{ $participant->membership_id }}</p>
@endif @endif
@elseif($membershipValue === 'non_member') @elseif($membershipValue === 'non_member')
<span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">{{ __('غير عضو') }}</span> <span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">{{ term('non_member') }}</span>
@else @else
<span class="text-gray-400"></span> <span class="text-gray-400"></span>
@endif @endif
...@@ -198,7 +198,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f ...@@ -198,7 +198,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
@endif @endif
@php $mobileMemType = $participant->membership_type?->value ?? $participant->membership_type; @endphp @php $mobileMemType = $participant->membership_type?->value ?? $participant->membership_type; @endphp
@if($mobileMemType === 'member') @if($mobileMemType === 'member')
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px]">{{ __('عضو') }}{{ $participant->membership_id ? ' #'.$participant->membership_id : '' }}</span> <span class="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px]">{{ term('member') }}{{ $participant->membership_id ? ' #'.$participant->membership_id : '' }}</span>
@endif @endif
@if($participant->registration_date) @if($participant->registration_date)
<span dir="ltr" class="whitespace-nowrap">{{ $participant->registration_date->format('Y-m-d') }}</span> <span dir="ltr" class="whitespace-nowrap">{{ $participant->registration_date->format('Y-m-d') }}</span>
......
...@@ -302,13 +302,15 @@ class="pb-3 border-b-2 text-sm font-medium transition whitespace-nowrap"> ...@@ -302,13 +302,15 @@ class="pb-3 border-b-2 text-sm font-medium transition whitespace-nowrap">
</dd> </dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">{{ __('نوع العضوية') }}</dt> <dt class="text-sm text-gray-500">{{ term('membership_type') }}</dt>
<dd class="text-sm text-gray-800"> <dd class="text-sm text-gray-800">
{{-- This used to look membership_type up in a map of tier
names (basic/silver/gold), which the column has never
held, so the row rendered "—" for every participant. --}}
@php @php
$memberLabels = ['basic' => 'أساسي', 'silver' => 'فضي', 'gold' => 'ذهبي', 'platinum' => 'بلاتيني', 'custom' => 'مخصص'];
$memberValue = $participant->membership_type?->value ?? $participant->membership_type; $memberValue = $participant->membership_type?->value ?? $participant->membership_type;
@endphp @endphp
{{ __($memberLabels[$memberValue] ?? '—') }} {{ $memberValue ? membership_label($memberValue, $participant->branch_id) : '—' }}
</dd> </dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<header> <header>
<h1 class="text-xl font-bold text-gray-900">{{ __('تسجيلات في انتظار المراجعة') }}</h1> <h1 class="text-xl font-bold text-gray-900">{{ __('تسجيلات في انتظار المراجعة') }}</h1>
<p class="mt-0.5 text-sm text-gray-500"> <p class="mt-0.5 text-sm text-gray-500">
{{ __('أعضاء سجّلوا أنفسهم عبر البوابة. راجع المطابقات المحتملة قبل الاعتماد حتى لا يُنشأ ملف ثانٍ لعضو قائم.') }} {{ __('مشتركون سجّلوا أنفسهم عبر البوابة. راجع المطابقات المحتملة قبل الاعتماد حتى لا يُنشأ ملف ثانٍ لمشترك قائم.') }}
</p> </p>
</header> </header>
...@@ -41,7 +41,7 @@ class="rounded-lg border border-red-300 px-4 py-2 text-xs font-bold text-red-700 ...@@ -41,7 +41,7 @@ class="rounded-lg border border-red-300 px-4 py-2 text-xs font-bold text-red-700
{{-- The findings DuplicateDetectionService has always produced {{-- The findings DuplicateDetectionService has always produced
and nothing ever showed anyone. --}} and nothing ever showed anyone. --}}
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2"> <div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
<p class="text-xs font-bold text-amber-900">{{ __('قد يكون عضواً قائماً بالفعل:') }}</p> <p class="text-xs font-bold text-amber-900">{{ __('قد يكون مسجلاً بالفعل:') }}</p>
<ul class="mt-1 space-y-0.5"> <ul class="mt-1 space-y-0.5">
@foreach($participant->duplicate_candidates as $candidate) @foreach($participant->duplicate_candidates as $candidate)
<li class="text-xs text-amber-800">• {{ $candidate['name'] ?? '—' }}</li> <li class="text-xs text-amber-800">• {{ $candidate['name'] ?? '—' }}</li>
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
</dl> </dl>
</x-portal.card> </x-portal.card>
<x-portal.card :title="__('الأعضاء المرتبطون')"> <x-portal.card :title="term('members') . ' ' . __('المرتبطون')">
<ul class="space-y-2"> <ul class="space-y-2">
@foreach($profiles as $profile) @foreach($profiles as $profile)
<li class="flex items-center gap-3"> <li class="flex items-center gap-3">
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{{ $profile->person?->name_ar ?? $profile->person?->name }}</p> <p class="truncate text-sm font-semibold">{{ $profile->person?->name_ar ?? $profile->person?->name }}</p>
<p class="text-[11px]" style="color: var(--portal-muted);"> <p class="text-[11px]" style="color: var(--portal-muted);">
{{ $profile->membership_type === 'member' ? __('عضو') : __('غير عضو') }} {{ membership_label($profile->membership_type, $profile->branch_id) }}
</p> </p>
</div> </div>
</li> </li>
......
...@@ -75,7 +75,7 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60" ...@@ -75,7 +75,7 @@ class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
@empty @empty
<x-portal.card> <x-portal.card>
<x-portal.empty :title="__('لا توجد مستندات بعد')" <x-portal.empty :title="__('لا توجد مستندات بعد')"
:body="__('ارفع الشهادة الطبية وشهادة الميلاد ليكتمل ملف العضو')" /> :body="__('ارفع الشهادة الطبية وشهادة الميلاد ليكتمل ملف :member', ['member' => term('member_definite')])" />
</x-portal.card> </x-portal.card>
@endforelse @endforelse
</div> </div>
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<header> <header>
<p class="text-xs" style="color: var(--portal-muted);">{{ $greeting }}</p> <p class="text-xs" style="color: var(--portal-muted);">{{ $greeting }}</p>
<h1 class="text-xl font-extrabold leading-tight"> <h1 class="text-xl font-extrabold leading-tight">
{{ $activeChild?->person?->name_ar ?? $activeChild?->person?->name ?? __('عضو') }} {{ $activeChild?->person?->name_ar ?? $activeChild?->person?->name ?? term('member') }}
</h1> </h1>
</header> </header>
......
<div class="space-y-5"> <div class="space-y-5">
<header> <header>
<h1 class="text-xl font-bold text-gray-900">{{ __('دعوات بوابة الأعضاء') }}</h1> <h1 class="text-xl font-bold text-gray-900">{{ __('دعوات بوابة') }} {{ term('members') }}</h1>
<p class="mt-0.5 text-sm text-gray-500"> <p class="mt-0.5 text-sm text-gray-500">
{{ __('أنشئ رابط تفعيل لولي أمر أو لاعب. الرابط صالح ٧٢ ساعة ويُستخدم مرة واحدة.') }} {{ __('أنشئ رابط تفعيل لولي أمر أو لاعب. الرابط صالح ٧٢ ساعة ويُستخدم مرة واحدة.') }}
</p> </p>
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
</x-portal.card> </x-portal.card>
@elseif(! $svg) @elseif(! $svg)
<x-portal.card> <x-portal.card>
<x-portal.empty :title="__('لا يوجد عضو محدد')" /> <x-portal.empty :title="__('لا يوجد :member محدد', ['member' => term('member')])" />
</x-portal.card> </x-portal.card>
@else @else
<div class="portal-card flex flex-col items-center gap-3 px-4 py-6"> <div class="portal-card flex flex-col items-center gap-3 px-4 py-6">
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<span class="text-3xl font-extrabold leading-none tracking-tight">{{ $total['amount'] }}</span> <span class="text-3xl font-extrabold leading-none tracking-tight">{{ $total['amount'] }}</span>
<span class="text-sm font-bold" style="opacity: .75;">{{ $total['currency'] }}</span> <span class="text-sm font-bold" style="opacity: .75;">{{ $total['currency'] }}</span>
</p> </p>
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ __('لكل الأعضاء المرتبطين بحسابك') }}</p> <p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ __('لكل') }} {{ term('members') }} {{ __('المرتبطين بحسابك') }}</p>
</div> </div>
<x-portal.tabs :tabs="['due' => __('عليك'), 'history' => __('السجل'), 'wallet' => __('المحفظة')]" <x-portal.tabs :tabs="['due' => __('عليك'), 'history' => __('السجل'), 'wallet' => __('المحفظة')]"
......
...@@ -43,7 +43,7 @@ class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold" ...@@ -43,7 +43,7 @@ class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
<x-portal.card :title="__('نسخة من بياناتي')"> <x-portal.card :title="__('نسخة من بياناتي')">
<p class="text-[11px] leading-relaxed" style="color: var(--portal-muted);"> <p class="text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('تنزيل كل ما يحتفظ به النظام عن حسابك والأعضاء المرتبطين به: الفواتير، المدفوعات، الموافقات.') }} {{ __('تنزيل كل ما يحتفظ به النظام عن حسابك و:members المرتبطين به: الفواتير، المدفوعات، الموافقات.', ['members' => term('members')]) }}
</p> </p>
<button type="button" wire:click="export" <button type="button" wire:click="export"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold" class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
{{-- Four steps because the phone must be proven before anything is {{-- Four steps because the phone must be proven before anything is
written; a member who abandons at step two leaves nothing behind. --}} written; a member who abandons at step two leaves nothing behind. --}}
<ol class="flex items-center gap-1" aria-label="{{ __('خطوات التسجيل') }}"> <ol class="flex items-center gap-1" aria-label="{{ __('خطوات التسجيل') }}">
@foreach([1 => 'بياناتك', 2 => 'التحقق', 3 => 'العضو', 4 => 'الموافقات'] as $n => $label) @foreach([1 => __('بياناتك'), 2 => __('التحقق'), 3 => term('member_definite'), 4 => __('الموافقات')] as $n => $label)
<li class="flex-1"> <li class="flex-1">
<div class="h-1 rounded-full" <div class="h-1 rounded-full"
style="background: {{ $step >= $n ? 'var(--brand-500)' : 'var(--portal-border)' }};"></div> style="background: {{ $step >= $n ? 'var(--brand-500)' : 'var(--portal-border)' }};"></div>
...@@ -79,10 +79,10 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-center text-lg trackin ...@@ -79,10 +79,10 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-center text-lg trackin
@elseif($step === 3) @elseif($step === 3)
<form wire:submit="goToConsents" class="portal-card space-y-3 px-4 py-4"> <form wire:submit="goToConsents" class="portal-card space-y-3 px-4 py-4">
<h2 class="text-sm font-bold">{{ __('بيانات العضو') }}</h2> <h2 class="text-sm font-bold">{{ __('بيانات') }} {{ term('member_definite') }}</h2>
<div> <div>
<label for="childName" class="block text-xs font-semibold">{{ __('اسم العضو') }}</label> <label for="childName" class="block text-xs font-semibold">{{ __('اسم') }} {{ term('member_definite') }}</label>
<input id="childName" type="text" wire:model="childName" <input id="childName" type="text" wire:model="childName"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"> style="border-color: var(--portal-border); background: var(--portal-surface);">
...@@ -157,7 +157,7 @@ class="mt-1 h-4 w-4 shrink-0 rounded" style="accent-color: var(--brand-500);"> ...@@ -157,7 +157,7 @@ class="mt-1 h-4 w-4 shrink-0 rounded" style="accent-color: var(--brand-500);">
<p class="rounded-xl px-3 py-2 text-[11px] leading-relaxed" <p class="rounded-xl px-3 py-2 text-[11px] leading-relaxed"
style="background: color-mix(in oklab, var(--brand-500) 8%, var(--portal-surface)); color: var(--portal-muted);"> style="background: color-mix(in oklab, var(--brand-500) 8%, var(--portal-surface)); color: var(--portal-muted);">
{{ __('يُنشأ حسابك فوراً، ويظل تسجيل العضو قيد مراجعة الأكاديمية قبل تفعيله.') }} {{ __('يُنشأ حسابك فوراً، ويظل تسجيل :member قيد مراجعة الأكاديمية قبل تفعيله.', ['member' => term('member_definite')]) }}
</p> </p>
<button type="submit" wire:loading.attr="disabled" <button type="submit" wire:loading.attr="disabled"
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
@if($sessions->isEmpty()) @if($sessions->isEmpty())
<x-portal.card> <x-portal.card>
<x-portal.empty :title="$view === 'upcoming' ? __('لا توجد حصص قادمة') : __('لا يوجد سجل حصص')" <x-portal.empty :title="$view === 'upcoming' ? __('لا توجد حصص قادمة') : __('لا يوجد سجل حصص')"
:body="__('تظهر الحصص هنا بمجرد جدولتها للمجموعة المسجَّل بها العضو')" /> :body="__('تظهر الحصص هنا بمجرد جدولتها للمجموعة المسجَّل بها :member', ['member' => term('member_definite')])" />
</x-portal.card> </x-portal.card>
@else @else
<ol class="stagger-in space-y-2"> <ol class="stagger-in space-y-2">
......
...@@ -46,7 +46,7 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text ...@@ -46,7 +46,7 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text
{{-- Which price list the grid below is showing, so a member rate is {{-- Which price list the grid below is showing, so a member rate is
something the cashier can see rather than something they hope for. --}} something the cashier can see rather than something they hope for. --}}
<span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium {{ $buyerTier === 'member' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-gray-100 text-gray-600 border border-gray-200' }}"> <span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium {{ $buyerTier === 'member' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-gray-100 text-gray-600 border border-gray-200' }}">
{{ $buyerTier === 'member' ? __('أسعار الأعضاء') : __('أسعار غير الأعضاء') }} {{ __('أسعار') }} {{ $buyerTier === 'member' ? term('members') : term('non_members') }}
</span> </span>
<button wire:click="clearParticipant" class="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 hover:text-red-500 transition-colors"> <button wire:click="clearParticipant" class="min-w-[44px] min-h-[44px] flex items-center justify-center text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
...@@ -56,7 +56,7 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text ...@@ -56,7 +56,7 @@ class="inline-flex items-center gap-2 px-4 py-2.5 min-h-[44px] bg-amber-600 text
</div> </div>
@else @else
<span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium bg-gray-100 text-gray-600 border border-gray-200"> <span class="shrink-0 px-2 py-1 rounded-lg text-[11px] font-medium bg-gray-100 text-gray-600 border border-gray-200">
{{ __('أسعار غير الأعضاء') }} {{ __('أسعار') }} {{ term('non_members') }}
</span> </span>
<div class="relative flex-1"> <div class="relative flex-1">
<input type="text" <input type="text"
......
...@@ -244,20 +244,20 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق ...@@ -244,20 +244,20 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('الأسعار') }}</h2> <h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('الأسعار') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر العضو (ج.م)') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('member_price') }} {{ __('(ج.م)') }}</label>
<input type="number" wire:model="member_price" dir="ltr" step="0.01" min="0" <input type="number" wire:model="member_price" dir="ltr" step="0.01" min="0"
placeholder="0.00" placeholder="0.00"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('member_price') border-red-500 @enderror"> class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('member_price') border-red-500 @enderror">
@error('member_price') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('member_price') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<p class="text-xs text-gray-500 mt-1">{{ __('السعر لأعضاء النادي') }}</p> <p class="text-xs text-gray-500 mt-1">{{ __('السعر') }} {{ term('for_members') }}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سعر غير العضو (ج.م)') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('non_member_price') }} {{ __('(ج.م)') }}</label>
<input type="number" wire:model="non_member_price" dir="ltr" step="0.01" min="0" <input type="number" wire:model="non_member_price" dir="ltr" step="0.01" min="0"
placeholder="0.00" placeholder="0.00"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('non_member_price') border-red-500 @enderror"> class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('non_member_price') border-red-500 @enderror">
@error('non_member_price') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('non_member_price') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<p class="text-xs text-gray-500 mt-1">{{ __('السعر لغير الأعضاء') }}</p> <p class="text-xs text-gray-500 mt-1">{{ __('السعر') }} {{ term('for_non_members') }}</p>
</div> </div>
</div> </div>
......
...@@ -246,20 +246,20 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin ...@@ -246,20 +246,20 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin
{{-- Membership Type --}} {{-- Membership Type --}}
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نوع العضوية') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('membership_type') }} <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="relative cursor-pointer"> <label class="relative cursor-pointer">
<input type="radio" wire:model.live="membership_type" value="non_member" class="peer sr-only"> <input type="radio" wire:model.live="membership_type" value="non_member" class="peer sr-only">
<div class="px-4 py-3 min-h-[52px] flex items-center justify-center border border-gray-300 rounded-lg text-center text-base font-medium transition-all <div class="px-4 py-3 min-h-[52px] flex items-center justify-center border border-gray-300 rounded-lg text-center text-base font-medium transition-all
peer-checked:border-amber-500 peer-checked:bg-amber-50 peer-checked:text-amber-700 hover:border-gray-400"> peer-checked:border-amber-500 peer-checked:bg-amber-50 peer-checked:text-amber-700 hover:border-gray-400">
{{ __('غير عضو') }} {{ term('non_member') }}
</div> </div>
</label> </label>
<label class="relative cursor-pointer"> <label class="relative cursor-pointer">
<input type="radio" wire:model.live="membership_type" value="member" class="peer sr-only"> <input type="radio" wire:model.live="membership_type" value="member" class="peer sr-only">
<div class="px-4 py-3 min-h-[52px] flex items-center justify-center border border-gray-300 rounded-lg text-center text-base font-medium transition-all <div class="px-4 py-3 min-h-[52px] flex items-center justify-center border border-gray-300 rounded-lg text-center text-base font-medium transition-all
peer-checked:border-green-500 peer-checked:bg-green-50 peer-checked:text-green-700 hover:border-gray-400"> peer-checked:border-green-500 peer-checked:bg-green-50 peer-checked:text-green-700 hover:border-gray-400">
{{ __('عضو نادي') }} {{ term('member') }}
</div> </div>
</label> </label>
</div> </div>
...@@ -268,10 +268,10 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin ...@@ -268,10 +268,10 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin
@if($membership_type === 'member') @if($membership_type === 'member')
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('رقم عضوية النادي') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ term('membership_id') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model.live.debounce.500ms="membership_id" dir="ltr" <input type="text" wire:model.live.debounce.500ms="membership_id" dir="ltr"
class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg" class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg"
placeholder="{{ __('أدخل رقم العضوية') }}"> placeholder="{{ __('أدخل') }} {{ term('membership_id') }}">
@error('membership_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('membership_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
...@@ -284,7 +284,7 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin ...@@ -284,7 +284,7 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg> </svg>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-semibold text-amber-800 mb-2">{{ __('رقم العضوية مسجل بالفعل للمشتركين التاليين:') }}</p> <p class="text-sm font-semibold text-amber-800 mb-2">{{ term('membership_id') }} {{ __('مسجل بالفعل للمشتركين التاليين:') }}</p>
<div class="space-y-2 mb-3"> <div class="space-y-2 mb-3">
@foreach($membershipIdSiblings as $sibling) @foreach($membershipIdSiblings as $sibling)
<div class="flex items-center gap-2 bg-white rounded-lg px-3 py-2 border border-amber-200"> <div class="flex items-center gap-2 bg-white rounded-lg px-3 py-2 border border-amber-200">
...@@ -296,7 +296,7 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin ...@@ -296,7 +296,7 @@ class="w-full px-4 py-3 min-h-[52px] border border-gray-300 rounded-lg focus:rin
@endforeach @endforeach
</div> </div>
<p class="text-sm text-amber-700 mb-3"> <p class="text-sm text-amber-700 mb-3">
{{ __('هل') }} <span class="font-bold">{{ $participant_name_ar }}</span> {{ __('أخو/أخت المشترك أعلاه ويشتركوا في نفس رقم العضوية؟') }} {{ __('هل') }} <span class="font-bold">{{ $participant_name_ar }}</span> {{ __('أخو/أخت المشترك أعلاه ويشتركوا في نفس :membership_id؟', ['membership_id' => term('membership_id')]) }}
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="button" wire:click="confirmMembershipSibling" <button type="button" wire:click="confirmMembershipSibling"
...@@ -305,7 +305,7 @@ class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg ...@@ -305,7 +305,7 @@ class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg
</button> </button>
<button type="button" wire:click="cancelMembershipSibling" <button type="button" wire:click="cancelMembershipSibling"
class="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-300 transition"> class="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-300 transition">
{{ __('لا — غيّر رقم العضوية') }} {{ __('لا — غيّر') }} {{ term('membership_id') }}
</button> </button>
</div> </div>
</div> </div>
...@@ -702,9 +702,9 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py ...@@ -702,9 +702,9 @@ class="flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-6 py
<div><span class="text-gray-500">{{ __('المحافظة') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_governorate }}</span></div> <div><span class="text-gray-500">{{ __('المحافظة') }}:</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_governorate }}</span></div>
@endif @endif
<div> <div>
<span class="text-gray-500">{{ __('العضوية') }}:</span> <span class="text-gray-500">{{ term('membership') }}:</span>
<span class="font-medium ms-1 {{ $membership_type === 'member' ? 'text-green-700' : 'text-amber-700' }}"> <span class="font-medium ms-1 {{ $membership_type === 'member' ? 'text-green-700' : 'text-amber-700' }}">
{{ $membership_type === 'member' ? __('عضو') : __('غير عضو') }} {{ membership_label($membership_type) }}
</span> </span>
</div> </div>
</div> </div>
......
...@@ -166,11 +166,12 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus ...@@ -166,11 +166,12 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus
<!-- By Membership Type --> <!-- By Membership Type -->
@if(!empty($reportData['by_membership_type'])) @if(!empty($reportData['by_membership_type']))
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('حسب نوع العضوية') }}</h3> <h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('حسب') }} {{ term('membership_type') }}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
@foreach($reportData['by_membership_type'] as $type => $count) @foreach($reportData['by_membership_type'] as $type => $count)
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-3"> <div class="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-3">
<span class="text-sm text-gray-700">{{ __($type) }}</span> {{-- $type is the raw column value, 'member' / 'non_member' --}}
<span class="text-sm text-gray-700">{{ membership_label($type) }}</span>
<span class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($count) }}</span> <span class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($count) }}</span>
</div> </div>
@endforeach @endforeach
......
<div class="space-y-5"> <div class="space-y-5">
<header class="flex flex-wrap items-center justify-between gap-3"> <header class="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-xl font-bold text-gray-900">{{ __('طلبات الأعضاء') }}</h1> <h1 class="text-xl font-bold text-gray-900">{{ __('طلبات') }} {{ term('members') }}</h1>
<p class="mt-0.5 text-sm text-gray-500"> <p class="mt-0.5 text-sm text-gray-500">
{{ __('التجميد والنقل والإلغاء والأعذار في قائمة واحدة. الموافقة تنفّذ الطلب فعلياً، ولا تغيّر حالته فقط.') }} {{ __('التجميد والنقل والإلغاء والأعذار في قائمة واحدة. الموافقة تنفّذ الطلب فعلياً، ولا تغيّر حالته فقط.') }}
</p> </p>
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
<h2 class="font-bold text-gray-900">{{ $handling->typeLabel() }}</h2> <h2 class="font-bold text-gray-900">{{ $handling->typeLabel() }}</h2>
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4"> <dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div> <div>
<dt class="text-xs text-gray-500">{{ __('العضو') }}</dt> <dt class="text-xs text-gray-500">{{ term('member_definite') }}</dt>
<dd class="font-semibold">{{ $handling->participant?->person?->name_ar ?? '—' }}</dd> <dd class="font-semibold">{{ $handling->participant?->person?->name_ar ?? '—' }}</dd>
</div> </div>
@if($handling->trainingSession) @if($handling->trainingSession)
...@@ -67,7 +67,7 @@ class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل') ...@@ -67,7 +67,7 @@ class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل')
@endif @endif
<div class="mt-4"> <div class="mt-4">
<label for="notes" class="block text-sm font-semibold text-gray-900">{{ __('ملاحظات للعضو') }}</label> <label for="notes" class="block text-sm font-semibold text-gray-900">{{ __('ملاحظات') }} {{ term('for_member') }}</label>
<textarea id="notes" rows="2" wire:model="notes" <textarea id="notes" rows="2" wire:model="notes"
class="mt-1 w-full rounded-lg border-gray-300 text-sm"></textarea> class="mt-1 w-full rounded-lg border-gray-300 text-sm"></textarea>
@error('notes') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror @error('notes') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
...@@ -94,7 +94,7 @@ class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500">{{ __('إغلا ...@@ -94,7 +94,7 @@ class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500">{{ __('إغلا
<thead class="bg-gray-50 text-xs text-gray-500"> <thead class="bg-gray-50 text-xs text-gray-500">
<tr> <tr>
<th class="px-4 py-3 text-start font-medium">{{ __('النوع') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('النوع') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('العضو') }}</th> <th class="px-4 py-3 text-start font-medium">{{ term('member_definite') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('السبب') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('السبب') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الحالة') }}</th> <th class="px-4 py-3 text-start font-medium">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-end font-medium">{{ __('إجراء') }}</th> <th class="px-4 py-3 text-end font-medium">{{ __('إجراء') }}</th>
......
...@@ -485,7 +485,7 @@ class="px-3 sm:px-5 py-2.5 sm:py-3 text-xs sm:text-sm font-medium whitespace-now ...@@ -485,7 +485,7 @@ class="px-3 sm:px-5 py-2.5 sm:py-3 text-xs sm:text-sm font-medium whitespace-now
<div class="bg-white rounded-xl border border-gray-200 p-4 sm:p-6 max-w-2xl space-y-6"> <div class="bg-white rounded-xl border border-gray-200 p-4 sm:p-6 max-w-2xl space-y-6">
<div> <div>
<h3 class="font-semibold text-gray-900">{{ __('اسم الأكاديمية المعروض') }}</h3> <h3 class="font-semibold text-gray-900">{{ __('اسم الأكاديمية المعروض') }}</h3>
<p class="text-xs text-gray-500 mt-1">{{ __('يظهر في عنوان الصفحة، على المطبوعات، وفي تطبيق الأعضاء') }}</p> <p class="text-xs text-gray-500 mt-1">{{ __('يظهر في عنوان الصفحة، على المطبوعات، وفي تطبيق :members', ['members' => term('members')]) }}</p>
<input type="text" wire:model="academy_name" maxlength="120" <input type="text" wire:model="academy_name" maxlength="120"
class="mt-3 w-full border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm"> class="mt-3 w-full border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm">
@error('academy_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror @error('academy_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
......
...@@ -825,7 +825,7 @@ class="w-10 h-8 border rounded flex items-center justify-center text-xs font-mon ...@@ -825,7 +825,7 @@ class="w-10 h-8 border rounded flex items-center justify-center text-xs font-mon
{{-- Enrollment Rules --}} {{-- Enrollment Rules --}}
<div class="bg-gray-50 rounded-xl p-5"> <div class="bg-gray-50 rounded-xl p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-4">{{ __('التسجيل والعضوية') }}</h3> <h3 class="text-sm font-semibold text-gray-700 mb-4">{{ __('التسجيل و') }}{{ term('membership') }}</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<html dir="rtl" lang="ar"> <html dir="rtl" lang="ar">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{{ __('بطاقة عضوية') }} - {{ $participant->full_name }}</title> <title>{{ term('membership_card', $participant->branch_id) }} - {{ $participant->full_name }}</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Cairo', 'Noto Sans Arabic', sans-serif; direction: rtl; padding: 40px; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; } body { font-family: 'Cairo', 'Noto Sans Arabic', sans-serif; direction: rtl; padding: 40px; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2>{{ app()->bound('current_academy') ? app('current_academy')->name_ar : 'الكابتن' }}</h2> <h2>{{ app()->bound('current_academy') ? app('current_academy')->name_ar : 'الكابتن' }}</h2>
<h1>{{ __('بطاقة عضوية') }}</h1> <h1>{{ term('membership_card', $participant->branch_id) }}</h1>
</div> </div>
<div class="avatar"> <div class="avatar">
......
...@@ -505,6 +505,8 @@ ...@@ -505,6 +505,8 @@
->middleware('permission:settings.manage'); ->middleware('permission:settings.manage');
Route::get('/settings/receipts', ReceiptSettings::class)->name('settings.receipts') Route::get('/settings/receipts', ReceiptSettings::class)->name('settings.receipts')
->middleware('permission:settings.manage'); ->middleware('permission:settings.manage');
Route::get('/settings/terminology', \App\Livewire\Settings\TerminologySettings::class)->name('settings.terminology')
->middleware('permission:settings.manage');
// Receipt Print // Receipt Print
Route::get('/receipts/pos/{transaction}', [\App\Http\Controllers\ReceiptController::class, 'posPrint']) Route::get('/receipts/pos/{transaction}', [\App\Http\Controllers\ReceiptController::class, 'posPrint'])
......
This diff is collapsed.
<?php
namespace Tests\Unit;
use App\Domain\Identity\Support\Terminology;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* The eight stored words, and the labels the whole app builds out of them.
*
* Every assertion here is grammar, not preference. A branch configures عضو or
* مقيم or نزيل once; if composition is wrong for one of them, that branch sees
* a broken sentence on a price field, a report header and a printed card, and
* nothing in the app will have thrown to say so.
*/
class TerminologyTest extends TestCase
{
/** @return array<string, array{label: string, terms: array<string, string>}> */
private static function presets(): array
{
return require dirname(__DIR__, 2) . '/config/terminology.php';
}
private static function vocabulary(string $preset, string $locale = 'ar'): Terminology
{
$config = self::presets();
return new Terminology($config['presets'][$preset]['terms'], $locale, $preset);
}
public static function presetNames(): array
{
$config = require dirname(__DIR__, 2) . '/config/terminology.php';
return array_map(fn ($key) => [$key], array_combine(
array_keys($config['presets']),
array_keys($config['presets']),
));
}
#[DataProvider('presetNames')]
public function test_every_preset_fills_every_stored_form(string $preset): void
{
$terms = self::presets()['presets'][$preset]['terms'];
foreach ([...Terminology::BASE_KEYS, ...Terminology::ENGLISH_KEYS] as $key) {
$this->assertArrayHasKey($key, $terms, "preset [{$preset}] is missing [{$key}]");
$this->assertNotSame('', trim($terms[$key]), "preset [{$preset}] left [{$key}] blank");
}
}
#[DataProvider('presetNames')]
public function test_every_composed_label_is_produced_for_every_preset(string $preset): void
{
$vocabulary = self::vocabulary($preset);
foreach (Terminology::COMPOSED_KEYS as $key) {
$label = $vocabulary->get($key);
$this->assertNotSame('', $label, "preset [{$preset}] produced no [{$key}]");
// A composed label that still contains the ال of a stray article
// twice, or a doubled space, is a composition bug rather than a
// wording choice.
$this->assertStringNotContainsString(' ', $label);
}
}
public function test_the_club_preset_reproduces_the_wording_the_app_shipped_with(): void
{
$club = self::vocabulary('club');
$this->assertSame('عضو', $club->member);
$this->assertSame('غير عضو', $club->non_member);
$this->assertSame('نوع العضوية', $club->membership_type);
$this->assertSame('رقم العضوية', $club->membership_id);
$this->assertSame('سعر العضو', $club->member_price);
$this->assertSame('سعر غير العضو', $club->non_member_price);
$this->assertSame('بطاقة عضوية', $club->membership_card);
}
public function test_a_compound_gets_its_own_composed_labels(): void
{
$compound = self::vocabulary('compound');
$this->assertSame('نوع الإقامة', $compound->membership_type);
$this->assertSame('رقم الإقامة', $compound->membership_id);
$this->assertSame('سعر المقيم', $compound->member_price);
$this->assertSame('سعر غير المقيم', $compound->non_member_price);
$this->assertSame('بطاقة إقامة', $compound->membership_card);
}
/**
* ل + العضو elides to للعضو; ل + غير العضو does not, because the article
* is not at the front. Getting this wrong prints لالعضو on a form.
*/
public function test_the_preposition_elides_the_article_only_when_the_article_leads(): void
{
$club = self::vocabulary('club');
$this->assertSame('للعضو', $club->for_member);
$this->assertSame('لغير العضو', $club->for_non_member);
$beach = self::vocabulary('beach');
$this->assertSame('للمشترك', $beach->for_member);
$this->assertSame('للزائر اليومي', $beach->for_non_member);
}
public function test_english_uses_the_english_words_and_drops_the_definite_distinction(): void
{
$compound = self::vocabulary('compound', 'en');
$this->assertSame('Resident', $compound->member);
$this->assertSame('Resident', $compound->member_definite);
$this->assertSame('Residents', $compound->members);
$this->assertSame('Residency type', $compound->membership_type);
$this->assertSame('Residency number', $compound->membership_id);
$this->assertSame('Non-resident price', $compound->non_member_price);
}
public function test_a_blank_english_word_falls_back_to_arabic_rather_than_to_nothing(): void
{
$terms = self::presets()['presets']['club']['terms'];
$terms['member_en'] = '';
$partial = new Terminology($terms, 'en', 'club');
$this->assertSame('عضو', $partial->member);
$this->assertSame('Members', $partial->members);
}
public function test_a_branch_that_edited_one_word_keeps_the_rest_of_its_preset(): void
{
$terms = self::presets()['presets']['compound']['terms'];
$terms['non_member'] = 'ضيف';
$terms['non_member_definite'] = 'الضيف';
$edited = new Terminology($terms, 'ar', 'compound');
$this->assertSame('ضيف', $edited->non_member);
$this->assertSame('سعر الضيف', $edited->non_member_price);
$this->assertSame('للضيف', $edited->for_non_member);
$this->assertSame('مقيم', $edited->member);
}
public function test_an_unknown_key_throws_instead_of_printing_itself(): void
{
$this->expectException(\InvalidArgumentException::class);
self::vocabulary('club')->get('membershipp_type');
}
public function test_a_missing_stored_word_yields_an_empty_string_not_a_crash(): void
{
$bare = new Terminology([], 'ar', 'club');
$this->assertSame('', $bare->member);
$this->assertSame('نوع ', $bare->membership_type);
}
}
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