feat(bookings): block new reservations on free plan limit + upgrade CTA

Le plan gratuit (BRONZE) est limite a maxShipmentsPerYear (5/an). Cote frontend,
rien ne le refletait: le bouton 'Nouvelle Reservation' restait actif. Ajout d'un
hook useReservationQuota (limite du plan vs reservations de l'annee) qui:
- remplace le bouton 'Nouvelle Reservation' par un bouton 'Ameliorer mon
  abonnement' + banniere sur la liste des reservations quand la limite est
  atteinte;
- bloque la page de recherche (nouvelle resa) avec un ecran d'upgrade, sauf en
  mode edition d'une resa existante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-03 17:02:56 +02:00
parent c312e7901e
commit f7427825d1
5 changed files with 160 additions and 16 deletions

View File

@ -4,11 +4,12 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { Plus, Clock, Eye, X, CreditCard, Pencil, MoreVertical } from 'lucide-react';
import { Plus, Clock, Eye, X, CreditCard, Pencil, MoreVertical, Lock, Sparkles } from 'lucide-react';
import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTranslations, useLocale } from 'next-intl';
import { useReservationQuota } from '@/hooks/useReservationQuota';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
@ -96,6 +97,29 @@ export default function BookingsListPage() {
if (csvError) console.error('CSV bookings error:', csvError);
const quota = useReservationQuota();
// When the free-plan reservation limit is reached, the "New reservation"
// entry point is replaced by an upgrade call-to-action.
const newReservationAction = quota.blocked ? (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
>
<Sparkles className="h-4 w-4" />
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
</Link>
) : (
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
);
const filterBookings = (bookings: any[]) => {
let filtered = bookings;
@ -254,17 +278,30 @@ export default function BookingsListPage() {
},
]}
/>
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('new')}</span>
</Link>
{newReservationAction}
</>
}
/>
{quota.blocked && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<Lock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-amber-800">{t('limit.title')}</p>
<p className="text-sm text-amber-700 mt-0.5">
{t('limit.message', { max: quota.max })}
</p>
<Link
href="/dashboard/settings/subscription"
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-amber-500 text-white text-sm font-medium rounded-lg hover:bg-amber-600 transition-colors"
>
<Sparkles className="h-4 w-4" />
{t('limit.upgrade')}
</Link>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
@ -669,6 +706,15 @@ export default function BookingsListPage() {
{searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}
</p>
<div className="mt-6">
{quota.blocked ? (
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center gap-2 px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600"
>
<Sparkles className="h-4 w-4" />
{t('limit.upgrade')}
</Link>
) : (
<Link
href="/dashboard/search-advanced"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
@ -676,6 +722,7 @@ export default function BookingsListPage() {
<Plus className="mr-2 h-4 w-4" />
{t('new')}
</Link>
)}
</div>
</div>
)}

View File

@ -3,10 +3,12 @@
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { Search, Loader2 } from 'lucide-react';
import { Search, Loader2, Lock, Sparkles } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates';
import { useReservationQuota } from '@/hooks/useReservationQuota';
import { Link } from '@/i18n/navigation';
import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => {
@ -69,8 +71,10 @@ const OPTION_KEYS: (keyof SearchForm)[] = [
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const tLimit = useTranslations('dashboard.bookingsList.limit');
const router = useRouter();
const searchParams = useSearchParams();
const quota = useReservationQuota();
const [editBookingId, setEditBookingId] = useState<string | null>(null);
const [searchForm, setSearchForm] = useState<SearchForm>({
origin: '',
@ -817,6 +821,39 @@ export default function AdvancedSearchPage() {
</div>
);
// Block starting a NEW reservation when the free-plan yearly limit is reached.
// Editing an existing unpaid booking (editBookingId) does not create a new
// shipment, so it is never blocked.
const isEditing = !!searchParams.get('editBookingId');
if (quota.blocked && !isEditing && !quota.loading) {
return (
<div className="max-w-2xl mx-auto py-10 px-4">
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mb-4">
<Lock className="h-7 w-7 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{tLimit('title')}</h1>
<p className="text-gray-600 mb-6">{tLimit('message', { max: quota.max })}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/dashboard/settings/subscription"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors"
>
<Sparkles className="h-5 w-5" />
{tLimit('upgrade')}
</Link>
<Link
href="/dashboard/bookings"
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
{tLimit('back')}
</Link>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>

View File

@ -398,6 +398,13 @@
"title": "Bookings",
"description": "Manage and track your shipments",
"new": "New Booking",
"limit": {
"title": "Reservation limit reached",
"message": "Your free plan is limited to {max} reservations per year. Upgrade your plan to keep booking.",
"upgrade": "Upgrade my plan",
"upgradeShort": "Upgrade",
"back": "Back to bookings"
},
"transferBanner": {
"title": "Transfer declared",
"message": "Your transfer has been recorded. An administrator will verify receipt and activate your booking. You will be notified once validated."

View File

@ -398,6 +398,13 @@
"title": "Réservations",
"description": "Gérez et suivez vos envois",
"new": "Nouvelle Réservation",
"limit": {
"title": "Limite de réservations atteinte",
"message": "Votre abonnement gratuit est limité à {max} réservations par an. Passez à un abonnement supérieur pour continuer à réserver.",
"upgrade": "Améliorer mon abonnement",
"upgradeShort": "Améliorer",
"back": "Retour aux réservations"
},
"transferBanner": {
"title": "Virement déclaré",
"message": "Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation."

View File

@ -0,0 +1,46 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api';
import { useSubscription } from '@/lib/context/subscription-context';
/**
* Reservation (shipment) quota for the current plan.
*
* The entry (free) plan is limited to a number of reservations per calendar
* year (`maxShipmentsPerYear`, e.g. 5). Higher plans are unlimited (-1). This
* hook lets the UI proactively prompt an upgrade once the limit is reached,
* instead of letting the user go through the whole flow only to be blocked.
*/
export function useReservationQuota() {
const { subscription, loading: subLoading } = useSubscription();
const { data, isLoading } = useQuery({
// Same query key as the bookings list so the request is shared/cached.
queryKey: ['csv-bookings'],
queryFn: () => listCsvBookings({ page: 1, limit: 1000 }),
});
const max = subscription?.planDetails?.maxShipmentsPerYear ?? -1;
const unlimited = max === -1;
const currentYear = new Date().getFullYear();
const bookings = data?.bookings ?? [];
const used = bookings.filter(
b => b.createdAt && new Date(b.createdAt).getFullYear() === currentYear
).length;
const limitReached = !unlimited && used >= max;
const blocked = limitReached;
return {
loading: subLoading || isLoading,
plan: subscription?.plan ?? null,
max,
used,
remaining: unlimited ? Infinity : Math.max(0, max - used),
unlimited,
limitReached,
blocked,
};
}