Compare commits
16 Commits
2e44eed699
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7111b70c65 | |||
| 3710bccd5a | |||
| 2c3c12103e | |||
| 358c129951 | |||
| e00f6d1b47 | |||
| 59017a2c9b | |||
| 203a40c713 | |||
| 3381836c1e | |||
| aea22e72af | |||
| 341032162a | |||
| c848a8b47f | |||
| ca464e8e06 | |||
| 6754d8684a | |||
| 703a75a11a | |||
| 8c75bb0e82 | |||
| 4754506f6c |
@@ -55,6 +55,13 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
ADMIN_EMAIL=
|
||||
|
||||
# Preprod mail interception
|
||||
# Set PREPROD=true to redirect all outgoing mails to the addresses below
|
||||
PREPROD=false
|
||||
PREPROD_ADMIN_MAILS=
|
||||
PREPROD_TEST_MAILS=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -44,42 +44,61 @@ jobs:
|
||||
|
||||
echo "SSH_HOST_SSH=$SSH_HOST" >> "$GITEA_ENV"
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
set -e
|
||||
git clone ${{ vars.GIT_REPO }} /workspace/roxane
|
||||
cd /workspace/roxane
|
||||
git checkout release
|
||||
|
||||
- name: Deploy Roxane to preprod
|
||||
env:
|
||||
SSH_USER: ${{ vars.PREPROD_USER }}
|
||||
SSH_PORT: ${{ vars.PREPROD_PORT }}
|
||||
PREPROD_PATH: ${{ vars.PREPROD_PATH }}
|
||||
GIT_REPO: ${{ vars.GIT_REPO }}
|
||||
# GIT_REPO: ${{ vars.GIT_REPO }} # Ancien système : le serveur preprod tirait le code depuis Gitea
|
||||
# # Ne fonctionne plus car Free bloque les ports entrants
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "[>>] Envoi du code vers le serveur preprod..."
|
||||
# Crée l'archive depuis le runner et l'envoie directement par SSH
|
||||
# Le serveur preprod n'a plus besoin de contacter Gitea
|
||||
git -C /workspace/roxane archive --format=tar.gz release | \
|
||||
ssh -6 -o StrictHostKeyChecking=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-o ServerAliveInterval=60 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST_SSH" \
|
||||
"mkdir -p $PREPROD_PATH && tar -xz -C $PREPROD_PATH"
|
||||
|
||||
ssh -6 -o StrictHostKeyChecking=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-o ServerAliveInterval=60 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST_SSH" bash -l -s <<'EOF' "$PREPROD_PATH" "$GIT_REPO"
|
||||
"$SSH_USER@$SSH_HOST_SSH" bash -l -s <<'EOF' "$PREPROD_PATH"
|
||||
set -e
|
||||
|
||||
PREPROD_PATH="$1"
|
||||
GIT_REPO="$2"
|
||||
|
||||
# Vérifier si le dépôt existe, sinon le cloner
|
||||
if [ ! -d "$PREPROD_PATH/.git" ]; then
|
||||
echo "[!] Repository not found. Cloning from $GIT_REPO..."
|
||||
mkdir -p "$(dirname "$PREPROD_PATH")"
|
||||
git clone "$GIT_REPO" "$PREPROD_PATH"
|
||||
cd "$PREPROD_PATH"
|
||||
git checkout release
|
||||
else
|
||||
cd "$PREPROD_PATH"
|
||||
git config --global --add safe.directory "$PREPROD_PATH" 2>/dev/null || true
|
||||
|
||||
echo "[>>] Pulling latest Roxane release..."
|
||||
git fetch origin
|
||||
git checkout release
|
||||
git reset --hard origin/release
|
||||
git clean -fd # Nettoyer les fichiers non trackés
|
||||
fi
|
||||
# Ancien système (commenté) :
|
||||
# if [ ! -d "$PREPROD_PATH/.git" ]; then
|
||||
# echo "[!] Repository not found. Cloning from $GIT_REPO..."
|
||||
# mkdir -p "$(dirname "$PREPROD_PATH")"
|
||||
# git clone "$GIT_REPO" "$PREPROD_PATH"
|
||||
# cd "$PREPROD_PATH"
|
||||
# git checkout release
|
||||
# else
|
||||
# cd "$PREPROD_PATH"
|
||||
# git config --global --add safe.directory "$PREPROD_PATH" 2>/dev/null || true
|
||||
# echo "[>>] Pulling latest Roxane release..."
|
||||
# git fetch origin
|
||||
# git checkout release
|
||||
# git reset --hard origin/release
|
||||
# git clean -fd
|
||||
# fi
|
||||
|
||||
echo "[*] Installing Composer dependencies..."
|
||||
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
|
||||
@@ -99,6 +118,9 @@ jobs:
|
||||
|
||||
echo "[<>] Restarting queue workers..."
|
||||
php artisan queue:restart || true
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
sudo supervisorctl restart roxane-worker:*
|
||||
|
||||
echo "[OK] Roxane deployed successfully to preprod!"
|
||||
EOF
|
||||
@@ -117,9 +139,8 @@ jobs:
|
||||
cd "$1"
|
||||
|
||||
echo "[?] Verifying deployment..."
|
||||
echo "Current branch: $(git branch --show-current)"
|
||||
echo "Last commit: $(git log -1 --oneline)"
|
||||
echo "Laravel version: $(php artisan --version)"
|
||||
echo "Déploiement effectué le : $(date)"
|
||||
EOF
|
||||
|
||||
- name: Cleanup on failure
|
||||
|
||||
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
*.log
|
||||
TODO.txt
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
|
||||
@@ -23,8 +23,7 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
protected ISPConfigMailService $mailService,
|
||||
protected NextcloudService $nextcloud,
|
||||
protected MemberService $memberService
|
||||
)
|
||||
{
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -65,11 +64,12 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
|
||||
if ($emailFilter) {
|
||||
$expiredMembers = $expiredMembers->filter(function ($member) use ($emailFilter) {
|
||||
return $this->extractRetzienEmail($member['email'] ?? null) === $emailFilter;
|
||||
return Member::extractRetzienEmail($member['email'] ?? '') === $emailFilter;
|
||||
});
|
||||
|
||||
if ($expiredMembers->isEmpty()) {
|
||||
$this->warn("Aucun adhérent expiré trouvé pour {$emailFilter}");
|
||||
|
||||
return CommandAlias::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -102,13 +102,13 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
*/
|
||||
protected function processMember(array $member, bool $dryRun): void
|
||||
{
|
||||
$email = $this->extractRetzienEmail($member['email'] ?? null);
|
||||
$email = Member::extractRetzienEmail($member['email'] ?? '');
|
||||
|
||||
$this->line("• {$member['id']} - {$email}");
|
||||
|
||||
// Résiliation Dolibarr
|
||||
if ($dryRun) {
|
||||
$this->info("[DRY-RUN] Résiliation Dolibarr");
|
||||
$this->info('[DRY-RUN] Résiliation Dolibarr');
|
||||
} else {
|
||||
$this->dolibarr->updateMember($member['id'], [
|
||||
'statut' => 0,
|
||||
@@ -129,7 +129,7 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
// Désactivation Nextcloud
|
||||
if ($email) {
|
||||
if ($dryRun) {
|
||||
$this->info("[DRY-RUN] Désactivation Nextcloud");
|
||||
$this->info('[DRY-RUN] Désactivation Nextcloud');
|
||||
} else {
|
||||
$this->nextcloud->disableUserByEmail($email);
|
||||
}
|
||||
@@ -143,13 +143,15 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
{
|
||||
$details = $this->mailService->getMailUserDetails($email);
|
||||
|
||||
if (!$details) {
|
||||
if (! $details) {
|
||||
$this->warn("Boîte mail inexistante : {$email}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("[DRY-RUN] Mail désactivé ({$email})");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,22 +162,10 @@ class HandleExpiredMembersDolibarr extends Command
|
||||
'disablepop3' => 'y',
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
if (! $result) {
|
||||
throw new \RuntimeException("Échec désactivation mail ISPConfig pour {$email}");
|
||||
}
|
||||
|
||||
$this->info("Mail désactivé : {$email}");
|
||||
}
|
||||
|
||||
protected function extractRetzienEmail(?string $emails): ?string
|
||||
{
|
||||
if (!$emails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collect(explode(';', $emails))
|
||||
->map(fn(string $email): string => trim($email))
|
||||
->filter(fn(string $email): bool => str_contains($email, '@retzien.fr'))
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,11 @@ class SyncDolibarrMembers extends Command
|
||||
[
|
||||
'status' => $memberStatuses[$member['status']] ?? 'draft',
|
||||
'nature' => 'physical',
|
||||
'member_type' => $member['type'],
|
||||
'group_id' => null,
|
||||
'lastname' => $member['lastname'],
|
||||
'firstname' => $member['firstname'],
|
||||
'email' => $member['email'] ?: null,
|
||||
'retzien_email' => '',
|
||||
'retzien_email' => Member::extractRetzienEmail($member['email'] ?? ''),
|
||||
'company' => $member['societe'],
|
||||
'website_url' => $member['url'],
|
||||
'address' => $member['address'],
|
||||
|
||||
@@ -7,20 +7,22 @@ use App\Models\IspconfigMember;
|
||||
use App\Models\Member;
|
||||
use App\Services\ISPConfig\ISPConfigMailService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\progress;
|
||||
|
||||
class SyncISPConfigMailMembers extends Command
|
||||
{
|
||||
protected $signature = 'sync:ispconfig-mail-members';
|
||||
|
||||
protected $description = 'Synchronise les services MAIL ISPConfig des membres - Email Retzien';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->info('Synchronisation ISPConfig MAIL');
|
||||
|
||||
$ispMail = new ISPConfigMailService();
|
||||
$ispMail = new ISPConfigMailService;
|
||||
|
||||
//Récupération de tous les mail users
|
||||
// Récupération de tous les mail users
|
||||
$mailUsers = collect($ispMail->getAllMailUsers());
|
||||
|
||||
$progressBar = progress(label: 'ISPConfig Mail Members import', steps: $mailUsers->count());
|
||||
@@ -35,38 +37,35 @@ class SyncISPConfigMailMembers extends Command
|
||||
$synced = 0;
|
||||
|
||||
// Parcours des membres
|
||||
Member::whereNotNull('email')->chunk(100, function ($members) use (
|
||||
Member::whereNotNull('retzien_email')->where('retzien_email', '!=', '')->chunk(100, function ($members) use (
|
||||
$progressBar,
|
||||
$emailToMailUserId,
|
||||
$ispMail,
|
||||
&$synced
|
||||
) {
|
||||
foreach ($members as $member) {
|
||||
$emails = array_map('trim', explode(';', $member->email));
|
||||
$retzienEmail = strtolower($member->retzien_email);
|
||||
|
||||
$retzienEmail = collect($emails)
|
||||
->map(fn ($e) => strtolower($e))
|
||||
->first(fn ($e) => str_ends_with($e, '@retzien.fr'));
|
||||
|
||||
if (!$retzienEmail) {
|
||||
if (! $retzienEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mailUserId = $emailToMailUserId->get($retzienEmail);
|
||||
|
||||
if (!$mailUserId) {
|
||||
if (! $mailUserId) {
|
||||
$this->warn("Aucun mail user ISPConfig pour {$retzienEmail}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
//Récupération des données complètes de la boîte mail
|
||||
// Récupération des données complètes de la boîte mail
|
||||
$mailUserData = $ispMail->getMailUserDetails($retzienEmail);
|
||||
|
||||
// Création / mise à jour
|
||||
IspconfigMember::updateOrCreate(
|
||||
[
|
||||
'member_id' => $member->id,
|
||||
//@todo : 'ispconfig_client_id' => ?,
|
||||
// @todo : 'ispconfig_client_id' => ?,
|
||||
'type' => IspconfigType::MAIL,
|
||||
'email' => $retzienEmail,
|
||||
],
|
||||
|
||||
119
app/Console/Commands/SyncListmonkMembers.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ListmonkMember;
|
||||
use App\Models\Member;
|
||||
use App\Services\ListMonk\ListMonkService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Console\Command\Command as CommandAlias;
|
||||
|
||||
use function Laravel\Prompts\progress;
|
||||
|
||||
class SyncListmonkMembers extends Command
|
||||
{
|
||||
protected $signature = 'listmonk:sync-members
|
||||
{--dry-run : Run without writing to the database}
|
||||
{--member= : Sync a single member by member_id}';
|
||||
|
||||
protected $description = 'Sync Listmonk user accounts with members';
|
||||
|
||||
public function __construct(
|
||||
protected ListMonkService $listmonk
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$memberFilter = $this->option('member');
|
||||
|
||||
$this->info(
|
||||
$dryRun
|
||||
? 'DRY-RUN enabled'
|
||||
: 'Syncing Listmonk → Members'
|
||||
);
|
||||
|
||||
$members = Member::query()
|
||||
->when($memberFilter, fn ($q) => $q->where('id', $memberFilter))
|
||||
->get()
|
||||
->filter(fn (Member $m) => ! empty($m->retzien_email))
|
||||
->keyBy(fn (Member $m) => strtolower($m->retzien_email));
|
||||
|
||||
if ($members->isEmpty()) {
|
||||
$this->warn('No members to sync');
|
||||
|
||||
return CommandAlias::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("{$members->count()} members to sync");
|
||||
|
||||
$listmonkUsers = $this->listmonk->getUsers();
|
||||
|
||||
dd($listmonkUsers);
|
||||
|
||||
$this->info(count($listmonkUsers).' Listmonk users found');
|
||||
|
||||
$progress = null;
|
||||
|
||||
if (! $dryRun) {
|
||||
$progress = progress(
|
||||
label: 'Syncing members',
|
||||
steps: $members->count()
|
||||
);
|
||||
$progress->start();
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
|
||||
foreach ($listmonkUsers as $user) {
|
||||
try {
|
||||
$email = strtolower($user['email'] ?? '');
|
||||
|
||||
if (! $email || ! $members->has($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$member = $members[$email];
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line("[DRY-RUN] {$member->id} ({$email}) ← Listmonk user #{$user['id']}");
|
||||
} else {
|
||||
ListmonkMember::query()->updateOrCreate(
|
||||
['member_id' => $member->id],
|
||||
[
|
||||
'listmonk_user_id' => $user['id'],
|
||||
'data' => $user,
|
||||
]
|
||||
);
|
||||
|
||||
$progress?->advance();
|
||||
}
|
||||
|
||||
$synced++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Listmonk sync error', [
|
||||
'user' => $user['id'] ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$progress?->advance();
|
||||
}
|
||||
}
|
||||
|
||||
if ($progress) {
|
||||
$progress->finish();
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$this->info("Sync complete — {$synced} accounts linked");
|
||||
|
||||
return CommandAlias::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Console\Command\Command as CommandAlias;
|
||||
|
||||
use function Laravel\Prompts\progress;
|
||||
|
||||
class SyncNextcloudMembers extends Command
|
||||
@@ -40,14 +41,15 @@ class SyncNextcloudMembers extends Command
|
||||
);
|
||||
|
||||
$members = Member::query()
|
||||
->where('email', 'like', '%@retzien.fr%')
|
||||
->whereNotNull('retzien_email')
|
||||
->where('retzien_email', '!=', '')
|
||||
->when($memberFilter, fn ($q) => $q->where('id', $memberFilter))
|
||||
->get()
|
||||
->filter(fn (Member $m) => !empty($m->retzien_email))
|
||||
->keyBy(fn (Member $m) => strtolower($m->retzien_email));
|
||||
|
||||
if ($members->isEmpty()) {
|
||||
$this->warn('Aucun membre à synchroniser');
|
||||
|
||||
return CommandAlias::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -55,11 +57,11 @@ class SyncNextcloudMembers extends Command
|
||||
|
||||
$userIds = $this->nextcloud->listUsers();
|
||||
|
||||
$this->info(count($userIds) . ' comptes Nextcloud trouvés');
|
||||
$this->info(count($userIds).' comptes Nextcloud trouvés');
|
||||
|
||||
$progress = null;
|
||||
|
||||
if (!$dryRun) {
|
||||
if (! $dryRun) {
|
||||
$progress = progress(
|
||||
label: 'Synchronisation des membres',
|
||||
steps: $members->count()
|
||||
@@ -75,7 +77,7 @@ class SyncNextcloudMembers extends Command
|
||||
|
||||
$email = strtolower($details['email'] ?? '');
|
||||
|
||||
if (!$email || !$members->has($email)) {
|
||||
if (! $email || ! $members->has($email)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Filament\Actions;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Membership;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use App\Models\Member;
|
||||
|
||||
class ServiceToggleAction extends Action
|
||||
{
|
||||
@@ -16,7 +16,7 @@ class ServiceToggleAction extends Action
|
||||
*/
|
||||
public static function forService(string $serviceIdentifier): static
|
||||
{
|
||||
return static::make('toggle_' . $serviceIdentifier)
|
||||
return static::make('toggle_'.$serviceIdentifier)
|
||||
->configureForService($serviceIdentifier);
|
||||
}
|
||||
|
||||
@@ -28,37 +28,35 @@ class ServiceToggleAction extends Action
|
||||
$this->serviceIdentifier = $serviceIdentifier;
|
||||
|
||||
return $this
|
||||
->label('Service actif')
|
||||
->icon(fn (Member|Membership $record) =>
|
||||
$this->getMember($record)?->hasService($serviceIdentifier)
|
||||
->label(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'Service actif'
|
||||
: 'Activer le service'
|
||||
)
|
||||
->icon(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'heroicon-o-check-circle'
|
||||
: 'heroicon-o-x-circle'
|
||||
)
|
||||
->color(fn (Member|Membership $record) =>
|
||||
$this->getMember($record)?->hasService($serviceIdentifier)
|
||||
->color(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'success'
|
||||
: 'gray'
|
||||
: 'warning'
|
||||
)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Member|Membership $record) =>
|
||||
$this->getMember($record)?->hasService($serviceIdentifier)
|
||||
->modalHeading(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'Désactiver le service'
|
||||
: 'Activer le service'
|
||||
)
|
||||
->modalDescription(fn (Member|Membership $record) =>
|
||||
$this->getMember($record)?->hasService($serviceIdentifier)
|
||||
->modalDescription(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'Êtes-vous sûr·e de vouloir désactiver ce service pour ce membre ?'
|
||||
: 'Êtes-vous sûr·e de vouloir activer ce service pour ce membre ?'
|
||||
)
|
||||
->modalSubmitActionLabel(fn (Member|Membership $record) =>
|
||||
$this->getMember($record)?->hasService($serviceIdentifier)
|
||||
->modalSubmitActionLabel(fn (Member|Membership|null $record) => $this->getMember($record)?->hasService($serviceIdentifier)
|
||||
? 'Désactiver'
|
||||
: 'Activer'
|
||||
)
|
||||
->action(function (Member|Membership $record) use ($serviceIdentifier) {
|
||||
->action(function (Member|Membership|null $record) {
|
||||
$member = $this->getMember($record);
|
||||
|
||||
if (!$member) {
|
||||
if (! $member) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,8 +76,12 @@ class ServiceToggleAction extends Action
|
||||
/**
|
||||
* Get the member associated with the given record.
|
||||
*/
|
||||
protected function getMember(Member|Membership $record): ?Member
|
||||
protected function getMember(Member|Membership|null $record): ?Member
|
||||
{
|
||||
if ($record === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $record instanceof Member ? $record : $record->member;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class Synchronisations extends Page
|
||||
'ispconfig_mail',
|
||||
'ispconfig_web',
|
||||
'nextcloud',
|
||||
'listmonk',
|
||||
'services',
|
||||
];
|
||||
|
||||
@@ -154,6 +155,17 @@ class Synchronisations extends Page
|
||||
});
|
||||
}
|
||||
|
||||
public function syncListmonkAction(): Action
|
||||
{
|
||||
return Action::make('syncListmonk')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('synchronisations.sections.listmonk.modal_heading'))
|
||||
->modalDescription(__('synchronisations.sections.listmonk.modal_description'))
|
||||
->modalSubmitActionLabel(__('synchronisations.action.submit'))
|
||||
->disabled(fn () => in_array($this->getCommandStatus('listmonk')['status'], ['pending', 'running']))
|
||||
->action(fn () => $this->enqueueCommand('listmonk', 'listmonk:sync-members'));
|
||||
}
|
||||
|
||||
public function syncServicesAction(): Action
|
||||
{
|
||||
return Action::make('syncServices')
|
||||
|
||||
@@ -4,7 +4,11 @@ namespace App\Filament\Resources\Members\Schemas;
|
||||
|
||||
use App\Enums\IspconfigType;
|
||||
use App\Filament\Actions\ServiceToggleAction;
|
||||
use App\Filament\Resources\Memberships\MembershipResource;
|
||||
use App\Models\ListmonkMember;
|
||||
use App\Models\Member;
|
||||
use App\Models\Membership;
|
||||
use App\Models\Package;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -159,11 +163,7 @@ class MemberForm
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
])
|
||||
->visible(fn (?Member $record) => $record?->ispconfigs()
|
||||
->where('type', IspconfigType::MAIL)
|
||||
->exists()
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make(__('members.sections.ispconfig_web'))
|
||||
->afterHeader([
|
||||
@@ -202,11 +202,7 @@ class MemberForm
|
||||
])
|
||||
->columns(3),
|
||||
|
||||
])
|
||||
->visible(fn (?Member $record) => $record?->ispconfigs()
|
||||
->where('type', IspconfigType::WEB)
|
||||
->exists()
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make(__('members.sections.nextcloud'))
|
||||
->afterHeader([
|
||||
@@ -244,10 +240,34 @@ class MemberForm
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
]),
|
||||
|
||||
Section::make(__('members.sections.listmonk'))
|
||||
->afterHeader([
|
||||
ServiceToggleAction::forService('listmonk'),
|
||||
])
|
||||
->visible(fn (?Member $record) => $record?->nextcloudAccounts()
|
||||
->exists()
|
||||
),
|
||||
->collapsible()
|
||||
->schema([
|
||||
RepeatableEntry::make('listmonk_accounts')
|
||||
->label(__('members.ispconfig.listmonk_data'))
|
||||
->state(fn (?Member $record) => $record?->listmonkMembers()
|
||||
->get()
|
||||
->map(fn (ListmonkMember $lm) => $lm->toArray())
|
||||
->all()
|
||||
)
|
||||
->schema([
|
||||
TextEntry::make('listmonk_user_id')
|
||||
->label(__('members.ispconfig.listmonk_id')),
|
||||
ViewEntry::make('data')
|
||||
->label('JSON')
|
||||
->view('filament.components.json-viewer')
|
||||
->viewData(fn ($state) => [
|
||||
'data' => $state,
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->contained(false),
|
||||
@@ -285,9 +305,59 @@ class MemberForm
|
||||
Section::make(__('members.sections.actions'))
|
||||
->collapsible()
|
||||
->schema([
|
||||
Action::make('create-membership')
|
||||
->label(__('members.actions.create_membership'))
|
||||
->icon('heroicon-o-plus-circle')
|
||||
->color('primary')
|
||||
->modalHeading(__('members.actions.create_membership'))
|
||||
->modalSubmitActionLabel(__('members.actions.create_membership_submit'))
|
||||
->form([
|
||||
Select::make('package_id')
|
||||
->label(Membership::getAttributeLabel('package_id'))
|
||||
->options(fn () => Package::all()->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->required(),
|
||||
Select::make('status')
|
||||
->label(Membership::getAttributeLabel('status'))
|
||||
->options([
|
||||
'pending' => Membership::getAttributeLabel('pending'),
|
||||
'active' => Membership::getAttributeLabel('active'),
|
||||
])
|
||||
->default('pending')
|
||||
->required(),
|
||||
Select::make('payment_status')
|
||||
->label(Membership::getAttributeLabel('payment_status'))
|
||||
->options([
|
||||
'paid' => Membership::getAttributeLabel('paid'),
|
||||
'unpaid' => Membership::getAttributeLabel('unpaid'),
|
||||
'partial' => Membership::getAttributeLabel('partial'),
|
||||
])
|
||||
->default('unpaid')
|
||||
->required(),
|
||||
TextInput::make('amount')
|
||||
->label(Membership::getAttributeLabel('amount'))
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
DatePicker::make('start_date')
|
||||
->label(Membership::getAttributeLabel('start_date'))
|
||||
->default(now()),
|
||||
DatePicker::make('end_date')
|
||||
->label(Membership::getAttributeLabel('end_date')),
|
||||
])
|
||||
->action(function (array $data, Member $record) {
|
||||
$membership = $record->memberships()->create([
|
||||
'admin_id' => auth()->id(),
|
||||
...$data,
|
||||
]);
|
||||
|
||||
return redirect(MembershipResource::getUrl('edit', ['record' => $membership->id]));
|
||||
}),
|
||||
|
||||
Action::make('send-payment-mail')
|
||||
->label(__('members.actions.send_payment_mail'))
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('primary')
|
||||
->action(function () {
|
||||
// Mail de paiement pour nouvelle inscription (Job)
|
||||
}),
|
||||
@@ -295,6 +365,7 @@ class MemberForm
|
||||
Action::make('send-renewal-mail')
|
||||
->label(__('members.actions.send_renewal_mail'))
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('primary')
|
||||
->action(function () {
|
||||
// Mail de relance à créer (Job)
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources\Memberships\Schemas;
|
||||
|
||||
use App\Enums\IspconfigType;
|
||||
use App\Filament\Actions\ServiceToggleAction;
|
||||
use App\Models\ListmonkMember;
|
||||
use App\Models\Membership;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
@@ -127,11 +128,7 @@ class MembershipForm
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
])
|
||||
->visible(fn (?Membership $record) => $record?->member?->ispconfigs()
|
||||
->where('type', IspconfigType::MAIL)
|
||||
->exists() ?? false
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make(__('memberships.sections.ispconfig_web'))
|
||||
->afterHeader([
|
||||
@@ -170,11 +167,7 @@ class MembershipForm
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
])
|
||||
->visible(fn (?Membership $record) => $record?->member?->ispconfigs()
|
||||
->where('type', IspconfigType::WEB)
|
||||
->exists() ?? false
|
||||
),
|
||||
]),
|
||||
|
||||
Section::make(__('memberships.sections.nextcloud'))
|
||||
->afterHeader([
|
||||
@@ -212,10 +205,34 @@ class MembershipForm
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(3),
|
||||
]),
|
||||
|
||||
Section::make(__('memberships.sections.listmonk'))
|
||||
->afterHeader([
|
||||
ServiceToggleAction::forService('listmonk'),
|
||||
])
|
||||
->visible(fn (?Membership $record) => $record?->member?->nextcloudAccounts()
|
||||
->exists() ?? false
|
||||
),
|
||||
->collapsible()
|
||||
->schema([
|
||||
RepeatableEntry::make('listmonk_accounts')
|
||||
->label(__('members.ispconfig.listmonk_data'))
|
||||
->state(fn (?Membership $record) => $record?->member?->listmonkMembers()
|
||||
->get()
|
||||
->map(fn (ListmonkMember $lm) => $lm->toArray())
|
||||
->all()
|
||||
)
|
||||
->schema([
|
||||
TextEntry::make('listmonk_user_id')
|
||||
->label(__('members.ispconfig.listmonk_id')),
|
||||
ViewEntry::make('data')
|
||||
->label('JSON')
|
||||
->view('filament.components.json-viewer')
|
||||
->viewData(fn ($state) => [
|
||||
'data' => $state,
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->contained(false),
|
||||
|
||||
48
app/Filament/Widgets/DashboardStatsWidget.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Membership;
|
||||
use Filament\Support\Enums\IconPosition;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class DashboardStatsWidget extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected function getStats(): array
|
||||
{
|
||||
$activeMembers = Member::where('status', 'valid')->count();
|
||||
$activeMemberships = Membership::where('status', 'active')->count();
|
||||
$newThisMonth = Membership::whereMonth('created_at', now()->month)
|
||||
->whereYear('created_at', now()->year)
|
||||
->count();
|
||||
$unpaidMemberships = Membership::where('payment_status', 'unpaid')
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
return [
|
||||
Stat::make('Membres actifs', $activeMembers)
|
||||
->description('Statut "Valide"')
|
||||
->descriptionIcon('heroicon-o-user-group', IconPosition::Before)
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Adhésions actives', $activeMemberships)
|
||||
->description('En cours')
|
||||
->descriptionIcon('heroicon-o-identification', IconPosition::Before)
|
||||
->color('primary'),
|
||||
|
||||
Stat::make('Nouvelles adhésions', $newThisMonth)
|
||||
->description('Ce mois-ci')
|
||||
->descriptionIcon('heroicon-o-calendar', IconPosition::Before)
|
||||
->color('info'),
|
||||
|
||||
Stat::make('Paiements en attente', $unpaidMemberships)
|
||||
->description('Adhésions actives non réglées')
|
||||
->descriptionIcon('heroicon-o-banknotes', IconPosition::Before)
|
||||
->color($unpaidMemberships > 0 ? 'warning' : 'success'),
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Filament/Widgets/LatestMembershipsWidget.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Filament\Resources\Memberships\MembershipResource;
|
||||
use App\Models\Membership;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class LatestMembershipsWidget extends TableWidget
|
||||
{
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected static ?string $heading = 'Dernières adhésions';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
fn (): Builder => Membership::query()
|
||||
->with(['member', 'package'])
|
||||
->latest()
|
||||
->limit(8)
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('member.full_name')
|
||||
->label('Adhérent')
|
||||
->searchable(['members.firstname', 'members.lastname']),
|
||||
|
||||
TextColumn::make('package.name')
|
||||
->label('Package'),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label('Statut adhésion')
|
||||
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
'expired' => 'danger',
|
||||
'pending' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('payment_status')
|
||||
->label('Paiement')
|
||||
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'paid' => 'success',
|
||||
'unpaid' => 'danger',
|
||||
'partial' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('amount')
|
||||
->label('Montant')
|
||||
->money('EUR'),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Créée le')
|
||||
->dateTime('d/m/Y')
|
||||
->sortable(),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('edit')
|
||||
->label('Voir')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Membership $record): string => MembershipResource::getUrl('edit', ['record' => $record])),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
50
app/Filament/Widgets/MembershipsPerMonthChart.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Membership;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
|
||||
class MembershipsPerMonthChart extends ChartWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected ?string $heading = 'Adhésions par mois';
|
||||
|
||||
protected ?string $description = 'Nouvelles adhésions sur les 12 derniers mois';
|
||||
|
||||
protected ?string $maxHeight = '280px';
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$data = collect(range(11, 0))->map(function (int $monthsAgo) {
|
||||
$date = now()->subMonths($monthsAgo);
|
||||
|
||||
return [
|
||||
'month' => $date->translatedFormat('M Y'),
|
||||
'count' => Membership::whereYear('created_at', $date->year)
|
||||
->whereMonth('created_at', $date->month)
|
||||
->count(),
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Adhésions',
|
||||
'data' => $data->pluck('count')->toArray(),
|
||||
'borderColor' => 'rgb(244, 63, 94)',
|
||||
'backgroundColor' => 'rgba(244, 63, 94, 0.1)',
|
||||
'fill' => true,
|
||||
'tension' => 0.4,
|
||||
],
|
||||
],
|
||||
'labels' => $data->pluck('month')->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,6 @@ class PasswordResetLinkController extends Controller
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return back()->with('status', __('A reset link will be sent if the account exists.'));
|
||||
return back()->with('status', __('passwords.sent_if_exists'));
|
||||
}
|
||||
}
|
||||
|
||||
47
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Resources\MemberResource;
|
||||
use App\Notifications\ServiceActivationRequestNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$member = $request->user()
|
||||
->members()
|
||||
->with([
|
||||
'lastActiveMembership.package',
|
||||
'lastActiveMembership.services',
|
||||
])
|
||||
->first();
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'member' => $member ? (new MemberResource($member))->resolve() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function requestServiceActivation(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'service_identifier' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$member = $request->user()->members()->first();
|
||||
|
||||
if ($member === null) {
|
||||
return back()->with('flash', ['error' => 'Aucun compte membre associé.']);
|
||||
}
|
||||
|
||||
Notification::route('mail', config('app.admin_email'))
|
||||
->notify(new ServiceActivationRequestNotification($member, $request->string('service_identifier')));
|
||||
|
||||
return back()->with('flash', ['success' => "Votre demande d'activation a bien été envoyée."]);
|
||||
}
|
||||
}
|
||||
@@ -7,25 +7,26 @@ use App\Http\Requests\Forms\ContactRequest;
|
||||
use App\Services\ContactService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ContactFormController extends Controller
|
||||
{
|
||||
public function __construct(protected ContactService $contactService) {}
|
||||
/**
|
||||
* Show the contact form page.
|
||||
*/
|
||||
public function create()
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('forms/contact');
|
||||
return Inertia::render('forms/contact', [
|
||||
'captcha_question' => $this->generateCaptcha(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming contact form submission.
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(ContactRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
$this->contactService->registerNewContactRequest($validated);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -41,4 +42,13 @@ class ContactFormController extends Controller
|
||||
return to_route('contact')->with('success', __('contacts.responses.success'));
|
||||
}
|
||||
|
||||
private function generateCaptcha(): string
|
||||
{
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
|
||||
session(['captcha_contact' => (string) ($a + $b)]);
|
||||
|
||||
return "Combien font {$a} + {$b} ?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,55 +4,69 @@ namespace App\Http\Controllers\Forms;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Forms\MembershipRequest;
|
||||
use App\Models\Membership;
|
||||
use App\Models\Package;
|
||||
use App\Models\Service;
|
||||
use App\Services\MemberService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class MembershipFormController extends Controller
|
||||
{
|
||||
public function __construct(protected MemberService $memberService) {}
|
||||
|
||||
/**
|
||||
* Show the contact form page.
|
||||
*/
|
||||
public function create()
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('forms/membership', [
|
||||
'plans' => Package::query()
|
||||
$remainingMonths = 13 - now()->month;
|
||||
|
||||
$plans = Package::query()
|
||||
->where('is_active', true)
|
||||
->select('id', 'identifier', 'name', 'price', 'description')
|
||||
->get()
|
||||
->map(fn (Package $p) => [
|
||||
'id' => $p->id,
|
||||
'identifier' => $p->identifier,
|
||||
'name' => $p->name,
|
||||
'description' => $p->description,
|
||||
'price' => $p->identifier === 'custom' ? $remainingMonths : (float) $p->price,
|
||||
'months' => $p->identifier === 'custom' ? $remainingMonths : null,
|
||||
]);
|
||||
|
||||
return Inertia::render('forms/membership', [
|
||||
'plans' => $plans,
|
||||
'services' => Service::query()->select('name', 'description')->get(),
|
||||
'captcha_question' => $this->generateCaptcha(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming membership form request.
|
||||
*
|
||||
*/
|
||||
public function store(MembershipRequest $request): RedirectResponse
|
||||
{
|
||||
dd($request->validated());
|
||||
$validated = $request->validated();
|
||||
|
||||
try {
|
||||
$this->memberService->registerNewMember($validated);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Erreur lors de la création d’un membre', [
|
||||
\Log::error('Erreur lors de la création d\'un membre', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
'data' => $validated,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('membership')
|
||||
->with('error', Membership::getAttributeLabel('memberships.subscription.error'));
|
||||
return to_route('membership')
|
||||
->with('error', __('memberships.fields.subscription.error'));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('membership')
|
||||
->with('success', Membership::getAttributeLabel('memberships.subscription.success'));
|
||||
return to_route('membership')
|
||||
->with('success', __('memberships.fields.subscription.success'));
|
||||
}
|
||||
|
||||
private function generateCaptcha(): string
|
||||
{
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
|
||||
session(['captcha_membership' => (string) ($a + $b)]);
|
||||
|
||||
return "Combien font {$a} + {$b} ?";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
|
||||
namespace App\Http\Requests\Forms;
|
||||
|
||||
use App\Rules\ValidCaptcha;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
@@ -25,9 +21,10 @@ class ContactRequest extends FormRequest
|
||||
'lastname' => 'required|string|max:255',
|
||||
'firstname' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'address' => 'string|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'subject' => 'required|string|max:255',
|
||||
'message' => 'required|string',
|
||||
'captcha' => ['required', new ValidCaptcha('captcha_contact')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,34 @@
|
||||
|
||||
namespace App\Http\Requests\Forms;
|
||||
|
||||
use App\Rules\ValidCaptcha;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class MembershipRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// Member
|
||||
'lastname' => 'required|string|max:255',
|
||||
'firstname' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'company' => 'string|max:255',
|
||||
'company' => 'nullable|string|max:255',
|
||||
'address' => 'required|string|max:255',
|
||||
'zipcode' => 'required|string|max:255',
|
||||
'city' => 'required|string|max:255',
|
||||
'phone1' => 'required|string|max:255',
|
||||
|
||||
// Membership
|
||||
'package' => 'required|string|max:255',
|
||||
'amount' => 'required|string|max:255',
|
||||
'amount' => 'required|numeric|min:0',
|
||||
'cgu' => 'required|accepted',
|
||||
'captcha' => ['required', new ValidCaptcha('captcha_membership')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Http/Resources/MemberResource.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class MemberResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'firstname' => $this->firstname,
|
||||
'lastname' => $this->lastname,
|
||||
'email' => $this->email,
|
||||
'retzien_email' => $this->retzien_email,
|
||||
'membership' => new MembershipResource($this->whenLoaded('lastActiveMembership')),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Resources/MembershipResource.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class MembershipResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'status' => $this->status,
|
||||
'payment_status' => $this->payment_status,
|
||||
'start_date' => $this->start_date,
|
||||
'end_date' => $this->end_date,
|
||||
'amount' => $this->amount,
|
||||
'package' => new PackageResource($this->whenLoaded('package')),
|
||||
'services' => ServiceResource::collection($this->whenLoaded('services')),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Http/Resources/PackageResource.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PackageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'identifier' => $this->identifier,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'price' => $this->price,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Resources/ServiceResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ServiceResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'identifier' => $this->identifier,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'url' => $this->url,
|
||||
'icon' => $this->icon,
|
||||
'is_active' => (bool) $this->whenPivotLoaded('services_memberships', fn () => $this->pivot->is_active),
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Listeners/PreprodMailInterceptor.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
class PreprodMailInterceptor
|
||||
{
|
||||
public function handle(MessageSending $event): void
|
||||
{
|
||||
if (! config('preprod.enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $event->message;
|
||||
$adminEmail = config('app.admin_email');
|
||||
|
||||
$originalRecipients = array_map(
|
||||
fn (Address $address) => $address->getAddress(),
|
||||
$message->getTo()
|
||||
);
|
||||
|
||||
$isAdminMail = collect($originalRecipients)->contains(
|
||||
fn (string $email) => $email === $adminEmail
|
||||
|| User::where('email', $email)->exists()
|
||||
);
|
||||
|
||||
$configKey = $isAdminMail ? 'preprod.admin_mails' : 'preprod.test_mails';
|
||||
|
||||
$emails = collect(explode(',', config($configKey, '')))
|
||||
->map(fn (string $email) => trim($email))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($emails)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all recipient headers before redirecting
|
||||
foreach (['To', 'Cc', 'Bcc'] as $header) {
|
||||
while ($message->getHeaders()->has($header)) {
|
||||
$message->getHeaders()->remove($header);
|
||||
}
|
||||
}
|
||||
|
||||
$message->to(...$emails);
|
||||
|
||||
$subject = $message->getSubject() ?? '';
|
||||
if (! str_starts_with($subject, '[PREPROD]')) {
|
||||
$message->subject('[PREPROD] '.$subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
app/Models/ListmonkMember.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $member_id
|
||||
* @property int|null $listmonk_user_id
|
||||
* @property array<array-key, mixed>|null $data
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property-read \App\Models\Member $member
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class ListmonkMember extends Model
|
||||
{
|
||||
protected $table = 'listmonks_members';
|
||||
|
||||
protected $fillable = [
|
||||
'member_id',
|
||||
'listmonk_user_id',
|
||||
'data',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'data' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Member::class);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
/**
|
||||
@@ -33,7 +35,7 @@ use Illuminate\Notifications\Notifiable;
|
||||
* @property string|null $website_url
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @property string|null $deleted_at
|
||||
* @property \Illuminate\Support\Carbon|null $deleted_at
|
||||
* @property-read string $full_name
|
||||
* @property-read \App\Models\MemberGroup|null $group
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\IspconfigMember> $ispconfigs
|
||||
@@ -80,7 +82,7 @@ use Illuminate\Notifications\Notifiable;
|
||||
*/
|
||||
class Member extends Model
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
@@ -113,18 +115,19 @@ class Member extends Model
|
||||
return __("members.fields.$attribute");
|
||||
}
|
||||
|
||||
public static function extractRetzienEmail(string $rawEmails): ?string
|
||||
{
|
||||
return collect(explode(';', $rawEmails))
|
||||
->map(fn (string $email) => trim($email))
|
||||
->filter(fn (string $email) => str_ends_with($email, '@retzien.fr'))
|
||||
->first();
|
||||
}
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return "{$this->firstname} {$this->lastname}";
|
||||
}
|
||||
|
||||
public function getRetzienEmailAttribute(): ?string
|
||||
{
|
||||
$emails = explode(';', $this->email);
|
||||
|
||||
return collect($emails)->filter(fn ($email) => str_contains($email, '@retzien.fr'))->first();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
@@ -150,7 +153,17 @@ class Member extends Model
|
||||
return $this->hasMany(NextCloudMember::class, 'member_id');
|
||||
}
|
||||
|
||||
public function lastMembership(): Membership
|
||||
public function listmonkMembers(): HasMany
|
||||
{
|
||||
return $this->hasMany(ListmonkMember::class, 'member_id');
|
||||
}
|
||||
|
||||
public function lastActiveMembership(): HasOne
|
||||
{
|
||||
return $this->hasOne(Membership::class)->where('status', 'active')->latest();
|
||||
}
|
||||
|
||||
public function lastMembership(): ?Membership
|
||||
{
|
||||
return $this->memberships()->where('status', 'active')->first();
|
||||
}
|
||||
@@ -159,14 +172,21 @@ class Member extends Model
|
||||
{
|
||||
$membership = $this->lastMembership();
|
||||
|
||||
if ($membership === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $membership->services()->where('identifier', $serviceIdentifier)->exists();
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
// Member ayant leur dernière adhésion non renouvellée de puis plus d'un mois
|
||||
$lastMembership = $this->lastMembership();
|
||||
|
||||
if ($lastMembership === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $lastMembership->status === 'expired' || $lastMembership->created_at->addMonths(1) < now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -32,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
* @property-read \App\Models\Package $package
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Service> $services
|
||||
* @property-read int|null $services_count
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership newQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership query()
|
||||
@@ -53,6 +52,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereStatus($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereUpdatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereValidationDate($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class Membership extends Model
|
||||
@@ -71,10 +71,9 @@ class Membership extends Model
|
||||
'note_public',
|
||||
'note_private',
|
||||
'dolibarr_id',
|
||||
'dolibarr_user_id'
|
||||
'dolibarr_user_id',
|
||||
];
|
||||
|
||||
|
||||
public static function getAttributeLabel(string $attribute): string
|
||||
{
|
||||
return __("memberships.fields.$attribute");
|
||||
@@ -97,7 +96,7 @@ class Membership extends Model
|
||||
|
||||
public function services(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Service::class, 'services_memberships', 'membership_id', 'service_id');
|
||||
return $this->belongsToMany(Service::class, 'services_memberships', 'membership_id', 'service_id')
|
||||
->withPivot('is_active');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
52
app/Notifications/ContactNewRequestNotification.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ContactNewRequestNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly Contact $contact) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('contact_new_request');
|
||||
|
||||
$vars = [
|
||||
'contact_name' => $this->contact->full_name,
|
||||
'contact_email' => $this->contact->email ?? '',
|
||||
'contact_subject' => $this->contact->subject ?? '',
|
||||
'contact_message' => $this->contact->message ?? '',
|
||||
'app_name' => config('app.name'),
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
50
app/Notifications/MemberDeactivatedAdminNotification.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class MemberDeactivatedAdminNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly Member $member) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('member_deactivated_admin');
|
||||
|
||||
$vars = [
|
||||
'member_name' => $this->member->full_name,
|
||||
'member_email' => $this->member->email ?? '',
|
||||
'app_name' => config('app.name'),
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
49
app/Notifications/MemberDeactivatedMemberNotification.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class MemberDeactivatedMemberNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly Member $member) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('member_deactivated_member');
|
||||
|
||||
$vars = [
|
||||
'member_name' => $this->member->full_name,
|
||||
'app_name' => config('app.name'),
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
65
app/Notifications/MemberNewRequestAdminNotification.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\Members\MemberResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class MemberNewRequestAdminNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Member $member,
|
||||
public readonly Package $package,
|
||||
public readonly float $amount,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('member_new_request_admin');
|
||||
|
||||
$vars = [
|
||||
'member_name' => $this->member->full_name,
|
||||
'member_email' => $this->member->email ?? '',
|
||||
'member_phone' => $this->member->phone1 ?? '',
|
||||
'member_address' => implode(', ', array_filter([
|
||||
$this->member->address,
|
||||
$this->member->zipcode,
|
||||
$this->member->city,
|
||||
])),
|
||||
'package_name' => $this->package->name,
|
||||
'amount' => number_format($this->amount, 2, ',', ' '),
|
||||
'member_url' => MemberResource::getUrl('edit', ['record' => $this->member->id]),
|
||||
'app_name' => config('app.name'),
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
54
app/Notifications/ServiceActivationRequestNotification.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ServiceActivationRequestNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Member $member,
|
||||
public readonly string $serviceIdentifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('service_activation_request');
|
||||
|
||||
$vars = [
|
||||
'member_name' => $this->member->full_name,
|
||||
'member_email' => $this->member->email ?? '',
|
||||
'service_identifier' => $this->serviceIdentifier,
|
||||
'app_name' => config('app.name'),
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\PreprodMailInterceptor;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
JsonResource::withoutWrapping();
|
||||
|
||||
Event::listen(MessageSending::class, PreprodMailInterceptor::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Andreia\FilamentNordTheme\FilamentNordThemePlugin;
|
||||
use App\Filament\Resources\Members\Widgets\MemberCount;
|
||||
use App\Filament\Resources\Memberships\Widgets\MembershipsChart;
|
||||
use App\Filament\Widgets\DashboardStatsWidget;
|
||||
use App\Filament\Widgets\LatestMembershipsWidget;
|
||||
use App\Filament\Widgets\MembershipsPerMonthChart;
|
||||
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@@ -14,8 +15,7 @@ use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
@@ -46,10 +46,9 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
MemberCount::class,
|
||||
// MembershipsChart::class,
|
||||
DashboardStatsWidget::class,
|
||||
MembershipsPerMonthChart::class,
|
||||
LatestMembershipsWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
@@ -68,6 +67,10 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::GLOBAL_SEARCH_BEFORE,
|
||||
fn () => view('filament.components.visit-site-button'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
app/Rules/ValidCaptcha.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidCaptcha implements ValidationRule
|
||||
{
|
||||
public function __construct(private readonly string $sessionKey = 'captcha_answer') {}
|
||||
|
||||
/**
|
||||
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (trim((string) $value) !== (string) session($this->sessionKey)) {
|
||||
$fail('Le code de vérification est incorrect.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,22 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Notifications\ContactNewRequestNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class ContactService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
public function __construct() {}
|
||||
|
||||
public function registerNewContactRequest(array $data): Contact
|
||||
{
|
||||
$contact = new Contact();
|
||||
$contact = new Contact;
|
||||
$contact->fill($data);
|
||||
$contact->save();
|
||||
|
||||
// Envoyer un email à l'administrateur
|
||||
Notification::route('mail', config('app.admin_email'))
|
||||
->notify(new ContactNewRequestNotification($contact));
|
||||
|
||||
return $contact;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,27 @@ class ListMonkService
|
||||
config('services.listmonk.password')
|
||||
)
|
||||
->withHeaders(['Accept' => 'application/json'])
|
||||
->baseUrl(config('services.listmonk.base_url').'/api');
|
||||
->baseUrl(config('services.listmonk.base_url'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lists
|
||||
// -------------------------------------------------------------------------
|
||||
/**
|
||||
* Retrieve all Listmonk user accounts.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function getUsers(): array
|
||||
{
|
||||
return $this->http
|
||||
->get('/users')
|
||||
->json('data') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all mailing lists.
|
||||
* Retrieve all mailing lists with their subscriber counts.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
@@ -35,188 +47,4 @@ class ListMonkService
|
||||
->get('/lists', ['per_page' => 'all'])
|
||||
->json('data.results') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single list by its ID.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function getList(int $listId): ?array
|
||||
{
|
||||
$response = $this->http->get("/lists/{$listId}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('data');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Subscribers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve subscribers with optional pagination.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function getSubscribers(int $page = 1, int $perPage = 100): array
|
||||
{
|
||||
return $this->http
|
||||
->get('/subscribers', [
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
])
|
||||
->json('data.results') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a single subscriber by their Listmonk ID.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function getSubscriber(int $subscriberId): ?array
|
||||
{
|
||||
$response = $this->http->get("/subscribers/{$subscriberId}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a subscriber by their email address.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function getSubscriberByEmail(string $email): ?array
|
||||
{
|
||||
$results = $this->http
|
||||
->get('/subscribers', ['query' => "subscribers.email = '{$email}'"])
|
||||
->json('data.results') ?? [];
|
||||
|
||||
return $results[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscriber and enrol them in the given lists.
|
||||
*
|
||||
* @param array<int> $listIds IDs of the lists to subscribe to.
|
||||
* @param array<string, mixed> $attribs Custom attributes (e.g. language preference).
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function createSubscriber(
|
||||
string $email,
|
||||
string $name,
|
||||
array $listIds = [],
|
||||
array $attribs = [],
|
||||
string $status = 'enabled',
|
||||
): ?array {
|
||||
$response = $this->http->post('/subscribers', [
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
'lists' => $listIds,
|
||||
'attribs' => $attribs,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing subscriber's information.
|
||||
*
|
||||
* @param array<int> $listIds
|
||||
* @param array<string, mixed> $attribs
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function updateSubscriber(
|
||||
int $subscriberId,
|
||||
string $email,
|
||||
string $name,
|
||||
array $listIds = [],
|
||||
array $attribs = [],
|
||||
string $status = 'enabled',
|
||||
): bool {
|
||||
$response = $this->http->put("/subscribers/{$subscriberId}", [
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
'status' => $status,
|
||||
'lists' => $listIds,
|
||||
'attribs' => $attribs,
|
||||
]);
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe or unsubscribe a set of subscribers from lists.
|
||||
*
|
||||
* @param array<int> $subscriberIds
|
||||
* @param array<int> $listIds
|
||||
* @param string $action subscribe | unsubscribe
|
||||
* @param string $status confirmed | unconfirmed
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function updateSubscriberLists(
|
||||
array $subscriberIds,
|
||||
array $listIds,
|
||||
string $action = 'subscribe',
|
||||
string $status = 'confirmed',
|
||||
): bool {
|
||||
$response = $this->http->put('/subscribers/lists', [
|
||||
'ids' => $subscriberIds,
|
||||
'action' => $action,
|
||||
'status' => $status,
|
||||
'list_ids' => $listIds,
|
||||
]);
|
||||
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a subscriber to the blocklist.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function blocklistSubscriber(int $subscriberId): bool
|
||||
{
|
||||
return $this->http
|
||||
->put("/subscribers/{$subscriberId}/blocklist")
|
||||
->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a subscriber.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function deleteSubscriber(int $subscriberId): bool
|
||||
{
|
||||
return $this->http
|
||||
->delete("/subscribers/{$subscriberId}")
|
||||
->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an opt-in confirmation email to a subscriber.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function sendOptin(int $subscriberId): bool
|
||||
{
|
||||
return $this->http
|
||||
->post("/subscribers/{$subscriberId}/optin")
|
||||
->successful();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ use App\Events\MemberRegistered;
|
||||
use App\Models\Member;
|
||||
use App\Models\MemberGroup;
|
||||
use App\Models\Package;
|
||||
use App\Notifications\MemberDeactivatedAdminNotification;
|
||||
use App\Notifications\MemberDeactivatedMemberNotification;
|
||||
use App\Notifications\MemberNewRequestAdminNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class MemberService
|
||||
{
|
||||
@@ -48,6 +52,9 @@ class MemberService
|
||||
|
||||
]);
|
||||
|
||||
Notification::route('mail', config('app.admin_email'))
|
||||
->notify(new MemberNewRequestAdminNotification($member, $package, (float) $data['amount']));
|
||||
|
||||
event(new MemberRegistered($member));
|
||||
|
||||
return $member;
|
||||
@@ -58,14 +65,16 @@ class MemberService
|
||||
*/
|
||||
public function deactivateMember(Member $member): void
|
||||
{
|
||||
// todo: send email to member + admin
|
||||
$member->update(['status' => 'excluded']);
|
||||
$membership = $member->memberships()
|
||||
->where('status', 'active')->first();
|
||||
$membership->update(['status' => 'inactive']);
|
||||
|
||||
// On détache les services côté Roxane - à tester
|
||||
$membership->services()->detach();
|
||||
|
||||
$member->notify(new MemberDeactivatedMemberNotification($member));
|
||||
|
||||
Notification::route('mail', config('app.admin_email'))
|
||||
->notify(new MemberDeactivatedAdminNotification($member));
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Support/CacheLineOutput.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\Console\Output\Output;
|
||||
|
||||
class CacheLineOutput extends Output
|
||||
{
|
||||
public function __construct(private readonly string $cacheKey)
|
||||
{
|
||||
parent::__construct(self::VERBOSITY_NORMAL);
|
||||
}
|
||||
|
||||
protected function doWrite(string $message, bool $newline): void
|
||||
{
|
||||
$current = Cache::get($this->cacheKey, []);
|
||||
$clean = preg_replace('/\x1b\[[0-9;]*m/', '', $message);
|
||||
$current['output'] = ($current['output'] ?? '').$clean.($newline ? "\n" : '');
|
||||
Cache::put($this->cacheKey, $current, now()->addHour());
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'admin_email' => env('ADMIN_EMAIL'),
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
|
||||
7
config/preprod.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => env('PREPROD', false),
|
||||
'admin_mails' => env('PREPROD_ADMIN_MAILS', ''),
|
||||
'test_mails' => env('PREPROD_TEST_MAILS', ''),
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('listmonks_members', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('member_id')->constrained('members')->onDelete('NO ACTION');
|
||||
$table->unsignedInteger('listmonk_user_id')->nullable();
|
||||
$table->json('data')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('listmonks_members');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('services_memberships', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(false)->after('membership_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('services_memberships', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -47,6 +47,89 @@ class NotificationTemplateSeeder extends Seeder
|
||||
]
|
||||
);
|
||||
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'member_new_request_admin'],
|
||||
[
|
||||
'name' => 'Nouvelle demande d\'adhésion — admin',
|
||||
'subject' => 'Nouvelle demande d\'adhésion : {member_name}',
|
||||
'body' => '<p>Une nouvelle demande d\'adhésion a été reçue.</p>'
|
||||
.'<p>'
|
||||
.'<strong>Nom :</strong> {member_name}<br>'
|
||||
.'<strong>Email :</strong> {member_email}<br>'
|
||||
.'<strong>Téléphone :</strong> {member_phone}<br>'
|
||||
.'<strong>Adresse :</strong> {member_address}<br>'
|
||||
.'<strong>Formule :</strong> {package_name}<br>'
|
||||
.'<strong>Montant :</strong> {amount} €'
|
||||
.'</p>'
|
||||
.'<p><a href="{member_url}" style="display:inline-block;padding:10px 20px;background:#f5a623;color:#000;font-weight:bold;text-decoration:none;border:3px solid #000;border-radius:6px;">Voir la fiche adhérent</a></p>',
|
||||
'variables' => [
|
||||
'member_name' => 'Nom complet du membre',
|
||||
'member_email' => 'Adresse email du membre',
|
||||
'member_phone' => 'Téléphone du membre',
|
||||
'member_address' => 'Adresse postale du membre',
|
||||
'package_name' => 'Nom de la formule choisie',
|
||||
'amount' => 'Montant de la cotisation',
|
||||
'member_url' => 'URL de la fiche dans le back office',
|
||||
'app_name' => 'Nom de l\'application',
|
||||
],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'contact_new_request'],
|
||||
[
|
||||
'name' => 'Nouvelle demande de contact',
|
||||
'subject' => 'Nouvelle demande de contact — {app_name}',
|
||||
'body' => '<p>Une nouvelle demande de contact a été reçue.</p>'
|
||||
.'<p><strong>Nom :</strong> {contact_name}<br>'
|
||||
.'<strong>Email :</strong> {contact_email}<br>'
|
||||
.'<strong>Sujet :</strong> {contact_subject}</p>'
|
||||
.'<p><strong>Message :</strong><br>{contact_message}</p>',
|
||||
'variables' => [
|
||||
'contact_name' => 'Nom complet de l\'expéditeur',
|
||||
'contact_email' => 'Adresse email de l\'expéditeur',
|
||||
'contact_subject' => 'Sujet du message',
|
||||
'contact_message' => 'Contenu du message',
|
||||
'app_name' => 'Nom de l\'application',
|
||||
],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'member_deactivated_member'],
|
||||
[
|
||||
'name' => 'Compte membre désactivé — membre',
|
||||
'subject' => 'Votre compte {app_name} a été désactivé',
|
||||
'body' => '<p>Bonjour {member_name},</p>'
|
||||
.'<p>Votre compte a été désactivé. Vos services associés ne sont plus accessibles.</p>'
|
||||
.'<p>Pour toute question, n\'hésitez pas à nous contacter.</p>',
|
||||
'variables' => [
|
||||
'member_name' => 'Nom complet du membre',
|
||||
'app_name' => 'Nom de l\'application',
|
||||
],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'member_deactivated_admin'],
|
||||
[
|
||||
'name' => 'Compte membre désactivé — admin',
|
||||
'subject' => 'Compte désactivé : {member_name}',
|
||||
'body' => '<p>Le compte du membre suivant a été désactivé.</p>'
|
||||
.'<p><strong>Nom :</strong> {member_name}<br>'
|
||||
.'<strong>Email :</strong> {member_email}</p>',
|
||||
'variables' => [
|
||||
'member_name' => 'Nom complet du membre',
|
||||
'member_email' => 'Adresse email du membre',
|
||||
'app_name' => 'Nom de l\'application',
|
||||
],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'admin_password_reset'],
|
||||
[
|
||||
|
||||
@@ -38,6 +38,7 @@ return [
|
||||
'ispconfig_mail' => 'ISPConfig Mail',
|
||||
'ispconfig_web' => 'Web Hosting',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
],
|
||||
|
||||
'ispconfig' => [
|
||||
@@ -53,9 +54,13 @@ return [
|
||||
'disabled' => 'Disabled',
|
||||
'nextcloud_id' => 'Nextcloud ID',
|
||||
'display_name' => 'Display name',
|
||||
'listmonk_data' => 'Listmonk data',
|
||||
'listmonk_id' => 'Listmonk ID',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'create_membership' => 'Create a membership',
|
||||
'create_membership_submit' => 'Create',
|
||||
'send_payment_mail' => 'Send payment email',
|
||||
'send_renewal_mail' => 'Send follow-up email',
|
||||
],
|
||||
|
||||
@@ -44,6 +44,7 @@ return [
|
||||
'ispconfig_mail' => 'ISPConfig Mail',
|
||||
'ispconfig_web' => 'Web Hosting',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
|
||||
10
lang/en/passwords.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'reset' => 'Your password has been reset.',
|
||||
'sent' => 'We have emailed your password reset link.',
|
||||
'sent_if_exists' => 'A reset link will be sent if the account exists.',
|
||||
'throttled' => 'Please wait before retrying.',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => 'We can\'t find a user with that email address.',
|
||||
];
|
||||
@@ -46,6 +46,13 @@ return [
|
||||
'dry_run_label' => 'Simulation mode (dry-run)',
|
||||
'dry_run_helper' => 'Simulates the operation without making any changes.',
|
||||
],
|
||||
'listmonk' => [
|
||||
'heading' => 'Listmonk',
|
||||
'description' => 'Link members to their Listmonk subscriber accounts (@retzien.fr).',
|
||||
'action_label' => 'Listmonk',
|
||||
'modal_heading' => 'Listmonk Synchronisation',
|
||||
'modal_description' => 'Link members to their Listmonk subscriber accounts using their @retzien.fr address.',
|
||||
],
|
||||
'services' => [
|
||||
'heading' => 'Member Services',
|
||||
'description' => 'Synchronise services associated with active members.',
|
||||
|
||||
@@ -54,6 +54,7 @@ return [
|
||||
'ispconfig_mail' => 'Messagerie ISPConfig',
|
||||
'ispconfig_web' => 'Hébergements Web',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
],
|
||||
|
||||
'ispconfig' => [
|
||||
@@ -69,9 +70,13 @@ return [
|
||||
'disabled' => 'Désactivé',
|
||||
'nextcloud_id' => 'Id Nextcloud',
|
||||
'display_name' => 'Nom de l\'utilisateur',
|
||||
'listmonk_data' => 'Données Listmonk',
|
||||
'listmonk_id' => 'ID Listmonk',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'create_membership' => 'Créer une adhésion',
|
||||
'create_membership_submit' => 'Créer',
|
||||
'send_payment_mail' => 'Envoyer le mail de paiement',
|
||||
'send_renewal_mail' => 'Envoyer un mail de relance',
|
||||
],
|
||||
|
||||
@@ -44,6 +44,7 @@ return [
|
||||
'ispconfig_mail' => 'Messagerie ISPConfig',
|
||||
'ispconfig_web' => 'Hébergements Web',
|
||||
'nextcloud' => 'NextCloud',
|
||||
'listmonk' => 'Listmonk',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
|
||||
10
lang/fr/passwords.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'reset' => 'Votre mot de passe a été réinitialisé.',
|
||||
'sent' => 'Nous vous avons envoyé le lien de réinitialisation par e-mail.',
|
||||
'sent_if_exists' => 'Un lien de réinitialisation sera envoyé si le compte existe.',
|
||||
'throttled' => 'Veuillez patienter avant de réessayer.',
|
||||
'token' => 'Ce jeton de réinitialisation est invalide.',
|
||||
'user' => 'Aucun utilisateur trouvé avec cette adresse e-mail.',
|
||||
];
|
||||
@@ -46,6 +46,13 @@ return [
|
||||
'dry_run_label' => 'Mode simulation (dry-run)',
|
||||
'dry_run_helper' => 'Simule l\'opération sans effectuer de modifications.',
|
||||
],
|
||||
'listmonk' => [
|
||||
'heading' => 'Listmonk',
|
||||
'description' => 'Lie les membres à leurs comptes abonnés Listmonk (@retzien.fr).',
|
||||
'action_label' => 'Listmonk',
|
||||
'modal_heading' => 'Synchronisation Listmonk',
|
||||
'modal_description' => 'Lie les membres à leurs comptes abonnés Listmonk en se basant sur leur adresse @retzien.fr.',
|
||||
],
|
||||
'services' => [
|
||||
'heading' => 'Services membres',
|
||||
'description' => 'Synchronise les services associés aux membres actifs.',
|
||||
|
||||
@@ -26,15 +26,24 @@
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -43,7 +52,15 @@
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -52,22 +69,40 @@
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
|
||||
--primary: #f5a623;
|
||||
--primary-foreground: #0a0a0a;
|
||||
|
||||
--secondary: #f48fb1;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
|
||||
--accent: #00473e;
|
||||
--accent-foreground: #ffffff;
|
||||
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #d4d4d4;
|
||||
|
||||
--chart-1: #f5a623; /* orange */
|
||||
--chart-2: #f48fb1; /* rose */
|
||||
--chart-3: #ffffff; /* blanc */
|
||||
--chart-1: #f5a623;
|
||||
--chart-2: #f48fb1;
|
||||
--chart-3: #ffffff;
|
||||
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #0a0a0a;
|
||||
--sidebar-primary: #f5a623;
|
||||
--sidebar-primary-foreground: #0a0a0a;
|
||||
--sidebar-accent: #f5f5f5;
|
||||
--sidebar-accent-foreground: #0a0a0a;
|
||||
--sidebar-border: #e5e5e5;
|
||||
--sidebar-ring: #d4d4d4;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -76,15 +111,24 @@
|
||||
--card: #171717;
|
||||
--card-foreground: #f9f9f9;
|
||||
|
||||
--primary: #007c6c; /* vert plus clair */
|
||||
--popover: #171717;
|
||||
--popover-foreground: #f9f9f9;
|
||||
|
||||
--primary: #007c6c;
|
||||
--primary-foreground: #0a0a0a;
|
||||
|
||||
--secondary: #2c2c2c;
|
||||
--secondary-foreground: #f9f9f9;
|
||||
|
||||
--muted: #2c2c2c;
|
||||
--muted-foreground: #a3a3a3;
|
||||
|
||||
--accent: #f48fb1;
|
||||
--accent-foreground: #171717;
|
||||
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--border: #2c2c2c;
|
||||
--input: #2c2c2c;
|
||||
--ring: #6f6f6f;
|
||||
@@ -92,6 +136,24 @@
|
||||
--chart-1: #f48fb1;
|
||||
--chart-2: #ffb300;
|
||||
--chart-3: #f9f9f9;
|
||||
|
||||
--sidebar: #171717;
|
||||
--sidebar-foreground: #f9f9f9;
|
||||
--sidebar-primary: #007c6c;
|
||||
--sidebar-primary-foreground: #f9f9f9;
|
||||
--sidebar-accent: #2c2c2c;
|
||||
--sidebar-accent-foreground: #f9f9f9;
|
||||
--sidebar-border: #2c2c2c;
|
||||
--sidebar-ring: #6f6f6f;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.nb-shadow {
|
||||
@apply border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out;
|
||||
}
|
||||
.nb-shadow-static {
|
||||
@apply border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)];
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -124,7 +186,7 @@
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
button:not([data-slot="button"]):not([data-slot="checkbox"]) {
|
||||
@apply bg-white border border-black shadow-sm text-black px-4 py-2 rounded-md hover:shadow-md transition;
|
||||
}
|
||||
}
|
||||
|
||||
141
resources/js/actions/App/Http/Controllers/DashboardController.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
index.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/dashboard',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
index.url = (options?: RouteQueryOptions) => {
|
||||
return index.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
index.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
index.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: index.url(options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
const indexForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
indexForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::index
|
||||
* @see app/Http/Controllers/DashboardController.php:15
|
||||
* @route '/dashboard'
|
||||
*/
|
||||
indexForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url({
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
index.form = indexForm
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::requestServiceActivation
|
||||
* @see app/Http/Controllers/DashboardController.php:30
|
||||
* @route '/dashboard/service-activation'
|
||||
*/
|
||||
export const requestServiceActivation = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
url: requestServiceActivation.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
requestServiceActivation.definition = {
|
||||
methods: ["post"],
|
||||
url: '/dashboard/service-activation',
|
||||
} satisfies RouteDefinition<["post"]>
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::requestServiceActivation
|
||||
* @see app/Http/Controllers/DashboardController.php:30
|
||||
* @route '/dashboard/service-activation'
|
||||
*/
|
||||
requestServiceActivation.url = (options?: RouteQueryOptions) => {
|
||||
return requestServiceActivation.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::requestServiceActivation
|
||||
* @see app/Http/Controllers/DashboardController.php:30
|
||||
* @route '/dashboard/service-activation'
|
||||
*/
|
||||
requestServiceActivation.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
url: requestServiceActivation.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::requestServiceActivation
|
||||
* @see app/Http/Controllers/DashboardController.php:30
|
||||
* @route '/dashboard/service-activation'
|
||||
*/
|
||||
const requestServiceActivationForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
action: requestServiceActivation.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\DashboardController::requestServiceActivation
|
||||
* @see app/Http/Controllers/DashboardController.php:30
|
||||
* @route '/dashboard/service-activation'
|
||||
*/
|
||||
requestServiceActivationForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
action: requestServiceActivation.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
requestServiceActivation.form = requestServiceActivationForm
|
||||
|
||||
const DashboardController = { index, requestServiceActivation }
|
||||
|
||||
export default DashboardController
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
@@ -16,7 +16,7 @@ create.definition = {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
create.url = (options?: RouteQueryOptions) => {
|
||||
@@ -25,7 +25,7 @@ create.url = (options?: RouteQueryOptions) => {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
@@ -35,7 +35,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
@@ -45,7 +45,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
@@ -55,7 +55,7 @@ const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
@@ -65,7 +65,7 @@ createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\ContactFormController::create
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:17
|
||||
* @see app/Http/Controllers/Forms/ContactFormController.php:16
|
||||
* @route '/contact'
|
||||
*/
|
||||
createForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
@@ -16,7 +16,7 @@ create.definition = {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
create.url = (options?: RouteQueryOptions) => {
|
||||
@@ -25,7 +25,7 @@ create.url = (options?: RouteQueryOptions) => {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
@@ -35,7 +35,7 @@ create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
@@ -45,7 +45,7 @@ create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
@@ -55,7 +55,7 @@ const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
@@ -65,7 +65,7 @@ createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::create
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:21
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:18
|
||||
* @route '/membership'
|
||||
*/
|
||||
createForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
@@ -82,7 +82,7 @@ create.form = createForm
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::store
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:35
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
|
||||
* @route '/membership'
|
||||
*/
|
||||
export const store = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
@@ -97,7 +97,7 @@ store.definition = {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::store
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:35
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
|
||||
* @route '/membership'
|
||||
*/
|
||||
store.url = (options?: RouteQueryOptions) => {
|
||||
@@ -106,7 +106,7 @@ store.url = (options?: RouteQueryOptions) => {
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::store
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:35
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
|
||||
* @route '/membership'
|
||||
*/
|
||||
store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
@@ -116,7 +116,7 @@ store.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::store
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:35
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
|
||||
* @route '/membership'
|
||||
*/
|
||||
const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
@@ -126,7 +126,7 @@ const storeForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> =>
|
||||
|
||||
/**
|
||||
* @see \App\Http\Controllers\Forms\MembershipFormController::store
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:35
|
||||
* @see app/Http/Controllers/Forms/MembershipFormController.php:42
|
||||
* @route '/membership'
|
||||
*/
|
||||
storeForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Auth from './Auth'
|
||||
import DashboardController from './DashboardController'
|
||||
import Settings from './Settings'
|
||||
import Forms from './Forms'
|
||||
|
||||
const Controllers = {
|
||||
Auth: Object.assign(Auth, Auth),
|
||||
DashboardController: Object.assign(DashboardController, DashboardController),
|
||||
Settings: Object.assign(Settings, Settings),
|
||||
Forms: Object.assign(Forms, Forms),
|
||||
}
|
||||
|
||||
@@ -13,54 +13,27 @@ import {
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import { dashboard, logout } from '@/routes';
|
||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||
import AppearanceToggleDropdown from './appearance-dropdown';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid, LogOut, Menu, Moon, Settings, Sun, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AppLogo from './app-logo';
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
title: 'Tableau de Bord',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
|
||||
interface AppHeaderProps {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
@@ -69,122 +42,68 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
const page = usePage<SharedData>();
|
||||
const { auth } = page.props;
|
||||
const getInitials = useInitials();
|
||||
const cleanup = useMobileNavigation();
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const toggleAppearance = () => {
|
||||
updateAppearance(appearance === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const closeMenu = () => setIsMenuOpen(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
cleanup();
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return router.on('navigate', closeMenu);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeMenu();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = isMenuOpen ? 'hidden' : '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isMenuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-sidebar-border/80">
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
||||
{/* Mobile Menu */}
|
||||
<div className="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 h-[34px] w-[34px]"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Navigation Menu
|
||||
</SheetTitle>
|
||||
<SheetHeader className="flex justify-start text-left">
|
||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
||||
<div className="flex h-full flex-col justify-between text-sm">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4">
|
||||
{rightNavItems.map((item) => (
|
||||
<a
|
||||
key={item.title}
|
||||
href={
|
||||
typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 font-medium"
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={dashboard()}
|
||||
prefetch
|
||||
className="flex items-center space-x-2 no-underline"
|
||||
>
|
||||
<AppLogo />
|
||||
{/* Logo */}
|
||||
<Link href={dashboard()} prefetch className="flex items-center no-underline text-foreground">
|
||||
<AppLogo className="h-8 w-auto max-w-[180px]" />
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
|
||||
{/* Desktop nav */}
|
||||
<div className="ml-6 hidden h-full items-center lg:flex">
|
||||
<NavigationMenu className="flex h-full items-stretch">
|
||||
<NavigationMenuList className="flex h-full items-stretch space-x-2">
|
||||
<NavigationMenuList className="flex h-full items-stretch gap-1">
|
||||
{mainNavItems.map((item, index) => (
|
||||
<NavigationMenuItem
|
||||
key={index}
|
||||
className="relative flex h-full items-center"
|
||||
>
|
||||
<NavigationMenuItem key={index} className="relative flex h-full items-center">
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
page.url ===
|
||||
(typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url) &&
|
||||
activeItemStyles,
|
||||
'h-9 cursor-pointer px-3',
|
||||
'h-9 cursor-pointer px-3 text-foreground no-underline',
|
||||
page.url === (typeof item.href === 'string' ? item.href : item.href.url) &&
|
||||
'font-semibold',
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
{item.icon && <Icon iconNode={item.icon} className="mr-2 h-4 w-4" />}
|
||||
{item.title}
|
||||
</Link>
|
||||
{page.url === item.href && (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"></div>
|
||||
{page.url === (typeof item.href === 'string' ? item.href : item.href.url) && (
|
||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary" />
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
@@ -192,66 +111,23 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
<div className="relative flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group h-9 w-9 cursor-pointer"
|
||||
{/* Right actions */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Theme toggle — desktop only */}
|
||||
<button
|
||||
onClick={toggleAppearance}
|
||||
className="hidden lg:flex nb-shadow bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 font-bold"
|
||||
aria-label="Changer le thème"
|
||||
>
|
||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
||||
</Button>
|
||||
<div className="hidden lg:flex">
|
||||
{rightNavItems.map((item) => (
|
||||
<TooltipProvider
|
||||
key={item.title}
|
||||
delayDuration={0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<a
|
||||
href={
|
||||
typeof item.href ===
|
||||
'string'
|
||||
? item.href
|
||||
: item.href.url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
iconNode={item.icon}
|
||||
className="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AppearanceToggleDropdown />
|
||||
{appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
|
||||
{/* Avatar dropdown — always visible */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-10 rounded-full p-1"
|
||||
>
|
||||
<Button variant="secondary" className="size-10 rounded-full mr-2">
|
||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
||||
<AvatarImage
|
||||
src={auth.user.avatar}
|
||||
alt={auth.user.name}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
<AvatarFallback className="rounded-full bg-secondary text-secondary-foreground font-semibold text-sm">
|
||||
{getInitials(auth.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -261,12 +137,109 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="flex lg:hidden nb-shadow bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 font-bold"
|
||||
aria-label={isMenuOpen ? 'Fermer le menu' : 'Ouvrir le menu'}
|
||||
aria-expanded={isMenuOpen}
|
||||
>
|
||||
{isMenuOpen ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 lg:hidden"
|
||||
onClick={closeMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-x-0 top-0 z-50 lg:hidden bg-[#F5F5F5] dark:bg-[#0a0a0a] border-b-4 border-black flex flex-col gap-6 p-6">
|
||||
{/* Header du panel */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Link href={dashboard()} onClick={closeMenu} className="flex items-center gap-2 no-underline text-foreground">
|
||||
<AppLogoIcon className="size-8" />
|
||||
<span className="font-bold text-foreground">Le Retzien Libre</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={closeMenu}
|
||||
className="p-2 rounded-md border border-black/20 dark:border-white/20 hover:bg-black/5 dark:hover:bg-white/5 transition"
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex flex-col">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
onClick={closeMenu}
|
||||
className="flex items-center gap-2 text-lg py-3 border-b border-black/10 dark:border-white/10 no-underline text-foreground hover:underline"
|
||||
>
|
||||
{item.icon && <Icon iconNode={item.icon} className="size-5" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleAppearance}
|
||||
className="flex items-center gap-2 text-lg py-3 border-b border-black/10 dark:border-white/10 text-foreground hover:underline w-full"
|
||||
aria-label="Changer le thème"
|
||||
>
|
||||
{appearance === 'dark' ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
||||
<span>{appearance === 'dark' ? 'Mode clair' : 'Mode sombre'}</span>
|
||||
</button>
|
||||
|
||||
{/* User actions */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 py-2 border-b border-black/10 dark:border-white/10">
|
||||
<Avatar className="size-8 rounded-full">
|
||||
<AvatarFallback className="rounded-full bg-secondary text-secondary-foreground font-semibold text-sm">
|
||||
{getInitials(auth.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">{auth.user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{auth.user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/profile/edit"
|
||||
onClick={closeMenu}
|
||||
className="flex items-center gap-2 text-lg py-3 border-b border-black/10 dark:border-white/10 no-underline text-foreground hover:underline"
|
||||
>
|
||||
<Settings className="size-5" />
|
||||
<span>Paramètres</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={logout()}
|
||||
method="post"
|
||||
as="button"
|
||||
onClick={() => { closeMenu(); handleLogout(); }}
|
||||
className="flex bg-primary items-center gap-2 text-lg py-3 no-underline text-foreground hover:underline border-black border-3 shadow-[4px_4px_0px_rgba(0,0,0,1)]"
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="size-5" />
|
||||
<span>Se déconnecter</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{breadcrumbs.length > 1 && (
|
||||
<div className="flex w-full border-b border-sidebar-border/70">
|
||||
<div className="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
|
||||
<div className="flex w-full border-b border-border">
|
||||
<div className="mx-auto flex h-12 w-full items-center justify-start px-4 text-muted-foreground md:max-w-7xl">
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { SVGAttributes } from 'react';
|
||||
import { ImgHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import logoDark from '@/img/utils/logo-icon.svg';
|
||||
import logoWhite from '@/img/utils/logo-icon-white.svg';
|
||||
|
||||
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 42 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="21.138" cy="16.5" r="15" stroke="#000" strokeWidth="3"/>
|
||||
<path d="M21.138 15c-4.4-5.2-11-2.167-13.5 0 1.6-10.4 9.333-12 13-12 10.4.4 13.667 8.167 14 12-6.4-5.6-11.5-2.333-13.5 0Z" fill="#000" stroke="#000"/>
|
||||
<circle cx="13" cy="17" r="2" fill="#FFA8BA"/>
|
||||
<circle cx="29" cy="17" r="2" fill="#FFA8BA"/>
|
||||
<path d="M5.638 11.5c-3.5 5.167-8.4 14.2 0 9v-9ZM36.638 11.5c3.5 5.167 8.4 14.2 0 9v-9Z" fill="#000" stroke="#000"/>
|
||||
<path d="M21.736 18.768a1.28 1.28 0 0 1-1.472 0l-2.879-2.117c-.778-.572-.298-1.651.736-1.651h5.758c1.034 0 1.514 1.079.736 1.651l-2.88 2.117Z" fill="#FAAE2B"/>
|
||||
</svg>
|
||||
);
|
||||
interface Props extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
variant?: 'dark' | 'white';
|
||||
}
|
||||
|
||||
export default function AppLogoIcon({ variant = 'dark', className, alt = '', ...props }: Props) {
|
||||
const src = variant === 'white' ? logoWhite : logoDark;
|
||||
|
||||
return <img src={src} alt={alt} className={cn('object-contain', className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NavFooter } from '@/components/nav-footer';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
import { dashboard } from '@/routes';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
@@ -24,19 +23,6 @@ const mainNavItems: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/react-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#react',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
@@ -44,7 +30,7 @@ export function AppSidebar() {
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href={dashboard()} prefetch>
|
||||
<Link href={dashboard()} prefetch className="text-foreground">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -57,7 +43,6 @@ export function AppSidebar() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter items={footerNavItems} className="mt-auto" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
35
resources/js/components/common/LegalLayout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import NavGuestLayout from '@/layouts/nav-guest-layout';
|
||||
import { Footer } from '@/components/footer';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LegalLayout({ title, description, children }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title={title} />
|
||||
<div className="flex flex-col min-h-screen bg-white text-[#1b1b18]">
|
||||
<div className="flex flex-col items-center px-4">
|
||||
<NavGuestLayout />
|
||||
</div>
|
||||
<main className="flex-1 w-full max-w-3xl mx-auto px-6 py-16">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-accent">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-3 text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
resources/js/components/common/LegalSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LegalSection({ heading, children }: Props) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-xl font-semibold text-accent border-b border-accent/20 pb-2">
|
||||
{heading}
|
||||
</h2>
|
||||
<div className="text-sm leading-relaxed text-foreground flex flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function ScrollToTop() {
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
aria-label="Retour en haut"
|
||||
className="fixed bottom-6 right-6 z-50 p-3 rounded-full border-3 border-black bg-primary shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition duration-200"
|
||||
className="nb-shadow fixed bottom-6 right-6 z-50 p-3 rounded-full bg-primary"
|
||||
>
|
||||
<ChevronUp className="size-5" />
|
||||
</button>
|
||||
|
||||
18
resources/js/components/features/dashboard/NoMemberCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function NoMemberCard() {
|
||||
return (
|
||||
<div className="nb-shadow-static bg-white dark:bg-[#171717] rounded-2xl p-8 flex flex-col items-center gap-4 text-center max-w-lg mx-auto">
|
||||
<KeyRound className="size-10 text-primary" />
|
||||
<h2 className="text-xl font-bold">Pas encore membre ?</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Votre compte n'est pas encore associé à une adhésion. Rejoignez l'association pour accéder aux services.
|
||||
</p>
|
||||
<Button variant="secondary" className="nb-shadow" onClick={() => router.visit('/formulaires/adhesion')}>
|
||||
Adhérer au Retzien Libre
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
resources/js/components/features/dashboard/ServiceCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Cloud, ExternalLink, Globe, Layers, Mail, Megaphone, Share2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type DashboardService } from '@/types';
|
||||
|
||||
const ACTIVATION_REQUESTED_KEY = 'service_activation_requested';
|
||||
|
||||
function getRequestedServices(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(ACTIVATION_REQUESTED_KEY) ?? '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function markServiceRequested(identifier: string): void {
|
||||
const current = getRequestedServices();
|
||||
if (!current.includes(identifier)) {
|
||||
localStorage.setItem(ACTIVATION_REQUESTED_KEY, JSON.stringify([...current, identifier]));
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap: Record<string, typeof Mail> = {
|
||||
envelope: Mail,
|
||||
share: Share2,
|
||||
cloud: Cloud,
|
||||
megaphone: Megaphone,
|
||||
'globe-alt': Globe,
|
||||
};
|
||||
|
||||
const colorSchemes = [
|
||||
{
|
||||
card: 'bg-secondary',
|
||||
titleBg: 'bg-accent',
|
||||
titleText: 'text-accent-foreground',
|
||||
linkText: 'text-foreground',
|
||||
iconText: 'text-foreground/10',
|
||||
},
|
||||
{
|
||||
card: 'bg-primary',
|
||||
titleBg: 'bg-white',
|
||||
titleText: 'text-black',
|
||||
linkText: 'text-foreground',
|
||||
iconText: 'text-foreground/10',
|
||||
},
|
||||
{
|
||||
card: 'bg-accent',
|
||||
titleBg: 'bg-primary',
|
||||
titleText: 'text-primary-foreground',
|
||||
linkText: 'text-accent-foreground',
|
||||
iconText: 'text-white/10',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
service: DashboardService;
|
||||
index: number;
|
||||
onRequest: (identifier: string) => void;
|
||||
}
|
||||
|
||||
export function ServiceCard({ service, index, onRequest }: Props) {
|
||||
const [alreadyRequested, setAlreadyRequested] = useState(() =>
|
||||
getRequestedServices().includes(service.identifier),
|
||||
);
|
||||
|
||||
const scheme = colorSchemes[index % colorSchemes.length];
|
||||
const Icon = iconMap[service.icon ?? ''] ?? Layers;
|
||||
|
||||
function handleRequest() {
|
||||
markServiceRequested(service.identifier);
|
||||
setAlreadyRequested(true);
|
||||
onRequest(service.identifier);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('nb-shadow flex items-center gap-4 rounded-4xl p-10', scheme.card)}>
|
||||
<div className="flex flex-col gap-3 flex-1 min-w-0">
|
||||
<div className="max-w-[200px]">
|
||||
<h3 className={cn('inline text-2xl font-semibold rounded p-1 line-clamp-2', scheme.titleBg, scheme.titleText)}>
|
||||
{service.name}
|
||||
</h3>
|
||||
</div>
|
||||
{service.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{service.description}</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{service.is_active ? (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn('inline-flex items-center gap-1.5 text-sm font-medium underline hover:no-underline', scheme.linkText)}
|
||||
>
|
||||
Accéder au service <ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={alreadyRequested}
|
||||
onClick={handleRequest}
|
||||
className="text-xs"
|
||||
>
|
||||
{alreadyRequested ? 'Demande envoyée' : "Demander l'activation"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('shrink-0', scheme.iconText)}>
|
||||
<Icon className="size-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { type DashboardService } from '@/types';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
|
||||
interface Props {
|
||||
services: DashboardService[];
|
||||
submitting: boolean;
|
||||
onRequest: (identifier: string) => void;
|
||||
}
|
||||
|
||||
export function ServicesSection({ services, submitting, onRequest }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold">Vos services</h2>
|
||||
{submitting && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Envoi en cours…
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{services.map((service, index) => (
|
||||
<ServiceCard
|
||||
key={service.identifier}
|
||||
service={service}
|
||||
index={index}
|
||||
onRequest={onRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
resources/js/components/features/dashboard/WelcomeCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { type DashboardMember } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
member: DashboardMember;
|
||||
}
|
||||
|
||||
export function WelcomeCard({ member }: Props) {
|
||||
const membership = member.membership;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-2xl text-black font-medium tracking-wide">
|
||||
Bienvenue sur votre espace Retzien, {member.firstname}
|
||||
</p>
|
||||
</div>
|
||||
<div className="nb-shadow-static bg-white rounded-2xl p-6 flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{member.firstname} {member.lastname}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{member.retzien_email || member.email}</p>
|
||||
|
||||
{membership ? (
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary/20 text-secondary-foreground px-3 py-1 font-medium border border-secondary/40">
|
||||
{membership.package?.name ?? 'Adhésion'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 font-medium border',
|
||||
membership.status === 'active'
|
||||
? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/20 dark:text-green-400 dark:border-green-700'
|
||||
: 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-700',
|
||||
)}
|
||||
>
|
||||
{membership.status === 'active' ? 'Actif' : 'En attente'}
|
||||
</span>
|
||||
{membership.end_date && (
|
||||
<span className="text-muted-foreground">
|
||||
Valide jusqu'au {new Date(membership.end_date).toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Aucune adhésion active.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import {Button} from '@/components/ui/button';
|
||||
|
||||
export function AboutSection() {
|
||||
return (
|
||||
<section className="w-full py-16">
|
||||
<section id="about-section" className="w-full py-16">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<SectionHeading title="Qui sommes-nous ?" color="secondary"
|
||||
subtitle="Le Retzien Libre, c’est une association qui promeut l’auto-hébergement et la décentralisation des services en ligne depuis 2017."
|
||||
align='left'/>
|
||||
<div
|
||||
className="bg-white rounded-4xl border-3 border-black mt-10 px-10 pt-20 pb-10 shadow-[4px_4px_0px_rgba(0,0,0,1)]">
|
||||
className="nb-shadow-static bg-white rounded-4xl mt-10 px-10 pt-20 pb-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="flex flex-col gap-3 lg:border-r-2 border-black lg:pr-10 border-0">
|
||||
<h3 className="text-xl text-primary font-semibold">Une association locale</h3>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function AlternativeSection() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<section className="w-full py-16">
|
||||
<section id="alternative" className="w-full py-16">
|
||||
<div className="bg-gray-100 rounded-4xl max-w-7xl mx-auto px-4">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-12">
|
||||
<div className="lg:w-1/2 flex justify-center">
|
||||
|
||||
@@ -3,13 +3,9 @@ import { ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { dashboard, membership } from '@/routes';
|
||||
import { type SharedData } from '@/types';
|
||||
import illustrationImage from '@/img/utils/lrl-illustration.png';
|
||||
import PawsDecoration from '@/components/common/PawsDecoration';
|
||||
import { useParallax } from '@/hooks/use-parallax';
|
||||
|
||||
import illustrationImage from '@/img/utils/lrl-illustration.svg';
|
||||
export function HeroSection() {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const pawsRef = useParallax<HTMLDivElement>(0.12);
|
||||
|
||||
const scrollToFirstSection = () => {
|
||||
document.getElementById('first-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||
@@ -18,7 +14,7 @@ export function HeroSection() {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative flex flex-col w-full max-w-[335px] lg:max-w-7xl mx-auto min-h-[calc(100vh-80px)] px-4"
|
||||
className="relative flex flex-col w-full max-w-[500px] lg:max-w-7xl mx-auto min-h-[calc(100vh-80px)] px-4"
|
||||
>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex flex-1 items-center justify-center gap-4 w-full">
|
||||
@@ -42,14 +38,10 @@ export function HeroSection() {
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden lg:flex w-full items-center justify-center">
|
||||
<img src={illustrationImage} alt="Illustration Le Retzien Libre" className="max-w-md w-full" />
|
||||
<img src={illustrationImage} alt="Illustration Le Retzien Libre" className="max-w-lg w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={pawsRef} className="absolute bottom-12 right-0 pointer-events-none hidden lg:block w-48 opacity-40">
|
||||
<PawsDecoration className="w-full h-auto" />
|
||||
</div>
|
||||
|
||||
{/* Flèche vers la première section */}
|
||||
<div className="flex justify-center pb-8">
|
||||
<button
|
||||
|
||||
114
resources/js/components/features/home/PawsScrollAnimation.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useScrollProgress } from '@/hooks/use-scroll-progress';
|
||||
|
||||
/**
|
||||
* Paw prints sorted bottom-to-top by their Y position in the SVG viewBox (0 0 396 347).
|
||||
* Each threshold is the scroll progress (0–1) at which that print becomes visible.
|
||||
*/
|
||||
const PAWS = [
|
||||
{
|
||||
id: 'paw-bottom',
|
||||
threshold: 0.05,
|
||||
d: 'M31.1294 292.304C35.7403 291.179 41.373 297.379 44.9911 306.863C51.1878 298.385 58.5611 293.725 62.7766 296.139C67.779 299.003 66.4409 310.744 59.7878 322.363C55.4402 329.955 49.8983 335.615 45.184 337.827C44.442 338.629 43.5848 339.171 42.6191 339.407C42.3446 339.474 42.0664 339.513 41.7853 339.529C36.8981 341.422 27.4458 338.751 18.3753 332.498C7.35225 324.9 1.06811 314.892 4.33959 310.146C7.1581 306.057 16.1472 307.21 25.6314 312.465C24.252 301.986 26.3892 293.46 31.1294 292.304Z',
|
||||
},
|
||||
{
|
||||
id: 'paw-lower-mid',
|
||||
threshold: 0.22,
|
||||
d: 'M145.999 243.334C150.289 245.365 150.759 253.728 147.596 263.374C157.749 260.691 166.411 261.716 168.165 266.246C170.246 271.621 161.812 279.898 149.327 284.732C141.168 287.891 133.299 288.797 128.243 287.546C127.162 287.702 126.155 287.583 125.256 287.158C125.001 287.037 124.761 286.892 124.533 286.728C119.544 285.121 113.884 277.092 110.777 266.522C107 253.677 108.422 241.946 113.953 240.32C118.717 238.919 124.973 245.476 129.03 255.532C134.559 246.523 141.589 241.247 145.999 243.334Z',
|
||||
},
|
||||
{
|
||||
id: 'paw-mid',
|
||||
threshold: 0.39,
|
||||
d: 'M112.344 139.976C117.024 140.761 119.756 148.679 119.34 158.822C128.378 153.474 136.992 152.1 139.914 155.98C143.381 160.585 137.522 170.847 126.826 178.9C119.836 184.163 112.511 187.18 107.306 187.354C106.308 187.797 105.307 187.957 104.327 187.793C104.049 187.746 103.777 187.673 103.513 187.576C98.275 187.389 90.6421 181.207 84.7724 171.884C77.6392 160.554 75.8108 148.879 80.6887 145.808C84.891 143.162 92.6967 147.765 99.3398 156.334C102.205 146.161 107.531 139.169 112.344 139.976Z',
|
||||
},
|
||||
{
|
||||
id: 'paw-upper-mid',
|
||||
threshold: 0.56,
|
||||
d: 'M247.503 117.591C250.861 120.945 248.494 128.98 242.273 137.002C252.737 137.887 260.551 141.763 260.681 146.618C260.835 152.381 250.11 157.342 236.727 157.7C227.98 157.934 220.263 156.143 215.922 153.266C214.852 153.049 213.944 152.598 213.241 151.896C213.041 151.697 212.863 151.48 212.704 151.248C208.545 148.058 205.912 138.595 206.537 127.595C207.297 114.228 212.578 103.657 218.333 103.984C223.291 104.266 226.98 112.543 227.422 123.377C235.657 116.75 244.05 114.143 247.503 117.591Z',
|
||||
},
|
||||
{
|
||||
id: 'paw-top',
|
||||
threshold: 0.73,
|
||||
d: 'M228.85 25.324C233.259 27.0801 234.256 35.3969 231.706 45.2229C241.67 41.9058 250.379 42.3828 252.415 46.793C254.831 52.0268 246.935 60.8186 234.78 66.4297C226.835 70.0967 219.038 71.4973 213.914 70.5672C212.845 70.79 211.833 70.7348 210.91 70.3671C210.647 70.2626 210.398 70.1332 210.16 69.9832C205.08 68.6932 198.926 61.0376 195.159 50.6849C190.581 38.1036 191.261 26.3061 196.678 24.3347C201.344 22.6367 208.001 28.7864 212.683 38.5661C217.633 29.2274 224.317 23.519 228.85 25.324Z',
|
||||
},
|
||||
{
|
||||
id: 'paw-topmost',
|
||||
threshold: 0.88,
|
||||
d: 'M366.871 13.7083C371.161 15.7387 371.631 24.1017 368.468 33.7476C378.621 31.0649 387.283 32.0897 389.037 36.6195C391.118 41.9951 382.684 50.2719 370.199 55.106C362.04 58.2651 354.17 59.1716 349.114 57.9206C348.034 58.0756 347.027 57.9567 346.128 57.5316C345.873 57.4109 345.633 57.266 345.405 57.1016C340.416 55.4951 334.756 47.4663 331.649 36.896C327.873 24.051 329.294 12.3198 334.825 10.6939C339.589 9.29333 345.846 15.8502 349.902 25.9055C355.431 16.8972 362.461 11.6211 366.871 13.7083Z',
|
||||
},
|
||||
];
|
||||
|
||||
function containerOpacity(progress: number): number {
|
||||
if (progress <= 0) return 0;
|
||||
if (progress >= 0.9) return 1 - (progress - 0.9) / 0.1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
elementId: string;
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
const LERP_FACTOR = 0.06;
|
||||
|
||||
export function PawsScrollAnimation({ elementId, side }: Props) {
|
||||
const rawProgress = useScrollProgress(elementId);
|
||||
const targetRef = useRef(rawProgress);
|
||||
const smoothRef = useRef(rawProgress);
|
||||
const rafRef = useRef<number>(0);
|
||||
const [progress, setProgress] = useState(rawProgress);
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current = rawProgress;
|
||||
}, [rawProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
const diff = targetRef.current - smoothRef.current;
|
||||
if (Math.abs(diff) > 0.0005) {
|
||||
smoothRef.current += diff * LERP_FACTOR;
|
||||
setProgress(smoothRef.current);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none fixed top-1/2 hidden -translate-y-1/2 lg:block ${side === 'left' ? 'left-8' : 'right-8'}`}
|
||||
style={{
|
||||
opacity: containerOpacity(progress),
|
||||
transition: 'opacity 0.4s ease',
|
||||
width: '14rem',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 396 347"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-auto"
|
||||
style={side === 'left' ? { transform: 'scaleX(-1)' } : undefined}
|
||||
>
|
||||
{PAWS.map((paw) => {
|
||||
const visible = progress >= paw.threshold;
|
||||
return (
|
||||
<path
|
||||
key={paw.id}
|
||||
d={paw.d}
|
||||
fill="#8A8A8A"
|
||||
style={{
|
||||
opacity: visible ? 0.35 : 0,
|
||||
transform: visible ? 'translateY(0)' : 'translateY(10px)',
|
||||
transition: 'opacity 0.5s ease, transform 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,53 @@
|
||||
import {Service} from "@/types";
|
||||
import { Service } from '@/types';
|
||||
|
||||
const bgColorValues: Record<string, string> = {
|
||||
primary: '#f5a623',
|
||||
secondary: '#f48fb1',
|
||||
accent: '#00473e',
|
||||
gray: '#f5f5f5',
|
||||
white: '#ffffff',
|
||||
};
|
||||
|
||||
const lightBackgrounds = ['gray', 'white'];
|
||||
|
||||
function LinkIcon({ bgColor }: { bgColor: string }) {
|
||||
const isAccent = bgColor === 'accent';
|
||||
const circleColor = isAccent ? 'white' : 'black';
|
||||
const arrowColor = bgColorValues[bgColor] ?? '#ffffff';
|
||||
|
||||
export function ServiceCard({title, colorTitle, bgColor, bgTitle, description, link, illustration}: Service) {
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-1 items-center bg-${bgColor} justify-center gap-4 rounded-4xl p-10 border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out`}>
|
||||
<svg width="32" height="32" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<circle cx="20.5" cy="20.5" r="20.5" fill={circleColor} />
|
||||
<path
|
||||
d="M11.25 24.7009C10.5326 25.1151 10.2867 26.0325 10.701 26.7499C11.1152 27.4674 12.0326 27.7132 12.75 27.299L12 25.9999L11.25 24.7009ZM30.7694 16.3882C30.9838 15.588 30.5089 14.7655 29.7087 14.5511L16.6687 11.057C15.8685 10.8426 15.046 11.3175 14.8316 12.1177C14.6172 12.9179 15.0921 13.7404 15.8923 13.9548L27.4834 17.0606L24.3776 28.6517C24.1631 29.4519 24.638 30.2744 25.4382 30.4888C26.2384 30.7032 27.0609 30.2284 27.2753 29.4282L30.7694 16.3882ZM12 25.9999L12.75 27.299L30.0705 17.299L29.3205 15.9999L28.5705 14.7009L11.25 24.7009L12 25.9999Z"
|
||||
fill={arrowColor}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceCard({ title, colorTitle, bgColor, bgTitle, descriptionColor, description, link, illustration }: Service) {
|
||||
const isLightBg = lightBackgrounds.includes(bgColor);
|
||||
|
||||
return (
|
||||
<div className={`nb-shadow flex items-center gap-4 bg-${bgColor} justify-center rounded-4xl p-10`}>
|
||||
<div>
|
||||
<div className="max-w-[150px]">
|
||||
<h3 className={`inline text-2xl font-semibold text-${colorTitle} font-medium bg-${bgTitle} rounded p-1 line-clamp-2`}>{title}</h3>
|
||||
<h3 className={`inline text-2xl font-semibold text-${colorTitle} font-medium bg-${bgTitle} rounded p-1 line-clamp-2`}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-5">{description}</p>
|
||||
<a href={link} className="text-white underline hover:font-medium mt-4 inline-block hover:underline">
|
||||
<p className={`text-sm text-${descriptionColor} mt-5`}>{description}</p>
|
||||
<a
|
||||
href={link}
|
||||
className={`inline-flex items-center gap-2 font-medium mt-4 hover:underline ${isLightBg ? 'text-black' : 'text-white'}`}
|
||||
>
|
||||
<LinkIcon bgColor={bgColor} />
|
||||
En savoir plus
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative w-full h-64">
|
||||
<img
|
||||
src={illustration}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
<div className="w-full">
|
||||
<img src={illustration} alt={title} className="w-full h-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,13 @@ import { type Service } from '@/types';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
import { SectionHeading } from '@/components/common/SectionHeading';
|
||||
import { Container } from '@/components/common/Container';
|
||||
import PawsDecoration from '@/components/common/PawsDecoration';
|
||||
import { useParallax } from '@/hooks/use-parallax';
|
||||
|
||||
import IllustrationMail from '@/img/utils/service-mail.svg';
|
||||
import IllustrationCloud from '@/img/utils/service-cloud.svg';
|
||||
import IllustrationHosting from '@/img/utils/service-webhosting.svg';
|
||||
import IllustrationShare from '@/img/utils/service-share.svg';
|
||||
import IllustrationMailing from '@/img/utils/service-mailing-marketing.svg';
|
||||
import IllustrationSurvey from '@/img/utils/service-survey.svg';
|
||||
|
||||
const services: Service[] = [
|
||||
{
|
||||
@@ -12,8 +17,9 @@ const services: Service[] = [
|
||||
colorTitle: 'white',
|
||||
bgTitle: 'accent',
|
||||
bgColor: 'secondary',
|
||||
descriptionColor: 'black',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
illustration: IllustrationMail
|
||||
},
|
||||
{
|
||||
title: 'Stockage Cloud',
|
||||
@@ -21,26 +27,29 @@ const services: Service[] = [
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'white',
|
||||
bgColor: 'primary',
|
||||
descriptionColor: 'black',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
},
|
||||
{
|
||||
title: 'Hébergement de site',
|
||||
description: "Solutions d'hébergement web éthiques et performantes",
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'primary',
|
||||
bgColor: 'accent',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
illustration: IllustrationCloud
|
||||
},
|
||||
{
|
||||
title: 'Email Marketing',
|
||||
description: "Gérez vos communications de groupe efficacement",
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'primary',
|
||||
bgColor: 'accent',
|
||||
descriptionColor: 'white',
|
||||
link: '#',
|
||||
illustration: IllustrationMailing
|
||||
},
|
||||
{
|
||||
title: 'Hébergement de site',
|
||||
description: "Solutions d'hébergement web éthiques et performantes",
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'secondary',
|
||||
bgColor: 'gray',
|
||||
descriptionColor: 'black',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
illustration: IllustrationHosting
|
||||
},
|
||||
{
|
||||
title: 'Partage de fichiers',
|
||||
@@ -48,8 +57,9 @@ const services: Service[] = [
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'white',
|
||||
bgColor: 'primary',
|
||||
descriptionColor: 'black',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
illustration: IllustrationShare
|
||||
},
|
||||
{
|
||||
title: 'Outil de Sondage',
|
||||
@@ -57,20 +67,16 @@ const services: Service[] = [
|
||||
colorTitle: 'black',
|
||||
bgTitle: 'primary',
|
||||
bgColor: 'accent',
|
||||
descriptionColor: 'white',
|
||||
link: '#',
|
||||
illustration: '../../img/utils/lrl-logo.svg'
|
||||
illustration: IllustrationSurvey
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
export function ServicesSection() {
|
||||
const pawsRef = useParallax<HTMLDivElement>(0.08);
|
||||
|
||||
return (
|
||||
<section id="first-section" className="relative overflow-hidden w-full bg-white py-16">
|
||||
<div ref={pawsRef} className="absolute -top-4 left-0 pointer-events-none hidden lg:block w-48 opacity-30">
|
||||
<PawsDecoration className="w-full h-auto" />
|
||||
</div>
|
||||
<Container className="flex flex-col gap-10">
|
||||
<SectionHeading
|
||||
title="Nos services"
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import {Link} from '@inertiajs/react';
|
||||
import {contact, home, membership} from '@/routes';
|
||||
import AppLogoIcon from '@/components/app-logo-icon';
|
||||
import LogoChaton from '@/img/utils/logo-chaton.png';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="gap-10 bg-accent rounded-t-4xl text-white py-10 px-20 mt-auto mx-5">
|
||||
<footer className="gap-10 bg-accent dark:bg-primary rounded-t-4xl text-white py-10 px-20 mt-auto mx-5">
|
||||
<div className="max-w-7xl mx-auto px-4 flex flex-col gap-8">
|
||||
<div className="flex flex-col lg:flex-row justify-between gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link href={home()} className="flex items-center gap-2 no-underline">
|
||||
<AppLogoIcon className="size-8 text-[var(--foreground)] dark:text-white"/>
|
||||
<AppLogoIcon variant="white" className="size-8" />
|
||||
<span className="font-bold text-white text-lg">Le Retzien Libre</span>
|
||||
</Link>
|
||||
<p className="text-sm max-w-xs">
|
||||
Une association locale pour un internet éthique, libre et respectueux.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start lg:items-center gap-3">
|
||||
<div className="size-24 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<img src={LogoChaton} alt="Logo Chatons" className="size-20" />
|
||||
</div>
|
||||
<p className="text-xs max-w-[180px] text-white/80 lg:text-center">
|
||||
Le Retzien Libre est membre du collectif CHATONS depuis 2017
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-3">
|
||||
<span className="font-semibold">Navigation</span>
|
||||
<Link href={home()} className="text-sm text-white no-underline hover:underline">Accueil</Link>
|
||||
@@ -25,16 +34,15 @@ export function Footer() {
|
||||
<Link href={membership()} className="text-sm text-white no-underline hover:underline">Adhérer</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-black/20 pt-6 text-sm text-center">
|
||||
<div className="text-left">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between gap-4 border-t border-black/20 pt-6 text-sm">
|
||||
<div className="text-center lg:text-left">
|
||||
© {currentYear} Le Retzien Libre. Tous droits réservés.
|
||||
</div>
|
||||
<div className="flex items-stretch text-right">
|
||||
<Link href="#" className="text-sm text-white underline mx-4 hover:underline">Mentions Légales</Link>
|
||||
<Link href="#" className="text-sm text-white underline mx-4 hover:underline">CGU</Link>
|
||||
<Link href={"#"} className="text-sm text-white underline mx-4 hover:underline">Confidentialité</Link>
|
||||
<div className="flex flex-wrap justify-center lg:justify-end gap-y-2">
|
||||
<Link href="/mentions-legales" className="text-sm text-white underline mx-2 hover:no-underline">Mentions légales</Link>
|
||||
<Link href="/conditions-generales" className="text-sm text-white underline mx-2 hover:no-underline">CGU</Link>
|
||||
<Link href="/confidentialite" className="text-sm text-white underline mx-2 hover:no-underline">Confidentialité</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -14,13 +14,13 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"bg-white shadow-sm hover:bg-background",
|
||||
"bg-background text-foreground shadow-sm hover:bg-muted",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "bg-accent hover:text-accent-foreground hover:text-accent-foreground",
|
||||
ghost: "bg-transparent text-foreground border-transparent shadow-none hover:bg-primary/10",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out",
|
||||
default: "h-10 px-4 py-2 nb-shadow",
|
||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
|
||||
@@ -12,7 +12,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"border-3 border-black bg-white peer pr-0 data-[state=checked]:bg-black data-[state=checked]:text-white data-[state=checked]:border-black focus-visible:shadow-[4px_4px_0px_rgba(0,0,0,1)] transition duration-100 ease-in-out aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex items-center justify-center border-3 border-black bg-white peer data-[state=checked]:bg-black data-[state=checked]:text-white data-[state=checked]:border-black focus-visible:shadow-[4px_4px_0px_rgba(0,0,0,1)] transition duration-100 ease-in-out aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive w-5 h-5 aspect-square shrink-0 rounded-[4px] shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -40,7 +40,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border-2 border-black dark:border-border p-1 shadow-[4px_4px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_rgba(255,255,255,0.1)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-primary/15 focus:text-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -90,7 +90,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-primary/15 focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -126,7 +126,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-primary/15 focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-primary/15 focus:text-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
|
||||
onClick={cleanup}
|
||||
>
|
||||
<Settings className="mr-2" />
|
||||
Settings
|
||||
Paramètres
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -56,7 +56,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut className="mr-2" />
|
||||
Log out
|
||||
Se déconnecter
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
||||
43
resources/js/hooks/use-scroll-progress.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useScrollProgress(elementId: string): number {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
let ticking = false;
|
||||
|
||||
const update = () => {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) {
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollY = window.scrollY;
|
||||
const elTop = el.offsetTop;
|
||||
const elHeight = el.offsetHeight;
|
||||
const computed = Math.max(0, Math.min(1, (scrollY - elTop) / elHeight));
|
||||
|
||||
setProgress(computed);
|
||||
ticking = false;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
rafId = requestAnimationFrame(update);
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
update();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [elementId]);
|
||||
|
||||
return progress;
|
||||
}
|
||||
BIN
resources/js/img/utils/logo-chaton.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
8
resources/js/img/utils/logo-icon-white.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="125.5" cy="125.5" r="125.5" fill="white"/>
|
||||
<ellipse cx="126.997" cy="134.883" rx="79.387" ry="62.7157" fill="white"/>
|
||||
<circle cx="85.6892" cy="127.255" r="10.03" fill="#FFA8BA"/>
|
||||
<circle cx="165.929" cy="127.255" r="10.03" fill="#FFA8BA"/>
|
||||
<path d="M126.505 42C161.23 42.0021 190.953 63.3975 203.229 93.7217L206.31 98.2666C210.73 104.792 215.6 112.551 219.758 120.114C223.894 127.638 227.417 135.132 229.033 141.08C229.832 144.021 230.245 146.862 229.847 149.234C229.642 150.453 229.203 151.652 228.401 152.668C227.58 153.706 226.474 154.426 225.179 154.808C222.72 155.529 219.655 155.03 216.192 153.774C213.287 152.72 209.825 151.02 205.741 148.632C195.489 182.684 163.897 207.493 126.505 207.495C89.1121 207.494 57.5095 182.689 47.2549 148.637C43.1737 151.022 39.7117 152.721 36.8086 153.774C33.3445 155.031 30.2804 155.53 27.8213 154.808C26.525 154.426 25.4198 153.707 24.5986 152.668C23.797 151.652 23.3591 150.453 23.1543 149.234C22.7559 146.862 23.1727 144.021 23.9717 141.08C25.5882 135.131 29.1069 127.639 33.2432 120.114C37.4013 112.55 42.2708 104.793 46.6914 98.2666L49.7617 93.7266C62.0378 63.3972 91.7755 42.0008 126.505 42ZM192.557 119.11C176.987 105.488 163.31 102.881 152.516 104.775C141.59 106.695 133.168 113.307 128.41 118.855L126.49 121.094L124.59 118.846C114.231 106.604 101.377 104.05 89.4561 105.848C77.403 107.667 66.4156 113.945 60.4434 119.12L58.9395 120.418C58.8491 121.849 58.8027 123.293 58.8027 124.747C58.8027 162.137 89.1148 192.449 126.505 192.45C163.894 192.447 194.207 162.136 194.207 124.747C194.207 123.298 194.155 121.859 194.065 120.433L192.557 119.11Z" fill="black"/>
|
||||
<path d="M129.499 136.12C127.385 137.673 124.233 137.673 122.119 136.12L107.68 125.505C103.776 122.635 106.187 117.225 111.37 117.225L140.248 117.225C145.431 117.225 147.842 122.635 143.938 125.505L129.499 136.12Z" fill="#FAAE2B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
151
resources/js/img/utils/lrl-illustration.svg
Normal file
|
After Width: | Height: | Size: 290 KiB |
69
resources/js/img/utils/service-cloud.svg
Normal file
@@ -0,0 +1,69 @@
|
||||
<svg width="327" height="265" viewBox="0 0 327 265" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_134_948" fill="white">
|
||||
<path d="M268.026 40.9898C261.138 41.9331 255.895 46.5642 255.084 51.933C253.949 51.8688 252.778 51.9097 251.59 52.0725C247.623 52.6157 244.203 54.3838 241.859 56.8096C238.811 54.2703 234.277 52.9721 229.452 53.6327C221.407 54.7345 215.603 60.8669 216.488 67.33C217.373 73.7931 224.613 78.1391 232.658 77.0373C233.231 76.9587 233.794 76.8537 234.343 76.7257C236.943 81.0415 242.813 83.5939 249.192 82.7203C253.376 82.1473 256.951 80.2112 259.297 77.5781C262.264 79.4835 266.228 80.3872 270.412 79.8142C273.46 79.3967 276.184 78.2555 278.347 76.6496C279.433 76.7014 280.551 76.6593 281.684 76.504C288.294 75.5987 293.39 71.2968 294.507 66.206C294.645 66.1902 294.784 66.1739 294.923 66.1549C302.967 65.0531 308.771 58.9207 307.886 52.4577C307.001 45.9947 299.762 41.6478 291.717 42.7494C288.158 43.2369 285.037 44.711 282.74 46.7614C280.075 42.5801 274.295 40.1312 268.026 40.9898Z"/>
|
||||
</mask>
|
||||
<path d="M268.026 40.9898C261.138 41.9331 255.895 46.5642 255.084 51.933C253.949 51.8688 252.778 51.9097 251.59 52.0725C247.623 52.6157 244.203 54.3838 241.859 56.8096C238.811 54.2703 234.277 52.9721 229.452 53.6327C221.407 54.7345 215.603 60.8669 216.488 67.33C217.373 73.7931 224.613 78.1391 232.658 77.0373C233.231 76.9587 233.794 76.8537 234.343 76.7257C236.943 81.0415 242.813 83.5939 249.192 82.7203C253.376 82.1473 256.951 80.2112 259.297 77.5781C262.264 79.4835 266.228 80.3872 270.412 79.8142C273.46 79.3967 276.184 78.2555 278.347 76.6496C279.433 76.7014 280.551 76.6593 281.684 76.504C288.294 75.5987 293.39 71.2968 294.507 66.206C294.645 66.1902 294.784 66.1739 294.923 66.1549C302.967 65.0531 308.771 58.9207 307.886 52.4577C307.001 45.9947 299.762 41.6478 291.717 42.7494C288.158 43.2369 285.037 44.711 282.74 46.7614C280.075 42.5801 274.295 40.1312 268.026 40.9898Z" fill="white"/>
|
||||
<path d="M268.026 40.9898L267.619 38.0175V38.0175L268.026 40.9898ZM255.084 51.933L258.05 52.381L257.642 55.0826L254.914 54.9283L255.084 51.933ZM251.59 52.0725L251.183 49.1002L251.183 49.1002L251.59 52.0725ZM241.859 56.8096L244.017 58.8942L242.08 60.8983L239.939 59.1146L241.859 56.8096ZM229.452 53.6327L229.045 50.6604L229.045 50.6604L229.452 53.6327ZM216.488 67.33L213.516 67.7371L213.516 67.7371L216.488 67.33ZM232.658 77.0373L233.065 80.0095L233.065 80.0095L232.658 77.0373ZM234.343 76.7257L233.662 73.804L235.787 73.3086L236.913 75.1775L234.343 76.7257ZM249.192 82.7203L249.599 85.6926L249.599 85.6926L249.192 82.7203ZM259.297 77.5781L257.057 75.5824L258.761 73.6692L260.918 75.0536L259.297 77.5781ZM270.412 79.8142L270.819 82.7864L270.819 82.7864L270.412 79.8142ZM278.347 76.6496L276.558 74.241L277.419 73.602L278.49 73.653L278.347 76.6496ZM281.684 76.504L282.091 79.4763L282.091 79.4763L281.684 76.504ZM294.507 66.206L291.577 65.5632L292.036 63.4685L294.167 63.2253L294.507 66.206ZM294.923 66.1549L295.33 69.1272L295.33 69.1272L294.923 66.1549ZM307.886 52.4577L310.859 52.0507L310.859 52.0508L307.886 52.4577ZM291.717 42.7494L291.31 39.7772L291.31 39.7771L291.717 42.7494ZM282.74 46.7614L284.738 48.9994L282.107 51.3486L280.211 48.374L282.74 46.7614ZM268.026 40.9898L268.433 43.962C262.5 44.7746 258.612 48.6589 258.05 52.381L255.084 51.933L252.117 51.485C253.177 44.4695 259.777 39.0915 267.619 38.0175L268.026 40.9898ZM255.084 51.933L254.914 54.9283C253.967 54.8747 252.99 54.9088 251.997 55.0447L251.59 52.0725L251.183 49.1002C252.566 48.9107 253.93 48.863 255.253 48.9378L255.084 51.933ZM251.59 52.0725L251.997 55.0447C248.655 55.5025 245.864 56.9821 244.017 58.8942L241.859 56.8096L239.702 54.7251C242.542 51.7856 246.592 49.7289 251.183 49.1002L251.59 52.0725ZM241.859 56.8096L239.939 59.1146C237.607 57.1721 233.942 56.046 229.859 56.605L229.452 53.6327L229.045 50.6604C234.612 49.8982 240.015 51.3685 243.78 54.5046L241.859 56.8096ZM229.452 53.6327L229.859 56.605C222.815 57.5697 218.878 62.676 219.46 66.923L216.488 67.33L213.516 67.7371C212.327 59.0578 220 51.8993 229.045 50.6604L229.452 53.6327ZM216.488 67.33L219.46 66.923C220.042 71.1699 225.207 75.0297 232.251 74.065L232.658 77.0373L233.065 80.0095C224.019 81.2484 214.704 76.4164 213.516 67.7371L216.488 67.33ZM232.658 77.0373L232.251 74.065C232.729 73.9994 233.2 73.9116 233.662 73.804L234.343 76.7257L235.024 79.6473C234.387 79.7958 233.733 79.9179 233.065 80.0095L232.658 77.0373ZM234.343 76.7257L236.913 75.1775C238.764 78.2505 243.322 80.4962 248.785 79.7481L249.192 82.7203L249.599 85.6926C242.305 86.6916 235.122 83.8325 231.773 78.2738L234.343 76.7257ZM249.192 82.7203L248.785 79.7481C252.313 79.2649 255.22 77.6444 257.057 75.5824L259.297 77.5781L261.537 79.5737C258.682 82.7779 254.439 85.0297 249.599 85.6926L249.192 82.7203ZM259.297 77.5781L260.918 75.0536C263.242 76.5458 266.477 77.3251 270.005 76.8419L270.412 79.8142L270.819 82.7864C265.979 83.4493 261.287 82.4212 257.676 80.1025L259.297 77.5781ZM270.412 79.8142L270.005 76.8419C272.566 76.4911 274.813 75.5371 276.558 74.241L278.347 76.6496L280.135 79.0582C277.556 80.9739 274.353 82.3024 270.819 82.7864L270.412 79.8142ZM278.347 76.6496L278.49 73.653C279.401 73.6965 280.334 73.6609 281.277 73.5318L281.684 76.504L282.091 79.4763C280.767 79.6576 279.465 79.7063 278.204 79.6462L278.347 76.6496ZM281.684 76.504L281.277 73.5318C286.953 72.7544 290.791 69.1441 291.577 65.5632L294.507 66.206L297.437 66.8488C295.989 73.4495 289.636 78.4429 282.091 79.4763L281.684 76.504ZM294.507 66.206L294.167 63.2253C294.302 63.2099 294.413 63.1968 294.516 63.1827L294.923 66.1549L295.33 69.1272C295.156 69.1511 294.988 69.1705 294.847 69.1866L294.507 66.206ZM294.923 66.1549L294.516 63.1827C301.559 62.218 305.496 57.1118 304.914 52.8647L307.886 52.4577L310.859 52.0508C312.047 60.7296 304.376 67.8882 295.33 69.1272L294.923 66.1549ZM307.886 52.4577L304.914 52.8648C304.332 48.6175 299.168 44.7571 292.124 45.7217L291.717 42.7494L291.31 39.7771C300.356 38.5384 309.67 43.3719 310.859 52.0507L307.886 52.4577ZM291.717 42.7494L292.124 45.7217C289.128 46.132 286.569 47.365 284.738 48.9994L282.74 46.7614L280.742 44.5234C283.505 42.057 287.187 40.3419 291.31 39.7772L291.717 42.7494ZM282.74 46.7614L280.211 48.374C278.303 45.3806 273.797 43.2274 268.433 43.962L268.026 40.9898L267.619 38.0175C274.793 37.035 281.848 39.7796 285.27 45.1489L282.74 46.7614Z" fill="black" mask="url(#path-1-inside-1_134_948)"/>
|
||||
<rect x="53.4551" y="33.8619" width="205.053" height="150.274" rx="6" transform="rotate(-1.92053 53.4551 33.8619)" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<line x1="54.2979" y1="58.9975" x2="259.236" y2="52.1255" stroke="black" stroke-width="3"/>
|
||||
<circle cx="212.204" cy="41.1301" r="4.44158" transform="rotate(-1.92053 212.204 41.1301)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="229.221" cy="40.5598" r="4.44158" transform="rotate(-1.92053 229.221 40.5598)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="246.237" cy="39.989" r="4.44158" transform="rotate(-1.92053 246.237 39.989)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<line x1="77.0519" y1="111.323" x2="103.907" y2="110.422" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="78.9864" y1="169.03" x2="105.842" y2="168.13" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="122.182" y1="109.809" x2="149.037" y2="108.909" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="124.117" y1="167.517" x2="150.972" y2="166.617" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="167.313" y1="108.296" x2="194.168" y2="107.396" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="169.248" y1="166.004" x2="196.103" y2="165.104" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="212.443" y1="106.783" x2="239.299" y2="105.882" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="214.379" y1="164.491" x2="241.234" y2="163.59" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="76.7671" y1="117.758" x2="104.622" y2="116.824" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="78.7022" y1="175.466" x2="106.557" y2="174.532" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="121.898" y1="116.245" x2="149.752" y2="115.311" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="123.833" y1="173.953" x2="151.687" y2="173.019" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="167.028" y1="114.732" x2="194.883" y2="113.798" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="168.963" y1="172.439" x2="196.818" y2="171.505" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="212.159" y1="113.217" x2="240.014" y2="112.283" stroke="black" stroke-linecap="round"/>
|
||||
<line x1="214.095" y1="170.926" x2="241.949" y2="169.992" stroke="black" stroke-linecap="round"/>
|
||||
<g clip-path="url(#clip0_134_948)">
|
||||
<path d="M70.1919 43.2406C69.8265 42.2136 68.8202 41.4937 67.6615 41.5326C66.2314 41.5805 65.1105 42.766 65.1579 44.1804C65.168 44.4815 65.2302 44.7687 65.3357 45.0337C64.2904 45.1394 63.4917 46.0299 63.527 47.0839C63.5639 48.184 64.4955 49.0455 65.6078 49.0082L70.7868 48.8346C70.8308 48.8331 70.8744 48.8302 70.9176 48.826C70.9696 48.8271 71.0219 48.8267 71.0745 48.8249C72.6635 48.7717 73.9089 47.4545 73.8562 45.8829C73.8035 44.3113 72.4727 43.0805 70.8836 43.1338C70.6441 43.1418 70.4125 43.1786 70.1919 43.2406Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<path d="M68.7733 48.9022L68.6492 45.203M68.6492 45.203L66.4892 47.053M68.6492 45.203L70.9283 46.9042" stroke="black" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M67.293 48.9517L69.8824 48.8649" stroke="black"/>
|
||||
<rect x="71.1168" y="74.0266" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 71.1168 74.0266)" fill="#FFA8BA" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M71.0135 91.2268L85.529 82.9323C89.1355 88.359 98.1611 98.1243 98.1611 98.1243C98.1611 98.1243 105.624 93.9701 108.449 92.4371" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="99.2527" cy="81.6502" r="2.25888" transform="rotate(-1.92053 99.2527 81.6502)" fill="black"/>
|
||||
<rect x="73.0519" y="131.735" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 73.0519 131.735)" fill="#FAAE2B" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M72.9495 148.935L87.4651 140.64C91.0715 146.067 100.097 155.832 100.097 155.832C100.097 155.832 107.56 151.678 110.385 150.145" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="101.188" cy="139.358" r="2.25888" transform="rotate(-1.92053 101.188 139.358)" fill="black"/>
|
||||
<rect x="116.429" y="72.5066" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 116.429 72.5066)" fill="#FAAE2B" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M116.327 89.7068L130.843 81.4123C134.449 86.839 143.475 96.6042 143.475 96.6042C143.475 96.6042 150.938 92.4501 153.763 90.9171" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="144.565" cy="80.1302" r="2.25888" transform="rotate(-1.92053 144.565 80.1302)" fill="black"/>
|
||||
<rect x="118.364" y="130.215" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 118.364 130.215)" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M118.262 147.415L132.778 139.121C136.384 144.547 145.41 154.313 145.41 154.313C145.41 154.313 152.873 150.159 155.698 148.626" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="146.5" cy="137.84" r="2.25888" transform="rotate(-1.92053 146.5 137.84)" fill="black"/>
|
||||
<rect x="161.742" y="70.9875" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 161.742 70.9875)" fill="#00473E" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M161.64 88.1872L176.156 79.8927C179.762 85.3194 188.788 95.0847 188.788 95.0847C188.788 95.0847 196.251 90.9306 199.076 89.3976" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="189.878" cy="78.6102" r="2.25888" transform="rotate(-1.92053 189.878 78.6102)" fill="black"/>
|
||||
<rect x="163.676" y="128.696" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 163.676 128.696)" fill="#00473E" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M163.575 145.896L178.09 137.601C181.696 143.028 190.722 152.793 190.722 152.793C190.722 152.793 198.185 148.639 201.01 147.106" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="191.813" cy="136.319" r="2.25888" transform="rotate(-1.92053 191.813 136.319)" fill="black"/>
|
||||
<rect x="207.055" y="69.468" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 207.055 69.468)" fill="#FFA8BA" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M206.953 86.6677L221.469 78.3732C225.075 83.7999 234.101 93.5652 234.101 93.5652C234.101 93.5652 241.564 89.411 244.389 87.878" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="235.192" cy="77.0921" r="2.25888" transform="rotate(-1.92053 235.192 77.0921)" fill="black"/>
|
||||
<rect x="208.99" y="127.176" width="36.4277" height="29.8564" rx="4.5" transform="rotate(-1.92053 208.99 127.176)" fill="#FAAE2B" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M208.887 144.376L223.402 136.081C227.009 141.508 236.034 151.273 236.034 151.273C236.034 151.273 243.497 147.119 246.322 145.586" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<circle cx="237.126" cy="134.8" r="2.25888" transform="rotate(-1.92053 237.126 134.8)" fill="black"/>
|
||||
<mask id="path-51-inside-2_134_948" fill="white">
|
||||
<path d="M134.534 123.711C154.718 120.947 173.244 130.427 179.867 145.669C183.017 144.562 186.354 143.746 189.837 143.269C201.463 141.677 212.539 144.149 221.108 149.443C227.657 139.464 239.396 131.96 253.535 130.023C277.114 126.794 298.434 140.273 301.154 160.129C303.873 179.986 286.962 198.701 263.383 201.93C261.703 202.16 260.034 202.302 258.383 202.366C254.534 217.249 240.033 229.547 221.336 232.108C209.075 233.787 197.427 230.946 188.685 225.043C181.853 233.079 171.398 238.946 159.138 240.625C150.212 241.848 141.61 240.674 134.218 237.669C131.194 238.7 128 239.463 124.674 239.919C105.3 242.572 87.4532 233.945 80.1948 219.768C79.792 219.833 79.3874 219.894 78.9807 219.95C55.401 223.179 34.0809 209.7 31.3615 189.844C28.6422 169.987 45.5531 151.272 69.1327 148.043C79.5683 146.614 89.5602 148.46 97.694 152.68C101.837 138.146 116.161 126.228 134.534 123.711Z"/>
|
||||
</mask>
|
||||
<path d="M134.534 123.711C154.718 120.947 173.244 130.427 179.867 145.669C183.017 144.562 186.354 143.746 189.837 143.269C201.463 141.677 212.539 144.149 221.108 149.443C227.657 139.464 239.396 131.96 253.535 130.023C277.114 126.794 298.434 140.273 301.154 160.129C303.873 179.986 286.962 198.701 263.383 201.93C261.703 202.16 260.034 202.302 258.383 202.366C254.534 217.249 240.033 229.547 221.336 232.108C209.075 233.787 197.427 230.946 188.685 225.043C181.853 233.079 171.398 238.946 159.138 240.625C150.212 241.848 141.61 240.674 134.218 237.669C131.194 238.7 128 239.463 124.674 239.919C105.3 242.572 87.4532 233.945 80.1948 219.768C79.792 219.833 79.3874 219.894 78.9807 219.95C55.401 223.179 34.0809 209.7 31.3615 189.844C28.6422 169.987 45.5531 151.272 69.1327 148.043C79.5683 146.614 89.5602 148.46 97.694 152.68C101.837 138.146 116.161 126.228 134.534 123.711Z" fill="white"/>
|
||||
<path d="M134.534 123.711L134.127 120.739L134.127 120.739L134.534 123.711ZM179.867 145.669L177.115 146.864L178.228 149.424L180.861 148.499L179.867 145.669ZM189.837 143.269L189.43 140.297V140.297L189.837 143.269ZM221.108 149.443L219.531 151.995L222.014 153.529L223.616 151.089L221.108 149.443ZM253.535 130.023L253.128 127.051V127.051L253.535 130.023ZM301.154 160.129L304.126 159.722L304.126 159.722L301.154 160.129ZM263.383 201.93L263.79 204.902V204.902L263.383 201.93ZM258.383 202.366L258.266 199.369L256.037 199.455L255.478 201.615L258.383 202.366ZM221.336 232.108L221.743 235.08L221.743 235.08L221.336 232.108ZM188.685 225.043L190.364 222.557L188.139 221.054L186.4 223.1L188.685 225.043ZM159.138 240.625L159.545 243.597L159.545 243.597L159.138 240.625ZM134.218 237.669L135.348 234.89L134.31 234.468L133.25 234.829L134.218 237.669ZM124.674 239.919L125.081 242.891L125.081 242.891L124.674 239.919ZM80.1948 219.768L82.8651 218.401L81.8711 216.459L79.7178 216.806L80.1948 219.768ZM78.9807 219.95L79.3877 222.922L79.3878 222.922L78.9807 219.95ZM31.3615 189.844L28.3892 190.251L28.3892 190.251L31.3615 189.844ZM69.1327 148.043L68.7256 145.071L68.7256 145.071L69.1327 148.043ZM97.694 152.68L96.3123 155.343L99.5722 157.035L100.579 153.503L97.694 152.68ZM134.534 123.711L134.941 126.684C154.076 124.063 171.134 133.099 177.115 146.864L179.867 145.669L182.618 144.473C175.354 127.755 155.36 117.831 134.127 120.739L134.534 123.711ZM179.867 145.669L180.861 148.499C183.821 147.459 186.961 146.691 190.244 146.242L189.837 143.269L189.43 140.297C185.746 140.801 182.213 141.665 178.873 142.838L179.867 145.669ZM189.837 143.269L190.244 146.242C201.211 144.739 211.581 147.083 219.531 151.995L221.108 149.443L222.685 146.89C213.497 141.214 201.715 138.614 189.43 140.297L189.837 143.269ZM221.108 149.443L223.616 151.089C229.652 141.891 240.593 134.824 253.942 132.995L253.535 130.023L253.128 127.051C238.199 129.095 225.662 137.036 218.6 147.797L221.108 149.443ZM253.535 130.023L253.942 132.995C276.379 129.923 295.75 142.78 298.182 160.536L301.154 160.129L304.126 159.722C301.119 137.766 277.85 123.665 253.128 127.051L253.535 130.023ZM301.154 160.129L298.182 160.536C300.613 178.293 285.413 195.885 262.976 198.958L263.383 201.93L263.79 204.902C288.512 201.516 307.133 181.678 304.126 159.722L301.154 160.129ZM263.383 201.93L262.976 198.958C261.396 199.174 259.824 199.308 258.266 199.369L258.383 202.366L258.499 205.364C260.244 205.296 262.01 205.146 263.79 204.902L263.383 201.93ZM258.383 202.366L255.478 201.615C251.984 215.126 238.63 226.711 220.929 229.136L221.336 232.108L221.743 235.08C241.435 232.383 257.084 219.372 261.287 203.118L258.383 202.366ZM221.336 232.108L220.929 229.136C209.361 230.72 198.463 228.026 190.364 222.557L188.685 225.043L187.006 227.529C196.391 233.867 208.789 236.854 221.743 235.08L221.336 232.108ZM188.685 225.043L186.4 223.1C180.07 230.544 170.298 236.069 158.731 237.653L159.138 240.625L159.545 243.597C172.498 241.823 183.636 235.613 190.971 226.986L188.685 225.043ZM159.138 240.625L158.731 237.653C150.315 238.806 142.244 237.694 135.348 234.89L134.218 237.669L133.088 240.448C140.976 243.655 150.108 244.89 159.545 243.597L159.138 240.625ZM134.218 237.669L133.25 234.829C130.408 235.798 127.403 236.517 124.267 236.947L124.674 239.919L125.081 242.891C128.598 242.41 131.979 241.602 135.186 240.508L134.218 237.669ZM124.674 239.919L124.267 236.947C105.913 239.46 89.4388 231.24 82.8651 218.401L80.1948 219.768L77.5244 221.135C85.4676 236.649 104.686 245.684 125.081 242.891L124.674 239.919ZM80.1948 219.768L79.7178 216.806C79.3323 216.868 78.9517 216.926 78.5736 216.978L78.9807 219.95L79.3878 222.922C79.8232 222.863 80.2518 222.797 80.6717 222.73L80.1948 219.768ZM78.9807 219.95L78.5736 216.978C56.1363 220.051 36.7656 207.194 34.3337 189.437L31.3615 189.844L28.3892 190.251C31.3963 212.207 54.6657 226.308 79.3877 222.922L78.9807 219.95ZM31.3615 189.844L34.3337 189.437C31.902 171.68 47.1025 154.088 69.5397 151.015L69.1327 148.043L68.7256 145.071C44.0036 148.457 25.3824 168.295 28.3892 190.251L31.3615 189.844ZM69.1327 148.043L69.5397 151.015C79.381 149.668 88.7457 151.417 96.3123 155.343L97.694 152.68L99.0757 150.017C90.3746 145.503 79.7556 143.56 68.7256 145.071L69.1327 148.043ZM97.694 152.68L100.579 153.503C104.345 140.293 117.55 129.065 134.941 126.684L134.534 123.711L134.127 120.739C114.771 123.39 99.3299 135.999 94.8089 151.858L97.694 152.68Z" fill="black" mask="url(#path-51-inside-2_134_948)"/>
|
||||
<path d="M33.001 102.99L45.274 104.929L36.7772 113.995L34.8384 126.268L25.7725 117.771L13.4995 115.833L21.9962 106.767L23.9351 94.4937L33.001 102.99Z" fill="black"/>
|
||||
<path d="M279.632 220.704H281.057L280.498 220.94L281.925 222.508C281.956 222.542 281.984 222.578 282.014 222.613L280.916 222.341V223.758L280.684 223.205L279.113 224.637C279.078 224.669 279.04 224.698 279.004 224.729L279.276 223.634H277.854L278.41 223.399L276.983 221.83C276.952 221.796 276.924 221.759 276.894 221.724L277.992 221.997V220.579L278.225 221.133L279.795 219.701C279.83 219.669 279.867 219.639 279.903 219.608L279.632 220.704Z" fill="black" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_134_948">
|
||||
<rect width="11.8442" height="11.8442" fill="white" transform="translate(62.5312 39.4825) rotate(-1.92053)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
4
resources/js/img/utils/service-link-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20.5" cy="20.5" r="20.5" fill="black"/>
|
||||
<path d="M11.25 24.7009C10.5326 25.1151 10.2867 26.0325 10.701 26.7499C11.1152 27.4674 12.0326 27.7132 12.75 27.299L12 25.9999L11.25 24.7009ZM30.7694 16.3882C30.9838 15.588 30.5089 14.7655 29.7087 14.5511L16.6687 11.057C15.8685 10.8426 15.046 11.3175 14.8316 12.1177C14.6172 12.9179 15.0921 13.7404 15.8923 13.9548L27.4834 17.0606L24.3776 28.6517C24.1631 29.4519 24.638 30.2744 25.4382 30.4888C26.2384 30.7032 27.0609 30.2284 27.2753 29.4282L30.7694 16.3882ZM12 25.9999L12.75 27.299L30.0705 17.299L29.3205 15.9999L28.5705 14.7009L11.25 24.7009L12 25.9999Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 727 B |
71
resources/js/img/utils/service-mail.svg
Normal file
|
After Width: | Height: | Size: 57 KiB |
31
resources/js/img/utils/service-mailing-marketing.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg width="305" height="247" viewBox="0 0 305 247" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M53.142 227.827L48.4172 123.713C48.3214 121.603 49.3419 119.598 51.1042 118.434L145.124 56.3344C147.017 55.0843 149.452 55.0084 151.419 56.138L244.557 109.633C246.336 110.655 247.47 112.514 247.563 114.564L252.292 218.79C252.443 222.1 249.881 224.905 246.571 225.056L59.4078 233.549C56.0975 233.699 53.2922 231.138 53.142 227.827Z" fill="#D9D9D9" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M74.1914 15.9611L80.5706 156.532C80.6808 158.96 82.245 161.082 84.5319 161.905L151.581 186.046C153.117 186.599 154.813 186.502 156.276 185.777L221.882 153.285C224.015 152.229 225.321 150.013 225.213 147.636L218.94 9.39231C218.79 6.08201 215.984 3.52026 212.674 3.67048L79.9133 9.69526C76.603 9.84548 74.0412 12.6508 74.1914 15.9611Z" fill="white" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M161.167 109.195L204.883 107.211M161.774 122.553L205.49 120.569M162.38 135.911L206.096 133.927" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M163.217 154.369L206.933 152.385M163.823 167.726L207.539 165.742M164.43 181.084L208.146 179.1" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M74.6914 26.9677L219.44 20.399" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="97.8892" y="108.214" width="51.4581" height="33.9537" rx="4.5" transform="rotate(-2.59834 97.8892 108.214)" fill="#FFA8BA" stroke="black" stroke-width="3"/>
|
||||
<rect x="99.961" y="153.873" width="51.4581" height="33.9537" rx="4.5" transform="rotate(-2.59834 99.961 153.873)" fill="#FAAE2B" stroke="black" stroke-width="3"/>
|
||||
<rect x="95.8624" y="42.1101" width="106.889" height="53.403" rx="4.5" transform="rotate(-2.59834 95.8624 42.1101)" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<path d="M98.037 72.6221L138.596 57.3721C148.556 66.4479 173.543 82.6051 173.543 82.6051C173.543 82.6051 194.394 74.9541 202.287 72.1258" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="179.144" cy="54.8384" r="5.34856" transform="rotate(-2.59834 179.144 54.8384)" fill="black"/>
|
||||
<path d="M150.475 178.512C134.234 171.678 50.0674 136.375 49.5791 138.596C49.1219 140.675 52.5297 207.359 53.5908 227.862C53.7613 231.155 56.5576 233.679 59.8515 233.529L246.57 225.056C249.881 224.906 252.443 222.104 252.292 218.794L248.244 129.58C248.105 126.512 170.817 169.74 155.712 178.219C154.092 179.128 152.187 179.233 150.475 178.512Z" fill="white" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="211.466" y="174.991" width="92.3016" height="23.2566" rx="4.5" transform="rotate(14.8441 211.466 174.991)" fill="#FFA8BA" stroke="black" stroke-width="3"/>
|
||||
<path d="M222.341 194.628C221.346 194.365 220.611 193.926 220.137 193.313C219.667 192.701 219.536 191.949 219.745 191.057L221.425 191.503C221.345 191.922 221.425 192.275 221.663 192.562C221.906 192.847 222.253 193.049 222.705 193.169C223.145 193.285 223.523 193.285 223.837 193.168C224.154 193.052 224.353 192.843 224.433 192.542C224.506 192.267 224.441 192.024 224.237 191.813C224.035 191.598 223.724 191.378 223.304 191.152L222.465 190.694C221.819 190.346 221.343 189.938 221.038 189.472C220.738 189.004 220.668 188.467 220.829 187.861C220.96 187.365 221.209 186.967 221.576 186.668C221.943 186.364 222.387 186.171 222.908 186.09C223.434 186.005 223.996 186.042 224.594 186.201C225.204 186.362 225.708 186.608 226.108 186.938C226.508 187.268 226.785 187.652 226.94 188.09C227.099 188.529 227.118 188.992 226.996 189.479L225.322 189.035C225.377 188.708 225.308 188.422 225.113 188.178C224.919 187.933 224.614 187.756 224.199 187.646C223.777 187.534 223.428 187.536 223.154 187.652C222.881 187.764 222.708 187.954 222.637 188.222C222.56 188.512 222.628 188.766 222.842 188.984C223.056 189.198 223.33 189.395 223.664 189.574L224.348 189.95C224.797 190.183 225.18 190.451 225.496 190.755C225.814 191.056 226.037 191.394 226.165 191.77C226.294 192.142 226.298 192.556 226.177 193.011C225.978 193.764 225.538 194.288 224.858 194.583C224.179 194.878 223.34 194.893 222.341 194.628Z" fill="black"/>
|
||||
<path d="M233.966 188.805L235.696 189.263L234.285 194.584C234.126 195.182 233.845 195.67 233.443 196.046C233.041 196.419 232.552 196.663 231.975 196.777C231.399 196.892 230.771 196.859 230.092 196.678C229.409 196.497 228.845 196.214 228.401 195.829C227.961 195.445 227.658 194.992 227.494 194.469C227.331 193.942 227.329 193.379 227.488 192.781L228.899 187.461L230.629 187.919L229.256 193.096C229.131 193.566 229.18 193.994 229.404 194.38C229.629 194.762 229.993 195.019 230.496 195.153C230.999 195.286 231.443 195.243 231.828 195.023C232.213 194.799 232.468 194.452 232.593 193.982L233.966 188.805Z" fill="black"/>
|
||||
<path d="M234.951 197.85L237.128 189.643L240.41 190.514C241.313 190.754 241.94 191.132 242.29 191.649C242.641 192.163 242.736 192.726 242.573 193.34C242.445 193.821 242.2 194.174 241.838 194.4C241.48 194.624 241.075 194.726 240.624 194.709L240.602 194.792C240.91 194.889 241.179 195.055 241.408 195.288C241.64 195.523 241.802 195.809 241.893 196.148C241.988 196.484 241.983 196.854 241.876 197.258C241.761 197.691 241.551 198.052 241.246 198.34C240.943 198.625 240.556 198.811 240.084 198.899C239.614 198.982 239.071 198.942 238.454 198.779L234.951 197.85ZM239.234 193.881C239.587 193.974 239.905 193.958 240.19 193.833C240.475 193.708 240.66 193.484 240.746 193.161C240.823 192.868 240.782 192.603 240.62 192.368C240.459 192.128 240.188 191.958 239.806 191.857L238.485 191.506L237.945 193.539L239.234 193.881ZM238.471 197.268C238.956 197.397 239.333 197.397 239.603 197.268C239.877 197.14 240.055 196.922 240.136 196.613C240.226 196.276 240.178 195.966 239.993 195.685C239.81 195.401 239.51 195.203 239.095 195.093L237.636 194.706L237.056 196.893L238.471 197.268Z" fill="black"/>
|
||||
<path d="M245.418 200.75C244.423 200.486 243.688 200.047 243.214 199.434C242.744 198.822 242.613 198.07 242.822 197.178L244.502 197.624C244.422 198.043 244.501 198.396 244.74 198.683C244.982 198.968 245.33 199.17 245.781 199.29C246.222 199.407 246.599 199.407 246.913 199.289C247.231 199.173 247.43 198.965 247.51 198.664C247.583 198.388 247.518 198.145 247.314 197.934C247.112 197.719 246.801 197.499 246.381 197.274L245.542 196.815C244.895 196.467 244.42 196.06 244.115 195.594C243.815 195.125 243.745 194.588 243.906 193.982C244.037 193.486 244.286 193.089 244.653 192.789C245.02 192.485 245.464 192.293 245.985 192.211C246.51 192.126 247.072 192.163 247.671 192.322C248.28 192.484 248.785 192.729 249.185 193.06C249.585 193.39 249.862 193.774 250.017 194.212C250.176 194.651 250.194 195.114 250.073 195.6L248.399 195.156C248.454 194.829 248.385 194.543 248.19 194.299C247.995 194.055 247.691 193.878 247.276 193.768C246.853 193.656 246.505 193.657 246.231 193.773C245.957 193.885 245.785 194.076 245.714 194.344C245.637 194.634 245.705 194.888 245.919 195.105C246.133 195.319 246.407 195.516 246.741 195.695L247.425 196.071C247.874 196.304 248.257 196.573 248.573 196.877C248.89 197.177 249.113 197.515 249.241 197.891C249.371 198.263 249.375 198.677 249.254 199.132C249.054 199.885 248.615 200.409 247.935 200.704C247.256 200.999 246.416 201.015 245.418 200.75Z" fill="black"/>
|
||||
<path d="M253.444 202.873C252.713 202.679 252.103 202.34 251.614 201.857C251.126 201.374 250.797 200.774 250.63 200.057C250.466 199.342 250.502 198.54 250.738 197.651C250.975 196.755 251.344 196.038 251.845 195.498C252.349 194.96 252.934 194.602 253.598 194.425C254.266 194.248 254.959 194.255 255.679 194.446C256.314 194.615 256.854 194.884 257.298 195.253C257.746 195.623 258.073 196.073 258.278 196.603C258.483 197.133 258.539 197.724 258.445 198.375L256.694 197.91C256.747 197.441 256.649 197.036 256.397 196.694C256.147 196.349 255.785 196.113 255.311 195.987C254.68 195.82 254.109 195.92 253.599 196.288C253.09 196.652 252.721 197.262 252.495 198.117C252.263 198.991 252.279 199.707 252.544 200.264C252.812 200.823 253.259 201.185 253.883 201.35C254.342 201.472 254.763 201.454 255.147 201.297C255.534 201.14 255.826 200.854 256.023 200.438L257.774 200.903C257.579 201.389 257.273 201.815 256.858 202.181C256.446 202.547 255.949 202.8 255.365 202.94C254.786 203.081 254.145 203.059 253.444 202.873Z" fill="black"/>
|
||||
<path d="M258.281 204.038L260.458 195.832L263.685 196.688C264.618 196.935 265.276 197.371 265.661 197.996C266.049 198.622 266.136 199.34 265.92 200.152C265.771 200.713 265.519 201.161 265.163 201.495C264.808 201.825 264.371 202.028 263.852 202.103L264.747 205.753L262.83 205.245L262.049 201.925L260.782 201.589L260.01 204.497L258.281 204.038ZM261.151 200.195L262.325 200.506C263.316 200.769 263.92 200.493 264.136 199.678C264.244 199.271 264.204 198.926 264.015 198.644C263.827 198.362 263.481 198.154 262.978 198.021L261.81 197.711L261.151 200.195Z" fill="black"/>
|
||||
<path d="M269.6 198.257L267.424 206.463L265.694 206.004L267.871 197.798L269.6 198.257Z" fill="black"/>
|
||||
<path d="M268.856 206.843L271.032 198.637L274.315 199.507C275.218 199.747 275.845 200.125 276.195 200.643C276.546 201.156 276.64 201.72 276.478 202.333C276.35 202.814 276.105 203.167 275.742 203.394C275.384 203.617 274.98 203.72 274.529 203.702L274.507 203.785C274.815 203.882 275.083 204.048 275.312 204.282C275.545 204.516 275.706 204.803 275.797 205.141C275.893 205.477 275.887 205.847 275.78 206.251C275.665 206.684 275.456 207.045 275.151 207.334C274.847 207.619 274.46 207.805 273.989 207.892C273.519 207.976 272.975 207.936 272.358 207.772L268.856 206.843ZM273.139 202.874C273.491 202.967 273.81 202.952 274.095 202.827C274.379 202.702 274.564 202.478 274.65 202.155C274.728 201.861 274.686 201.596 274.525 201.361C274.364 201.122 274.093 200.952 273.711 200.85L272.389 200.5L271.85 202.532L273.139 202.874ZM272.376 206.262C272.861 206.39 273.238 206.39 273.508 206.261C273.781 206.133 273.959 205.915 274.041 205.607C274.131 205.269 274.083 204.96 273.898 204.679C273.714 204.394 273.415 204.197 273 204.087L271.54 203.7L270.96 205.886L272.376 206.262Z" fill="black"/>
|
||||
<path d="M276.313 208.821L278.49 200.615L284.019 202.081L283.639 203.513L279.839 202.505L279.328 204.433L282.836 205.364L282.464 206.768L278.955 205.837L278.422 207.848L282.233 208.859L281.853 210.291L276.313 208.821Z" fill="black"/>
|
||||
<path d="M284.355 208.172L285.716 202.531L287.544 203.016L285.936 208.591L284.355 208.172ZM283.685 209.875C283.76 209.592 283.912 209.389 284.142 209.265C284.375 209.142 284.636 209.119 284.926 209.196C285.22 209.274 285.436 209.423 285.574 209.645C285.715 209.867 285.748 210.119 285.673 210.402C285.598 210.685 285.444 210.889 285.21 211.016C284.981 211.14 284.719 211.163 284.425 211.085C284.135 211.008 283.919 210.858 283.778 210.636C283.641 210.411 283.61 210.157 283.685 209.875Z" fill="black"/>
|
||||
<path d="M40.3547 46.7291C41.8205 45.09 44.3472 44.9752 45.9554 46.475C47.6993 48.1014 50.2485 48.5236 52.4237 47.5461C54.4295 46.6446 56.7846 47.5676 57.6438 49.5918C58.5755 51.787 60.7331 53.2102 63.1178 53.2019C65.3167 53.1944 67.0922 54.9949 67.0538 57.1936C67.012 59.5779 68.4042 61.7555 70.5862 62.7178C72.5983 63.6053 73.4881 65.9728 72.5585 67.9658C71.5507 70.127 71.9372 72.682 73.539 74.4486C75.0162 76.0777 74.8663 78.6029 73.2064 80.0456C71.4067 81.61 70.72 84.101 71.4648 86.3664C72.1517 88.4554 70.9881 90.7009 68.8851 91.3439C66.6046 92.0409 64.9639 94.0375 64.7228 96.4099C64.5006 98.5978 62.5235 100.176 60.3407 99.908C57.974 99.6174 55.6636 100.775 54.4785 102.844C53.3855 104.752 50.9375 105.389 49.0526 104.256C47.0086 103.028 44.427 103.145 42.5027 104.553C40.7282 105.852 38.2335 105.439 36.9721 103.638C35.6044 101.685 33.198 100.742 30.8672 101.246C28.7177 101.71 26.6057 100.317 26.1863 98.1587C25.7313 95.8179 23.9174 93.9781 21.5832 93.4905C19.4306 93.0408 18.0675 90.9098 18.5622 88.7671C19.0988 86.4437 18.1902 84.025 16.2563 82.63C14.4726 81.3436 14.0945 78.8424 15.4181 77.0861C16.8534 75.1817 17.006 72.6023 15.8064 70.5413C14.7002 68.6406 15.3728 66.2022 17.2963 65.1362C19.382 63.9801 20.5704 61.6854 20.3128 59.3147C20.0754 57.1285 21.6814 55.1745 23.8721 54.9829C26.2477 54.7752 28.2676 53.1624 28.9966 50.8919C29.669 48.7982 31.9308 47.6657 34.01 48.3817C36.2647 49.1581 38.7653 48.5068 40.3547 46.7291Z" fill="#FAAE2B" stroke="black" stroke-width="2"/>
|
||||
<path d="M34.678 76.3871L37.9843 84.4471L36.5328 85.0426L31.0136 81.3851L30.9498 81.4113L33.029 86.4799L31.3595 87.1647L28.0532 79.1047L29.5206 78.5028L35.0186 82.1689L35.0877 82.1406L33.0085 77.072L34.678 76.3871Z" fill="white"/>
|
||||
<path d="M39.3667 83.88L36.0604 75.8201L41.3986 73.6303L41.9755 75.0367L38.3069 76.5416L39.0835 78.4349L42.4704 77.0456L43.0362 78.425L39.6494 79.8143L40.4593 81.7888L44.1386 80.2795L44.7156 81.6859L39.3667 83.88Z" fill="white"/>
|
||||
<path d="M47.7994 80.4209L42.228 73.29L44.0571 72.5397L47.667 77.5997L47.7361 77.5714L46.8803 71.3816L48.4488 70.7382L52.1877 75.7642L52.2621 75.7337L51.2668 69.5822L53.1011 68.8298L54.1371 77.8211L52.5048 78.4906L48.8388 73.8391L48.775 73.8653L49.437 79.7491L47.7994 80.4209Z" fill="white"/>
|
||||
<path d="M60.2769 75.4352C59.3163 75.8292 58.4572 75.9204 57.6995 75.7088C56.9454 75.4957 56.3777 74.9597 55.9963 74.1008L57.618 73.4356C57.812 73.8278 58.093 74.0686 58.4609 74.1578C58.8308 74.242 59.2338 74.1946 59.6698 74.0158C60.0952 73.8413 60.3977 73.6098 60.5775 73.3212C60.7608 73.0311 60.7918 72.7382 60.6705 72.4425C60.5595 72.172 60.3575 72.0126 60.0644 71.9644C59.7698 71.9125 59.3848 71.9229 58.9095 71.9957L57.9541 72.1349C57.2211 72.246 56.589 72.204 56.0576 72.0091C55.5283 71.8091 55.1417 71.4116 54.8976 70.8166C54.6979 70.3297 54.6527 69.8511 54.762 69.3808C54.8699 68.9068 55.1075 68.4765 55.4748 68.0899C55.8442 67.6982 56.3178 67.3838 56.8955 67.1468C57.4839 66.9055 58.0401 66.7974 58.5639 66.8227C59.0878 66.8479 59.5467 66.9925 59.9407 67.2564C60.3382 67.5188 60.6382 67.8866 60.8405 68.3598L59.2242 69.0228C59.0671 68.7207 58.8354 68.5293 58.529 68.4485C58.2225 68.3678 57.869 68.4096 57.4685 68.5739C57.0609 68.7411 56.7827 68.9563 56.6339 69.2196C56.4837 69.4792 56.4626 69.7407 56.5706 70.0039C56.6874 70.2888 56.8985 70.455 57.2037 70.5026C57.5075 70.5465 57.8483 70.5394 58.2261 70.4813L59.0065 70.3697C59.5102 70.2853 59.9825 70.2706 60.4233 70.3257C60.8627 70.3772 61.2495 70.5176 61.5838 70.747C61.9166 70.9728 62.1747 71.3093 62.3582 71.7565C62.6614 72.4958 62.6314 73.1948 62.2681 73.8537C61.9047 74.5125 61.241 75.0397 60.2769 75.4352Z" fill="white"/>
|
||||
<path d="M17.6947 229.267C19.4165 230.978 21.3914 232.237 23.4603 232.862L24.0075 233.027L23.7213 233.522C22.6385 235.392 22.0581 237.661 21.9723 240.087C20.2507 238.375 18.2767 237.117 16.208 236.492L15.6596 236.327L15.947 235.832C17.0299 233.962 17.609 231.693 17.6947 229.267Z" fill="black" stroke="black" stroke-width="3"/>
|
||||
<path d="M278.43 17.7871C278.246 18.1352 278.099 18.5136 277.998 18.9228L277.535 20.7851H279.453C280.427 20.7851 281.386 20.9919 282.289 21.3642C281.963 21.4427 281.638 21.5475 281.317 21.6826L279.362 22.5049L280.789 24.0742C281.453 24.8042 281.989 25.665 282.377 26.5918C282.029 26.4076 281.651 26.2591 281.242 26.1572L279.379 25.6933V27.6133C279.379 28.5871 279.173 29.5447 278.802 30.4472C278.724 30.1231 278.621 29.7998 278.487 29.4804L277.664 27.5205L276.093 28.9521C275.36 29.6207 274.497 30.1593 273.57 30.5498C273.754 30.2018 273.902 29.8241 274.003 29.415L274.466 27.5527H272.548C271.574 27.5527 270.615 27.3453 269.711 26.9726C270.038 26.8941 270.363 26.7905 270.684 26.6552L272.639 25.833L271.212 24.2636C270.548 23.5333 270.011 22.6723 269.623 21.7451C269.971 21.9295 270.349 22.0786 270.759 22.1806L272.622 22.6445V20.7246C272.622 19.7505 272.827 18.7923 273.198 17.8896C273.276 18.2141 273.38 18.5377 273.514 18.8574L274.337 20.8174L275.908 19.3857C276.641 18.7173 277.503 18.1776 278.43 17.7871Z" fill="black" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
25
resources/js/img/utils/service-share.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg width="314" height="300" viewBox="0 0 314 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M301.233 279.583C300.932 279.838 300.655 280.134 300.408 280.476L299.286 282.033L301.066 282.746C301.971 283.109 302.784 283.657 303.483 284.339C303.152 284.291 302.811 284.267 302.463 284.273L300.342 284.309L301.083 286.296C301.427 287.221 301.605 288.22 301.621 289.224C301.366 288.924 301.07 288.645 300.728 288.398L299.172 287.275L298.458 289.057C298.096 289.961 297.548 290.773 296.868 291.473C296.916 291.143 296.941 290.805 296.935 290.458L296.9 288.333L294.91 289.077C293.98 289.425 292.979 289.604 291.973 289.622C292.273 289.367 292.551 289.071 292.798 288.73L293.92 287.173L292.14 286.46C291.235 286.097 290.422 285.548 289.722 284.866C290.054 284.914 290.395 284.939 290.743 284.933L292.864 284.897L292.123 282.91C291.779 281.985 291.6 280.986 291.585 279.98C291.839 280.281 292.135 280.56 292.478 280.808L294.034 281.931L294.748 280.149C295.11 279.245 295.658 278.432 296.338 277.732C296.289 278.062 296.266 278.401 296.271 278.748L296.306 280.873L298.296 280.129C299.226 279.781 300.227 279.601 301.233 279.583Z" fill="black" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
<rect x="193.869" y="147.945" width="99.6994" height="78.7178" rx="9.5" transform="rotate(9.21695 193.869 147.945)" fill="#00473E" stroke="black" stroke-width="3"/>
|
||||
<path d="M203.247 149.466L282.904 162.392C288.083 163.232 291.6 168.112 290.76 173.291L282.857 221.995L184.445 206.026L192.348 157.322C193.188 152.143 198.068 148.626 203.247 149.466Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M224.876 240.238L234.77 241.843C239.047 242.537 241.951 246.566 241.258 250.842C241.142 251.553 240.472 252.036 239.762 251.921L216.955 248.22C216.244 248.104 215.762 247.434 215.878 246.724C216.572 242.448 220.6 239.544 224.876 240.238Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M69.6299 66.1948L158.767 49.4318C161.209 48.9725 163.561 50.5802 164.021 53.0226L174.676 109.683L76.6946 128.109L66.0391 71.449C65.5798 69.0065 67.1875 66.6542 69.6299 66.1948Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
<rect x="75.3183" y="72.9909" width="82.0307" height="46.6933" rx="4.5" transform="rotate(-10.6506 75.3183 72.9909)" fill="#FFA8BA" stroke="black" stroke-width="3"/>
|
||||
<path d="M67.8164 129.46L183.44 107.716C184.562 107.505 185.643 108.244 185.854 109.366C186.915 115.007 183.202 120.439 177.561 121.5L78.301 140.167C72.6603 141.228 67.2275 137.515 66.1667 131.874C65.9559 130.752 66.6944 129.672 67.8164 129.46Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M147.476 114.48C147.624 115.888 146.669 117.197 145.253 117.464L108.02 124.466C106.603 124.732 105.239 123.859 104.865 122.493L147.476 114.48Z" fill="black" stroke="black" stroke-width="2.76074"/>
|
||||
<rect x="225.825" y="233.679" width="9.14724" height="5.83436" transform="rotate(9.21695 225.825 233.679)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M157.132 202.955C157.95 203.087 158.721 202.532 158.853 201.714C158.986 200.896 158.431 200.126 157.613 199.993L157.373 201.474L157.132 202.955ZM112.119 159.494C111.635 158.822 110.697 158.67 110.025 159.154L99.0736 167.048C98.4015 167.532 98.2494 168.47 98.7338 169.142C99.2182 169.814 100.156 169.966 100.828 169.482L110.563 162.465L117.579 172.2C118.064 172.872 119.001 173.024 119.673 172.54C120.345 172.055 120.497 171.118 120.013 170.446L112.119 159.494ZM157.373 201.474L157.613 199.993L125.578 194.795L125.338 196.276L125.098 197.756L157.132 202.955L157.373 201.474ZM108.799 173.33L110.28 173.571L112.383 160.611L110.902 160.371L109.422 160.131L107.319 173.09L108.799 173.33ZM125.338 196.276L125.578 194.795C115.493 193.159 108.644 183.656 110.28 173.571L108.799 173.33L107.319 173.09C105.417 184.811 113.377 195.854 125.098 197.756L125.338 196.276Z" fill="black"/>
|
||||
<path d="M206.781 86.5884C205.963 86.4557 205.192 87.011 205.06 87.8288C204.927 88.6465 205.482 89.417 206.3 89.5497L206.54 88.069L206.781 86.5884ZM251.794 130.049C252.278 130.721 253.216 130.873 253.888 130.389L264.84 122.495C265.512 122.011 265.664 121.073 265.179 120.401C264.695 119.729 263.757 119.577 263.085 120.061L253.351 127.078L246.334 117.343C245.85 116.671 244.912 116.519 244.24 117.003C243.568 117.488 243.416 118.425 243.9 119.097L251.794 130.049ZM206.54 88.069L206.3 89.5497L238.335 94.7479L238.575 93.2673L238.816 91.7867L206.781 86.5884L206.54 88.069ZM255.114 116.213L253.633 115.972L251.53 128.932L253.011 129.172L254.491 129.412L256.594 116.453L255.114 116.213ZM238.575 93.2673L238.335 94.7479C248.42 96.3845 255.27 105.887 253.633 115.972L255.114 116.213L256.594 116.453C258.496 104.732 250.536 93.6886 238.816 91.7867L238.575 93.2673Z" fill="black"/>
|
||||
<rect x="34.639" y="202.334" width="30.6645" height="25.2204" rx="5" transform="rotate(-15.4549 34.639 202.334)" fill="white" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M38.1905 216.377L48.274 206.881C52.231 210.553 61.3941 216.669 61.3941 216.669C61.3941 216.669 66.5998 211.876 68.5781 210.093" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<circle cx="59.0788" cy="203.188" r="1.8714" transform="rotate(-15.4549 59.0788 203.188)" fill="black"/>
|
||||
<g clip-path="url(#clip0_134_1340)">
|
||||
<path d="M250.14 46.2142C250.969 43.9647 251.384 42.8399 252.138 42.1421C252.802 41.5282 253.643 41.1402 254.541 41.0336C255.561 40.9124 256.686 41.327 258.935 42.156L260.999 42.9166C262.386 43.4275 263.079 43.6829 263.62 44.1156C264.099 44.4985 264.491 44.9798 264.768 45.527C265.081 46.1455 265.189 46.8762 265.406 48.3376L265.669 50.1121L272.422 52.6008C274.672 53.4298 275.797 53.8443 276.495 54.5987C277.108 55.2623 277.496 56.1032 277.603 57.0009C277.724 58.0214 277.31 59.1462 276.481 61.3957L274.112 67.8223C273.283 70.0719 272.869 71.1966 272.114 71.8945C271.451 72.5084 270.61 72.8964 269.712 73.0029C268.692 73.1241 267.567 72.7096 265.317 71.8806L250.054 66.2558C247.805 65.4268 246.68 65.0122 245.982 64.2578C245.368 63.5942 244.98 62.7533 244.873 61.8556C244.752 60.8351 245.167 59.7103 245.996 57.4608L250.14 46.2142Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
</g>
|
||||
<path d="M45.4761 31.9185L45.8124 32.2337L46.2676 32.3056L50.8994 33.0372L47.693 36.4595L47.3778 36.7958L47.3059 37.251L46.5734 41.8831L43.152 38.6764L42.8157 38.3613L42.3605 38.2893L37.7275 37.5571L40.9351 34.1354L41.2503 33.7991L41.3222 33.3439L42.0535 28.7112L45.4761 31.9185Z" fill="black" stroke="black" stroke-width="3"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_134_1340">
|
||||
<rect width="34.2456" height="34.2456" fill="white" transform="translate(251.98 35.0308) rotate(20.23)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
46
resources/js/img/utils/service-survey.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg width="323" height="263" viewBox="0 0 323 263" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="50.7495" y="47.9443" width="234.542" height="171.884" rx="6" transform="rotate(-2.7432 50.7495 47.9443)" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
|
||||
<line x1="52.1367" y1="76.8933" x2="286.41" y2="65.6682" stroke="black" stroke-width="3"/>
|
||||
<ellipse cx="232.429" cy="53.6502" rx="5.08033" ry="5.08033" transform="rotate(-2.7432 232.429 53.6502)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<ellipse cx="251.881" cy="52.7181" rx="5.08033" ry="5.08033" transform="rotate(-2.7432 251.881 52.7181)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<ellipse cx="271.333" cy="51.786" rx="5.08033" ry="5.08033" transform="rotate(-2.7432 271.333 51.786)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<rect x="99.738" y="101.352" width="142.636" height="93.5262" rx="4.5" transform="rotate(-2.7432 99.738 101.352)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M129.423 166.897L147.885 166.012C150.367 165.893 152.476 167.809 152.595 170.292L153.654 192.402L126.203 193.717L125.144 171.607C125.025 169.124 126.941 167.016 129.423 166.897Z" fill="#FFA8BA" stroke="black" stroke-width="3"/>
|
||||
<path d="M165.345 155.851L182.114 155.047C184.596 154.928 186.705 156.844 186.824 159.327L188.329 190.74L162.571 191.974L161.066 160.561C160.947 158.079 162.863 155.97 165.345 155.851Z" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M198.562 123.742L214.485 122.979C216.968 122.86 219.076 124.776 219.195 127.259L222.159 189.12L197.247 190.313L194.283 128.453C194.164 125.97 196.08 123.861 198.562 123.742Z" fill="#FAAE2B" stroke="black" stroke-width="3"/>
|
||||
<rect x="5.85962" y="105.85" width="79.9787" height="19.8615" rx="4.5" transform="rotate(-2.7432 5.85962 105.85)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<path d="M19.7557 108.768L21.2809 112.795L25.5822 112.589L22.2235 115.284L23.7487 119.311L20.1477 116.95L16.789 119.645L17.9222 115.49L14.3212 113.129L18.6225 112.923L19.7557 108.768Z" fill="#FAAE2B"/>
|
||||
<path d="M33.2874 108.12L34.8126 112.147L39.114 111.941L35.7553 114.636L37.2804 118.663L33.6795 116.302L30.3208 118.997L31.4539 114.842L27.853 112.481L32.1543 112.275L33.2874 108.12Z" fill="#FAAE2B"/>
|
||||
<path d="M47.6659 107.431L49.1911 111.458L53.4924 111.252L50.1337 113.947L51.6589 117.974L48.0579 115.612L44.6992 118.307L45.8324 114.153L42.2314 111.791L46.5327 111.585L47.6659 107.431Z" fill="#FAAE2B"/>
|
||||
<path d="M60.3519 106.823L61.8771 110.85L66.1784 110.644L62.8197 113.339L64.3449 117.366L60.7439 115.005L57.3852 117.7L58.5184 113.545L54.9174 111.184L59.2187 110.978L60.3519 106.823Z" fill="#00473E"/>
|
||||
<path d="M73.0379 106.215L74.5631 110.242L78.8644 110.036L75.5057 112.731L77.0309 116.758L73.43 114.396L70.0713 117.091L71.2044 112.937L67.6034 110.575L71.9048 110.369L73.0379 106.215Z" fill="#00473E"/>
|
||||
<mask id="path-16-inside-1_134_1339" fill="white">
|
||||
<path d="M66.9848 169.13C70.2329 168.474 73.3977 170.576 74.0533 173.824L79.82 202.393C80.4756 205.642 78.3738 208.806 75.1257 209.462L62.3961 212.031L58.218 224.455L49.5488 214.625L36.8202 217.194C33.572 217.849 30.4073 215.748 29.7516 212.5L23.985 183.93C23.3294 180.682 25.4311 177.517 28.6792 176.862L66.9848 169.13Z"/>
|
||||
</mask>
|
||||
<path d="M66.9848 169.13C70.2329 168.474 73.3977 170.576 74.0533 173.824L79.82 202.393C80.4756 205.642 78.3738 208.806 75.1257 209.462L62.3961 212.031L58.218 224.455L49.5488 214.625L36.8202 217.194C33.572 217.849 30.4073 215.748 29.7516 212.5L23.985 183.93C23.3294 180.682 25.4311 177.517 28.6792 176.862L66.9848 169.13Z" fill="#FAAE2B"/>
|
||||
<path d="M66.9848 169.13L66.3914 166.189L66.3913 166.189L66.9848 169.13ZM75.1257 209.462L75.7193 212.403L75.7194 212.403L75.1257 209.462ZM62.3961 212.031L61.8026 209.091L60.1048 209.433L59.5526 211.075L62.3961 212.031ZM58.218 224.455L55.9679 226.439L59.405 230.337L61.0615 225.411L58.218 224.455ZM49.5488 214.625L51.7989 212.64L50.6532 211.341L48.9552 211.684L49.5488 214.625ZM36.8202 217.194L37.4137 220.134L37.4137 220.134L36.8202 217.194ZM29.7516 212.5L32.6923 211.906L29.7516 212.5ZM23.985 183.93L21.0443 184.524L23.985 183.93ZM28.6792 176.862L28.0857 173.921L28.0856 173.921L28.6792 176.862ZM66.9848 169.13L67.5783 172.071C69.2025 171.743 70.7849 172.794 71.1127 174.418L74.0533 173.824L76.994 173.23C76.0106 168.358 71.2634 165.206 66.3914 166.189L66.9848 169.13ZM74.0533 173.824L71.1127 174.418L76.8793 202.987L79.82 202.393L82.7607 201.8L76.994 173.23L74.0533 173.824ZM79.82 202.393L76.8793 202.987C77.2071 204.611 76.1562 206.193 74.5321 206.521L75.1257 209.462L75.7194 212.403C80.5914 211.419 83.7442 206.672 82.7607 201.8L79.82 202.393ZM75.1257 209.462L74.5322 206.521L61.8026 209.091L62.3961 212.031L62.9897 214.972L75.7193 212.403L75.1257 209.462ZM62.3961 212.031L59.5526 211.075L55.3745 223.499L58.218 224.455L61.0615 225.411L65.2397 212.988L62.3961 212.031ZM58.218 224.455L60.4681 222.471L51.7989 212.64L49.5488 214.625L47.2987 216.609L55.9679 226.439L58.218 224.455ZM49.5488 214.625L48.9552 211.684L36.2266 214.253L36.8202 217.194L37.4137 220.134L50.1424 217.565L49.5488 214.625ZM36.8202 217.194L36.2266 214.253C34.6025 214.581 33.0201 213.53 32.6923 211.906L29.7516 212.5L26.8109 213.093C27.7944 217.965 32.5415 221.118 37.4137 220.134L36.8202 217.194ZM29.7516 212.5L32.6923 211.906L26.9257 183.337L23.985 183.93L21.0443 184.524L26.8109 213.093L29.7516 212.5ZM23.985 183.93L26.9257 183.337C26.5979 181.713 27.6487 180.13 29.2728 179.802L28.6792 176.862L28.0856 173.921C23.2134 174.905 20.0608 179.651 21.0443 184.524L23.985 183.93ZM28.6792 176.862L29.2728 179.802L67.5784 172.07L66.9848 169.13L66.3913 166.189L28.0857 173.921L28.6792 176.862Z" fill="black" mask="url(#path-16-inside-1_134_1339)"/>
|
||||
<g clip-path="url(#clip0_134_1339)">
|
||||
<path d="M43.7307 195.095C43.5573 194.235 44.1134 193.398 44.9728 193.224C45.8323 193.051 46.6697 193.607 46.8432 194.466L47.8902 199.654C48.0637 200.513 47.5076 201.351 46.6481 201.524C45.7887 201.698 44.9513 201.141 44.7778 200.282L43.7307 195.095Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M46.9582 195.036C46.8766 194.632 46.8359 194.43 46.834 194.228C46.8324 194.049 46.8535 193.871 46.8968 193.697C46.9456 193.501 47.0323 193.314 47.2057 192.94L50.4998 185.84C50.5674 185.694 50.7003 185.59 50.8577 185.558C51.9501 185.337 53.0145 186.044 53.235 187.137L53.9352 190.605L54.1548 190.561C55.4139 190.307 56.0434 190.18 56.5577 190.338C57.0096 190.477 57.4019 190.764 57.672 191.152C57.9795 191.594 58.0501 192.232 58.1913 193.509L58.4657 195.991C58.5859 197.078 58.646 197.621 58.489 198.073C58.3508 198.471 58.0969 198.818 57.7599 199.071C57.3772 199.358 56.8413 199.467 55.7695 199.683L51.5243 200.54C50.3622 200.774 49.7812 200.892 49.2917 200.755C48.8611 200.635 48.4796 200.382 48.2018 200.031C47.8861 199.633 47.7688 199.052 47.5342 197.89L46.9582 195.036Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<mask id="path-19-inside-2_134_1339" fill="white">
|
||||
<path d="M310.492 111.68C313.727 112.398 315.767 115.603 315.049 118.837L308.04 150.404C307.322 153.639 304.117 155.679 300.882 154.96L286.77 151.827L277.225 162.236L272.982 148.766L258.869 145.632C255.634 144.913 253.594 141.709 254.312 138.474L261.321 106.908C262.04 103.673 265.245 101.633 268.479 102.351L310.492 111.68Z"/>
|
||||
</mask>
|
||||
<path d="M310.492 111.68C313.727 112.398 315.767 115.603 315.049 118.837L308.04 150.404C307.322 153.639 304.117 155.679 300.882 154.96L286.77 151.827L277.225 162.236L272.982 148.766L258.869 145.632C255.634 144.913 253.594 141.709 254.312 138.474L261.321 106.908C262.04 103.673 265.245 101.633 268.479 102.351L310.492 111.68Z" fill="#FFA8BA"/>
|
||||
<path d="M315.049 118.837L317.978 119.488L317.978 119.488L315.049 118.837ZM286.77 151.827L287.42 148.898L285.729 148.523L284.559 149.799L286.77 151.827ZM277.225 162.236L274.364 163.137L275.924 168.093L279.436 164.263L277.225 162.236ZM272.982 148.766L275.844 147.864L275.324 146.212L273.633 145.837L272.982 148.766ZM261.321 106.908L258.393 106.257L258.393 106.257L261.321 106.908ZM310.492 111.68L309.842 114.608C311.459 114.967 312.48 116.57 312.12 118.187L315.049 118.837L317.978 119.488C319.055 114.635 315.995 109.828 311.143 108.751L310.492 111.68ZM315.049 118.837L312.12 118.187L305.111 149.753L308.04 150.404L310.969 151.054L317.978 119.488L315.049 118.837ZM308.04 150.404L305.111 149.753C304.752 151.371 303.15 152.391 301.532 152.032L300.882 154.96L300.232 157.889C305.084 158.967 309.891 155.906 310.969 151.054L308.04 150.404ZM300.882 154.96L301.532 152.032L287.42 148.898L286.77 151.827L286.119 154.756L300.232 157.889L300.882 154.96ZM286.77 151.827L284.559 149.799L275.014 160.208L277.225 162.236L279.436 164.263L288.981 153.854L286.77 151.827ZM277.225 162.236L280.086 161.335L275.844 147.864L272.982 148.766L270.121 149.667L274.364 163.137L277.225 162.236ZM272.982 148.766L273.633 145.837L259.519 142.703L258.869 145.632L258.219 148.56L272.332 151.694L272.982 148.766ZM258.869 145.632L259.519 142.703C257.902 142.344 256.882 140.742 257.241 139.124L254.312 138.474L251.384 137.824C250.306 142.676 253.367 147.483 258.219 148.56L258.869 145.632ZM254.312 138.474L257.241 139.124L264.25 107.558L261.321 106.908L258.393 106.257L251.384 137.824L254.312 138.474ZM261.321 106.908L264.25 107.558C264.609 105.94 266.212 104.92 267.829 105.28L268.479 102.351L269.13 99.4222C264.277 98.3448 259.47 101.405 258.393 106.257L261.321 106.908ZM268.479 102.351L267.829 105.28L309.842 114.608L310.492 111.68L311.143 108.751L269.13 99.4222L268.479 102.351Z" fill="black" mask="url(#path-19-inside-2_134_1339)"/>
|
||||
<path d="M294.883 124.472C293.316 122.106 290.083 121.388 287.663 122.869L286.168 123.914L285.256 122.334C283.689 119.968 280.456 119.251 278.035 120.731C275.615 122.211 274.922 125.329 276.489 127.695L283.109 137.691L293.336 131.436C295.757 129.956 296.45 126.838 294.883 124.472Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<g clip-path="url(#clip1_134_1339)">
|
||||
<path d="M70.7107 66.4848C73.2797 66.3617 75.2625 64.1793 75.1394 61.6103L70.4878 61.8332L70.2649 57.1816C67.6959 57.3047 65.7131 59.487 65.8362 62.0561C65.9592 64.6251 68.1416 66.6079 70.7107 66.4848Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M77.5348 58.5286C77.4229 56.1931 75.4389 54.3906 73.1035 54.5025L73.3061 58.7312L77.5348 58.5286Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<path d="M29.2309 20.4097C31.6386 23.8324 34.6418 26.4664 37.9295 27.9266L39.702 28.7141L38.5292 30.2589C36.3531 33.124 34.9929 36.8799 34.5353 41.0394L34.4604 41.7171L34.068 41.1596C31.6603 37.7369 28.657 35.1029 25.3694 33.6426L23.596 32.8553L24.7697 31.3103C26.9457 28.4452 28.306 24.6893 28.7636 20.5298L28.8382 19.8512L29.2309 20.4097Z" fill="black" stroke="black" stroke-width="3"/>
|
||||
<path d="M288.993 237.884L290.563 238.514L289.729 238.529L290.469 240.515C290.507 240.616 290.54 240.719 290.571 240.823L289.534 240.075L288.906 241.64L288.892 240.812L286.902 241.557C286.799 241.595 286.695 241.629 286.589 241.661L287.337 240.624L285.766 239.995L286.6 239.98L285.859 237.993C285.821 237.891 285.787 237.788 285.756 237.683L286.796 238.434L287.423 236.868L287.436 237.697L289.426 236.952C289.529 236.914 289.634 236.879 289.739 236.848L288.993 237.884Z" fill="black" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_134_1339">
|
||||
<rect width="16.9344" height="16.9344" fill="white" transform="translate(40.9131 186.485) rotate(-11.4117)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_134_1339">
|
||||
<rect width="13.5475" height="13.5475" fill="white" transform="translate(64.6055 54.062) rotate(-2.7432)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
50
resources/js/img/utils/service-webhosting.svg
Normal file
@@ -0,0 +1,50 @@
|
||||
<svg width="316" height="279" viewBox="0 0 316 279" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100.93 71.7381C105.639 89.3625 123.071 100.613 141.083 97.7042C131.964 113.518 136.288 133.794 151.104 144.459C133.48 149.169 122.229 166.6 125.138 184.612C109.324 175.494 89.0474 179.817 78.3832 194.633C73.673 177.009 56.2422 165.757 38.2301 168.667C47.3508 152.849 43.0238 132.568 28.1996 121.905C45.8156 117.158 57.0845 99.7723 54.1752 81.7591C69.9891 90.8782 90.2653 86.5543 100.93 71.7381Z" fill="#FAAE2B" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
<path d="M43.248 143.872L38.0479 125.451L42.271 125.458L45.2827 138.253L45.4423 138.253L48.7897 125.469L52.4112 125.474L55.7051 138.307L55.877 138.307L58.9176 125.485L63.1529 125.492L57.8813 143.896L54.1125 143.89L50.6577 131.848L50.5104 131.848L47.0291 143.878L43.248 143.872Z" fill="white"/>
|
||||
<path d="M73.4104 144.168C71.79 144.165 70.3338 143.792 69.0419 143.049C67.7499 142.305 66.7287 141.228 65.978 139.819C65.2273 138.409 64.8536 136.707 64.8568 134.713C64.86 132.703 65.2392 130.994 65.9944 129.587C66.7496 128.179 67.7744 127.105 69.0687 126.366C70.363 125.627 71.8203 125.258 73.4408 125.261C75.0613 125.264 76.5134 125.637 77.7971 126.38C79.089 127.124 80.1103 128.2 80.861 129.61C81.6116 131.02 81.9854 132.73 81.9821 134.741C81.9789 136.751 81.5997 138.46 80.8445 139.867C80.0893 141.266 79.0645 142.336 77.7703 143.075C76.4842 143.806 75.0309 144.171 73.4104 144.168ZM68.7729 134.719C68.7698 136.664 69.1889 138.151 70.0302 139.182C70.8797 140.214 72.0083 140.73 73.4159 140.733C74.8154 140.735 75.9375 140.222 76.7821 139.193C77.6349 138.165 78.0629 136.679 78.066 134.734C78.0692 132.782 77.646 131.29 76.7965 130.259C75.9552 129.219 74.8348 128.699 73.4353 128.696C72.0276 128.694 70.8974 129.211 70.0445 130.248C69.1999 131.276 68.776 132.767 68.7729 134.719Z" fill="white"/>
|
||||
<path d="M93.077 144.2C91.4565 144.197 90.0003 143.824 88.7084 143.08C87.4165 142.337 86.3952 141.26 85.6445 139.85C84.8939 138.44 84.5201 136.738 84.5233 134.745C84.5266 132.734 84.9058 131.026 85.661 129.618C86.4162 128.211 87.4409 127.137 88.7352 126.398C90.0295 125.658 91.4869 125.29 93.1074 125.293C94.7278 125.295 96.1799 125.668 97.4637 126.412C98.7556 127.155 99.7769 128.232 100.528 129.642C101.278 131.052 101.652 132.762 101.649 134.772C101.645 136.782 101.266 138.491 100.511 139.899C99.7559 141.298 98.7311 142.367 97.4368 143.107C96.1507 143.838 94.6975 144.202 93.077 144.2ZM88.4395 134.751C88.4363 136.695 88.8554 138.183 89.6967 139.214C90.5462 140.245 91.6748 140.762 93.0825 140.764C94.482 140.766 95.6041 140.253 96.4487 139.225C97.3015 138.196 97.7295 136.71 97.7326 134.766C97.7357 132.813 97.3125 131.322 96.463 130.29C95.6217 129.251 94.5013 128.73 93.1018 128.728C91.6942 128.726 90.5639 129.243 89.7111 130.28C88.8665 131.308 88.4426 132.798 88.4395 134.751Z" fill="white"/>
|
||||
<path d="M107.649 143.976L103.5 143.969L109.852 125.567L114.824 125.575L121.104 143.997L116.967 143.991L115.611 139.787L109.006 139.776L107.649 143.976ZM109.993 136.738L114.634 136.746L112.398 129.772L112.251 129.772L109.993 136.738Z" fill="white"/>
|
||||
<path d="M123.289 144.001L123.319 125.588L127.174 125.594L127.161 133.194L135.006 133.207L135.018 125.607L138.885 125.613L138.856 144.026L134.989 144.02L135.001 136.408L127.156 136.395L127.144 144.007L123.289 144.001Z" fill="white"/>
|
||||
<rect x="214.699" y="92.3064" width="95.4313" height="32.2408" rx="4.5" transform="rotate(7.0438 214.699 92.3064)" fill="#FAAE2B" stroke="black" stroke-width="3"/>
|
||||
<circle cx="227.284" cy="107.044" r="3.6456" transform="rotate(7.0438 227.284 107.044)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="220.441" cy="117.83" r="1.8228" transform="rotate(7.0438 220.441 117.83)" fill="black"/>
|
||||
<circle cx="226.471" cy="118.575" r="1.8228" transform="rotate(7.0438 226.471 118.575)" fill="black"/>
|
||||
<circle cx="232.502" cy="119.32" r="1.8228" transform="rotate(7.0438 232.502 119.32)" fill="black"/>
|
||||
<line x1="264.23" y1="107.649" x2="297.434" y2="111.752" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="263.336" y1="114.885" x2="296.54" y2="118.988" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="262.442" y1="122.121" x2="295.646" y2="126.224" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="209.781" y="132.106" width="95.4313" height="32.2408" rx="4.5" transform="rotate(7.0438 209.781 132.106)" fill="#FFA8BA" stroke="black" stroke-width="3"/>
|
||||
<circle cx="222.367" cy="146.843" r="3.6456" transform="rotate(7.0438 222.367 146.843)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="215.524" cy="157.63" r="1.8228" transform="rotate(7.0438 215.524 157.63)" fill="black"/>
|
||||
<circle cx="221.554" cy="158.375" r="1.8228" transform="rotate(7.0438 221.554 158.375)" fill="black"/>
|
||||
<circle cx="227.584" cy="159.12" r="1.8228" transform="rotate(7.0438 227.584 159.12)" fill="black"/>
|
||||
<line x1="259.312" y1="147.448" x2="292.516" y2="151.55" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="258.418" y1="154.684" x2="291.622" y2="158.787" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="257.524" y1="161.92" x2="290.728" y2="166.023" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="204.864" y="171.905" width="95.4313" height="32.2408" rx="4.5" transform="rotate(7.0438 204.864 171.905)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="217.449" cy="186.641" r="3.6456" transform="rotate(7.0438 217.449 186.641)" fill="white" stroke="black" stroke-width="3"/>
|
||||
<circle cx="210.606" cy="197.428" r="1.8228" transform="rotate(7.0438 210.606 197.428)" fill="black"/>
|
||||
<circle cx="216.636" cy="198.173" r="1.8228" transform="rotate(7.0438 216.636 198.173)" fill="black"/>
|
||||
<circle cx="222.667" cy="198.918" r="1.8228" transform="rotate(7.0438 222.667 198.918)" fill="black"/>
|
||||
<line x1="254.395" y1="187.246" x2="287.598" y2="191.349" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="253.501" y1="194.482" x2="286.704" y2="198.585" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="252.607" y1="201.718" x2="285.81" y2="205.821" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<mask id="path-27-inside-1_134_2519" fill="white">
|
||||
<path d="M275.22 254.212C275.085 255.308 274.086 256.087 272.99 255.951L215.452 248.842C214.356 248.706 213.577 247.708 213.712 246.612L215.01 236.108C215.146 235.012 216.144 234.233 217.24 234.369L274.778 241.478C275.875 241.614 276.653 242.612 276.518 243.708L275.22 254.212Z"/>
|
||||
</mask>
|
||||
<path d="M275.22 254.212C275.085 255.308 274.086 256.087 272.99 255.951L215.452 248.842C214.356 248.706 213.577 247.708 213.712 246.612L215.01 236.108C215.146 235.012 216.144 234.233 217.24 234.369L274.778 241.478C275.875 241.614 276.653 242.612 276.518 243.708L275.22 254.212Z" fill="white"/>
|
||||
<path d="M272.99 255.951L273.358 252.974L215.82 245.864L215.452 248.842L215.084 251.819L272.622 258.929L272.99 255.951ZM213.712 246.612L216.69 246.979L217.987 236.476L215.01 236.108L212.033 235.741L210.735 246.244L213.712 246.612ZM217.24 234.369L216.872 237.346L274.41 244.456L274.778 241.478L275.146 238.501L217.608 231.391L217.24 234.369ZM276.518 243.708L273.541 243.341L272.243 253.844L275.22 254.212L278.198 254.579L279.495 244.076L276.518 243.708ZM274.778 241.478L274.41 244.456C273.862 244.388 273.473 243.889 273.541 243.341L276.518 243.708L279.495 244.076C279.834 241.336 277.887 238.839 275.146 238.501L274.778 241.478ZM215.01 236.108L217.987 236.476C217.92 237.024 217.421 237.414 216.872 237.346L217.24 234.369L217.608 231.391C214.868 231.053 212.371 233 212.033 235.741L215.01 236.108ZM215.452 248.842L215.82 245.864C216.368 245.932 216.757 246.431 216.69 246.979L213.712 246.612L210.735 246.244C210.396 248.984 212.344 251.48 215.084 251.819L215.452 248.842ZM272.99 255.951L272.622 258.929C275.363 259.267 277.859 257.32 278.198 254.579L275.22 254.212L272.243 253.844C272.311 253.296 272.81 252.906 273.358 252.974L272.99 255.951Z" fill="black" mask="url(#path-27-inside-1_134_2519)"/>
|
||||
<path d="M213.836 242.49L74.4706 225.27" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M245.131 238.155L247.377 219.977" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M74.5957 224.26L76.0931 212.141" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<mask id="path-32-inside-2_134_2519" fill="white">
|
||||
<path d="M298.759 62.7911C298.894 61.6949 298.115 60.6964 297.019 60.561L241.76 53.7331C240.663 53.5976 239.665 54.3765 239.529 55.4727L238.298 65.4389C238.163 66.5352 238.941 67.5336 240.038 67.6691L295.297 74.497C296.393 74.6324 297.392 73.8536 297.527 72.7573L298.759 62.7911Z"/>
|
||||
</mask>
|
||||
<path d="M298.759 62.7911C298.894 61.6949 298.115 60.6964 297.019 60.561L241.76 53.7331C240.663 53.5976 239.665 54.3765 239.529 55.4727L238.298 65.4389C238.163 66.5352 238.941 67.5336 240.038 67.6691L295.297 74.497C296.393 74.6324 297.392 73.8536 297.527 72.7573L298.759 62.7911Z" fill="white"/>
|
||||
<path d="M297.019 60.561L296.651 63.5383L241.392 56.7104L241.76 53.7331L242.127 50.7557L297.387 57.5836L297.019 60.561ZM239.529 55.4727L242.507 55.8406L241.275 65.8068L238.298 65.4389L235.321 65.071L236.552 55.1048L239.529 55.4727ZM240.038 67.6691L240.406 64.6917L295.665 71.5196L295.297 74.497L294.929 77.4743L239.67 70.6464L240.038 67.6691ZM297.527 72.7573L294.55 72.3894L295.781 62.4232L298.759 62.7911L301.736 63.159L300.505 73.1252L297.527 72.7573ZM295.297 74.497L295.665 71.5196C295.117 71.4519 294.618 71.8413 294.55 72.3894L297.527 72.7573L300.505 73.1252C300.166 75.8658 297.67 77.813 294.929 77.4743L295.297 74.497ZM238.298 65.4389L241.275 65.8068C241.343 65.2587 240.954 64.7595 240.406 64.6917L240.038 67.6691L239.67 70.6464C236.929 70.3078 234.982 67.8116 235.321 65.071L238.298 65.4389ZM241.76 53.7331L241.392 56.7104C241.94 56.7782 242.439 56.3887 242.507 55.8406L239.529 55.4727L236.552 55.1048C236.891 52.3643 239.387 50.4171 242.127 50.7557L241.76 53.7331ZM297.019 60.561L297.387 57.5836C300.128 57.9222 302.075 60.4184 301.736 63.159L298.759 62.7911L295.781 62.4232C295.714 62.9714 296.103 63.4706 296.651 63.5383L297.019 60.561Z" fill="black" mask="url(#path-32-inside-2_134_2519)"/>
|
||||
<path d="M239.452 60.0737L104.126 43.3528" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M266.73 71.647L264.609 88.8152" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M104.125 43.3525L102.753 54.4614" stroke="black" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M273.561 5.86749L273.561 5.86846C274.476 9.6993 276.186 13.0526 278.559 15.4798L279.748 16.6959L278.291 17.575C275.384 19.3277 272.885 22.1437 271.037 25.6219L270.852 25.9694L270.761 25.5867C269.847 21.7557 268.136 18.4017 265.763 15.9744L264.573 14.7571L266.031 13.8791C268.939 12.1265 271.438 9.31033 273.285 5.83222L273.47 5.48363L273.561 5.86749Z" fill="black" stroke="black" stroke-width="3"/>
|
||||
<path d="M56.0321 259.113C55.8129 259.357 55.6121 259.627 55.4371 259.928L54.4736 261.586L56.3153 262.122C57.1417 262.362 57.9091 262.762 58.5961 263.284C58.3343 263.28 58.0682 263.292 57.7994 263.324L55.6934 263.567L56.625 265.473C57.0128 266.266 57.2664 267.134 57.3856 268.025C57.1426 267.806 56.8735 267.604 56.5726 267.428L54.914 266.463L54.3777 268.306C54.1375 269.132 53.7381 269.898 53.2185 270.585C53.2223 270.325 53.2097 270.061 53.179 269.794L52.9357 267.683L51.028 268.619C50.231 269.01 49.36 269.266 48.4685 269.387C48.6876 269.144 48.8892 268.875 49.0641 268.574L50.0288 266.915L48.1872 266.379C47.3608 266.139 46.5933 265.739 45.9063 265.217C46.1679 265.221 46.4334 265.209 46.7018 265.178L48.8091 264.934L47.8775 263.028C47.4896 262.236 47.2348 261.367 47.1156 260.477C47.3588 260.696 47.6284 260.898 47.9295 261.074L49.5884 262.038L50.1244 260.196C50.3648 259.37 50.7632 258.602 51.2833 257.915C51.2794 258.175 51.2927 258.44 51.3235 258.707L51.5655 260.818L53.4744 259.882C54.271 259.491 55.1412 259.234 56.0321 259.113Z" fill="black" stroke="black" stroke-width="3" stroke-miterlimit="10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -27,7 +27,7 @@ export default function AuthCardLayout({
|
||||
className="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center">
|
||||
<AppLogoIcon className="size-9 fill-current text-black dark:text-white" />
|
||||
<AppLogoIcon className="size-9" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AuthSimpleLayout({
|
||||
className="flex flex-col items-center font-medium no-underline"
|
||||
>
|
||||
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<AppLogoIcon className="size-9 text-[var(--foreground)] dark:text-white" />
|
||||
<AppLogoIcon className="size-9" />
|
||||
</div>
|
||||
<h1 className="text-black">Le Retzien Libre</h1>
|
||||
</Link>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AuthSplitLayout({
|
||||
href={home()}
|
||||
className="relative z-20 flex items-center text-lg font-medium"
|
||||
>
|
||||
<AppLogoIcon className="mr-2 size-8 fill-current text-white" />
|
||||
<AppLogoIcon variant="white" className="mr-2 size-8" />
|
||||
{name}
|
||||
</Link>
|
||||
{quote && (
|
||||
@@ -46,7 +46,7 @@ export default function AuthSplitLayout({
|
||||
href={home()}
|
||||
className="relative z-20 flex items-center justify-center lg:hidden"
|
||||
>
|
||||
<AppLogoIcon className="h-10 fill-current text-black sm:h-12" />
|
||||
<AppLogoIcon variant="dark" className="h-10 sm:h-12" />
|
||||
</Link>
|
||||
<div className="flex flex-col items-start gap-2 text-left sm:items-center sm:text-center">
|
||||
<h1 className="text-xl font-medium">{title}</h1>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function NavGuestLayout() {
|
||||
<>
|
||||
<header className="flex justify-between items-center my-6 w-full max-w-[335px] lg:max-w-7xl text-sm">
|
||||
{/* Logo */}
|
||||
<Link href={home()} className="flex items-center gap-2 font-medium no-underline">
|
||||
<Link href={home()} className="flex items-center gap-2 font-medium no-underline text-foreground">
|
||||
<div className="flex items-center justify-center rounded-md">
|
||||
<AppLogo className="max-w-[200px] max-h-[42px] w-full h-auto" />
|
||||
</div>
|
||||
@@ -79,13 +79,13 @@ export default function NavGuestLayout() {
|
||||
|
||||
{auth.user ? (
|
||||
<>
|
||||
<Link href={dashboard()} className="no-underline">
|
||||
<Link href={dashboard()} className="no-underline text-foreground">
|
||||
<Button variant="outline">Tableau de bord</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={logout()}
|
||||
onClick={handleLogout}
|
||||
className="border-3 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out font-bold no-underline"
|
||||
className="nb-shadow bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 font-bold no-underline"
|
||||
data-test="logout-button"
|
||||
>
|
||||
Se déconnecter
|
||||
@@ -103,7 +103,7 @@ export default function NavGuestLayout() {
|
||||
)}
|
||||
<button
|
||||
onClick={toggleAppearance}
|
||||
className="border-3 bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out font-bold no-underline"
|
||||
className="nb-shadow bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 font-bold no-underline"
|
||||
aria-label="Changer le thème"
|
||||
>
|
||||
{appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
@@ -114,7 +114,7 @@ export default function NavGuestLayout() {
|
||||
<div className="flex lg:hidden items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="border-3 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out font-bold no-underline"
|
||||
className="nb-shadow bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 font-bold no-underline"
|
||||
aria-label={isMenuOpen ? 'Fermer le menu' : 'Ouvrir le menu'}
|
||||
aria-expanded={isMenuOpen}
|
||||
>
|
||||
@@ -122,7 +122,7 @@ export default function NavGuestLayout() {
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleAppearance}
|
||||
className="border-3 bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out font-bold no-underline"
|
||||
className="nb-shadow bg-primary text-secondary-foreground hover:bg-primary/80 h-10 px-4 py-2 font-bold no-underline"
|
||||
aria-label="Changer le thème"
|
||||
>
|
||||
{appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
@@ -145,7 +145,7 @@ export default function NavGuestLayout() {
|
||||
{/* En-tête du panel */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Link href={home()} onClick={closeMenu} className="flex items-center gap-2 no-underline">
|
||||
<AppLogoIcon className="size-8 text-[var(--foreground)] dark:text-white" />
|
||||
<AppLogoIcon className="size-8" />
|
||||
<span className="font-bold text-black dark:text-white">Le Retzien Libre</span>
|
||||
</Link>
|
||||
<button
|
||||
@@ -177,7 +177,7 @@ export default function NavGuestLayout() {
|
||||
<div className="flex flex-col gap-3">
|
||||
{auth.user ? (
|
||||
<>
|
||||
<Link href={dashboard()} onClick={closeMenu} className="no-underline mx-auto mb-4">
|
||||
<Link href={dashboard()} onClick={closeMenu} className="no-underline text-foreground mx-auto mb-4">
|
||||
<Button variant="outline" className="max-w-[150px]">Tableau de bord</Button>
|
||||
</Link>
|
||||
<Link
|
||||
@@ -185,7 +185,7 @@ export default function NavGuestLayout() {
|
||||
method="post"
|
||||
as="button"
|
||||
onClick={() => { closeMenu(); handleLogout(); }}
|
||||
className="inline-flex items-center justify-center max-w-[150px] mx-auto gap-2 whitespace-nowrap rounded-md text-sm font-bold cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-2 transition delay-50 duration-200 ease-in-out w-full no-underline"
|
||||
className="nb-shadow inline-flex items-center justify-center max-w-[150px] mx-auto gap-2 whitespace-nowrap rounded-md text-sm font-bold cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 w-full no-underline"
|
||||
data-test="logout-button"
|
||||
>
|
||||
Se déconnecter
|
||||
|
||||
@@ -14,10 +14,10 @@ import AuthLayout from '@/layouts/auth-layout';
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Forgot password"
|
||||
description="Enter your email to receive a password reset link"
|
||||
title="Mot de passe oublié"
|
||||
description="Entrez votre adresse mail pour recevoir le lien de réinitialisation de mot de passe"
|
||||
>
|
||||
<Head title="Forgot password" />
|
||||
<Head title="Mot de passe oublié" />
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
@@ -30,14 +30,14 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Label htmlFor="email">Adresse Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder="email@example.com"
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
@@ -52,7 +52,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Email password reset link
|
||||
Envoyer le mail de réinitialisation
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -60,8 +60,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>log in</TextLink>
|
||||
<span>Ou, retourner à la page</span>
|
||||
<TextLink href={login()}>Se connecter</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -1,35 +1,82 @@
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { type BreadcrumbItem, type PageProps } from '@/types';
|
||||
import { Head, router, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import DashboardController from '@/actions/App/Http/Controllers/DashboardController';
|
||||
import { FlashMessage } from '@/components/flash-message';
|
||||
import { WelcomeCard } from '@/components/features/dashboard/WelcomeCard';
|
||||
import { NoMemberCard } from '@/components/features/dashboard/NoMemberCard';
|
||||
import { ServicesSection } from '@/components/features/dashboard/ServicesSection';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Tableau de Bord',
|
||||
href: dashboard().url,
|
||||
title: 'Tableau de bord',
|
||||
href: DashboardController.index().url,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { flash, member } = usePage<PageProps>().props;
|
||||
const [showFlash, setShowFlash] = useState(!!flash);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (flash) {
|
||||
setShowFlash(true);
|
||||
const timer = setTimeout(() => setShowFlash(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [flash]);
|
||||
|
||||
function handleActivationRequest(identifier: string) {
|
||||
setSubmitting(true);
|
||||
router.post(
|
||||
DashboardController.requestServiceActivation().url,
|
||||
{ service_identifier: identifier },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onFinish: () => setSubmitting(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const membership = member?.membership ?? null;
|
||||
const services = membership?.services ?? [];
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Tableau de bord" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
|
||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||
{showFlash && flash && <FlashMessage messages={flash} />}
|
||||
|
||||
{member ? (
|
||||
<>
|
||||
<WelcomeCard member={member} />
|
||||
|
||||
{services.length > 0 && (
|
||||
<ServicesSection
|
||||
services={services}
|
||||
submitting={submitting}
|
||||
onRequest={handleActivationRequest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{services.length === 0 && membership && (
|
||||
<div className="nb-shadow-static bg-white dark:bg-[#171717] rounded-2xl p-6 text-center text-muted-foreground text-sm">
|
||||
Aucun service associé à votre adhésion pour le moment.
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
)}
|
||||
|
||||
{!membership && (
|
||||
<div className="nb-shadow-static bg-white dark:bg-[#171717] rounded-2xl p-6 text-center text-muted-foreground text-sm">
|
||||
Votre demande d'adhésion est en cours de traitement.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<NoMemberCard />
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {Form, Head, usePage} from "@inertiajs/react";
|
||||
import {LoaderCircle} from 'lucide-react';
|
||||
import ContactFormController from "@/actions/App/Http/Controllers/Forms/ContactFormController";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import InputError from "@/components/input-error";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import ContactFormController from '@/actions/App/Http/Controllers/Forms/ContactFormController';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -13,16 +13,19 @@ import {
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import {Textarea} from "@/components/ui/textarea";
|
||||
import NavGuestLayout from "@/layouts/nav-guest-layout";
|
||||
import {PageProps} from "@/types";
|
||||
import {FlashMessage} from "@/components/flash-message";
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import NavGuestLayout from '@/layouts/nav-guest-layout';
|
||||
import { PageProps } from '@/types';
|
||||
import { FlashMessage } from '@/components/flash-message';
|
||||
import { Container } from '@/components/common/Container';
|
||||
import { SectionHeading } from '@/components/common/SectionHeading';
|
||||
import { Footer } from '@/components/footer';
|
||||
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
|
||||
|
||||
export default function Contact() {
|
||||
const {flash} = usePage().props as PageProps;
|
||||
|
||||
const { flash, captcha_question } = usePage().props as PageProps;
|
||||
const [showFlashMessage, setFlashMessage] = useState(!!flash);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,167 +36,137 @@ export default function Contact() {
|
||||
}
|
||||
}, [flash]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Nous contacter"/>
|
||||
<div
|
||||
className="flex flex-col items-center bg-[#F5F5F5] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]">
|
||||
<NavGuestLayout/>
|
||||
|
||||
<section className="flex flex-col h-screen items-center mt-15 gap-4">
|
||||
<div>
|
||||
<h1>Nous contacter</h1>
|
||||
<p>
|
||||
Vous désirez nous contacter, merci de remplir le formulaire suivant :
|
||||
</p>
|
||||
<Head title="Nous contacter" />
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-[#0a0a0a] text-[#1b1b18] dark:text-[#EDEDEC]">
|
||||
<div className="flex flex-col items-center px-4">
|
||||
<NavGuestLayout />
|
||||
</div>
|
||||
|
||||
{showFlashMessage && (
|
||||
<FlashMessage messages={flash ?? {}} />
|
||||
)}
|
||||
<main className="flex-1 py-12">
|
||||
<Container className="flex flex-col gap-10">
|
||||
<SectionHeading
|
||||
title="Nous contacter"
|
||||
color="primary"
|
||||
subtitle="Une question, une remarque ? Remplissez le formulaire ci-dessous, nous vous répondrons dans les plus brefs délais."
|
||||
align="left"
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{showFlashMessage && <FlashMessage messages={flash ?? {}} />}
|
||||
|
||||
<Form
|
||||
{...ContactFormController.store.form()}
|
||||
resetOnSuccess
|
||||
disableWhileProcessing
|
||||
>
|
||||
{({processing, errors}) => (
|
||||
<div className="lg:w-5xl px-10">
|
||||
<div className="flex gap-6 w-full">
|
||||
<div className="w-1/2">
|
||||
<div className="grid gap-2 my-4">
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
|
||||
{/* Left — Identité + adresse */}
|
||||
<div className="nb-shadow-static bg-primary dark:bg-[#171717] rounded-2xl p-6 flex flex-col gap-4">
|
||||
<div className="lg:w-1/2 flex justify-center mx-auto">
|
||||
<img
|
||||
src={IllustrationLogo}
|
||||
alt="Le Retzien Libre"
|
||||
className="rounded-lg max-w-md w-full"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg text-accent font-semibold">Vos informations</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="lastname">Nom*</Label>
|
||||
<Input
|
||||
id="lastname"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="lastname"
|
||||
name="lastname"
|
||||
placeholder="Nom"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
className="mt-2"
|
||||
/>
|
||||
<Input id="lastname" name="lastname" type="text" required tabIndex={1} autoComplete="family-name" placeholder="Votre nom" />
|
||||
<InputError message={errors.lastname} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 my-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="firstname">Prénom*</Label>
|
||||
<Input
|
||||
id="firstname"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={2}
|
||||
autoComplete="firstname"
|
||||
name="firstname"
|
||||
placeholder="Prénom"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="email">Adresse Mail*</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
tabIndex={3}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
<InputError message={errors.email}/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="address">Votre adresse postale</Label>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={4}
|
||||
autoComplete="address"
|
||||
name="address"
|
||||
placeholder="Votre adresse postale (facultatif)"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
className="mt-2"
|
||||
/>
|
||||
<Input id="firstname" name="firstname" type="text" required tabIndex={2} autoComplete="given-name" placeholder="Votre prénom" />
|
||||
<InputError message={errors.firstname} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2">
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="subject">Objet de votre demande*</Label>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="email">Adresse e-mail*</Label>
|
||||
<Input id="email" name="email" type="email" required tabIndex={3} autoComplete="email" placeholder="email@exemple.com" />
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="address">
|
||||
Adresse postale <span className="text-muted-foreground text-xs">(facultatif)</span>
|
||||
</Label>
|
||||
<Input id="address" name="address" type="text" tabIndex={4} autoComplete="street-address" placeholder="Votre adresse postale" />
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — Message + captcha + submit */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Votre message</h2>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="subject">Objet*</Label>
|
||||
<Select name="subject" required>
|
||||
<SelectTrigger tabIndex={5}>
|
||||
<SelectValue placeholder="Sélectionnez un objet"/>
|
||||
<SelectValue placeholder="Sélectionnez un objet" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Objets</SelectLabel>
|
||||
<SelectItem value="info-request">Demande
|
||||
d'informations</SelectItem>
|
||||
<SelectItem value="info-request">Demande d'informations</SelectItem>
|
||||
<SelectItem value="service-request">Services</SelectItem>
|
||||
<SelectItem value="other">Autres</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.subject} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="message">Votre message</Label>
|
||||
<Textarea
|
||||
className="h-28"
|
||||
id="message"
|
||||
name="message"
|
||||
tabIndex={6}
|
||||
required
|
||||
placeholder="Entrez votre message ici..."
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="message">Message*</Label>
|
||||
<Textarea id="message" name="message" tabIndex={6} required placeholder="Entrez votre message ici..." className="h-32 resize-none" />
|
||||
<InputError message={errors.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="captcha">Captcha</Label>
|
||||
<div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-5">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label>
|
||||
<Input
|
||||
id="captcha"
|
||||
type="text"
|
||||
autoFocus
|
||||
tabIndex={7}
|
||||
name="captcha"
|
||||
placeholder="Entrez le captcha ci-dessous"
|
||||
type="text"
|
||||
tabIndex={7}
|
||||
placeholder="Votre réponse"
|
||||
autoComplete="off"
|
||||
className="max-w-[180px]"
|
||||
/>
|
||||
<InputError message={errors.captcha} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto justify-center">
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="mt-2"
|
||||
variant="secondary"
|
||||
tabIndex={8}
|
||||
data-test="register-user-button"
|
||||
className="nb-shadow w-full font-bold text-base py-5"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin"/>
|
||||
)}
|
||||
Envoyer
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Envoyer mon message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
import {Form, Head, usePage} from "@inertiajs/react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {CheckCircle2, CheckIcon, LoaderCircle} from 'lucide-react';
|
||||
import MembershipFormController from "@/actions/App/Http/Controllers/Forms/MembershipFormController";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import InputError from "@/components/input-error";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import NavGuestLayout from "@/layouts/nav-guest-layout";
|
||||
import {Checkbox} from "@/components/ui/checkbox";
|
||||
import {useEffect, useState} from "react";
|
||||
import {PageProps} from "@/types";
|
||||
import {FlashMessage} from "@/components/flash-message";
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, LoaderCircle } from 'lucide-react';
|
||||
import MembershipFormController from '@/actions/App/Http/Controllers/Forms/MembershipFormController';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import NavGuestLayout from '@/layouts/nav-guest-layout';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PageProps } from '@/types';
|
||||
import { FlashMessage } from '@/components/flash-message';
|
||||
import { Container } from '@/components/common/Container';
|
||||
import { SectionHeading } from '@/components/common/SectionHeading';
|
||||
import { Footer } from '@/components/footer';
|
||||
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
|
||||
|
||||
export default function Membership() {
|
||||
const {flash, plans} = usePage().props as PageProps
|
||||
const { flash, plans, services, captcha_question } = usePage().props as PageProps;
|
||||
const [showFlashMessage, setFlashMessage] = useState(!!flash);
|
||||
const [selectedPlan, setSelectedPlan] = useState(plans?.[0]?.identifier ?? null);
|
||||
const [amount, setAmount] = useState(plans?.[0]?.price ?? 0);
|
||||
const features = [
|
||||
"Boîte Mail",
|
||||
"NextCloud",
|
||||
"Mailing list",
|
||||
"Hébergement de site",
|
||||
"Sondage",
|
||||
"Et plus encore ...",
|
||||
];
|
||||
|
||||
// /!\ Existant à discuter avec client
|
||||
/*const today = new Date();
|
||||
const actualMonth = today.getMonth() + 1;
|
||||
const leftMonths = 12 - actualMonth;*/
|
||||
/*const getAmount = (plan: string | null): number => {
|
||||
if (!plan) return 0;
|
||||
const baseAmount = leftMonths;
|
||||
switch (plan) {
|
||||
case 'custom':
|
||||
return baseAmount;
|
||||
case 'one-year':
|
||||
return baseAmount + 12;
|
||||
case 'two-year':
|
||||
return baseAmount + 24;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};*/
|
||||
|
||||
useEffect(() => {
|
||||
if (plans && selectedPlan) {
|
||||
const plan = plans.find(p => p.identifier === selectedPlan);
|
||||
if (plan) {
|
||||
setAmount(plan.price);
|
||||
}
|
||||
const plan = plans.find((p) => p.identifier === selectedPlan);
|
||||
if (plan) setAmount(plan.price);
|
||||
}
|
||||
}, [selectedPlan, plans]);
|
||||
|
||||
@@ -64,257 +39,203 @@ export default function Membership() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Adhérer au Retzien Libre"/>
|
||||
<div
|
||||
className="flex min-h-screen flex-col items-center bg-[#F5F5F5] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]">
|
||||
<NavGuestLayout/>
|
||||
|
||||
<section className="flex flex-col items-center justify-center gap-4">
|
||||
<div>
|
||||
<h1>Adhérer au Retzien Libre</h1>
|
||||
<p>
|
||||
Saisissez vos informations ci-dessous pour créer une demande d'adhésion :
|
||||
</p>
|
||||
<Head title="Adhérer au Retzien Libre" />
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-[#0a0a0a] text-[#1b1b18] dark:text-[#EDEDEC]">
|
||||
<div className="flex flex-col items-center px-4">
|
||||
<NavGuestLayout />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-12">
|
||||
<Container className="flex flex-col gap-10">
|
||||
<SectionHeading
|
||||
title="Adhérer au Retzien Libre"
|
||||
color="primary"
|
||||
subtitle="Rejoignez notre association et accédez à des outils libres, éthiques et respectueux de vos données."
|
||||
align="left"
|
||||
/>
|
||||
|
||||
{showFlashMessage && (
|
||||
<FlashMessage messages={flash ?? {}}/>
|
||||
<FlashMessage messages={flash ?? {}} />
|
||||
)}
|
||||
|
||||
<Form
|
||||
{...MembershipFormController.store.form()}
|
||||
resetOnSuccess
|
||||
disableWhileProcessing
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({processing, errors}) => (
|
||||
<div className="lg:w-5xl px-10">
|
||||
<div className="flex flex-col md:flex-row gap-6 w-full">
|
||||
<div className="w-full lg:w-1/2">
|
||||
<div className="grid gap-2 my-4">
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* Left — Personal info */}
|
||||
<div className="bg-primary dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-4">
|
||||
<div className="lg:w-1/2 flex justify-center mx-auto">
|
||||
<img
|
||||
src={IllustrationLogo}
|
||||
alt="Le Retzien Libre"
|
||||
className="rounded-lg max-w-md w-full pt-4"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-accent">Vos informations</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="lastname">Nom*</Label>
|
||||
<Input
|
||||
id="lastname"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="lastname"
|
||||
name="lastname"
|
||||
placeholder="Votre Nom"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.lastname}
|
||||
className="mt-2"
|
||||
/>
|
||||
<Input id="lastname" name="lastname" type="text" required tabIndex={1} autoComplete="family-name" placeholder="Votre nom" />
|
||||
<InputError message={errors.lastname} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="firstname">Prénom*</Label>
|
||||
<Input
|
||||
id="firstname"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={2}
|
||||
autoComplete="firstname"
|
||||
name="firstname"
|
||||
placeholder="Votre Prénom"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.firstname}
|
||||
className="mt-2"
|
||||
/>
|
||||
<Input id="firstname" name="firstname" type="text" required tabIndex={2} autoComplete="given-name" placeholder="Votre prénom" />
|
||||
<InputError message={errors.firstname} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="company">Société</Label>
|
||||
<Input
|
||||
id="company"
|
||||
type="text"
|
||||
autoFocus
|
||||
tabIndex={3}
|
||||
autoComplete="company"
|
||||
name="company"
|
||||
placeholder="Votre société"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.firstname}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="email">Adresse Mail*</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
tabIndex={4}
|
||||
autoComplete="email"
|
||||
name="email"
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
<InputError message={errors.email}/>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="email">Adresse e-mail*</Label>
|
||||
<Input id="email" name="email" type="email" required tabIndex={3} autoComplete="email" placeholder="email@exemple.com" />
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="phone1">Téléphone*</Label>
|
||||
<Input
|
||||
id="phone1"
|
||||
type="phone"
|
||||
required
|
||||
tabIndex={5}
|
||||
autoComplete="phone"
|
||||
name="phone1"
|
||||
placeholder="Votre numéro de téléphone"
|
||||
/>
|
||||
<InputError message={errors.phone}/>
|
||||
<Input id="phone1" name="phone1" type="tel" required tabIndex={4} autoComplete="tel" placeholder="Votre numéro de téléphone" />
|
||||
<InputError message={errors.phone1} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="address">Votre adresse*</Label>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={6}
|
||||
autoComplete="address"
|
||||
name="address"
|
||||
placeholder="Votre adresse"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
className="mt-2"
|
||||
/>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="company">Société <span className="text-muted-foreground text-xs">(facultatif)</span></Label>
|
||||
<Input id="company" name="company" type="text" tabIndex={5} autoComplete="organization" placeholder="Votre société" />
|
||||
<InputError message={errors.company} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="zipcode">Votre code postale*</Label>
|
||||
<Input
|
||||
id="zipcode"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={7}
|
||||
autoComplete="zipcode"
|
||||
name="zipcode"
|
||||
placeholder="Votre code postale"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.zipcode}
|
||||
className="mt-2"
|
||||
/>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="address">Adresse*</Label>
|
||||
<Input id="address" name="address" type="text" required tabIndex={6} autoComplete="street-address" placeholder="Votre adresse" />
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<Label htmlFor="city">Votre ville*</Label>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={8}
|
||||
autoComplete="city"
|
||||
name="city"
|
||||
placeholder="Votre ville"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.city}
|
||||
className="mt-2"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="zipcode">Code postal*</Label>
|
||||
<Input id="zipcode" name="zipcode" type="text" required tabIndex={7} autoComplete="postal-code" placeholder="Code postal" />
|
||||
<InputError message={errors.zipcode} />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="city">Ville*</Label>
|
||||
<Input id="city" name="city" type="text" required tabIndex={8} autoComplete="address-level2" placeholder="Ville" />
|
||||
<InputError message={errors.city} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full lg:w-1/2">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="package">Formule d'adhésion*</Label>
|
||||
<div className="grid grid-cols-3 gap-2 my-4">
|
||||
</div>
|
||||
|
||||
{/* Right — Plan, services, captcha, submit */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Plan selection */}
|
||||
<div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Choisissez votre formule</h2>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{plans?.map((plan) => (
|
||||
<button
|
||||
key={plan.id}
|
||||
type="button"
|
||||
tabIndex={8}
|
||||
tabIndex={9}
|
||||
onClick={() => setSelectedPlan(plan.identifier)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded border-3 p-4 transition-colors",
|
||||
'flex items-center justify-between rounded-xl border-3 border-black px-5 py-4 text-left transition-all duration-150',
|
||||
'shadow-[3px_3px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-0.5 hover:translate-y-0.5',
|
||||
selectedPlan === plan.identifier
|
||||
? "border-primary"
|
||||
: "border-black hover:border-primary"
|
||||
? 'bg-secondary text-black'
|
||||
: 'bg-white dark:bg-[#1a1a1a] hover:bg-primary/20',
|
||||
)}
|
||||
>
|
||||
<span className="font-semibold">{plan.name}</span>
|
||||
<span className="font-bold text-lg">{plan.price}€</span>
|
||||
<span className="text-center text-muted-foreground text-xs">
|
||||
{plan.description}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-base">{plan.name}</span>
|
||||
{plan.months != null ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plan.months} mois × 1€/mois
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{plan.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-2xl font-black">{plan.price}€</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="package" value={selectedPlan ?? ''} />
|
||||
<input type="hidden" name="amount" value={amount} />
|
||||
</div>
|
||||
<div className="flex-col gap-6 ">
|
||||
<div className="text-center">
|
||||
<p className="text-center font-semibold text-lg">
|
||||
Montant à payer : <br/> <span className="text-primary">{amount} €</span>
|
||||
<InputError message={errors.package} />
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground border-t border-border pt-3">
|
||||
Montant total : <strong className="text-secondary text-lg">{amount}€</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="pl-10 space-y-2">
|
||||
<h4 className="font-semibold text-sm">Fonctionnalités inclues :</h4>
|
||||
<ul className="space-y-2">
|
||||
{features.map((feature, index) => (
|
||||
<li className="flex items-center gap-2 my-4 text-sm"
|
||||
key={index}>
|
||||
<CheckIcon className="h-4 w-4 text-primary"/>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
|
||||
{/* Services included */}
|
||||
<div className="bg-accent text-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Services inclus</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{services?.map((service) => (
|
||||
<div key={service.name} className="flex items-start gap-2">
|
||||
<CheckIcon className="h-4 w-4 mt-0.5 shrink-0 text-white" />
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">{service.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto justify-center">
|
||||
<div className="w-[300px] grid gap-2 my-4">
|
||||
<Label htmlFor="captcha">Captcha</Label>
|
||||
{/* Captcha + CGU + Submit */}
|
||||
<div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-5">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label>
|
||||
<Input
|
||||
id="captcha"
|
||||
type="text"
|
||||
autoFocus
|
||||
tabIndex={9}
|
||||
name="captcha"
|
||||
placeholder="Entrez le captcha ci-dessous"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 my-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="cgu"
|
||||
name="cgu"
|
||||
type="text"
|
||||
tabIndex={10}
|
||||
required
|
||||
placeholder="Votre réponse"
|
||||
autoComplete="off"
|
||||
className="max-w-[180px]"
|
||||
/>
|
||||
<Label htmlFor="remember">J'ai lu et j'accepte les <a
|
||||
href="#">C.G.U.</a>,
|
||||
je comprends la nécessité des enregistrements de mes données
|
||||
personnelles.</Label>
|
||||
<InputError message={errors.captcha} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox id="cgu" name="cgu" tabIndex={11} required className="mt-0.5" />
|
||||
<Label htmlFor="cgu" className="text-sm leading-snug cursor-pointer">
|
||||
J'ai lu et j'accepte les <a href="#">C.G.U.</a> et je comprends la
|
||||
nécessité des enregistrements de mes données personnelles.
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex justify-center items-center">
|
||||
<InputError message={errors.cgu} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="mt-2 w-full max-w-1/3"
|
||||
tabIndex={11}
|
||||
data-test="register-user-button"
|
||||
variant="secondary"
|
||||
tabIndex={12}
|
||||
className="nb-shadow w-full font-bold text-base py-5"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin"/>
|
||||
)}
|
||||
Envoyer
|
||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||
Envoyer ma demande d'adhésion
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||