From f7427825d128689740ce5a1ce17952c04e3fbef7 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 Jul 2026 17:02:56 +0200 Subject: [PATCH] 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 --- .../app/[locale]/dashboard/bookings/page.tsx | 77 +++++++++++++++---- .../dashboard/search-advanced/page.tsx | 39 +++++++++- apps/frontend/messages/en.json | 7 ++ apps/frontend/messages/fr.json | 7 ++ .../frontend/src/hooks/useReservationQuota.ts | 46 +++++++++++ 5 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 apps/frontend/src/hooks/useReservationQuota.ts diff --git a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx index 3ab88bb..9cf128a 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx @@ -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 ? ( + + + {t('limit.upgrade')} + {t('limit.upgradeShort')} + + ) : ( + + + {t('new')} + + ); + const filterBookings = (bookings: any[]) => { let filtered = bookings; @@ -254,17 +278,30 @@ export default function BookingsListPage() { }, ]} /> - - - {t('new')} - + {newReservationAction} } /> + {quota.blocked && ( +
+ +
+

{t('limit.title')}

+

+ {t('limit.message', { max: quota.max })} +

+ + + {t('limit.upgrade')} + +
+
+ )} +
@@ -669,13 +706,23 @@ export default function BookingsListPage() { {searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings')}

- - - {t('new')} - + {quota.blocked ? ( + + + {t('limit.upgrade')} + + ) : ( + + + {t('new')} + + )}
)} diff --git a/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx b/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx index 50e18bb..f7fdcf5 100644 --- a/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx @@ -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(null); const [searchForm, setSearchForm] = useState({ origin: '', @@ -817,6 +821,39 @@ export default function AdvancedSearchPage() {
); + // 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 ( +
+
+
+ +
+

{tLimit('title')}

+

{tLimit('message', { max: quota.max })}

+
+ + + {tLimit('upgrade')} + + + {tLimit('back')} + +
+
+
+ ); + } + return (
diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index c8bd9eb..c1947ed 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -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." diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 51d7eda..556b82b 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -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." diff --git a/apps/frontend/src/hooks/useReservationQuota.ts b/apps/frontend/src/hooks/useReservationQuota.ts new file mode 100644 index 0000000..e7e0922 --- /dev/null +++ b/apps/frontend/src/hooks/useReservationQuota.ts @@ -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, + }; +}