Compare commits

...

2 Commits

Author SHA1 Message Date
aea22e72af feat(wip member dashboard)
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 26h10m31s
2026-04-08 15:17:05 +02:00
341032162a feat(fixing forms tunnel & wip dashboard) 2026-04-08 14:02:36 +02:00
36 changed files with 1041 additions and 326 deletions

View File

@@ -36,6 +36,6 @@ class PasswordResetLinkController extends Controller
$request->only('email') $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'));
} }
} }

View 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) : 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."]);
}
}

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

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

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

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

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -152,6 +153,11 @@ class Member extends Model
return $this->hasMany(NextCloudMember::class, 'member_id'); return $this->hasMany(NextCloudMember::class, 'member_id');
} }
public function lastActiveMembership(): HasOne
{
return $this->hasOne(Membership::class)->where('status', 'active')->latest();
}
public function lastMembership(): ?Membership public function lastMembership(): ?Membership
{ {
return $this->memberships()->where('status', 'active')->first(); return $this->memberships()->where('status', 'active')->first();

View File

@@ -5,8 +5,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/** /**
* @property int $id * @property int $id
@@ -32,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property-read \App\Models\Package $package * @property-read \App\Models\Package $package
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Service> $services * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Service> $services
* @property-read int|null $services_count * @property-read int|null $services_count
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership newModelQuery() * @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 newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership query() * @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 whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereValidationDate($value) * @method static \Illuminate\Database\Eloquent\Builder<static>|Membership whereValidationDate($value)
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class Membership extends Model class Membership extends Model
@@ -71,10 +71,9 @@ class Membership extends Model
'note_public', 'note_public',
'note_private', 'note_private',
'dolibarr_id', 'dolibarr_id',
'dolibarr_user_id' 'dolibarr_user_id',
]; ];
public static function getAttributeLabel(string $attribute): string public static function getAttributeLabel(string $attribute): string
{ {
return __("memberships.fields.$attribute"); return __("memberships.fields.$attribute");
@@ -97,7 +96,7 @@ class Membership extends Model
public function services(): BelongsToMany 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');
} }
} }

View 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 [];
}
}

View File

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

10
lang/en/passwords.php Normal file
View 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.',
];

10
lang/fr/passwords.php Normal file
View 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.',
];

View File

@@ -26,15 +26,24 @@
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -43,7 +52,15 @@
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --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 { :root {
@@ -52,22 +69,40 @@
--card: #ffffff; --card: #ffffff;
--card-foreground: #0a0a0a; --card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #f5a623; --primary: #f5a623;
--primary-foreground: #0a0a0a; --primary-foreground: #0a0a0a;
--secondary: #f48fb1; --secondary: #f48fb1;
--secondary-foreground: #0a0a0a; --secondary-foreground: #0a0a0a;
--muted: #f5f5f5;
--muted-foreground: #737373;
--accent: #00473e; --accent: #00473e;
--accent-foreground: #ffffff; --accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e5e5e5; --border: #e5e5e5;
--input: #e5e5e5; --input: #e5e5e5;
--ring: #d4d4d4; --ring: #d4d4d4;
--chart-1: #f5a623; /* orange */ --chart-1: #f5a623;
--chart-2: #f48fb1; /* rose */ --chart-2: #f48fb1;
--chart-3: #ffffff; /* blanc */ --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 { .dark {
@@ -76,15 +111,24 @@
--card: #171717; --card: #171717;
--card-foreground: #f9f9f9; --card-foreground: #f9f9f9;
--primary: #007c6c; /* vert plus clair */ --popover: #171717;
--popover-foreground: #f9f9f9;
--primary: #007c6c;
--primary-foreground: #0a0a0a; --primary-foreground: #0a0a0a;
--secondary: #2c2c2c; --secondary: #2c2c2c;
--secondary-foreground: #f9f9f9; --secondary-foreground: #f9f9f9;
--muted: #2c2c2c;
--muted-foreground: #a3a3a3;
--accent: #f48fb1; --accent: #f48fb1;
--accent-foreground: #171717; --accent-foreground: #171717;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #2c2c2c; --border: #2c2c2c;
--input: #2c2c2c; --input: #2c2c2c;
--ring: #6f6f6f; --ring: #6f6f6f;
@@ -92,6 +136,24 @@
--chart-1: #f48fb1; --chart-1: #f48fb1;
--chart-2: #ffb300; --chart-2: #ffb300;
--chart-3: #f9f9f9; --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 { @layer base {
@@ -124,7 +186,7 @@
text-decoration-line: underline; 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; @apply bg-white border border-black shadow-sm text-black px-4 py-2 rounded-md hover:shadow-md transition;
} }
} }

View 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

View File

@@ -1,9 +1,11 @@
import Auth from './Auth' import Auth from './Auth'
import DashboardController from './DashboardController'
import Settings from './Settings' import Settings from './Settings'
import Forms from './Forms' import Forms from './Forms'
const Controllers = { const Controllers = {
Auth: Object.assign(Auth, Auth), Auth: Object.assign(Auth, Auth),
DashboardController: Object.assign(DashboardController, DashboardController),
Settings: Object.assign(Settings, Settings), Settings: Object.assign(Settings, Settings),
Forms: Object.assign(Forms, Forms), Forms: Object.assign(Forms, Forms),
} }

View File

@@ -13,54 +13,27 @@ import {
NavigationMenuList, NavigationMenuList,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu'; } 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 { UserMenuContent } from '@/components/user-menu-content';
import { useAppearance } from '@/hooks/use-appearance';
import { useInitials } from '@/hooks/use-initials'; import { useInitials } from '@/hooks/use-initials';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard, logout } from '@/routes';
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types'; import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react'; import { Link, router, usePage } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; import { LayoutGrid, LogOut, Menu, Moon, Settings, Sun, X } from 'lucide-react';
import AppearanceToggleDropdown from './appearance-dropdown'; import { useEffect, useState } from 'react';
import AppLogo from './app-logo'; import AppLogo from './app-logo';
import AppLogoIcon from './app-logo-icon'; import AppLogoIcon from './app-logo-icon';
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Tableau de Bord',
href: dashboard(), href: dashboard(),
icon: LayoutGrid, 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 { interface AppHeaderProps {
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
} }
@@ -69,122 +42,68 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
const page = usePage<SharedData>(); const page = usePage<SharedData>();
const { auth } = page.props; const { auth } = page.props;
const getInitials = useInitials(); 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 ( 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"> <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"> {/* Logo */}
{rightNavItems.map((item) => ( <Link href={dashboard()} prefetch className="flex items-center no-underline text-foreground">
<a <AppLogo className="h-8 w-auto max-w-[180px]" />
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 />
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop nav */}
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex"> <div className="ml-6 hidden h-full items-center lg:flex">
<NavigationMenu className="flex h-full items-stretch"> <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) => ( {mainNavItems.map((item, index) => (
<NavigationMenuItem <NavigationMenuItem key={index} className="relative flex h-full items-center">
key={index}
className="relative flex h-full items-center"
>
<Link <Link
href={item.href} href={item.href}
className={cn( className={cn(
navigationMenuTriggerStyle(), navigationMenuTriggerStyle(),
page.url === 'h-9 cursor-pointer px-3 text-foreground no-underline',
(typeof item.href === page.url === (typeof item.href === 'string' ? item.href : item.href.url) &&
'string' 'font-semibold',
? item.href
: item.href.url) &&
activeItemStyles,
'h-9 cursor-pointer px-3',
)} )}
> >
{item.icon && ( {item.icon && <Icon iconNode={item.icon} className="mr-2 h-4 w-4" />}
<Icon
iconNode={item.icon}
className="mr-2 h-4 w-4"
/>
)}
{item.title} {item.title}
</Link> </Link>
{page.url === item.href && ( {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-black dark:bg-white"></div> <div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary" />
)} )}
</NavigationMenuItem> </NavigationMenuItem>
))} ))}
@@ -192,66 +111,23 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
</NavigationMenu> </NavigationMenu>
</div> </div>
<div className="ml-auto flex items-center space-x-2"> {/* Right actions */}
<div className="relative flex items-center space-x-1"> <div className="ml-auto flex items-center gap-2">
<Button {/* Theme toggle — desktop only */}
variant="ghost" <button
size="icon" onClick={toggleAppearance}
className="group h-9 w-9 cursor-pointer" 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" /> {appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button> </button>
<div className="hidden lg:flex">
{rightNavItems.map((item) => ( {/* Avatar dropdown — always visible */}
<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 />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="secondary" className="size-10 rounded-full mr-2">
variant="ghost"
className="size-10 rounded-full p-1"
>
<Avatar className="size-8 overflow-hidden rounded-full"> <Avatar className="size-8 overflow-hidden rounded-full">
<AvatarImage <AvatarFallback className="rounded-full bg-secondary text-secondary-foreground font-semibold text-sm">
src={auth.user.avatar}
alt={auth.user.name}
/>
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{getInitials(auth.user.name)} {getInitials(auth.user.name)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -261,12 +137,109 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
<UserMenuContent user={auth.user} /> <UserMenuContent user={auth.user} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> </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 && ( {breadcrumbs.length > 1 && (
<div className="flex w-full border-b border-sidebar-border/70"> <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-neutral-500 md:max-w-7xl"> <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} /> <Breadcrumbs breadcrumbs={breadcrumbs} />
</div> </div>
</div> </div>

View File

@@ -3,11 +3,11 @@ import { SVGAttributes } from 'react';
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) { export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
return ( return (
<svg {...props} viewBox="0 0 42 33" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <circle cx="21.138" cy="16.5" r="15" stroke="currentColor" 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"/> <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="currentColor" stroke="currentColor"/>
<circle cx="13" cy="17" r="2" fill="#FFA8BA"/> <circle cx="13" cy="17" r="2" fill="#FFA8BA"/>
<circle cx="29" 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="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="currentColor" stroke="currentColor"/>
<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"/> <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> </svg>
); );

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,3 @@
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main'; import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
import { import {
@@ -13,7 +12,7 @@ import {
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { type NavItem } from '@/types'; import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; import { LayoutGrid } from 'lucide-react';
import AppLogo from './app-logo'; import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [ 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() { export function AppSidebar() {
return ( return (
<Sidebar collapsible="icon" variant="inset"> <Sidebar collapsible="icon" variant="inset">
@@ -44,7 +30,7 @@ export function AppSidebar() {
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link href={dashboard()} prefetch> <Link href={dashboard()} prefetch className="text-foreground">
<AppLogo /> <AppLogo />
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
@@ -57,7 +43,6 @@ export function AppSidebar() {
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavFooter items={footerNavItems} className="mt-auto" />
<NavUser /> <NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>

View File

@@ -25,7 +25,7 @@ export function ScrollToTop() {
<button <button
onClick={scrollToTop} onClick={scrollToTop}
aria-label="Retour en haut" 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" /> <ChevronUp className="size-5" />
</button> </button>

View File

@@ -9,7 +9,7 @@ export function AboutSection() {
subtitle="Le Retzien Libre, cest une association qui promeut lauto-hébergement et la décentralisation des services en ligne depuis 2017." subtitle="Le Retzien Libre, cest une association qui promeut lauto-hébergement et la décentralisation des services en ligne depuis 2017."
align='left'/> align='left'/>
<div <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="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"> <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> <h3 className="text-xl text-primary font-semibold">Une association locale</h3>

View File

@@ -3,7 +3,7 @@ import {Service} from "@/types";
export function ServiceCard({title, colorTitle, bgColor, bgTitle, description, link, illustration}: Service) { export function ServiceCard({title, colorTitle, bgColor, bgTitle, description, link, illustration}: Service) {
return ( return (
<div <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`}> className={`nb-shadow flex gap-1 items-center bg-${bgColor} justify-center gap-4 rounded-4xl p-10`}>
<div> <div>
<div className="max-w-[150px]"> <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>

View File

@@ -6,7 +6,7 @@ export function Footer() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( 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="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 lg:flex-row justify-between gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">

View File

@@ -14,13 +14,13 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline: outline:
"bg-white shadow-sm hover:bg-background", "bg-background text-foreground shadow-sm hover:bg-muted",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", "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: { 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", sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",

View File

@@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -40,7 +40,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -72,7 +72,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -90,7 +90,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( 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 className
)} )}
checked={checked} checked={checked}
@@ -126,7 +126,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -42,7 +42,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
onClick={cleanup} onClick={cleanup}
> >
<Settings className="mr-2" /> <Settings className="mr-2" />
Settings Paramètres
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -56,7 +56,7 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
data-test="logout-button" data-test="logout-button"
> >
<LogOut className="mr-2" /> <LogOut className="mr-2" />
Log out Se déconnecter
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</> </>

View File

@@ -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"> <header className="flex justify-between items-center my-6 w-full max-w-[335px] lg:max-w-7xl text-sm">
{/* Logo */} {/* 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"> <div className="flex items-center justify-center rounded-md">
<AppLogo className="max-w-[200px] max-h-[42px] w-full h-auto" /> <AppLogo className="max-w-[200px] max-h-[42px] w-full h-auto" />
</div> </div>
@@ -79,13 +79,13 @@ export default function NavGuestLayout() {
{auth.user ? ( {auth.user ? (
<> <>
<Link href={dashboard()} className="no-underline"> <Link href={dashboard()} className="no-underline text-foreground">
<Button variant="outline">Tableau de bord</Button> <Button variant="outline">Tableau de bord</Button>
</Link> </Link>
<Link <Link
href={logout()} href={logout()}
onClick={handleLogout} 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" data-test="logout-button"
> >
Se déconnecter Se déconnecter
@@ -103,7 +103,7 @@ export default function NavGuestLayout() {
)} )}
<button <button
onClick={toggleAppearance} 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" aria-label="Changer le thème"
> >
{appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />} {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"> <div className="flex lg:hidden items-center gap-2">
<button <button
onClick={() => setIsMenuOpen(!isMenuOpen)} 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-label={isMenuOpen ? 'Fermer le menu' : 'Ouvrir le menu'}
aria-expanded={isMenuOpen} aria-expanded={isMenuOpen}
> >
@@ -122,7 +122,7 @@ export default function NavGuestLayout() {
</button> </button>
<button <button
onClick={toggleAppearance} 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" aria-label="Changer le thème"
> >
{appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />} {appearance === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
@@ -177,7 +177,7 @@ export default function NavGuestLayout() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{auth.user ? ( {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> <Button variant="outline" className="max-w-[150px]">Tableau de bord</Button>
</Link> </Link>
<Link <Link
@@ -185,7 +185,7 @@ export default function NavGuestLayout() {
method="post" method="post"
as="button" as="button"
onClick={() => { closeMenu(); handleLogout(); }} 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" data-test="logout-button"
> >
Se déconnecter Se déconnecter

View File

@@ -14,10 +14,10 @@ import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) { export default function ForgotPassword({ status }: { status?: string }) {
return ( return (
<AuthLayout <AuthLayout
title="Forgot password" title="Mot de passe oublié"
description="Enter your email to receive a password reset link" 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 && ( {status && (
<div className="mb-4 text-center text-sm font-medium text-green-600"> <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 }) => ( {({ processing, errors }) => (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Adresse Mail</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
name="email" name="email"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
placeholder="email@example.com" placeholder="email@exemple.com"
/> />
<InputError message={errors.email} /> <InputError message={errors.email} />
@@ -52,7 +52,7 @@ export default function ForgotPassword({ status }: { status?: string }) {
{processing && ( {processing && (
<LoaderCircle className="h-4 w-4 animate-spin" /> <LoaderCircle className="h-4 w-4 animate-spin" />
)} )}
Email password reset link Envoyer le mail de réinitialisation
</Button> </Button>
</div> </div>
</> </>
@@ -60,8 +60,8 @@ export default function ForgotPassword({ status }: { status?: string }) {
</Form> </Form>
<div className="space-x-1 text-center text-sm text-muted-foreground"> <div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span> <span>Ou, retourner à la page</span>
<TextLink href={login()}>log in</TextLink> <TextLink href={login()}>Se connecter</TextLink>
</div> </div>
</div> </div>
</AuthLayout> </AuthLayout>

View File

@@ -1,35 +1,220 @@
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import AppLayout from '@/layouts/app-layout'; import AppLayout from '@/layouts/app-layout';
import { dashboard } from '@/routes'; import { type BreadcrumbItem, type DashboardMember, type DashboardService, type PageProps } from '@/types';
import { type BreadcrumbItem } from '@/types'; import { Head, router, usePage } from '@inertiajs/react';
import { Head } from '@inertiajs/react'; import DashboardController from '@/actions/App/Http/Controllers/DashboardController';
import { ExternalLink, KeyRound, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FlashMessage } from '@/components/flash-message';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Tableau de Bord', title: 'Tableau de bord',
href: dashboard().url, href: DashboardController.index().url,
}, },
]; ];
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]));
}
}
function WelcomeCard({ member }: { member: DashboardMember }) {
const membership = member.membership;
return (
<div className="nb-shadow-static bg-primary rounded-2xl p-6 flex flex-col gap-2">
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wide">Bienvenue</p>
<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>
);
}
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>
);
}
function ServiceCard({ service, onRequest }: { service: DashboardService; onRequest: (identifier: string) => void }) {
const [alreadyRequested, setAlreadyRequested] = useState(() =>
getRequestedServices().includes(service.identifier),
);
function handleRequest() {
markServiceRequested(service.identifier);
setAlreadyRequested(true);
onRequest(service.identifier);
}
return (
<div className={cn(
'nb-shadow-static bg-white dark:bg-[#171717] rounded-2xl p-5 flex flex-col gap-3',
!service.is_active && 'opacity-80',
)}>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-semibold text-base">{service.name}</h3>
{service.description && (
<p className="text-xs text-muted-foreground mt-0.5">{service.description}</p>
)}
</div>
<span className={cn(
'shrink-0 text-xs rounded-full px-2 py-0.5 font-medium border',
service.is_active
? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/20 dark:text-green-400 dark:border-green-700'
: 'bg-gray-100 text-gray-600 border-gray-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600',
)}>
{service.is_active ? 'Actif' : 'Inactif'}
</span>
</div>
{service.is_active ? (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Accéder au service <ExternalLink className="size-3.5" />
</a>
) : (
<Button
variant="outline"
size="sm"
disabled={alreadyRequested}
onClick={handleRequest}
className="self-start text-xs"
>
{alreadyRequested ? 'Demande envoyée' : 'Demander l\'activation'}
</Button>
)}
</div>
);
}
export default function Dashboard() { 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 ( return (
<AppLayout breadcrumbs={breadcrumbs}> <AppLayout breadcrumbs={breadcrumbs}>
<Head title="Tableau de bord" /> <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="flex flex-col gap-6 p-4 md:p-6">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> {showFlash && flash && <FlashMessage messages={flash} />}
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
{member ? (
<>
<WelcomeCard member={member} />
{services.length > 0 && (
<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>
<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="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
</div> {services.map((service) => (
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"> <ServiceCard
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" /> key={service.identifier}
service={service}
onRequest={handleActivationRequest}
/>
))}
</div> </div>
</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" />
{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>
)}
{!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> </div>
</AppLayout> </AppLayout>
); );

View File

@@ -22,6 +22,7 @@ import { FlashMessage } from '@/components/flash-message';
import { Container } from '@/components/common/Container'; import { Container } from '@/components/common/Container';
import { SectionHeading } from '@/components/common/SectionHeading'; import { SectionHeading } from '@/components/common/SectionHeading';
import { Footer } from '@/components/footer'; import { Footer } from '@/components/footer';
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
export default function Contact() { export default function Contact() {
const { flash, captcha_question } = usePage().props as PageProps; const { flash, captcha_question } = usePage().props as PageProps;
@@ -50,6 +51,7 @@ export default function Contact() {
color="primary" color="primary"
subtitle="Une question, une remarque ? Remplissez le formulaire ci-dessous, nous vous répondrons dans les plus brefs délais." subtitle="Une question, une remarque ? Remplissez le formulaire ci-dessous, nous vous répondrons dans les plus brefs délais."
align="left" align="left"
className="mx-auto"
/> />
{showFlashMessage && <FlashMessage messages={flash ?? {}} />} {showFlashMessage && <FlashMessage messages={flash ?? {}} />}
@@ -60,11 +62,18 @@ export default function Contact() {
disableWhileProcessing disableWhileProcessing
> >
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-4xl"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-4xl mx-auto">
{/* Left — Identité + adresse */} {/* Left — Identité + adresse */}
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-4"> <div className="nb-shadow-static bg-primary dark:bg-[#171717] rounded-2xl p-6 flex flex-col gap-4">
<h2 className="text-lg font-semibold text-primary">Vos informations</h2> <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 grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-1"> <div className="grid gap-1">
@@ -96,7 +105,7 @@ export default function Contact() {
{/* Right — Message + captcha + submit */} {/* Right — Message + captcha + submit */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-4"> <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> <h2 className="text-lg font-semibold text-primary">Votre message</h2>
<div className="grid gap-1"> <div className="grid gap-1">
@@ -124,7 +133,7 @@ export default function Contact() {
</div> </div>
</div> </div>
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-5"> <div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-5">
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label> <Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label>
<Input <Input
@@ -143,10 +152,10 @@ export default function Contact() {
type="submit" type="submit"
variant="secondary" variant="secondary"
tabIndex={8} tabIndex={8}
className="w-full border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-0.5 hover:translate-y-0.5 font-bold text-base py-5" className="nb-shadow w-full font-bold text-base py-5"
> >
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />} {processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Envoyer Envoyer mon message
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { FlashMessage } from '@/components/flash-message';
import { Container } from '@/components/common/Container'; import { Container } from '@/components/common/Container';
import { SectionHeading } from '@/components/common/SectionHeading'; import { SectionHeading } from '@/components/common/SectionHeading';
import { Footer } from '@/components/footer'; import { Footer } from '@/components/footer';
import IllustrationLogo from "@/img/utils/lrl-logo-full.svg";
export default function Membership() { export default function Membership() {
const { flash, plans, services, captcha_question } = usePage().props as PageProps; const { flash, plans, services, captcha_question } = usePage().props as PageProps;
@@ -66,8 +67,15 @@ export default function Membership() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left — Personal info */} {/* Left — Personal info */}
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-4"> <div className="bg-primary dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-4">
<h2 className="text-lg font-semibold text-primary">Vos informations</h2> <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 grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-1"> <div className="grid gap-1">
@@ -124,7 +132,7 @@ export default function Membership() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Plan selection */} {/* Plan selection */}
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-4"> <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> <h2 className="text-lg font-semibold text-primary">Choisissez votre formule</h2>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -138,7 +146,7 @@ export default function Membership() {
'flex items-center justify-between rounded-xl border-3 border-black px-5 py-4 text-left transition-all duration-150', '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', '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 selectedPlan === plan.identifier
? 'bg-primary text-black' ? 'bg-secondary text-black'
: 'bg-white dark:bg-[#1a1a1a] hover:bg-primary/20', : 'bg-white dark:bg-[#1a1a1a] hover:bg-primary/20',
)} )}
> >
@@ -162,17 +170,17 @@ export default function Membership() {
<InputError message={errors.package} /> <InputError message={errors.package} />
<p className="text-center text-sm text-muted-foreground border-t border-border pt-3"> <p className="text-center text-sm text-muted-foreground border-t border-border pt-3">
Montant total : <strong className="text-primary text-lg">{amount}</strong> Montant total : <strong className="text-secondary text-lg">{amount}</strong>
</p> </p>
</div> </div>
{/* Services included */} {/* Services included */}
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-4"> <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> <h2 className="text-lg font-semibold text-primary">Services inclus</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{services?.map((service) => ( {services?.map((service) => (
<div key={service.name} className="flex items-start gap-2"> <div key={service.name} className="flex items-start gap-2">
<CheckIcon className="h-4 w-4 mt-0.5 shrink-0 text-accent" /> <CheckIcon className="h-4 w-4 mt-0.5 shrink-0 text-white" />
<div> <div>
<p className="text-sm font-medium leading-tight">{service.name}</p> <p className="text-sm font-medium leading-tight">{service.name}</p>
<p className="text-xs text-muted-foreground">{service.description}</p> <p className="text-xs text-muted-foreground">{service.description}</p>
@@ -183,7 +191,7 @@ export default function Membership() {
</div> </div>
{/* Captcha + CGU + Submit */} {/* Captcha + CGU + Submit */}
<div className="bg-white dark:bg-[#171717] rounded-2xl border-3 border-black p-6 shadow-[4px_4px_0px_rgba(0,0,0,1)] flex flex-col gap-5"> <div className="bg-white dark:bg-[#171717] nb-shadow-static rounded-2xl p-6 flex flex-col gap-5">
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label> <Label htmlFor="captcha" className="font-semibold">{captcha_question}</Label>
<Input <Input
@@ -213,7 +221,7 @@ export default function Membership() {
type="submit" type="submit"
variant="secondary" variant="secondary"
tabIndex={12} tabIndex={12}
className="w-full border-3 border-black shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-0.5 hover:translate-y-0.5 font-bold text-base py-5" className="nb-shadow w-full font-bold text-base py-5"
> >
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />} {processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Envoyer ma demande d'adhésion Envoyer ma demande d'adhésion

View File

@@ -0,0 +1,62 @@
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../wayfinder'
/**
* @see \App\Http\Controllers\DashboardController::serviceActivation
* @see app/Http/Controllers/DashboardController.php:30
* @route '/dashboard/service-activation'
*/
export const serviceActivation = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
url: serviceActivation.url(options),
method: 'post',
})
serviceActivation.definition = {
methods: ["post"],
url: '/dashboard/service-activation',
} satisfies RouteDefinition<["post"]>
/**
* @see \App\Http\Controllers\DashboardController::serviceActivation
* @see app/Http/Controllers/DashboardController.php:30
* @route '/dashboard/service-activation'
*/
serviceActivation.url = (options?: RouteQueryOptions) => {
return serviceActivation.definition.url + queryParams(options)
}
/**
* @see \App\Http\Controllers\DashboardController::serviceActivation
* @see app/Http/Controllers/DashboardController.php:30
* @route '/dashboard/service-activation'
*/
serviceActivation.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
url: serviceActivation.url(options),
method: 'post',
})
/**
* @see \App\Http\Controllers\DashboardController::serviceActivation
* @see app/Http/Controllers/DashboardController.php:30
* @route '/dashboard/service-activation'
*/
const serviceActivationForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
action: serviceActivation.url(options),
method: 'post',
})
/**
* @see \App\Http\Controllers\DashboardController::serviceActivation
* @see app/Http/Controllers/DashboardController.php:30
* @route '/dashboard/service-activation'
*/
serviceActivationForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
action: serviceActivation.url(options),
method: 'post',
})
serviceActivation.form = serviceActivationForm
const dashboard = {
serviceActivation: Object.assign(serviceActivation, serviceActivation),
}
export default dashboard

View File

@@ -137,7 +137,7 @@ logoutForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> =>
logout.form = logoutForm logout.form = logoutForm
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
export const home = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ export const home = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -151,7 +151,7 @@ home.definition = {
} satisfies RouteDefinition<["get","head"]> } satisfies RouteDefinition<["get","head"]>
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
home.url = (options?: RouteQueryOptions) => { home.url = (options?: RouteQueryOptions) => {
@@ -159,7 +159,7 @@ home.url = (options?: RouteQueryOptions) => {
} }
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
home.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ home.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -168,7 +168,7 @@ home.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
}) })
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
home.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ home.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -177,7 +177,7 @@ home.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
}) })
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
const homeForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ const homeForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -186,7 +186,7 @@ const homeForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
}) })
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
homeForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ homeForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -195,7 +195,7 @@ homeForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
}) })
/** /**
* @see routes/web.php:6 * @see routes/web.php:7
* @route '/welcome' * @route '/welcome'
*/ */
homeForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ homeForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -211,7 +211,7 @@ homeForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
home.form = homeForm home.form = homeForm
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
export const maintenance = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ export const maintenance = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -225,7 +225,7 @@ maintenance.definition = {
} satisfies RouteDefinition<["get","head"]> } satisfies RouteDefinition<["get","head"]>
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
maintenance.url = (options?: RouteQueryOptions) => { maintenance.url = (options?: RouteQueryOptions) => {
@@ -233,7 +233,7 @@ maintenance.url = (options?: RouteQueryOptions) => {
} }
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
maintenance.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ maintenance.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -242,7 +242,7 @@ maintenance.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
}) })
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
maintenance.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ maintenance.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -251,7 +251,7 @@ maintenance.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
}) })
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
const maintenanceForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ const maintenanceForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -260,7 +260,7 @@ const maintenanceForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'
}) })
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
maintenanceForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ maintenanceForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -269,7 +269,7 @@ maintenanceForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'>
}) })
/** /**
* @see routes/web.php:10 * @see routes/web.php:11
* @route '/' * @route '/'
*/ */
maintenanceForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ maintenanceForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -285,7 +285,8 @@ maintenanceForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'>
maintenance.form = maintenanceForm maintenance.form = maintenanceForm
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
export const dashboard = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ export const dashboard = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -299,7 +300,8 @@ dashboard.definition = {
} satisfies RouteDefinition<["get","head"]> } satisfies RouteDefinition<["get","head"]>
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
dashboard.url = (options?: RouteQueryOptions) => { dashboard.url = (options?: RouteQueryOptions) => {
@@ -307,7 +309,8 @@ dashboard.url = (options?: RouteQueryOptions) => {
} }
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
dashboard.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ dashboard.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
@@ -316,7 +319,8 @@ dashboard.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
}) })
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
dashboard.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ dashboard.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
@@ -325,7 +329,8 @@ dashboard.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
}) })
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
const dashboardForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ const dashboardForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -334,7 +339,8 @@ const dashboardForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'>
}) })
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
dashboardForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ dashboardForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
@@ -343,7 +349,8 @@ dashboardForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> =>
}) })
/** /**
* @see routes/web.php:15 * @see \App\Http\Controllers\DashboardController::dashboard
* @see app/Http/Controllers/DashboardController.php:15
* @route '/dashboard' * @route '/dashboard'
*/ */
dashboardForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ dashboardForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({

View File

@@ -74,12 +74,47 @@ export interface Service {
illustration: string; illustration: string;
} }
export interface DashboardService {
identifier: string;
name: string;
description: string | null;
url: string;
icon: string | null;
is_active: boolean;
}
export interface DashboardPackage {
identifier: string;
name: string;
description: string | null;
price: string;
}
export interface DashboardMembership {
status: string;
payment_status: string;
start_date: string | null;
end_date: string | null;
amount: string;
package: DashboardPackage | null;
services: DashboardService[];
}
export interface DashboardMember {
firstname: string | null;
lastname: string | null;
email: string;
retzien_email: string;
membership: DashboardMembership | null;
}
export interface PageProps { export interface PageProps {
flash?: FlashMessages; flash?: FlashMessages;
auth?: Auth; auth?: Auth;
plans?: Plans[]; plans?: Plans[];
services?: MembershipService[]; services?: MembershipService[];
captcha_question?: string; captcha_question?: string;
member?: DashboardMember | null;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
@@ -12,9 +13,8 @@ Route::get('/', function () {
})->name('maintenance'); })->name('maintenance');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
return Inertia::render('dashboard'); Route::post('dashboard/service-activation', [DashboardController::class, 'requestServiceActivation'])->name('dashboard.service-activation');
})->name('dashboard');
}); });
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';