Compare commits
10 Commits
d9868dd49f
...
94039598d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94039598d9 | ||
|
|
a200987288 | ||
|
|
10b45599ae | ||
|
|
301409624b | ||
|
|
40f785ddeb | ||
|
|
5c7834c7e4 | ||
|
|
dd5d806180 | ||
|
|
de4126a657 | ||
|
|
0a8e2043cc | ||
|
|
0d814e9a94 |
@ -84,3 +84,18 @@ RATE_LIMIT_MAX=100
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
|
||||
# Frontend URL (for redirects)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Stripe (Subscriptions & Payments)
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
|
||||
# Stripe Price IDs (create these in Stripe Dashboard)
|
||||
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
|
||||
STRIPE_STARTER_YEARLY_PRICE_ID=price_starter_yearly
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly
|
||||
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_enterprise_yearly
|
||||
|
||||
17
apps/backend/package-lock.json
generated
17
apps/backend/package-lock.json
generated
@ -59,7 +59,9 @@
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"typeorm": "^0.3.17"
|
||||
"stripe": "^14.14.0",
|
||||
"typeorm": "^0.3.17",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
@ -14570,6 +14572,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "14.25.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
|
||||
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=8.1.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.*"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||
|
||||
@ -75,7 +75,9 @@
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"typeorm": "^0.3.17"
|
||||
"stripe": "^14.14.0",
|
||||
"typeorm": "^0.3.17",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
|
||||
55
apps/backend/scripts/list-stripe-prices.js
Normal file
55
apps/backend/scripts/list-stripe-prices.js
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Script to list all Stripe prices
|
||||
* Run with: node scripts/list-stripe-prices.js
|
||||
*/
|
||||
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
||||
|
||||
async function listPrices() {
|
||||
console.log('Fetching Stripe prices...\n');
|
||||
|
||||
try {
|
||||
const prices = await stripe.prices.list({ limit: 50, expand: ['data.product'] });
|
||||
|
||||
if (prices.data.length === 0) {
|
||||
console.log('No prices found. You need to create prices in Stripe Dashboard.');
|
||||
console.log('\nSteps:');
|
||||
console.log('1. Go to https://dashboard.stripe.com/products');
|
||||
console.log('2. Click on each product (Starter, Pro, Enterprise)');
|
||||
console.log('3. Add a recurring price (monthly and yearly)');
|
||||
console.log('4. Copy the Price IDs (format: price_xxxxx)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Available Prices:\n');
|
||||
console.log('='.repeat(100));
|
||||
|
||||
for (const price of prices.data) {
|
||||
const product = typeof price.product === 'object' ? price.product : { name: price.product };
|
||||
const interval = price.recurring ? `${price.recurring.interval}ly` : 'one-time';
|
||||
const amount = (price.unit_amount / 100).toFixed(2);
|
||||
|
||||
console.log(`Price ID: ${price.id}`);
|
||||
console.log(`Product: ${product.name || product.id}`);
|
||||
console.log(`Amount: ${amount} ${price.currency.toUpperCase()}`);
|
||||
console.log(`Interval: ${interval}`);
|
||||
console.log(`Active: ${price.active}`);
|
||||
console.log('-'.repeat(100));
|
||||
}
|
||||
|
||||
console.log('\n\nCopy the relevant Price IDs to your .env file:');
|
||||
console.log('STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching prices:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
listPrices();
|
||||
@ -19,6 +19,7 @@ import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||
import { AdminModule } from './application/admin/admin.module';
|
||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||
import { SecurityModule } from './infrastructure/security/security.module';
|
||||
@ -56,6 +57,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
SMTP_PASS: Joi.string().required(),
|
||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||
SMTP_SECURE: Joi.boolean().default(false),
|
||||
// Stripe Configuration (optional for development)
|
||||
STRIPE_SECRET_KEY: Joi.string().optional(),
|
||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional(),
|
||||
STRIPE_STARTER_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_STARTER_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_PRO_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID: Joi.string().optional(),
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID: Joi.string().optional(),
|
||||
}),
|
||||
}),
|
||||
|
||||
@ -117,6 +127,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
WebhooksModule,
|
||||
GDPRModule,
|
||||
AdminModule,
|
||||
SubscriptionsModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@ -20,6 +20,7 @@ import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeo
|
||||
import { InvitationService } from '../services/invitation.service';
|
||||
import { InvitationsController } from '../controllers/invitations.controller';
|
||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -43,6 +44,9 @@ import { EmailModule } from '../../infrastructure/email/email.module';
|
||||
|
||||
// Email module for sending invitations
|
||||
EmailModule,
|
||||
|
||||
// Subscriptions module for license checks
|
||||
SubscriptionsModule,
|
||||
],
|
||||
controllers: [AuthController, InvitationsController],
|
||||
providers: [
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import { Organization } from '@domain/entities/organization.entity';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // user ID
|
||||
@ -37,7 +38,8 @@ export class AuthService {
|
||||
@Inject(ORGANIZATION_REPOSITORY)
|
||||
private readonly organizationRepository: OrganizationRepository,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService
|
||||
private readonly configService: ConfigService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -100,6 +102,16 @@ export class AuthService {
|
||||
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
|
||||
// Allocate a license for the new user
|
||||
try {
|
||||
await this.subscriptionService.allocateLicense(savedUser.id, finalOrganizationId);
|
||||
this.logger.log(`License allocated for user: ${email}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to allocate license for user ${email}:`, error);
|
||||
// Note: We don't throw here because the user is already created.
|
||||
// The license check should happen before invitation.
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(savedUser);
|
||||
|
||||
this.logger.log(`User registered successfully: ${email}`);
|
||||
|
||||
@ -372,14 +372,20 @@ export class BookingsController {
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||||
|
||||
// Fetch rate quotes for all bookings
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
// Fetch rate quotes for all bookings (filter out those with missing rate quotes)
|
||||
const bookingsWithQuotesRaw = await Promise.all(
|
||||
paginatedBookings.map(async (booking: any) => {
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
return { booking, rateQuote: rateQuote! };
|
||||
return { booking, rateQuote };
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
||||
item.rateQuote !== null && item.rateQuote !== undefined
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||
|
||||
@ -440,14 +446,21 @@ export class BookingsController {
|
||||
);
|
||||
|
||||
// Map ORM entities to domain and fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
const bookingsWithQuotesRaw = await Promise.all(
|
||||
bookingOrms.map(async bookingOrm => {
|
||||
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||
return { booking: booking!, rateQuote: rateQuote! };
|
||||
return { booking, rateQuote };
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out bookings or rate quotes that are null
|
||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
||||
(item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
||||
item.booking !== null && item.booking !== undefined &&
|
||||
item.rateQuote !== null && item.rateQuote !== undefined
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||
BookingMapper.toDto(booking, rateQuote)
|
||||
@ -487,8 +500,10 @@ export class BookingsController {
|
||||
// Apply filters
|
||||
bookings = this.applyFilters(bookings, filter);
|
||||
|
||||
// Sort bookings
|
||||
bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
|
||||
// Sort bookings (use defaults if not provided)
|
||||
const sortBy = filter.sortBy || 'createdAt';
|
||||
const sortOrder = filter.sortOrder || 'desc';
|
||||
bookings = this.sortBookings(bookings, sortBy, sortOrder);
|
||||
|
||||
// Total count before pagination
|
||||
const total = bookings.length;
|
||||
@ -498,14 +513,20 @@ export class BookingsController {
|
||||
const endIndex = startIndex + (filter.pageSize || 20);
|
||||
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
||||
|
||||
// Fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
||||
const bookingsWithQuotesRaw = await Promise.all(
|
||||
paginatedBookings.map(async booking => {
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
return { booking, rateQuote: rateQuote! };
|
||||
return { booking, rateQuote };
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
||||
item.rateQuote !== null && item.rateQuote !== undefined
|
||||
);
|
||||
|
||||
// Convert to DTOs
|
||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||
|
||||
@ -562,14 +583,20 @@ export class BookingsController {
|
||||
bookings = this.applyFilters(bookings, filter);
|
||||
}
|
||||
|
||||
// Fetch rate quotes
|
||||
const bookingsWithQuotes = await Promise.all(
|
||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
||||
const bookingsWithQuotesRaw = await Promise.all(
|
||||
bookings.map(async booking => {
|
||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||
return { booking, rateQuote: rateQuote! };
|
||||
return { booking, rateQuote };
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
||||
item.rateQuote !== null && item.rateQuote !== undefined
|
||||
);
|
||||
|
||||
// Generate export file
|
||||
const exportResult = await this.exportService.exportBookings(
|
||||
bookingsWithQuotes,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
Post,
|
||||
Get,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@ -39,12 +40,20 @@ import {
|
||||
* CSV Bookings Controller
|
||||
*
|
||||
* Handles HTTP requests for CSV-based booking requests
|
||||
*
|
||||
* IMPORTANT: Route order matters in NestJS!
|
||||
* Static routes MUST come BEFORE parameterized routes.
|
||||
* Otherwise, `:id` will capture "stats", "organization", etc.
|
||||
*/
|
||||
@ApiTags('CSV Bookings')
|
||||
@Controller('csv-bookings')
|
||||
export class CsvBookingsController {
|
||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||
|
||||
// ============================================================================
|
||||
// STATIC ROUTES (must come FIRST)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new CSV booking request
|
||||
*
|
||||
@ -151,6 +160,112 @@ export class CsvBookingsController {
|
||||
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's bookings (paginated)
|
||||
*
|
||||
* GET /api/v1/csv-bookings
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get user bookings',
|
||||
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Bookings retrieved successfully',
|
||||
type: CsvBookingListResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUserBookings(
|
||||
@Request() req: any,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||
): Promise<CsvBookingListResponseDto> {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking statistics for user
|
||||
*
|
||||
* GET /api/v1/csv-bookings/stats/me
|
||||
*/
|
||||
@Get('stats/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get user booking statistics',
|
||||
description:
|
||||
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved successfully',
|
||||
type: CsvBookingStatsDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.getUserStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization booking statistics
|
||||
*
|
||||
* GET /api/v1/csv-bookings/stats/organization
|
||||
*/
|
||||
@Get('stats/organization')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get organization booking statistics',
|
||||
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved successfully',
|
||||
type: CsvBookingStatsDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||
const organizationId = req.user.organizationId;
|
||||
return await this.csvBookingService.getOrganizationStats(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization bookings (for managers/admins)
|
||||
*
|
||||
* GET /api/v1/csv-bookings/organization/all
|
||||
*/
|
||||
@Get('organization/all')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get organization bookings',
|
||||
description:
|
||||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Organization bookings retrieved successfully',
|
||||
type: CsvBookingListResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getOrganizationBookings(
|
||||
@Request() req: any,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||
): Promise<CsvBookingListResponseDto> {
|
||||
const organizationId = req.user.organizationId;
|
||||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a booking request (PUBLIC - token-based)
|
||||
*
|
||||
@ -226,10 +341,17 @@ export class CsvBookingsController {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PARAMETERIZED ROUTES (must come LAST)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a booking by ID
|
||||
*
|
||||
* GET /api/v1/csv-bookings/:id
|
||||
*
|
||||
* IMPORTANT: This route MUST be after all static GET routes
|
||||
* Otherwise it will capture "stats", "organization", etc.
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -252,59 +374,6 @@ export class CsvBookingsController {
|
||||
return await this.csvBookingService.getBookingById(id, userId, carrierId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's bookings (paginated)
|
||||
*
|
||||
* GET /api/v1/csv-bookings
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get user bookings',
|
||||
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Bookings retrieved successfully',
|
||||
type: CsvBookingListResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUserBookings(
|
||||
@Request() req: any,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||
): Promise<CsvBookingListResponseDto> {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking statistics for user
|
||||
*
|
||||
* GET /api/v1/csv-bookings/stats/me
|
||||
*/
|
||||
@Get('stats/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get user booking statistics',
|
||||
description:
|
||||
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved successfully',
|
||||
type: CsvBookingStatsDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.getUserStats(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a booking (user action)
|
||||
*
|
||||
@ -335,55 +404,165 @@ export class CsvBookingsController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization bookings (for managers/admins)
|
||||
* Add documents to an existing booking
|
||||
*
|
||||
* GET /api/v1/csv-bookings/organization/all
|
||||
* POST /api/v1/csv-bookings/:id/documents
|
||||
*/
|
||||
@Get('organization/all')
|
||||
@Post(':id/documents')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Get organization bookings',
|
||||
summary: 'Add documents to an existing booking',
|
||||
description:
|
||||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||||
'Upload additional documents to a pending booking. Only the booking owner can add documents.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
documents: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
description: 'Documents to add (max 10 files)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Organization bookings retrieved successfully',
|
||||
type: CsvBookingListResponseDto,
|
||||
description: 'Documents added successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: 'Documents added successfully' },
|
||||
documentsAdded: { type: 'number', example: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getOrganizationBookings(
|
||||
@Request() req: any,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||
): Promise<CsvBookingListResponseDto> {
|
||||
const organizationId = req.user.organizationId;
|
||||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
async addDocuments(
|
||||
@Param('id') id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: any
|
||||
) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('At least one document is required');
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.addDocuments(id, files, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization booking statistics
|
||||
* Replace a document in a booking
|
||||
*
|
||||
* GET /api/v1/csv-bookings/stats/organization
|
||||
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
||||
*/
|
||||
@Get('stats/organization')
|
||||
@Patch(':bookingId/documents/:documentId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FilesInterceptor('document', 1))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Get organization booking statistics',
|
||||
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
||||
summary: 'Replace a document in a booking',
|
||||
description:
|
||||
'Replace an existing document with a new one. Only the booking owner can replace documents.',
|
||||
})
|
||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'New document file to replace the existing one',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved successfully',
|
||||
type: CsvBookingStatsDto,
|
||||
description: 'Document replaced successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: 'Document replaced successfully' },
|
||||
newDocument: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
fileName: { type: 'string' },
|
||||
filePath: { type: 'string' },
|
||||
mimeType: { type: 'string' },
|
||||
size: { type: 'number' },
|
||||
uploadedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Invalid request - missing file' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||
const organizationId = req.user.organizationId;
|
||||
return await this.csvBookingService.getOrganizationStats(organizationId);
|
||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||
async replaceDocument(
|
||||
@Param('bookingId') bookingId: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: any
|
||||
) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('A document file is required');
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from a booking
|
||||
*
|
||||
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
||||
*/
|
||||
@Delete(':bookingId/documents/:documentId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Delete a document from a booking',
|
||||
description:
|
||||
'Remove a document from a pending booking. Only the booking owner can delete documents.',
|
||||
})
|
||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Document deleted successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: 'Document deleted successfully' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||
async deleteDocument(
|
||||
@Param('bookingId') bookingId: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Request() req: any
|
||||
) {
|
||||
const userId = req.user.id;
|
||||
return await this.csvBookingService.deleteDocument(bookingId, documentId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Subscriptions Controller
|
||||
*
|
||||
* Handles subscription management endpoints:
|
||||
* - GET /subscriptions - Get subscription overview
|
||||
* - GET /subscriptions/plans - Get all available plans
|
||||
* - GET /subscriptions/can-invite - Check if can invite users
|
||||
* - POST /subscriptions/checkout - Create Stripe checkout session
|
||||
* - POST /subscriptions/portal - Create Stripe portal session
|
||||
* - POST /subscriptions/webhook - Handle Stripe webhooks
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Headers,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiExcludeEndpoint,
|
||||
} from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
import {
|
||||
CreateCheckoutSessionDto,
|
||||
CreatePortalSessionDto,
|
||||
SyncSubscriptionDto,
|
||||
SubscriptionOverviewResponseDto,
|
||||
CanInviteResponseDto,
|
||||
CheckoutSessionResponseDto,
|
||||
PortalSessionResponseDto,
|
||||
AllPlansResponseDto,
|
||||
} from '../dto/subscription.dto';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
|
||||
@ApiTags('Subscriptions')
|
||||
@Controller('subscriptions')
|
||||
export class SubscriptionsController {
|
||||
private readonly logger = new Logger(SubscriptionsController.name);
|
||||
|
||||
constructor(private readonly subscriptionService: SubscriptionService) {}
|
||||
|
||||
/**
|
||||
* Get subscription overview for current organization
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get subscription overview',
|
||||
description:
|
||||
'Get the subscription details including licenses for the current organization. Admin/manager only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Subscription overview retrieved successfully',
|
||||
type: SubscriptionOverviewResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Forbidden - requires admin or manager role',
|
||||
})
|
||||
async getSubscriptionOverview(
|
||||
@CurrentUser() user: UserPayload,
|
||||
): Promise<SubscriptionOverviewResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
||||
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available plans
|
||||
*/
|
||||
@Get('plans')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Get all plans',
|
||||
description: 'Get details of all available subscription plans.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Plans retrieved successfully',
|
||||
type: AllPlansResponseDto,
|
||||
})
|
||||
getAllPlans(): AllPlansResponseDto {
|
||||
this.logger.log('Getting all subscription plans');
|
||||
return this.subscriptionService.getAllPlans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if organization can invite more users
|
||||
*/
|
||||
@Get('can-invite')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Check license availability',
|
||||
description:
|
||||
'Check if the organization can invite more users based on license availability. Admin/manager only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'License availability check result',
|
||||
type: CanInviteResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Forbidden - requires admin or manager role',
|
||||
})
|
||||
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
||||
return this.subscriptionService.canInviteUser(user.organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Checkout session for subscription upgrade
|
||||
*/
|
||||
@Post('checkout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Create checkout session',
|
||||
description:
|
||||
'Create a Stripe Checkout session for upgrading subscription. Admin/Manager only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Checkout session created successfully',
|
||||
type: CheckoutSessionResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Bad request - invalid plan or already subscribed',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Forbidden - requires admin or manager role',
|
||||
})
|
||||
async createCheckoutSession(
|
||||
@Body() dto: CreateCheckoutSessionDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
): Promise<CheckoutSessionResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
||||
return this.subscriptionService.createCheckoutSession(
|
||||
user.organizationId,
|
||||
user.id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Customer Portal session
|
||||
*/
|
||||
@Post('portal')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Create portal session',
|
||||
description:
|
||||
'Create a Stripe Customer Portal session for subscription management. Admin/Manager only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Portal session created successfully',
|
||||
type: PortalSessionResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Bad request - no Stripe customer found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Forbidden - requires admin or manager role',
|
||||
})
|
||||
async createPortalSession(
|
||||
@Body() dto: CreatePortalSessionDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
): Promise<PortalSessionResponseDto> {
|
||||
this.logger.log(`[User: ${user.email}] Creating portal session`);
|
||||
return this.subscriptionService.createPortalSession(user.organizationId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subscription from Stripe
|
||||
* Useful when webhooks are not available (e.g., local development)
|
||||
*/
|
||||
@Post('sync')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Sync subscription from Stripe',
|
||||
description:
|
||||
'Manually sync subscription data from Stripe. Useful when webhooks are not working (local dev). Pass sessionId after checkout to sync new subscription. Admin/Manager only.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Subscription synced successfully',
|
||||
type: SubscriptionOverviewResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Bad request - no Stripe subscription found',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Forbidden - requires admin or manager role',
|
||||
})
|
||||
async syncFromStripe(
|
||||
@Body() dto: SyncSubscriptionDto,
|
||||
@CurrentUser() user: UserPayload,
|
||||
): Promise<SubscriptionOverviewResponseDto> {
|
||||
this.logger.log(
|
||||
`[User: ${user.email}] Syncing subscription from Stripe${dto.sessionId ? ` (sessionId: ${dto.sessionId})` : ''}`,
|
||||
);
|
||||
return this.subscriptionService.syncFromStripe(user.organizationId, dto.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Stripe webhook events
|
||||
*/
|
||||
@Post('webhook')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiExcludeEndpoint()
|
||||
async handleWebhook(
|
||||
@Headers('stripe-signature') signature: string,
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
): Promise<{ received: boolean }> {
|
||||
const rawBody = req.rawBody;
|
||||
if (!rawBody) {
|
||||
this.logger.error('No raw body found in request');
|
||||
return { received: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.subscriptionService.handleStripeWebhook(rawBody, signature);
|
||||
return { received: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Webhook processing failed', error);
|
||||
return { received: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -49,6 +49,7 @@ import { Roles } from '../decorators/roles.decorator';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as crypto from 'crypto';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
/**
|
||||
* Users Controller
|
||||
@ -68,7 +69,10 @@ import * as crypto from 'crypto';
|
||||
export class UsersController {
|
||||
private readonly logger = new Logger(UsersController.name);
|
||||
|
||||
constructor(@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository) {}
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create/Invite a new user
|
||||
@ -273,8 +277,21 @@ export class UsersController {
|
||||
if (dto.isActive !== undefined) {
|
||||
if (dto.isActive) {
|
||||
user.activate();
|
||||
// Reallocate license if reactivating user
|
||||
try {
|
||||
await this.subscriptionService.allocateLicense(id, user.organizationId);
|
||||
this.logger.log(`License reallocated for reactivated user: ${id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
|
||||
throw new ForbiddenException(
|
||||
'Cannot reactivate user: no licenses available. Please upgrade your subscription.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
user.deactivate();
|
||||
// Revoke license when deactivating user
|
||||
await this.subscriptionService.revokeLicense(id);
|
||||
this.logger.log(`License revoked for deactivated user: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -321,6 +338,10 @@ export class UsersController {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
|
||||
// Revoke license before deleting user
|
||||
await this.subscriptionService.revokeLicense(id);
|
||||
this.logger.log(`License revoked for user being deleted: ${id}`);
|
||||
|
||||
// Permanently delete user from database
|
||||
await this.userRepository.deleteById(id);
|
||||
|
||||
|
||||
378
apps/backend/src/application/dto/subscription.dto.ts
Normal file
378
apps/backend/src/application/dto/subscription.dto.ts
Normal file
@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Subscription DTOs
|
||||
*
|
||||
* Data Transfer Objects for subscription management API
|
||||
*/
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsUrl,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Subscription plan types
|
||||
*/
|
||||
export enum SubscriptionPlanDto {
|
||||
FREE = 'FREE',
|
||||
STARTER = 'STARTER',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription status types
|
||||
*/
|
||||
export enum SubscriptionStatusDto {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAST_DUE = 'PAST_DUE',
|
||||
CANCELED = 'CANCELED',
|
||||
INCOMPLETE = 'INCOMPLETE',
|
||||
INCOMPLETE_EXPIRED = 'INCOMPLETE_EXPIRED',
|
||||
TRIALING = 'TRIALING',
|
||||
UNPAID = 'UNPAID',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing interval types
|
||||
*/
|
||||
export enum BillingIntervalDto {
|
||||
MONTHLY = 'monthly',
|
||||
YEARLY = 'yearly',
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Checkout Session DTO
|
||||
*/
|
||||
export class CreateCheckoutSessionDto {
|
||||
@ApiProperty({
|
||||
example: SubscriptionPlanDto.STARTER,
|
||||
description: 'The subscription plan to purchase',
|
||||
enum: SubscriptionPlanDto,
|
||||
})
|
||||
@IsEnum(SubscriptionPlanDto)
|
||||
plan: SubscriptionPlanDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: BillingIntervalDto.MONTHLY,
|
||||
description: 'Billing interval (monthly or yearly)',
|
||||
enum: BillingIntervalDto,
|
||||
})
|
||||
@IsEnum(BillingIntervalDto)
|
||||
billingInterval: BillingIntervalDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription?success=true',
|
||||
description: 'URL to redirect to after successful payment',
|
||||
})
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
successUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription?canceled=true',
|
||||
description: 'URL to redirect to if payment is canceled',
|
||||
})
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
cancelUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Portal Session DTO
|
||||
*/
|
||||
export class CreatePortalSessionDto {
|
||||
@ApiPropertyOptional({
|
||||
example: 'https://app.xpeditis.com/dashboard/settings/subscription',
|
||||
description: 'URL to return to after using the portal',
|
||||
})
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Subscription DTO
|
||||
*/
|
||||
export class SyncSubscriptionDto {
|
||||
@ApiPropertyOptional({
|
||||
example: 'cs_test_a1b2c3d4e5f6g7h8',
|
||||
description: 'Stripe checkout session ID (used after checkout completes)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout Session Response DTO
|
||||
*/
|
||||
export class CheckoutSessionResponseDto {
|
||||
@ApiProperty({
|
||||
example: 'cs_test_a1b2c3d4e5f6g7h8',
|
||||
description: 'Stripe checkout session ID',
|
||||
})
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'https://checkout.stripe.com/pay/cs_test_a1b2c3',
|
||||
description: 'URL to redirect user to for payment',
|
||||
})
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal Session Response DTO
|
||||
*/
|
||||
export class PortalSessionResponseDto {
|
||||
@ApiProperty({
|
||||
example: 'https://billing.stripe.com/session/test_YWNjdF8x',
|
||||
description: 'URL to redirect user to for subscription management',
|
||||
})
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* License Response DTO
|
||||
*/
|
||||
export class LicenseResponseDto {
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
description: 'License ID',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440001',
|
||||
description: 'User ID',
|
||||
})
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'john.doe@example.com',
|
||||
description: 'User email',
|
||||
})
|
||||
userEmail: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'John Doe',
|
||||
description: 'User full name',
|
||||
})
|
||||
userName: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'ADMIN',
|
||||
description: 'User role (ADMIN users have unlimited licenses)',
|
||||
})
|
||||
userRole: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'ACTIVE',
|
||||
description: 'License status',
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-01-15T10:00:00Z',
|
||||
description: 'When the license was assigned',
|
||||
})
|
||||
assignedAt: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2025-02-15T10:00:00Z',
|
||||
description: 'When the license was revoked (if applicable)',
|
||||
})
|
||||
revokedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan Details DTO
|
||||
*/
|
||||
export class PlanDetailsDto {
|
||||
@ApiProperty({
|
||||
example: SubscriptionPlanDto.STARTER,
|
||||
description: 'Plan identifier',
|
||||
enum: SubscriptionPlanDto,
|
||||
})
|
||||
plan: SubscriptionPlanDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Starter',
|
||||
description: 'Plan display name',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 5,
|
||||
description: 'Maximum number of licenses (-1 for unlimited)',
|
||||
})
|
||||
maxLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 49,
|
||||
description: 'Monthly price in EUR',
|
||||
})
|
||||
monthlyPriceEur: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 470,
|
||||
description: 'Yearly price in EUR',
|
||||
})
|
||||
yearlyPriceEur: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['Up to 5 users', 'Advanced rate search', 'CSV imports'],
|
||||
description: 'List of features included in this plan',
|
||||
type: [String],
|
||||
})
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription Response DTO
|
||||
*/
|
||||
export class SubscriptionResponseDto {
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
description: 'Subscription ID',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '550e8400-e29b-41d4-a716-446655440001',
|
||||
description: 'Organization ID',
|
||||
})
|
||||
organizationId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: SubscriptionPlanDto.STARTER,
|
||||
description: 'Current subscription plan',
|
||||
enum: SubscriptionPlanDto,
|
||||
})
|
||||
plan: SubscriptionPlanDto;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Details about the current plan',
|
||||
type: PlanDetailsDto,
|
||||
})
|
||||
planDetails: PlanDetailsDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: SubscriptionStatusDto.ACTIVE,
|
||||
description: 'Current subscription status',
|
||||
enum: SubscriptionStatusDto,
|
||||
})
|
||||
status: SubscriptionStatusDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: 3,
|
||||
description: 'Number of licenses currently in use',
|
||||
})
|
||||
usedLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 5,
|
||||
description: 'Maximum licenses available (-1 for unlimited)',
|
||||
})
|
||||
maxLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 2,
|
||||
description: 'Number of licenses available',
|
||||
})
|
||||
availableLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: false,
|
||||
description: 'Whether the subscription is scheduled for cancellation',
|
||||
})
|
||||
cancelAtPeriodEnd: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2025-01-01T00:00:00Z',
|
||||
description: 'Start of current billing period',
|
||||
})
|
||||
currentPeriodStart?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2025-02-01T00:00:00Z',
|
||||
description: 'End of current billing period',
|
||||
})
|
||||
currentPeriodEnd?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-01-01T00:00:00Z',
|
||||
description: 'When the subscription was created',
|
||||
})
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2025-01-15T10:00:00Z',
|
||||
description: 'When the subscription was last updated',
|
||||
})
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription Overview Response DTO (includes licenses)
|
||||
*/
|
||||
export class SubscriptionOverviewResponseDto extends SubscriptionResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'List of active licenses',
|
||||
type: [LicenseResponseDto],
|
||||
})
|
||||
licenses: LicenseResponseDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Can Invite Response DTO
|
||||
*/
|
||||
export class CanInviteResponseDto {
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
description: 'Whether the organization can invite more users',
|
||||
})
|
||||
canInvite: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: 2,
|
||||
description: 'Number of available licenses',
|
||||
})
|
||||
availableLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 3,
|
||||
description: 'Number of used licenses',
|
||||
})
|
||||
usedLicenses: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 5,
|
||||
description: 'Maximum licenses allowed (-1 for unlimited)',
|
||||
})
|
||||
maxLicenses: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Upgrade to Starter plan to add more users',
|
||||
description: 'Message explaining why invitations are blocked',
|
||||
})
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Plans Response DTO
|
||||
*/
|
||||
export class AllPlansResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'List of all available plans',
|
||||
type: [PlanDetailsDto],
|
||||
})
|
||||
plans: PlanDetailsDto[];
|
||||
}
|
||||
@ -454,6 +454,219 @@ export class CsvBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add documents to an existing booking
|
||||
*/
|
||||
async addDocuments(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId: string
|
||||
): Promise<{ success: boolean; message: string; documentsAdded: number }> {
|
||||
this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns this booking
|
||||
if (booking.userId !== userId) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify booking is still pending
|
||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
||||
}
|
||||
|
||||
// Upload new documents
|
||||
const newDocuments = await this.uploadDocuments(files, bookingId);
|
||||
|
||||
// Add documents to booking
|
||||
const updatedDocuments = [...booking.documents, ...newDocuments];
|
||||
|
||||
// Update booking in database using ORM repository directly
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (ormBooking) {
|
||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
filePath: doc.filePath,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
uploadedAt: doc.uploadedAt,
|
||||
}));
|
||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||
}
|
||||
|
||||
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Documents added successfully',
|
||||
documentsAdded: newDocuments.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from a booking
|
||||
*/
|
||||
async deleteDocument(
|
||||
bookingId: string,
|
||||
documentId: string,
|
||||
userId: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns this booking
|
||||
if (booking.userId !== userId) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify booking is still pending
|
||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
||||
}
|
||||
|
||||
// Find the document
|
||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
||||
|
||||
if (documentIndex === -1) {
|
||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
||||
}
|
||||
|
||||
// Ensure at least one document remains
|
||||
if (booking.documents.length <= 1) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete the last document. At least one document is required.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
|
||||
const _documentToDelete = booking.documents[documentIndex];
|
||||
|
||||
// Remove document from array
|
||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
||||
|
||||
// Update booking in database using ORM repository directly
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (ormBooking) {
|
||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
filePath: doc.filePath,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
uploadedAt: doc.uploadedAt,
|
||||
}));
|
||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||
}
|
||||
|
||||
// Optionally delete from S3 (commented out for safety - keep files for audit)
|
||||
// try {
|
||||
// await this.storageAdapter.delete({
|
||||
// bucket: 'xpeditis-documents',
|
||||
// key: documentToDelete.filePath,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`);
|
||||
// }
|
||||
|
||||
this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Document deleted successfully',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a document in an existing booking
|
||||
*/
|
||||
async replaceDocument(
|
||||
bookingId: string,
|
||||
documentId: string,
|
||||
file: Express.Multer.File,
|
||||
userId: string
|
||||
): Promise<{ success: boolean; message: string; newDocument: any }> {
|
||||
this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns this booking
|
||||
if (booking.userId !== userId) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Find the document to replace
|
||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
||||
|
||||
if (documentIndex === -1) {
|
||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
||||
}
|
||||
|
||||
// Upload the new document
|
||||
const newDocuments = await this.uploadDocuments([file], bookingId);
|
||||
const newDocument = newDocuments[0];
|
||||
|
||||
// Replace the document in the array
|
||||
const updatedDocuments = [...booking.documents];
|
||||
updatedDocuments[documentIndex] = newDocument;
|
||||
|
||||
// Update booking in database using ORM repository directly
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (ormBooking) {
|
||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||
id: doc.id,
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
filePath: doc.filePath,
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
uploadedAt: doc.uploadedAt,
|
||||
}));
|
||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||
}
|
||||
|
||||
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Document replaced successfully',
|
||||
newDocument: {
|
||||
id: newDocument.id,
|
||||
type: newDocument.type,
|
||||
fileName: newDocument.fileName,
|
||||
filePath: newDocument.filePath,
|
||||
mimeType: newDocument.mimeType,
|
||||
size: newDocument.size,
|
||||
uploadedAt: newDocument.uploadedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer document type from filename
|
||||
*/
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
@ -19,6 +20,7 @@ import {
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
||||
import { UserRole } from '@domain/entities/user.entity';
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@ -35,7 +37,8 @@ export class InvitationService {
|
||||
private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(EMAIL_PORT)
|
||||
private readonly emailService: EmailPort,
|
||||
private readonly configService: ConfigService
|
||||
private readonly configService: ConfigService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -65,6 +68,18 @@ export class InvitationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if licenses are available for this organization
|
||||
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
|
||||
if (!canInviteResult.canInvite) {
|
||||
this.logger.warn(
|
||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`,
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
canInviteResult.message ||
|
||||
`License limit reached. Please upgrade your subscription to invite more users.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate unique token
|
||||
const token = this.generateToken();
|
||||
|
||||
|
||||
684
apps/backend/src/application/services/subscription.service.ts
Normal file
684
apps/backend/src/application/services/subscription.service.ts
Normal file
@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Subscription Service
|
||||
*
|
||||
* Business logic for subscription and license management.
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SubscriptionRepository,
|
||||
SUBSCRIPTION_REPOSITORY,
|
||||
} from '@domain/ports/out/subscription.repository';
|
||||
import {
|
||||
LicenseRepository,
|
||||
LICENSE_REPOSITORY,
|
||||
} from '@domain/ports/out/license.repository';
|
||||
import {
|
||||
OrganizationRepository,
|
||||
ORGANIZATION_REPOSITORY,
|
||||
} from '@domain/ports/out/organization.repository';
|
||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
||||
import { Subscription } from '@domain/entities/subscription.entity';
|
||||
import { License } from '@domain/entities/license.entity';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanType,
|
||||
} from '@domain/value-objects/subscription-plan.vo';
|
||||
import { SubscriptionStatus } from '@domain/value-objects/subscription-status.vo';
|
||||
import {
|
||||
NoLicensesAvailableException,
|
||||
SubscriptionNotFoundException,
|
||||
LicenseAlreadyAssignedException,
|
||||
} from '@domain/exceptions/subscription.exceptions';
|
||||
import {
|
||||
CreateCheckoutSessionDto,
|
||||
CreatePortalSessionDto,
|
||||
SubscriptionOverviewResponseDto,
|
||||
CanInviteResponseDto,
|
||||
CheckoutSessionResponseDto,
|
||||
PortalSessionResponseDto,
|
||||
LicenseResponseDto,
|
||||
PlanDetailsDto,
|
||||
AllPlansResponseDto,
|
||||
SubscriptionPlanDto,
|
||||
SubscriptionStatusDto,
|
||||
} from '../dto/subscription.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||
private readonly subscriptionRepository: SubscriptionRepository,
|
||||
@Inject(LICENSE_REPOSITORY)
|
||||
private readonly licenseRepository: LicenseRepository,
|
||||
@Inject(ORGANIZATION_REPOSITORY)
|
||||
private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(STRIPE_PORT)
|
||||
private readonly stripeAdapter: StripePort,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get subscription overview for an organization
|
||||
*/
|
||||
async getSubscriptionOverview(
|
||||
organizationId: string,
|
||||
): Promise<SubscriptionOverviewResponseDto> {
|
||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
// Enrich licenses with user information
|
||||
const enrichedLicenses = await Promise.all(
|
||||
activeLicenses.map(async (license) => {
|
||||
const user = await this.userRepository.findById(license.userId);
|
||||
return this.mapLicenseToDto(license, user);
|
||||
}),
|
||||
);
|
||||
|
||||
// Count only non-ADMIN licenses for quota calculation
|
||||
// ADMIN users have unlimited licenses and don't count against the quota
|
||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||
subscription.id,
|
||||
);
|
||||
const maxLicenses = subscription.maxLicenses;
|
||||
const availableLicenses = subscription.isUnlimited()
|
||||
? -1
|
||||
: Math.max(0, maxLicenses - usedLicenses);
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
organizationId: subscription.organizationId,
|
||||
plan: subscription.plan.value as SubscriptionPlanDto,
|
||||
planDetails: this.mapPlanToDto(subscription.plan),
|
||||
status: subscription.status.value as SubscriptionStatusDto,
|
||||
usedLicenses,
|
||||
maxLicenses,
|
||||
availableLicenses,
|
||||
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
||||
currentPeriodStart: subscription.currentPeriodStart || undefined,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
licenses: enrichedLicenses,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available plans
|
||||
*/
|
||||
getAllPlans(): AllPlansResponseDto {
|
||||
const plans = SubscriptionPlan.getAllPlans().map((plan) =>
|
||||
this.mapPlanToDto(plan),
|
||||
);
|
||||
return { plans };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if organization can invite more users
|
||||
* Note: ADMIN users don't count against the license quota
|
||||
*/
|
||||
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
|
||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
const maxLicenses = subscription.maxLicenses;
|
||||
const canInvite =
|
||||
subscription.isActive() &&
|
||||
(subscription.isUnlimited() || usedLicenses < maxLicenses);
|
||||
|
||||
const availableLicenses = subscription.isUnlimited()
|
||||
? -1
|
||||
: Math.max(0, maxLicenses - usedLicenses);
|
||||
|
||||
let message: string | undefined;
|
||||
if (!subscription.isActive()) {
|
||||
message = 'Your subscription is not active. Please update your payment method.';
|
||||
} else if (!canInvite) {
|
||||
message = `You have reached the maximum number of users (${maxLicenses}) for your ${subscription.plan.name} plan. Upgrade to add more users.`;
|
||||
}
|
||||
|
||||
return {
|
||||
canInvite,
|
||||
availableLicenses,
|
||||
usedLicenses,
|
||||
maxLicenses,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout session for subscription upgrade
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateCheckoutSessionDto,
|
||||
): Promise<CheckoutSessionResponseDto> {
|
||||
const organization = await this.organizationRepository.findById(organizationId);
|
||||
if (!organization) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
// Cannot checkout for FREE plan
|
||||
if (dto.plan === SubscriptionPlanDto.FREE) {
|
||||
throw new BadRequestException('Cannot create checkout session for FREE plan');
|
||||
}
|
||||
|
||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||
|
||||
const frontendUrl = this.configService.get<string>(
|
||||
'FRONTEND_URL',
|
||||
'http://localhost:3000',
|
||||
);
|
||||
// Include {CHECKOUT_SESSION_ID} placeholder - Stripe replaces it with actual session ID
|
||||
const successUrl =
|
||||
dto.successUrl ||
|
||||
`${frontendUrl}/dashboard/settings/organization?success=true&session_id={CHECKOUT_SESSION_ID}`;
|
||||
const cancelUrl =
|
||||
dto.cancelUrl ||
|
||||
`${frontendUrl}/dashboard/settings/organization?canceled=true`;
|
||||
|
||||
const result = await this.stripeAdapter.createCheckoutSession({
|
||||
organizationId,
|
||||
organizationName: organization.name,
|
||||
email: user.email,
|
||||
plan: dto.plan as SubscriptionPlanType,
|
||||
billingInterval: dto.billingInterval as 'monthly' | 'yearly',
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
customerId: subscription.stripeCustomerId || undefined,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Created checkout session for organization ${organizationId}, plan ${dto.plan}`,
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
sessionUrl: result.sessionUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal session
|
||||
*/
|
||||
async createPortalSession(
|
||||
organizationId: string,
|
||||
dto: CreatePortalSessionDto,
|
||||
): Promise<PortalSessionResponseDto> {
|
||||
const subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||
organizationId,
|
||||
);
|
||||
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
throw new BadRequestException(
|
||||
'No Stripe customer found for this organization. Please complete a checkout first.',
|
||||
);
|
||||
}
|
||||
|
||||
const frontendUrl = this.configService.get<string>(
|
||||
'FRONTEND_URL',
|
||||
'http://localhost:3000',
|
||||
);
|
||||
const returnUrl =
|
||||
dto.returnUrl || `${frontendUrl}/dashboard/settings/organization`;
|
||||
|
||||
const result = await this.stripeAdapter.createPortalSession({
|
||||
customerId: subscription.stripeCustomerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
this.logger.log(`Created portal session for organization ${organizationId}`);
|
||||
|
||||
return {
|
||||
sessionUrl: result.sessionUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subscription from Stripe
|
||||
* Useful when webhooks are not available (e.g., local development)
|
||||
* @param organizationId - The organization ID
|
||||
* @param sessionId - Optional Stripe checkout session ID (used after checkout completes)
|
||||
*/
|
||||
async syncFromStripe(
|
||||
organizationId: string,
|
||||
sessionId?: string,
|
||||
): Promise<SubscriptionOverviewResponseDto> {
|
||||
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||
organizationId,
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await this.getOrCreateSubscription(organizationId);
|
||||
}
|
||||
|
||||
let stripeSubscriptionId = subscription.stripeSubscriptionId;
|
||||
let stripeCustomerId = subscription.stripeCustomerId;
|
||||
|
||||
// If we have a session ID, ALWAYS retrieve the checkout session to get the latest subscription details
|
||||
// This is important for upgrades where Stripe may create a new subscription
|
||||
if (sessionId) {
|
||||
this.logger.log(`Retrieving checkout session ${sessionId} for organization ${organizationId}`);
|
||||
const checkoutSession = await this.stripeAdapter.getCheckoutSession(sessionId);
|
||||
|
||||
if (checkoutSession) {
|
||||
this.logger.log(
|
||||
`Checkout session found: subscriptionId=${checkoutSession.subscriptionId}, customerId=${checkoutSession.customerId}, status=${checkoutSession.status}`,
|
||||
);
|
||||
|
||||
// Always use the subscription ID from the checkout session if available
|
||||
// This handles upgrades where a new subscription is created
|
||||
if (checkoutSession.subscriptionId) {
|
||||
stripeSubscriptionId = checkoutSession.subscriptionId;
|
||||
}
|
||||
if (checkoutSession.customerId) {
|
||||
stripeCustomerId = checkoutSession.customerId;
|
||||
}
|
||||
|
||||
// Update subscription with customer ID if we got it from checkout session
|
||||
if (stripeCustomerId && !subscription.stripeCustomerId) {
|
||||
subscription = subscription.updateStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Checkout session ${sessionId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stripeSubscriptionId) {
|
||||
this.logger.log(`No Stripe subscription found for organization ${organizationId}`);
|
||||
// Return current subscription data without syncing
|
||||
return this.getSubscriptionOverview(organizationId);
|
||||
}
|
||||
|
||||
// Get fresh data from Stripe
|
||||
const stripeData = await this.stripeAdapter.getSubscription(stripeSubscriptionId);
|
||||
|
||||
if (!stripeData) {
|
||||
this.logger.warn(`Could not retrieve Stripe subscription ${stripeSubscriptionId}`);
|
||||
return this.getSubscriptionOverview(organizationId);
|
||||
}
|
||||
|
||||
// Map the price ID to our plan
|
||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
|
||||
let updatedSubscription = subscription;
|
||||
|
||||
if (plan) {
|
||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||
subscription.id,
|
||||
);
|
||||
const newPlan = SubscriptionPlan.create(plan);
|
||||
|
||||
// Update plan
|
||||
updatedSubscription = updatedSubscription.updatePlan(newPlan, usedLicenses);
|
||||
this.logger.log(`Updated plan to ${plan} for organization ${organizationId}`);
|
||||
}
|
||||
|
||||
// Update Stripe IDs if not already set
|
||||
if (!updatedSubscription.stripeCustomerId && stripeData.customerId) {
|
||||
updatedSubscription = updatedSubscription.updateStripeCustomerId(stripeData.customerId);
|
||||
}
|
||||
|
||||
// Update Stripe subscription data
|
||||
updatedSubscription = updatedSubscription.updateStripeSubscription({
|
||||
stripeSubscriptionId: stripeData.subscriptionId,
|
||||
currentPeriodStart: stripeData.currentPeriodStart,
|
||||
currentPeriodEnd: stripeData.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
||||
});
|
||||
|
||||
// Update status
|
||||
updatedSubscription = updatedSubscription.updateStatus(
|
||||
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
||||
);
|
||||
|
||||
await this.subscriptionRepository.save(updatedSubscription);
|
||||
|
||||
this.logger.log(
|
||||
`Synced subscription for organization ${organizationId} from Stripe (plan: ${updatedSubscription.plan.value})`,
|
||||
);
|
||||
|
||||
return this.getSubscriptionOverview(organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Stripe webhook events
|
||||
*/
|
||||
async handleStripeWebhook(payload: string | Buffer, signature: string): Promise<void> {
|
||||
const event = await this.stripeAdapter.constructWebhookEvent(payload, signature);
|
||||
|
||||
this.logger.log(`Processing Stripe webhook event: ${event.type}`);
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await this.handleCheckoutCompleted(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(event.data.object);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.log(`Unhandled Stripe event type: ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a license to a user
|
||||
* Note: ADMIN users always get a license (unlimited) and don't count against the quota
|
||||
*/
|
||||
async allocateLicense(userId: string, organizationId: string): Promise<License> {
|
||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||
|
||||
// Check if user already has a license
|
||||
const existingLicense = await this.licenseRepository.findByUserId(userId);
|
||||
if (existingLicense?.isActive()) {
|
||||
throw new LicenseAlreadyAssignedException(userId);
|
||||
}
|
||||
|
||||
// Get the user to check if they're an ADMIN
|
||||
const user = await this.userRepository.findById(userId);
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
// ADMIN users have unlimited licenses - skip quota check for them
|
||||
if (!isAdmin) {
|
||||
// Count only non-ADMIN licenses for quota check
|
||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
if (!subscription.canAllocateLicenses(usedLicenses)) {
|
||||
throw new NoLicensesAvailableException(
|
||||
organizationId,
|
||||
usedLicenses,
|
||||
subscription.maxLicenses,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a revoked license, reactivate it
|
||||
if (existingLicense?.isRevoked()) {
|
||||
const reactivatedLicense = existingLicense.reactivate();
|
||||
return this.licenseRepository.save(reactivatedLicense);
|
||||
}
|
||||
|
||||
// Create new license
|
||||
const license = License.create({
|
||||
id: uuidv4(),
|
||||
subscriptionId: subscription.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const savedLicense = await this.licenseRepository.save(license);
|
||||
this.logger.log(`Allocated license ${savedLicense.id} to user ${userId} (isAdmin: ${isAdmin})`);
|
||||
|
||||
return savedLicense;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a user's license
|
||||
*/
|
||||
async revokeLicense(userId: string): Promise<void> {
|
||||
const license = await this.licenseRepository.findByUserId(userId);
|
||||
if (!license) {
|
||||
this.logger.warn(`No license found for user ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (license.isRevoked()) {
|
||||
this.logger.warn(`License for user ${userId} is already revoked`);
|
||||
return;
|
||||
}
|
||||
|
||||
const revokedLicense = license.revoke();
|
||||
await this.licenseRepository.save(revokedLicense);
|
||||
|
||||
this.logger.log(`Revoked license ${license.id} for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a subscription for an organization
|
||||
*/
|
||||
async getOrCreateSubscription(organizationId: string): Promise<Subscription> {
|
||||
let subscription = await this.subscriptionRepository.findByOrganizationId(
|
||||
organizationId,
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
// Create FREE subscription for the organization
|
||||
subscription = Subscription.create({
|
||||
id: uuidv4(),
|
||||
organizationId,
|
||||
plan: SubscriptionPlan.free(),
|
||||
});
|
||||
|
||||
subscription = await this.subscriptionRepository.save(subscription);
|
||||
this.logger.log(
|
||||
`Created FREE subscription for organization ${organizationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private async handleCheckoutCompleted(
|
||||
session: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const metadata = session.metadata as Record<string, string> | undefined;
|
||||
const organizationId = metadata?.organizationId;
|
||||
const customerId = session.customer as string;
|
||||
const subscriptionId = session.subscription as string;
|
||||
|
||||
if (!organizationId || !customerId || !subscriptionId) {
|
||||
this.logger.warn('Checkout session missing required metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get subscription details from Stripe
|
||||
const stripeSubscription = await this.stripeAdapter.getSubscription(subscriptionId);
|
||||
if (!stripeSubscription) {
|
||||
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create our subscription
|
||||
let subscription = await this.getOrCreateSubscription(organizationId);
|
||||
|
||||
// Map the price ID to our plan
|
||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeSubscription.planId);
|
||||
if (!plan) {
|
||||
this.logger.error(`Unknown Stripe price ID: ${stripeSubscription.planId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update subscription
|
||||
subscription = subscription.updateStripeCustomerId(customerId);
|
||||
subscription = subscription.updateStripeSubscription({
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
currentPeriodStart: stripeSubscription.currentPeriodStart,
|
||||
currentPeriodEnd: stripeSubscription.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancelAtPeriodEnd,
|
||||
});
|
||||
subscription = subscription.updatePlan(
|
||||
SubscriptionPlan.create(plan),
|
||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
||||
);
|
||||
subscription = subscription.updateStatus(
|
||||
SubscriptionStatus.fromStripeStatus(stripeSubscription.status),
|
||||
);
|
||||
|
||||
await this.subscriptionRepository.save(subscription);
|
||||
|
||||
this.logger.log(
|
||||
`Updated subscription for organization ${organizationId} to plan ${plan}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSubscriptionUpdated(
|
||||
stripeSubscription: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const subscriptionId = stripeSubscription.id as string;
|
||||
|
||||
let subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
||||
subscriptionId,
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fresh data from Stripe
|
||||
const stripeData = await this.stripeAdapter.getSubscription(subscriptionId);
|
||||
if (!stripeData) {
|
||||
this.logger.error(`Could not retrieve Stripe subscription ${subscriptionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map the price ID to our plan
|
||||
const plan = this.stripeAdapter.mapPriceIdToPlan(stripeData.planId);
|
||||
if (plan) {
|
||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||
subscription.id,
|
||||
);
|
||||
const newPlan = SubscriptionPlan.create(plan);
|
||||
|
||||
// Only update plan if it can accommodate current non-ADMIN users
|
||||
if (newPlan.canAccommodateUsers(usedLicenses)) {
|
||||
subscription = subscription.updatePlan(newPlan, usedLicenses);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Cannot update to plan ${plan} - would exceed license limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
subscription = subscription.updateStripeSubscription({
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
currentPeriodStart: stripeData.currentPeriodStart,
|
||||
currentPeriodEnd: stripeData.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: stripeData.cancelAtPeriodEnd,
|
||||
});
|
||||
subscription = subscription.updateStatus(
|
||||
SubscriptionStatus.fromStripeStatus(stripeData.status),
|
||||
);
|
||||
|
||||
await this.subscriptionRepository.save(subscription);
|
||||
|
||||
this.logger.log(`Updated subscription ${subscriptionId}`);
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(
|
||||
stripeSubscription: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const subscriptionId = stripeSubscription.id as string;
|
||||
|
||||
const subscription = await this.subscriptionRepository.findByStripeSubscriptionId(
|
||||
subscriptionId,
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.warn(`Subscription ${subscriptionId} not found in database`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Downgrade to FREE plan - count only non-ADMIN licenses
|
||||
const canceledSubscription = subscription
|
||||
.updatePlan(
|
||||
SubscriptionPlan.free(),
|
||||
await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id),
|
||||
)
|
||||
.updateStatus(SubscriptionStatus.canceled());
|
||||
|
||||
await this.subscriptionRepository.save(canceledSubscription);
|
||||
|
||||
this.logger.log(`Subscription ${subscriptionId} canceled, downgraded to FREE`);
|
||||
}
|
||||
|
||||
private async handlePaymentFailed(invoice: Record<string, unknown>): Promise<void> {
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
const subscription = await this.subscriptionRepository.findByStripeCustomerId(
|
||||
customerId,
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.warn(`Subscription for customer ${customerId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSubscription = subscription.updateStatus(
|
||||
SubscriptionStatus.pastDue(),
|
||||
);
|
||||
|
||||
await this.subscriptionRepository.save(updatedSubscription);
|
||||
|
||||
this.logger.log(
|
||||
`Subscription ${subscription.id} marked as past due due to payment failure`,
|
||||
);
|
||||
}
|
||||
|
||||
private mapLicenseToDto(
|
||||
license: License,
|
||||
user: { email: string; firstName: string; lastName: string; role: string } | null,
|
||||
): LicenseResponseDto {
|
||||
return {
|
||||
id: license.id,
|
||||
userId: license.userId,
|
||||
userEmail: user?.email || 'Unknown',
|
||||
userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown User',
|
||||
userRole: user?.role || 'USER',
|
||||
status: license.status.value,
|
||||
assignedAt: license.assignedAt,
|
||||
revokedAt: license.revokedAt || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPlanToDto(plan: SubscriptionPlan): PlanDetailsDto {
|
||||
return {
|
||||
plan: plan.value as SubscriptionPlanDto,
|
||||
name: plan.name,
|
||||
maxLicenses: plan.maxLicenses,
|
||||
monthlyPriceEur: plan.monthlyPriceEur,
|
||||
yearlyPriceEur: plan.yearlyPriceEur,
|
||||
features: [...plan.features],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Subscriptions Module
|
||||
*
|
||||
* Provides subscription and license management endpoints.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
// Controller
|
||||
import { SubscriptionsController } from '../controllers/subscriptions.controller';
|
||||
|
||||
// Service
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
|
||||
// ORM Entities
|
||||
import { SubscriptionOrmEntity } from '@infrastructure/persistence/typeorm/entities/subscription.orm-entity';
|
||||
import { LicenseOrmEntity } from '@infrastructure/persistence/typeorm/entities/license.orm-entity';
|
||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
|
||||
// Repositories
|
||||
import { TypeOrmSubscriptionRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-subscription.repository';
|
||||
import { TypeOrmLicenseRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-license.repository';
|
||||
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||
|
||||
// Repository tokens
|
||||
import { SUBSCRIPTION_REPOSITORY } from '@domain/ports/out/subscription.repository';
|
||||
import { LICENSE_REPOSITORY } from '@domain/ports/out/license.repository';
|
||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
|
||||
// Stripe
|
||||
import { StripeModule } from '@infrastructure/stripe/stripe.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TypeOrmModule.forFeature([
|
||||
SubscriptionOrmEntity,
|
||||
LicenseOrmEntity,
|
||||
OrganizationOrmEntity,
|
||||
UserOrmEntity,
|
||||
]),
|
||||
StripeModule,
|
||||
],
|
||||
controllers: [SubscriptionsController],
|
||||
providers: [
|
||||
SubscriptionService,
|
||||
{
|
||||
provide: SUBSCRIPTION_REPOSITORY,
|
||||
useClass: TypeOrmSubscriptionRepository,
|
||||
},
|
||||
{
|
||||
provide: LICENSE_REPOSITORY,
|
||||
useClass: TypeOrmLicenseRepository,
|
||||
},
|
||||
{
|
||||
provide: ORGANIZATION_REPOSITORY,
|
||||
useClass: TypeOrmOrganizationRepository,
|
||||
},
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: TypeOrmUserRepository,
|
||||
},
|
||||
],
|
||||
exports: [SubscriptionService, SUBSCRIPTION_REPOSITORY, LICENSE_REPOSITORY],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
@ -6,10 +6,12 @@ import { UsersController } from '../controllers/users.controller';
|
||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserOrmEntity]), // 👈 Add this line
|
||||
TypeOrmModule.forFeature([UserOrmEntity]),
|
||||
SubscriptionsModule,
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [
|
||||
|
||||
@ -332,4 +332,64 @@ export class CsvBooking {
|
||||
toString(): string {
|
||||
return this.getSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CsvBooking from persisted data (skips document validation)
|
||||
*
|
||||
* Use this when loading from database where bookings might have been created
|
||||
* before document requirement was enforced, or documents were lost.
|
||||
*/
|
||||
static fromPersistence(
|
||||
id: string,
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
carrierName: string,
|
||||
carrierEmail: string,
|
||||
origin: PortCode,
|
||||
destination: PortCode,
|
||||
volumeCBM: number,
|
||||
weightKG: number,
|
||||
palletCount: number,
|
||||
priceUSD: number,
|
||||
priceEUR: number,
|
||||
primaryCurrency: string,
|
||||
transitDays: number,
|
||||
containerType: string,
|
||||
status: CsvBookingStatus,
|
||||
documents: CsvBookingDocument[],
|
||||
confirmationToken: string,
|
||||
requestedAt: Date,
|
||||
respondedAt?: Date,
|
||||
notes?: string,
|
||||
rejectionReason?: string
|
||||
): CsvBooking {
|
||||
// Create instance without calling constructor validation
|
||||
const booking = Object.create(CsvBooking.prototype);
|
||||
|
||||
// Assign all properties directly
|
||||
booking.id = id;
|
||||
booking.userId = userId;
|
||||
booking.organizationId = organizationId;
|
||||
booking.carrierName = carrierName;
|
||||
booking.carrierEmail = carrierEmail;
|
||||
booking.origin = origin;
|
||||
booking.destination = destination;
|
||||
booking.volumeCBM = volumeCBM;
|
||||
booking.weightKG = weightKG;
|
||||
booking.palletCount = palletCount;
|
||||
booking.priceUSD = priceUSD;
|
||||
booking.priceEUR = priceEUR;
|
||||
booking.primaryCurrency = primaryCurrency;
|
||||
booking.transitDays = transitDays;
|
||||
booking.containerType = containerType;
|
||||
booking.status = status;
|
||||
booking.documents = documents || [];
|
||||
booking.confirmationToken = confirmationToken;
|
||||
booking.requestedAt = requestedAt;
|
||||
booking.respondedAt = respondedAt;
|
||||
booking.notes = notes;
|
||||
booking.rejectionReason = rejectionReason;
|
||||
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,3 +11,5 @@ export * from './port.entity';
|
||||
export * from './rate-quote.entity';
|
||||
export * from './container.entity';
|
||||
export * from './booking.entity';
|
||||
export * from './subscription.entity';
|
||||
export * from './license.entity';
|
||||
|
||||
270
apps/backend/src/domain/entities/license.entity.spec.ts
Normal file
270
apps/backend/src/domain/entities/license.entity.spec.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* License Entity Tests
|
||||
*
|
||||
* Unit tests for the License domain entity
|
||||
*/
|
||||
|
||||
import { License } from './license.entity';
|
||||
|
||||
describe('License Entity', () => {
|
||||
const createValidLicense = () => {
|
||||
return License.create({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
});
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a license with valid data', () => {
|
||||
const license = createValidLicense();
|
||||
|
||||
expect(license.id).toBe('license-123');
|
||||
expect(license.subscriptionId).toBe('sub-123');
|
||||
expect(license.userId).toBe('user-123');
|
||||
expect(license.status.value).toBe('ACTIVE');
|
||||
expect(license.assignedAt).toBeInstanceOf(Date);
|
||||
expect(license.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should create a license with different user', () => {
|
||||
const license = License.create({
|
||||
id: 'license-456',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-456',
|
||||
});
|
||||
|
||||
expect(license.userId).toBe('user-456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromPersistence', () => {
|
||||
it('should reconstitute an active license from persistence data', () => {
|
||||
const assignedAt = new Date('2024-01-15');
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'ACTIVE',
|
||||
assignedAt,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(license.id).toBe('license-123');
|
||||
expect(license.status.value).toBe('ACTIVE');
|
||||
expect(license.assignedAt).toEqual(assignedAt);
|
||||
expect(license.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should reconstitute a revoked license from persistence data', () => {
|
||||
const revokedAt = new Date('2024-02-01');
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt,
|
||||
});
|
||||
|
||||
expect(license.status.value).toBe('REVOKED');
|
||||
expect(license.revokedAt).toEqual(revokedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active license', () => {
|
||||
const license = createValidLicense();
|
||||
expect(license.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for revoked license', () => {
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
expect(license.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRevoked', () => {
|
||||
it('should return false for active license', () => {
|
||||
const license = createValidLicense();
|
||||
expect(license.isRevoked()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for revoked license', () => {
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
expect(license.isRevoked()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke an active license', () => {
|
||||
const license = createValidLicense();
|
||||
const revoked = license.revoke();
|
||||
|
||||
expect(revoked.status.value).toBe('REVOKED');
|
||||
expect(revoked.revokedAt).toBeInstanceOf(Date);
|
||||
expect(revoked.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw when revoking an already revoked license', () => {
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
expect(() => license.revoke()).toThrow('License is already revoked');
|
||||
});
|
||||
|
||||
it('should preserve original data when revoking', () => {
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-456',
|
||||
userId: 'user-789',
|
||||
status: 'ACTIVE',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const revoked = license.revoke();
|
||||
|
||||
expect(revoked.id).toBe('license-123');
|
||||
expect(revoked.subscriptionId).toBe('sub-456');
|
||||
expect(revoked.userId).toBe('user-789');
|
||||
expect(revoked.assignedAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactivate', () => {
|
||||
it('should reactivate a revoked license', () => {
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt: new Date('2024-02-01'),
|
||||
});
|
||||
|
||||
const reactivated = license.reactivate();
|
||||
|
||||
expect(reactivated.status.value).toBe('ACTIVE');
|
||||
expect(reactivated.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw when reactivating an active license', () => {
|
||||
const license = createValidLicense();
|
||||
|
||||
expect(() => license.reactivate()).toThrow('License is already active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveDuration', () => {
|
||||
it('should calculate duration for active license', () => {
|
||||
const assignedAt = new Date();
|
||||
assignedAt.setHours(assignedAt.getHours() - 1); // 1 hour ago
|
||||
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'ACTIVE',
|
||||
assignedAt,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const duration = license.getActiveDuration();
|
||||
// Should be approximately 1 hour in milliseconds (allow some variance)
|
||||
expect(duration).toBeGreaterThan(3600000 - 1000);
|
||||
expect(duration).toBeLessThan(3600000 + 1000);
|
||||
});
|
||||
|
||||
it('should calculate duration for revoked license', () => {
|
||||
const assignedAt = new Date('2024-01-15T10:00:00Z');
|
||||
const revokedAt = new Date('2024-01-15T12:00:00Z'); // 2 hours later
|
||||
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt,
|
||||
revokedAt,
|
||||
});
|
||||
|
||||
const duration = license.getActiveDuration();
|
||||
expect(duration).toBe(2 * 60 * 60 * 1000); // 2 hours in ms
|
||||
});
|
||||
});
|
||||
|
||||
describe('toObject', () => {
|
||||
it('should convert to plain object for persistence', () => {
|
||||
const license = createValidLicense();
|
||||
const obj = license.toObject();
|
||||
|
||||
expect(obj.id).toBe('license-123');
|
||||
expect(obj.subscriptionId).toBe('sub-123');
|
||||
expect(obj.userId).toBe('user-123');
|
||||
expect(obj.status).toBe('ACTIVE');
|
||||
expect(obj.assignedAt).toBeInstanceOf(Date);
|
||||
expect(obj.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should include revokedAt for revoked license', () => {
|
||||
const revokedAt = new Date('2024-02-01');
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-123',
|
||||
userId: 'user-123',
|
||||
status: 'REVOKED',
|
||||
assignedAt: new Date('2024-01-15'),
|
||||
revokedAt,
|
||||
});
|
||||
|
||||
const obj = license.toObject();
|
||||
expect(obj.status).toBe('REVOKED');
|
||||
expect(obj.revokedAt).toEqual(revokedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property accessors', () => {
|
||||
it('should correctly expose all properties', () => {
|
||||
const assignedAt = new Date('2024-01-15');
|
||||
|
||||
const license = License.fromPersistence({
|
||||
id: 'license-123',
|
||||
subscriptionId: 'sub-456',
|
||||
userId: 'user-789',
|
||||
status: 'ACTIVE',
|
||||
assignedAt,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(license.id).toBe('license-123');
|
||||
expect(license.subscriptionId).toBe('sub-456');
|
||||
expect(license.userId).toBe('user-789');
|
||||
expect(license.status.value).toBe('ACTIVE');
|
||||
expect(license.assignedAt).toEqual(assignedAt);
|
||||
expect(license.revokedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
167
apps/backend/src/domain/entities/license.entity.ts
Normal file
167
apps/backend/src/domain/entities/license.entity.ts
Normal file
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* License Entity
|
||||
*
|
||||
* Represents a user license within a subscription.
|
||||
* Each active user in an organization consumes one license.
|
||||
*/
|
||||
|
||||
import {
|
||||
LicenseStatus,
|
||||
LicenseStatusType,
|
||||
} from '../value-objects/license-status.vo';
|
||||
|
||||
export interface LicenseProps {
|
||||
readonly id: string;
|
||||
readonly subscriptionId: string;
|
||||
readonly userId: string;
|
||||
readonly status: LicenseStatus;
|
||||
readonly assignedAt: Date;
|
||||
readonly revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export class License {
|
||||
private readonly props: LicenseProps;
|
||||
|
||||
private constructor(props: LicenseProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new license for a user
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
userId: string;
|
||||
}): License {
|
||||
return new License({
|
||||
id: props.id,
|
||||
subscriptionId: props.subscriptionId,
|
||||
userId: props.userId,
|
||||
status: LicenseStatus.active(),
|
||||
assignedAt: new Date(),
|
||||
revokedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute from persistence
|
||||
*/
|
||||
static fromPersistence(props: {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
userId: string;
|
||||
status: LicenseStatusType;
|
||||
assignedAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}): License {
|
||||
return new License({
|
||||
id: props.id,
|
||||
subscriptionId: props.subscriptionId,
|
||||
userId: props.userId,
|
||||
status: LicenseStatus.create(props.status),
|
||||
assignedAt: props.assignedAt,
|
||||
revokedAt: props.revokedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this.props.id;
|
||||
}
|
||||
|
||||
get subscriptionId(): string {
|
||||
return this.props.subscriptionId;
|
||||
}
|
||||
|
||||
get userId(): string {
|
||||
return this.props.userId;
|
||||
}
|
||||
|
||||
get status(): LicenseStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
get assignedAt(): Date {
|
||||
return this.props.assignedAt;
|
||||
}
|
||||
|
||||
get revokedAt(): Date | null {
|
||||
return this.props.revokedAt;
|
||||
}
|
||||
|
||||
// Business logic
|
||||
|
||||
/**
|
||||
* Check if the license is currently active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.props.status.isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the license has been revoked
|
||||
*/
|
||||
isRevoked(): boolean {
|
||||
return this.props.status.isRevoked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke this license
|
||||
*/
|
||||
revoke(): License {
|
||||
if (this.isRevoked()) {
|
||||
throw new Error('License is already revoked');
|
||||
}
|
||||
|
||||
return new License({
|
||||
...this.props,
|
||||
status: LicenseStatus.revoked(),
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a revoked license
|
||||
*/
|
||||
reactivate(): License {
|
||||
if (this.isActive()) {
|
||||
throw new Error('License is already active');
|
||||
}
|
||||
|
||||
return new License({
|
||||
...this.props,
|
||||
status: LicenseStatus.active(),
|
||||
revokedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the duration the license was/is active
|
||||
*/
|
||||
getActiveDuration(): number {
|
||||
const endTime = this.props.revokedAt ?? new Date();
|
||||
return endTime.getTime() - this.props.assignedAt.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object for persistence
|
||||
*/
|
||||
toObject(): {
|
||||
id: string;
|
||||
subscriptionId: string;
|
||||
userId: string;
|
||||
status: LicenseStatusType;
|
||||
assignedAt: Date;
|
||||
revokedAt: Date | null;
|
||||
} {
|
||||
return {
|
||||
id: this.props.id,
|
||||
subscriptionId: this.props.subscriptionId,
|
||||
userId: this.props.userId,
|
||||
status: this.props.status.value,
|
||||
assignedAt: this.props.assignedAt,
|
||||
revokedAt: this.props.revokedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
405
apps/backend/src/domain/entities/subscription.entity.spec.ts
Normal file
405
apps/backend/src/domain/entities/subscription.entity.spec.ts
Normal file
@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Subscription Entity Tests
|
||||
*
|
||||
* Unit tests for the Subscription domain entity
|
||||
*/
|
||||
|
||||
import { Subscription } from './subscription.entity';
|
||||
import { SubscriptionPlan } from '../value-objects/subscription-plan.vo';
|
||||
import { SubscriptionStatus } from '../value-objects/subscription-status.vo';
|
||||
import {
|
||||
InvalidSubscriptionDowngradeException,
|
||||
SubscriptionNotActiveException,
|
||||
} from '../exceptions/subscription.exceptions';
|
||||
|
||||
describe('Subscription Entity', () => {
|
||||
const createValidSubscription = () => {
|
||||
return Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
});
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a subscription with default FREE plan', () => {
|
||||
const subscription = createValidSubscription();
|
||||
|
||||
expect(subscription.id).toBe('sub-123');
|
||||
expect(subscription.organizationId).toBe('org-123');
|
||||
expect(subscription.plan.value).toBe('FREE');
|
||||
expect(subscription.status.value).toBe('ACTIVE');
|
||||
expect(subscription.cancelAtPeriodEnd).toBe(false);
|
||||
});
|
||||
|
||||
it('should create a subscription with custom plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
|
||||
expect(subscription.plan.value).toBe('STARTER');
|
||||
});
|
||||
|
||||
it('should create a subscription with Stripe IDs', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
stripeCustomerId: 'cus_123',
|
||||
stripeSubscriptionId: 'sub_stripe_123',
|
||||
});
|
||||
|
||||
expect(subscription.stripeCustomerId).toBe('cus_123');
|
||||
expect(subscription.stripeSubscriptionId).toBe('sub_stripe_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromPersistence', () => {
|
||||
it('should reconstitute a subscription from persistence data', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'PRO',
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: 'cus_123',
|
||||
stripeSubscriptionId: 'sub_stripe_123',
|
||||
currentPeriodStart: new Date('2024-01-01'),
|
||||
currentPeriodEnd: new Date('2024-02-01'),
|
||||
cancelAtPeriodEnd: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
});
|
||||
|
||||
expect(subscription.id).toBe('sub-123');
|
||||
expect(subscription.plan.value).toBe('PRO');
|
||||
expect(subscription.status.value).toBe('ACTIVE');
|
||||
expect(subscription.cancelAtPeriodEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLicenses', () => {
|
||||
it('should return correct limits for FREE plan', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.maxLicenses).toBe(2);
|
||||
});
|
||||
|
||||
it('should return correct limits for STARTER plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
expect(subscription.maxLicenses).toBe(5);
|
||||
});
|
||||
|
||||
it('should return correct limits for PRO plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.pro(),
|
||||
});
|
||||
expect(subscription.maxLicenses).toBe(20);
|
||||
});
|
||||
|
||||
it('should return -1 for ENTERPRISE plan (unlimited)', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.enterprise(),
|
||||
});
|
||||
expect(subscription.maxLicenses).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnlimited', () => {
|
||||
it('should return false for FREE plan', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.isUnlimited()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for ENTERPRISE plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.enterprise(),
|
||||
});
|
||||
expect(subscription.isUnlimited()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for ACTIVE status', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for TRIALING status', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'FREE',
|
||||
status: 'TRIALING',
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
currentPeriodStart: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(subscription.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for CANCELED status', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'FREE',
|
||||
status: 'CANCELED',
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
currentPeriodStart: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(subscription.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAllocateLicenses', () => {
|
||||
it('should return true when licenses are available', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.canAllocateLicenses(0, 1)).toBe(true);
|
||||
expect(subscription.canAllocateLicenses(1, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no licenses available', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.canAllocateLicenses(2, 1)).toBe(false); // FREE has 2 licenses
|
||||
});
|
||||
|
||||
it('should always return true for ENTERPRISE plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.enterprise(),
|
||||
});
|
||||
expect(subscription.canAllocateLicenses(1000, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when subscription is not active', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'FREE',
|
||||
status: 'CANCELED',
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
currentPeriodStart: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(subscription.canAllocateLicenses(0, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpgradeTo', () => {
|
||||
it('should allow upgrade from FREE to STARTER', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow upgrade from FREE to PRO', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow downgrade via canUpgradeTo', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
expect(subscription.canUpgradeTo(SubscriptionPlan.free())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDowngradeTo', () => {
|
||||
it('should allow downgrade when user count fits', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent downgrade when user count exceeds new plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
expect(subscription.canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlan', () => {
|
||||
it('should update to new plan when valid', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const updated = subscription.updatePlan(SubscriptionPlan.starter(), 1);
|
||||
|
||||
expect(updated.plan.value).toBe('STARTER');
|
||||
});
|
||||
|
||||
it('should throw when subscription is not active', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'FREE',
|
||||
status: 'CANCELED',
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
currentPeriodStart: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 0)).toThrow(
|
||||
SubscriptionNotActiveException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when downgrading with too many users', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.pro(),
|
||||
});
|
||||
|
||||
expect(() => subscription.updatePlan(SubscriptionPlan.starter(), 10)).toThrow(
|
||||
InvalidSubscriptionDowngradeException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update subscription status', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const updated = subscription.updateStatus(SubscriptionStatus.pastDue());
|
||||
|
||||
expect(updated.status.value).toBe('PAST_DUE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStripeCustomerId', () => {
|
||||
it('should update Stripe customer ID', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const updated = subscription.updateStripeCustomerId('cus_new_123');
|
||||
|
||||
expect(updated.stripeCustomerId).toBe('cus_new_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStripeSubscription', () => {
|
||||
it('should update Stripe subscription details', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const periodStart = new Date('2024-02-01');
|
||||
const periodEnd = new Date('2024-03-01');
|
||||
|
||||
const updated = subscription.updateStripeSubscription({
|
||||
stripeSubscriptionId: 'sub_new_123',
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
cancelAtPeriodEnd: true,
|
||||
});
|
||||
|
||||
expect(updated.stripeSubscriptionId).toBe('sub_new_123');
|
||||
expect(updated.currentPeriodStart).toEqual(periodStart);
|
||||
expect(updated.currentPeriodEnd).toEqual(periodEnd);
|
||||
expect(updated.cancelAtPeriodEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleCancellation', () => {
|
||||
it('should mark subscription for cancellation', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const updated = subscription.scheduleCancellation();
|
||||
|
||||
expect(updated.cancelAtPeriodEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unscheduleCancellation', () => {
|
||||
it('should unmark subscription for cancellation', () => {
|
||||
const subscription = Subscription.fromPersistence({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: 'STARTER',
|
||||
status: 'ACTIVE',
|
||||
stripeCustomerId: 'cus_123',
|
||||
stripeSubscriptionId: 'sub_123',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(),
|
||||
cancelAtPeriodEnd: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const updated = subscription.unscheduleCancellation();
|
||||
expect(updated.cancelAtPeriodEnd).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel the subscription immediately', () => {
|
||||
const subscription = createValidSubscription();
|
||||
const updated = subscription.cancel();
|
||||
|
||||
expect(updated.status.value).toBe('CANCELED');
|
||||
expect(updated.cancelAtPeriodEnd).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFree and isPaid', () => {
|
||||
it('should return true for isFree when FREE plan', () => {
|
||||
const subscription = createValidSubscription();
|
||||
expect(subscription.isFree()).toBe(true);
|
||||
expect(subscription.isPaid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for isPaid when STARTER plan', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
plan: SubscriptionPlan.starter(),
|
||||
});
|
||||
expect(subscription.isFree()).toBe(false);
|
||||
expect(subscription.isPaid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toObject', () => {
|
||||
it('should convert to plain object for persistence', () => {
|
||||
const subscription = Subscription.create({
|
||||
id: 'sub-123',
|
||||
organizationId: 'org-123',
|
||||
stripeCustomerId: 'cus_123',
|
||||
});
|
||||
|
||||
const obj = subscription.toObject();
|
||||
|
||||
expect(obj.id).toBe('sub-123');
|
||||
expect(obj.organizationId).toBe('org-123');
|
||||
expect(obj.plan).toBe('FREE');
|
||||
expect(obj.status).toBe('ACTIVE');
|
||||
expect(obj.stripeCustomerId).toBe('cus_123');
|
||||
});
|
||||
});
|
||||
});
|
||||
355
apps/backend/src/domain/entities/subscription.entity.ts
Normal file
355
apps/backend/src/domain/entities/subscription.entity.ts
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Subscription Entity
|
||||
*
|
||||
* Represents an organization's subscription, including their plan,
|
||||
* Stripe integration, and billing period information.
|
||||
*/
|
||||
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanType,
|
||||
} from '../value-objects/subscription-plan.vo';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatusType,
|
||||
} from '../value-objects/subscription-status.vo';
|
||||
import {
|
||||
InvalidSubscriptionDowngradeException,
|
||||
SubscriptionNotActiveException,
|
||||
} from '../exceptions/subscription.exceptions';
|
||||
|
||||
export interface SubscriptionProps {
|
||||
readonly id: string;
|
||||
readonly organizationId: string;
|
||||
readonly plan: SubscriptionPlan;
|
||||
readonly status: SubscriptionStatus;
|
||||
readonly stripeCustomerId: string | null;
|
||||
readonly stripeSubscriptionId: string | null;
|
||||
readonly currentPeriodStart: Date | null;
|
||||
readonly currentPeriodEnd: Date | null;
|
||||
readonly cancelAtPeriodEnd: boolean;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export class Subscription {
|
||||
private readonly props: SubscriptionProps;
|
||||
|
||||
private constructor(props: SubscriptionProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subscription (defaults to FREE plan)
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
plan?: SubscriptionPlan;
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string | null;
|
||||
}): Subscription {
|
||||
const now = new Date();
|
||||
return new Subscription({
|
||||
id: props.id,
|
||||
organizationId: props.organizationId,
|
||||
plan: props.plan ?? SubscriptionPlan.free(),
|
||||
status: SubscriptionStatus.active(),
|
||||
stripeCustomerId: props.stripeCustomerId ?? null,
|
||||
stripeSubscriptionId: props.stripeSubscriptionId ?? null,
|
||||
currentPeriodStart: null,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitute from persistence
|
||||
*/
|
||||
static fromPersistence(props: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
plan: SubscriptionPlanType;
|
||||
status: SubscriptionStatusType;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
currentPeriodStart: Date | null;
|
||||
currentPeriodEnd: Date | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Subscription {
|
||||
return new Subscription({
|
||||
id: props.id,
|
||||
organizationId: props.organizationId,
|
||||
plan: SubscriptionPlan.create(props.plan),
|
||||
status: SubscriptionStatus.create(props.status),
|
||||
stripeCustomerId: props.stripeCustomerId,
|
||||
stripeSubscriptionId: props.stripeSubscriptionId,
|
||||
currentPeriodStart: props.currentPeriodStart,
|
||||
currentPeriodEnd: props.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: props.cancelAtPeriodEnd,
|
||||
createdAt: props.createdAt,
|
||||
updatedAt: props.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this.props.id;
|
||||
}
|
||||
|
||||
get organizationId(): string {
|
||||
return this.props.organizationId;
|
||||
}
|
||||
|
||||
get plan(): SubscriptionPlan {
|
||||
return this.props.plan;
|
||||
}
|
||||
|
||||
get status(): SubscriptionStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
|
||||
get stripeCustomerId(): string | null {
|
||||
return this.props.stripeCustomerId;
|
||||
}
|
||||
|
||||
get stripeSubscriptionId(): string | null {
|
||||
return this.props.stripeSubscriptionId;
|
||||
}
|
||||
|
||||
get currentPeriodStart(): Date | null {
|
||||
return this.props.currentPeriodStart;
|
||||
}
|
||||
|
||||
get currentPeriodEnd(): Date | null {
|
||||
return this.props.currentPeriodEnd;
|
||||
}
|
||||
|
||||
get cancelAtPeriodEnd(): boolean {
|
||||
return this.props.cancelAtPeriodEnd;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
// Business logic
|
||||
|
||||
/**
|
||||
* Get the maximum number of licenses allowed by this subscription
|
||||
*/
|
||||
get maxLicenses(): number {
|
||||
return this.props.plan.maxLicenses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subscription has unlimited licenses
|
||||
*/
|
||||
isUnlimited(): boolean {
|
||||
return this.props.plan.isUnlimited();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subscription is active and allows access
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.props.status.allowsAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subscription is in good standing
|
||||
*/
|
||||
isInGoodStanding(): boolean {
|
||||
return this.props.status.isInGoodStanding();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subscription requires user action
|
||||
*/
|
||||
requiresAction(): boolean {
|
||||
return this.props.status.requiresAction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a free subscription
|
||||
*/
|
||||
isFree(): boolean {
|
||||
return this.props.plan.isFree();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a paid subscription
|
||||
*/
|
||||
isPaid(): boolean {
|
||||
return this.props.plan.isPaid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the subscription is scheduled to be canceled
|
||||
*/
|
||||
isScheduledForCancellation(): boolean {
|
||||
return this.props.cancelAtPeriodEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given number of licenses can be allocated
|
||||
*/
|
||||
canAllocateLicenses(currentCount: number, additionalCount: number = 1): boolean {
|
||||
if (!this.isActive()) return false;
|
||||
if (this.isUnlimited()) return true;
|
||||
return currentCount + additionalCount <= this.maxLicenses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if upgrade to target plan is possible
|
||||
*/
|
||||
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
||||
return this.props.plan.canUpgradeTo(targetPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if downgrade to target plan is possible given current user count
|
||||
*/
|
||||
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
||||
return this.props.plan.canDowngradeTo(targetPlan, currentUserCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the subscription plan
|
||||
*/
|
||||
updatePlan(newPlan: SubscriptionPlan, currentUserCount: number): Subscription {
|
||||
if (!this.isActive()) {
|
||||
throw new SubscriptionNotActiveException(this.props.id, this.props.status.value);
|
||||
}
|
||||
|
||||
// Check if downgrade is valid
|
||||
if (!newPlan.canAccommodateUsers(currentUserCount)) {
|
||||
throw new InvalidSubscriptionDowngradeException(
|
||||
this.props.plan.value,
|
||||
newPlan.value,
|
||||
currentUserCount,
|
||||
newPlan.maxLicenses,
|
||||
);
|
||||
}
|
||||
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
plan: newPlan,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscription status
|
||||
*/
|
||||
updateStatus(newStatus: SubscriptionStatus): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
status: newStatus,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Stripe customer ID
|
||||
*/
|
||||
updateStripeCustomerId(stripeCustomerId: string): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
stripeCustomerId,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Stripe subscription details
|
||||
*/
|
||||
updateStripeSubscription(params: {
|
||||
stripeSubscriptionId: string;
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
}): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
stripeSubscriptionId: params.stripeSubscriptionId,
|
||||
currentPeriodStart: params.currentPeriodStart,
|
||||
currentPeriodEnd: params.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: params.cancelAtPeriodEnd ?? this.props.cancelAtPeriodEnd,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark subscription as scheduled for cancellation at period end
|
||||
*/
|
||||
scheduleCancellation(): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
cancelAtPeriodEnd: true,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unschedule cancellation
|
||||
*/
|
||||
unscheduleCancellation(): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
cancelAtPeriodEnd: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the subscription immediately
|
||||
*/
|
||||
cancel(): Subscription {
|
||||
return new Subscription({
|
||||
...this.props,
|
||||
status: SubscriptionStatus.canceled(),
|
||||
cancelAtPeriodEnd: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object for persistence
|
||||
*/
|
||||
toObject(): {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
plan: SubscriptionPlanType;
|
||||
status: SubscriptionStatusType;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
currentPeriodStart: Date | null;
|
||||
currentPeriodEnd: Date | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
} {
|
||||
return {
|
||||
id: this.props.id,
|
||||
organizationId: this.props.organizationId,
|
||||
plan: this.props.plan.value,
|
||||
status: this.props.status.value,
|
||||
stripeCustomerId: this.props.stripeCustomerId,
|
||||
stripeSubscriptionId: this.props.stripeSubscriptionId,
|
||||
currentPeriodStart: this.props.currentPeriodStart,
|
||||
currentPeriodEnd: this.props.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: this.props.cancelAtPeriodEnd,
|
||||
createdAt: this.props.createdAt,
|
||||
updatedAt: this.props.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -10,3 +10,4 @@ export * from './carrier-timeout.exception';
|
||||
export * from './carrier-unavailable.exception';
|
||||
export * from './rate-quote-expired.exception';
|
||||
export * from './port-not-found.exception';
|
||||
export * from './subscription.exceptions';
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Subscription Domain Exceptions
|
||||
*/
|
||||
|
||||
export class NoLicensesAvailableException extends Error {
|
||||
constructor(
|
||||
public readonly organizationId: string,
|
||||
public readonly currentLicenses: number,
|
||||
public readonly maxLicenses: number,
|
||||
) {
|
||||
super(
|
||||
`No licenses available for organization ${organizationId}. ` +
|
||||
`Currently using ${currentLicenses}/${maxLicenses} licenses.`,
|
||||
);
|
||||
this.name = 'NoLicensesAvailableException';
|
||||
Object.setPrototypeOf(this, NoLicensesAvailableException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionNotFoundException extends Error {
|
||||
constructor(public readonly identifier: string) {
|
||||
super(`Subscription not found: ${identifier}`);
|
||||
this.name = 'SubscriptionNotFoundException';
|
||||
Object.setPrototypeOf(this, SubscriptionNotFoundException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class LicenseNotFoundException extends Error {
|
||||
constructor(public readonly identifier: string) {
|
||||
super(`License not found: ${identifier}`);
|
||||
this.name = 'LicenseNotFoundException';
|
||||
Object.setPrototypeOf(this, LicenseNotFoundException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class LicenseAlreadyAssignedException extends Error {
|
||||
constructor(public readonly userId: string) {
|
||||
super(`User ${userId} already has an assigned license`);
|
||||
this.name = 'LicenseAlreadyAssignedException';
|
||||
Object.setPrototypeOf(this, LicenseAlreadyAssignedException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidSubscriptionDowngradeException extends Error {
|
||||
constructor(
|
||||
public readonly currentPlan: string,
|
||||
public readonly targetPlan: string,
|
||||
public readonly currentUsers: number,
|
||||
public readonly targetMaxLicenses: number,
|
||||
) {
|
||||
super(
|
||||
`Cannot downgrade from ${currentPlan} to ${targetPlan}. ` +
|
||||
`Current users (${currentUsers}) exceed target plan limit (${targetMaxLicenses}).`,
|
||||
);
|
||||
this.name = 'InvalidSubscriptionDowngradeException';
|
||||
Object.setPrototypeOf(this, InvalidSubscriptionDowngradeException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionNotActiveException extends Error {
|
||||
constructor(
|
||||
public readonly subscriptionId: string,
|
||||
public readonly currentStatus: string,
|
||||
) {
|
||||
super(
|
||||
`Subscription ${subscriptionId} is not active. Current status: ${currentStatus}`,
|
||||
);
|
||||
this.name = 'SubscriptionNotActiveException';
|
||||
Object.setPrototypeOf(this, SubscriptionNotActiveException.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidSubscriptionStatusTransitionException extends Error {
|
||||
constructor(
|
||||
public readonly fromStatus: string,
|
||||
public readonly toStatus: string,
|
||||
) {
|
||||
super(`Invalid subscription status transition from ${fromStatus} to ${toStatus}`);
|
||||
this.name = 'InvalidSubscriptionStatusTransitionException';
|
||||
Object.setPrototypeOf(
|
||||
this,
|
||||
InvalidSubscriptionStatusTransitionException.prototype,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -23,3 +23,6 @@ export * from './pdf.port';
|
||||
export * from './storage.port';
|
||||
export * from './carrier-connector.port';
|
||||
export * from './csv-rate-loader.port';
|
||||
export * from './subscription.repository';
|
||||
export * from './license.repository';
|
||||
export * from './stripe.port';
|
||||
|
||||
62
apps/backend/src/domain/ports/out/license.repository.ts
Normal file
62
apps/backend/src/domain/ports/out/license.repository.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* License Repository Port
|
||||
*
|
||||
* Interface for license persistence operations.
|
||||
*/
|
||||
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
export const LICENSE_REPOSITORY = 'LICENSE_REPOSITORY';
|
||||
|
||||
export interface LicenseRepository {
|
||||
/**
|
||||
* Save a license (create or update)
|
||||
*/
|
||||
save(license: License): Promise<License>;
|
||||
|
||||
/**
|
||||
* Find a license by its ID
|
||||
*/
|
||||
findById(id: string): Promise<License | null>;
|
||||
|
||||
/**
|
||||
* Find a license by user ID
|
||||
*/
|
||||
findByUserId(userId: string): Promise<License | null>;
|
||||
|
||||
/**
|
||||
* Find all licenses for a subscription
|
||||
*/
|
||||
findBySubscriptionId(subscriptionId: string): Promise<License[]>;
|
||||
|
||||
/**
|
||||
* Find all active licenses for a subscription
|
||||
*/
|
||||
findActiveBySubscriptionId(subscriptionId: string): Promise<License[]>;
|
||||
|
||||
/**
|
||||
* Count active licenses for a subscription
|
||||
*/
|
||||
countActiveBySubscriptionId(subscriptionId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Count active licenses for a subscription, excluding ADMIN users
|
||||
* ADMIN users have unlimited licenses and don't consume the organization's quota
|
||||
*/
|
||||
countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Find all active licenses for a subscription, excluding ADMIN users
|
||||
*/
|
||||
findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]>;
|
||||
|
||||
/**
|
||||
* Delete a license
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all licenses for a subscription
|
||||
*/
|
||||
deleteBySubscriptionId(subscriptionId: string): Promise<void>;
|
||||
}
|
||||
113
apps/backend/src/domain/ports/out/stripe.port.ts
Normal file
113
apps/backend/src/domain/ports/out/stripe.port.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Stripe Port
|
||||
*
|
||||
* Interface for Stripe payment integration.
|
||||
*/
|
||||
|
||||
import { SubscriptionPlanType } from '../../value-objects/subscription-plan.vo';
|
||||
|
||||
export const STRIPE_PORT = 'STRIPE_PORT';
|
||||
|
||||
export interface CreateCheckoutSessionInput {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
email: string;
|
||||
plan: SubscriptionPlanType;
|
||||
billingInterval: 'monthly' | 'yearly';
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
export interface CreateCheckoutSessionOutput {
|
||||
sessionId: string;
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
export interface CreatePortalSessionInput {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
}
|
||||
|
||||
export interface CreatePortalSessionOutput {
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
export interface StripeSubscriptionData {
|
||||
subscriptionId: string;
|
||||
customerId: string;
|
||||
status: string;
|
||||
planId: string;
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
}
|
||||
|
||||
export interface StripeCheckoutSessionData {
|
||||
sessionId: string;
|
||||
customerId: string | null;
|
||||
subscriptionId: string | null;
|
||||
status: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StripeWebhookEvent {
|
||||
type: string;
|
||||
data: {
|
||||
object: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StripePort {
|
||||
/**
|
||||
* Create a Stripe Checkout session for subscription purchase
|
||||
*/
|
||||
createCheckoutSession(
|
||||
input: CreateCheckoutSessionInput,
|
||||
): Promise<CreateCheckoutSessionOutput>;
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal session for subscription management
|
||||
*/
|
||||
createPortalSession(
|
||||
input: CreatePortalSessionInput,
|
||||
): Promise<CreatePortalSessionOutput>;
|
||||
|
||||
/**
|
||||
* Retrieve subscription details from Stripe
|
||||
*/
|
||||
getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null>;
|
||||
|
||||
/**
|
||||
* Retrieve checkout session details from Stripe
|
||||
*/
|
||||
getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null>;
|
||||
|
||||
/**
|
||||
* Cancel a subscription at period end
|
||||
*/
|
||||
cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancel a subscription immediately
|
||||
*/
|
||||
cancelSubscriptionImmediately(subscriptionId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resume a canceled subscription
|
||||
*/
|
||||
resumeSubscription(subscriptionId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Verify and parse a Stripe webhook event
|
||||
*/
|
||||
constructWebhookEvent(
|
||||
payload: string | Buffer,
|
||||
signature: string,
|
||||
): Promise<StripeWebhookEvent>;
|
||||
|
||||
/**
|
||||
* Map a Stripe price ID to a subscription plan
|
||||
*/
|
||||
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null;
|
||||
}
|
||||
46
apps/backend/src/domain/ports/out/subscription.repository.ts
Normal file
46
apps/backend/src/domain/ports/out/subscription.repository.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Subscription Repository Port
|
||||
*
|
||||
* Interface for subscription persistence operations.
|
||||
*/
|
||||
|
||||
import { Subscription } from '../../entities/subscription.entity';
|
||||
|
||||
export const SUBSCRIPTION_REPOSITORY = 'SUBSCRIPTION_REPOSITORY';
|
||||
|
||||
export interface SubscriptionRepository {
|
||||
/**
|
||||
* Save a subscription (create or update)
|
||||
*/
|
||||
save(subscription: Subscription): Promise<Subscription>;
|
||||
|
||||
/**
|
||||
* Find a subscription by its ID
|
||||
*/
|
||||
findById(id: string): Promise<Subscription | null>;
|
||||
|
||||
/**
|
||||
* Find a subscription by organization ID
|
||||
*/
|
||||
findByOrganizationId(organizationId: string): Promise<Subscription | null>;
|
||||
|
||||
/**
|
||||
* Find a subscription by Stripe subscription ID
|
||||
*/
|
||||
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<Subscription | null>;
|
||||
|
||||
/**
|
||||
* Find a subscription by Stripe customer ID
|
||||
*/
|
||||
findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null>;
|
||||
|
||||
/**
|
||||
* Find all subscriptions
|
||||
*/
|
||||
findAll(): Promise<Subscription[]>;
|
||||
|
||||
/**
|
||||
* Delete a subscription
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@ -11,3 +11,6 @@ export * from './container-type.vo';
|
||||
export * from './date-range.vo';
|
||||
export * from './booking-number.vo';
|
||||
export * from './booking-status.vo';
|
||||
export * from './subscription-plan.vo';
|
||||
export * from './subscription-status.vo';
|
||||
export * from './license-status.vo';
|
||||
|
||||
74
apps/backend/src/domain/value-objects/license-status.vo.ts
Normal file
74
apps/backend/src/domain/value-objects/license-status.vo.ts
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* License Status Value Object
|
||||
*
|
||||
* Represents the status of a user license within a subscription.
|
||||
*/
|
||||
|
||||
export type LicenseStatusType = 'ACTIVE' | 'REVOKED';
|
||||
|
||||
export class LicenseStatus {
|
||||
private constructor(private readonly status: LicenseStatusType) {}
|
||||
|
||||
static create(status: LicenseStatusType): LicenseStatus {
|
||||
if (status !== 'ACTIVE' && status !== 'REVOKED') {
|
||||
throw new Error(`Invalid license status: ${status}`);
|
||||
}
|
||||
return new LicenseStatus(status);
|
||||
}
|
||||
|
||||
static fromString(value: string): LicenseStatus {
|
||||
const upperValue = value.toUpperCase() as LicenseStatusType;
|
||||
if (upperValue !== 'ACTIVE' && upperValue !== 'REVOKED') {
|
||||
throw new Error(`Invalid license status: ${value}`);
|
||||
}
|
||||
return new LicenseStatus(upperValue);
|
||||
}
|
||||
|
||||
static active(): LicenseStatus {
|
||||
return new LicenseStatus('ACTIVE');
|
||||
}
|
||||
|
||||
static revoked(): LicenseStatus {
|
||||
return new LicenseStatus('REVOKED');
|
||||
}
|
||||
|
||||
get value(): LicenseStatusType {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.status === 'ACTIVE';
|
||||
}
|
||||
|
||||
isRevoked(): boolean {
|
||||
return this.status === 'REVOKED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke this license, returning a new revoked status
|
||||
*/
|
||||
revoke(): LicenseStatus {
|
||||
if (this.status === 'REVOKED') {
|
||||
throw new Error('License is already revoked');
|
||||
}
|
||||
return LicenseStatus.revoked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate this license, returning a new active status
|
||||
*/
|
||||
reactivate(): LicenseStatus {
|
||||
if (this.status === 'ACTIVE') {
|
||||
throw new Error('License is already active');
|
||||
}
|
||||
return LicenseStatus.active();
|
||||
}
|
||||
|
||||
equals(other: LicenseStatus): boolean {
|
||||
return this.status === other.status;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* SubscriptionPlan Value Object Tests
|
||||
*
|
||||
* Unit tests for the SubscriptionPlan value object
|
||||
*/
|
||||
|
||||
import { SubscriptionPlan } from './subscription-plan.vo';
|
||||
|
||||
describe('SubscriptionPlan Value Object', () => {
|
||||
describe('static factory methods', () => {
|
||||
it('should create FREE plan', () => {
|
||||
const plan = SubscriptionPlan.free();
|
||||
expect(plan.value).toBe('FREE');
|
||||
});
|
||||
|
||||
it('should create STARTER plan', () => {
|
||||
const plan = SubscriptionPlan.starter();
|
||||
expect(plan.value).toBe('STARTER');
|
||||
});
|
||||
|
||||
it('should create PRO plan', () => {
|
||||
const plan = SubscriptionPlan.pro();
|
||||
expect(plan.value).toBe('PRO');
|
||||
});
|
||||
|
||||
it('should create ENTERPRISE plan', () => {
|
||||
const plan = SubscriptionPlan.enterprise();
|
||||
expect(plan.value).toBe('ENTERPRISE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create plan from valid type', () => {
|
||||
const plan = SubscriptionPlan.create('STARTER');
|
||||
expect(plan.value).toBe('STARTER');
|
||||
});
|
||||
|
||||
it('should throw for invalid plan type', () => {
|
||||
expect(() => SubscriptionPlan.create('INVALID' as any)).toThrow('Invalid subscription plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromString', () => {
|
||||
it('should create plan from lowercase string', () => {
|
||||
const plan = SubscriptionPlan.fromString('starter');
|
||||
expect(plan.value).toBe('STARTER');
|
||||
});
|
||||
|
||||
it('should throw for invalid string', () => {
|
||||
expect(() => SubscriptionPlan.fromString('invalid')).toThrow('Invalid subscription plan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLicenses', () => {
|
||||
it('should return 2 for FREE plan', () => {
|
||||
const plan = SubscriptionPlan.free();
|
||||
expect(plan.maxLicenses).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 5 for STARTER plan', () => {
|
||||
const plan = SubscriptionPlan.starter();
|
||||
expect(plan.maxLicenses).toBe(5);
|
||||
});
|
||||
|
||||
it('should return 20 for PRO plan', () => {
|
||||
const plan = SubscriptionPlan.pro();
|
||||
expect(plan.maxLicenses).toBe(20);
|
||||
});
|
||||
|
||||
it('should return -1 (unlimited) for ENTERPRISE plan', () => {
|
||||
const plan = SubscriptionPlan.enterprise();
|
||||
expect(plan.maxLicenses).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnlimited', () => {
|
||||
it('should return false for FREE plan', () => {
|
||||
expect(SubscriptionPlan.free().isUnlimited()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for STARTER plan', () => {
|
||||
expect(SubscriptionPlan.starter().isUnlimited()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for PRO plan', () => {
|
||||
expect(SubscriptionPlan.pro().isUnlimited()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for ENTERPRISE plan', () => {
|
||||
expect(SubscriptionPlan.enterprise().isUnlimited()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPaid', () => {
|
||||
it('should return false for FREE plan', () => {
|
||||
expect(SubscriptionPlan.free().isPaid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for STARTER plan', () => {
|
||||
expect(SubscriptionPlan.starter().isPaid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for PRO plan', () => {
|
||||
expect(SubscriptionPlan.pro().isPaid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for ENTERPRISE plan', () => {
|
||||
expect(SubscriptionPlan.enterprise().isPaid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFree', () => {
|
||||
it('should return true for FREE plan', () => {
|
||||
expect(SubscriptionPlan.free().isFree()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for STARTER plan', () => {
|
||||
expect(SubscriptionPlan.starter().isFree()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccommodateUsers', () => {
|
||||
it('should return true for FREE plan with 2 users', () => {
|
||||
expect(SubscriptionPlan.free().canAccommodateUsers(2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for FREE plan with 3 users', () => {
|
||||
expect(SubscriptionPlan.free().canAccommodateUsers(3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for STARTER plan with 5 users', () => {
|
||||
expect(SubscriptionPlan.starter().canAccommodateUsers(5)).toBe(true);
|
||||
});
|
||||
|
||||
it('should always return true for ENTERPRISE plan', () => {
|
||||
expect(SubscriptionPlan.enterprise().canAccommodateUsers(1000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpgradeTo', () => {
|
||||
it('should allow upgrade from FREE to STARTER', () => {
|
||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.starter())).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow upgrade from FREE to PRO', () => {
|
||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow upgrade from FREE to ENTERPRISE', () => {
|
||||
expect(SubscriptionPlan.free().canUpgradeTo(SubscriptionPlan.enterprise())).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow upgrade from STARTER to PRO', () => {
|
||||
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.pro())).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow downgrade from STARTER to FREE', () => {
|
||||
expect(SubscriptionPlan.starter().canUpgradeTo(SubscriptionPlan.free())).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow same plan upgrade', () => {
|
||||
expect(SubscriptionPlan.pro().canUpgradeTo(SubscriptionPlan.pro())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDowngradeTo', () => {
|
||||
it('should allow downgrade from STARTER to FREE when users fit', () => {
|
||||
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow downgrade from STARTER to FREE when users exceed', () => {
|
||||
expect(SubscriptionPlan.starter().canDowngradeTo(SubscriptionPlan.free(), 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow upgrade via canDowngradeTo', () => {
|
||||
expect(SubscriptionPlan.free().canDowngradeTo(SubscriptionPlan.starter(), 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan details', () => {
|
||||
it('should return correct name for FREE plan', () => {
|
||||
expect(SubscriptionPlan.free().name).toBe('Free');
|
||||
});
|
||||
|
||||
it('should return correct prices for STARTER plan', () => {
|
||||
const plan = SubscriptionPlan.starter();
|
||||
expect(plan.monthlyPriceEur).toBe(49);
|
||||
expect(plan.yearlyPriceEur).toBe(470);
|
||||
});
|
||||
|
||||
it('should return features for PRO plan', () => {
|
||||
const plan = SubscriptionPlan.pro();
|
||||
expect(plan.features).toContain('Up to 20 users');
|
||||
expect(plan.features).toContain('API access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPlans', () => {
|
||||
it('should return all 4 plans', () => {
|
||||
const plans = SubscriptionPlan.getAllPlans();
|
||||
|
||||
expect(plans).toHaveLength(4);
|
||||
expect(plans.map(p => p.value)).toEqual(['FREE', 'STARTER', 'PRO', 'ENTERPRISE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals', () => {
|
||||
it('should return true for same plan', () => {
|
||||
expect(SubscriptionPlan.free().equals(SubscriptionPlan.free())).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different plans', () => {
|
||||
expect(SubscriptionPlan.free().equals(SubscriptionPlan.starter())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('should return plan value as string', () => {
|
||||
expect(SubscriptionPlan.free().toString()).toBe('FREE');
|
||||
expect(SubscriptionPlan.starter().toString()).toBe('STARTER');
|
||||
});
|
||||
});
|
||||
});
|
||||
203
apps/backend/src/domain/value-objects/subscription-plan.vo.ts
Normal file
203
apps/backend/src/domain/value-objects/subscription-plan.vo.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Subscription Plan Value Object
|
||||
*
|
||||
* Represents the different subscription plans available for organizations.
|
||||
* Each plan has a maximum number of licenses that determine how many users
|
||||
* can be active in an organization.
|
||||
*/
|
||||
|
||||
export type SubscriptionPlanType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||
|
||||
interface PlanDetails {
|
||||
readonly name: string;
|
||||
readonly maxLicenses: number; // -1 means unlimited
|
||||
readonly monthlyPriceEur: number;
|
||||
readonly yearlyPriceEur: number;
|
||||
readonly features: readonly string[];
|
||||
}
|
||||
|
||||
const PLAN_DETAILS: Record<SubscriptionPlanType, PlanDetails> = {
|
||||
FREE: {
|
||||
name: 'Free',
|
||||
maxLicenses: 2,
|
||||
monthlyPriceEur: 0,
|
||||
yearlyPriceEur: 0,
|
||||
features: [
|
||||
'Up to 2 users',
|
||||
'Basic rate search',
|
||||
'Email support',
|
||||
],
|
||||
},
|
||||
STARTER: {
|
||||
name: 'Starter',
|
||||
maxLicenses: 5,
|
||||
monthlyPriceEur: 49,
|
||||
yearlyPriceEur: 470, // ~20% discount
|
||||
features: [
|
||||
'Up to 5 users',
|
||||
'Advanced rate search',
|
||||
'CSV imports',
|
||||
'Priority email support',
|
||||
],
|
||||
},
|
||||
PRO: {
|
||||
name: 'Pro',
|
||||
maxLicenses: 20,
|
||||
monthlyPriceEur: 149,
|
||||
yearlyPriceEur: 1430, // ~20% discount
|
||||
features: [
|
||||
'Up to 20 users',
|
||||
'All Starter features',
|
||||
'API access',
|
||||
'Custom integrations',
|
||||
'Phone support',
|
||||
],
|
||||
},
|
||||
ENTERPRISE: {
|
||||
name: 'Enterprise',
|
||||
maxLicenses: -1, // unlimited
|
||||
monthlyPriceEur: 0, // custom pricing
|
||||
yearlyPriceEur: 0, // custom pricing
|
||||
features: [
|
||||
'Unlimited users',
|
||||
'All Pro features',
|
||||
'Dedicated account manager',
|
||||
'Custom SLA',
|
||||
'On-premise deployment option',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export class SubscriptionPlan {
|
||||
private constructor(private readonly plan: SubscriptionPlanType) {}
|
||||
|
||||
static create(plan: SubscriptionPlanType): SubscriptionPlan {
|
||||
if (!PLAN_DETAILS[plan]) {
|
||||
throw new Error(`Invalid subscription plan: ${plan}`);
|
||||
}
|
||||
return new SubscriptionPlan(plan);
|
||||
}
|
||||
|
||||
static fromString(value: string): SubscriptionPlan {
|
||||
const upperValue = value.toUpperCase() as SubscriptionPlanType;
|
||||
if (!PLAN_DETAILS[upperValue]) {
|
||||
throw new Error(`Invalid subscription plan: ${value}`);
|
||||
}
|
||||
return new SubscriptionPlan(upperValue);
|
||||
}
|
||||
|
||||
static free(): SubscriptionPlan {
|
||||
return new SubscriptionPlan('FREE');
|
||||
}
|
||||
|
||||
static starter(): SubscriptionPlan {
|
||||
return new SubscriptionPlan('STARTER');
|
||||
}
|
||||
|
||||
static pro(): SubscriptionPlan {
|
||||
return new SubscriptionPlan('PRO');
|
||||
}
|
||||
|
||||
static enterprise(): SubscriptionPlan {
|
||||
return new SubscriptionPlan('ENTERPRISE');
|
||||
}
|
||||
|
||||
static getAllPlans(): SubscriptionPlan[] {
|
||||
return ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'].map(
|
||||
(p) => new SubscriptionPlan(p as SubscriptionPlanType),
|
||||
);
|
||||
}
|
||||
|
||||
get value(): SubscriptionPlanType {
|
||||
return this.plan;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return PLAN_DETAILS[this.plan].name;
|
||||
}
|
||||
|
||||
get maxLicenses(): number {
|
||||
return PLAN_DETAILS[this.plan].maxLicenses;
|
||||
}
|
||||
|
||||
get monthlyPriceEur(): number {
|
||||
return PLAN_DETAILS[this.plan].monthlyPriceEur;
|
||||
}
|
||||
|
||||
get yearlyPriceEur(): number {
|
||||
return PLAN_DETAILS[this.plan].yearlyPriceEur;
|
||||
}
|
||||
|
||||
get features(): readonly string[] {
|
||||
return PLAN_DETAILS[this.plan].features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this plan has unlimited licenses
|
||||
*/
|
||||
isUnlimited(): boolean {
|
||||
return this.maxLicenses === -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a paid plan
|
||||
*/
|
||||
isPaid(): boolean {
|
||||
return this.plan !== 'FREE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is the free plan
|
||||
*/
|
||||
isFree(): boolean {
|
||||
return this.plan === 'FREE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given number of users can be accommodated by this plan
|
||||
*/
|
||||
canAccommodateUsers(userCount: number): boolean {
|
||||
if (this.isUnlimited()) return true;
|
||||
return userCount <= this.maxLicenses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if upgrade to target plan is allowed
|
||||
*/
|
||||
canUpgradeTo(targetPlan: SubscriptionPlan): boolean {
|
||||
const planOrder: SubscriptionPlanType[] = [
|
||||
'FREE',
|
||||
'STARTER',
|
||||
'PRO',
|
||||
'ENTERPRISE',
|
||||
];
|
||||
const currentIndex = planOrder.indexOf(this.plan);
|
||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
||||
return targetIndex > currentIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if downgrade to target plan is allowed given current user count
|
||||
*/
|
||||
canDowngradeTo(targetPlan: SubscriptionPlan, currentUserCount: number): boolean {
|
||||
const planOrder: SubscriptionPlanType[] = [
|
||||
'FREE',
|
||||
'STARTER',
|
||||
'PRO',
|
||||
'ENTERPRISE',
|
||||
];
|
||||
const currentIndex = planOrder.indexOf(this.plan);
|
||||
const targetIndex = planOrder.indexOf(targetPlan.value);
|
||||
|
||||
if (targetIndex >= currentIndex) return false; // Not a downgrade
|
||||
return targetPlan.canAccommodateUsers(currentUserCount);
|
||||
}
|
||||
|
||||
equals(other: SubscriptionPlan): boolean {
|
||||
return this.plan === other.plan;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.plan;
|
||||
}
|
||||
}
|
||||
215
apps/backend/src/domain/value-objects/subscription-status.vo.ts
Normal file
215
apps/backend/src/domain/value-objects/subscription-status.vo.ts
Normal file
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Subscription Status Value Object
|
||||
*
|
||||
* Represents the different statuses a subscription can have.
|
||||
* Follows Stripe subscription lifecycle states.
|
||||
*/
|
||||
|
||||
export type SubscriptionStatusType =
|
||||
| 'ACTIVE'
|
||||
| 'PAST_DUE'
|
||||
| 'CANCELED'
|
||||
| 'INCOMPLETE'
|
||||
| 'INCOMPLETE_EXPIRED'
|
||||
| 'TRIALING'
|
||||
| 'UNPAID'
|
||||
| 'PAUSED';
|
||||
|
||||
interface StatusDetails {
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly allowsAccess: boolean;
|
||||
readonly requiresAction: boolean;
|
||||
}
|
||||
|
||||
const STATUS_DETAILS: Record<SubscriptionStatusType, StatusDetails> = {
|
||||
ACTIVE: {
|
||||
label: 'Active',
|
||||
description: 'Subscription is active and fully paid',
|
||||
allowsAccess: true,
|
||||
requiresAction: false,
|
||||
},
|
||||
PAST_DUE: {
|
||||
label: 'Past Due',
|
||||
description: 'Payment failed but subscription still active. Action required.',
|
||||
allowsAccess: true, // Grace period
|
||||
requiresAction: true,
|
||||
},
|
||||
CANCELED: {
|
||||
label: 'Canceled',
|
||||
description: 'Subscription has been canceled',
|
||||
allowsAccess: false,
|
||||
requiresAction: false,
|
||||
},
|
||||
INCOMPLETE: {
|
||||
label: 'Incomplete',
|
||||
description: 'Initial payment failed during subscription creation',
|
||||
allowsAccess: false,
|
||||
requiresAction: true,
|
||||
},
|
||||
INCOMPLETE_EXPIRED: {
|
||||
label: 'Incomplete Expired',
|
||||
description: 'Subscription creation payment window expired',
|
||||
allowsAccess: false,
|
||||
requiresAction: false,
|
||||
},
|
||||
TRIALING: {
|
||||
label: 'Trialing',
|
||||
description: 'Subscription is in trial period',
|
||||
allowsAccess: true,
|
||||
requiresAction: false,
|
||||
},
|
||||
UNPAID: {
|
||||
label: 'Unpaid',
|
||||
description: 'All payment retry attempts have failed',
|
||||
allowsAccess: false,
|
||||
requiresAction: true,
|
||||
},
|
||||
PAUSED: {
|
||||
label: 'Paused',
|
||||
description: 'Subscription has been paused',
|
||||
allowsAccess: false,
|
||||
requiresAction: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Status transitions that are valid
|
||||
const VALID_TRANSITIONS: Record<SubscriptionStatusType, SubscriptionStatusType[]> = {
|
||||
ACTIVE: ['PAST_DUE', 'CANCELED', 'PAUSED'],
|
||||
PAST_DUE: ['ACTIVE', 'CANCELED', 'UNPAID'],
|
||||
CANCELED: [], // Terminal state
|
||||
INCOMPLETE: ['ACTIVE', 'INCOMPLETE_EXPIRED'],
|
||||
INCOMPLETE_EXPIRED: [], // Terminal state
|
||||
TRIALING: ['ACTIVE', 'PAST_DUE', 'CANCELED'],
|
||||
UNPAID: ['ACTIVE', 'CANCELED'],
|
||||
PAUSED: ['ACTIVE', 'CANCELED'],
|
||||
};
|
||||
|
||||
export class SubscriptionStatus {
|
||||
private constructor(private readonly status: SubscriptionStatusType) {}
|
||||
|
||||
static create(status: SubscriptionStatusType): SubscriptionStatus {
|
||||
if (!STATUS_DETAILS[status]) {
|
||||
throw new Error(`Invalid subscription status: ${status}`);
|
||||
}
|
||||
return new SubscriptionStatus(status);
|
||||
}
|
||||
|
||||
static fromString(value: string): SubscriptionStatus {
|
||||
const upperValue = value.toUpperCase().replace(/-/g, '_') as SubscriptionStatusType;
|
||||
if (!STATUS_DETAILS[upperValue]) {
|
||||
throw new Error(`Invalid subscription status: ${value}`);
|
||||
}
|
||||
return new SubscriptionStatus(upperValue);
|
||||
}
|
||||
|
||||
static fromStripeStatus(stripeStatus: string): SubscriptionStatus {
|
||||
// Map Stripe status to our internal status
|
||||
const mapping: Record<string, SubscriptionStatusType> = {
|
||||
active: 'ACTIVE',
|
||||
past_due: 'PAST_DUE',
|
||||
canceled: 'CANCELED',
|
||||
incomplete: 'INCOMPLETE',
|
||||
incomplete_expired: 'INCOMPLETE_EXPIRED',
|
||||
trialing: 'TRIALING',
|
||||
unpaid: 'UNPAID',
|
||||
paused: 'PAUSED',
|
||||
};
|
||||
|
||||
const mappedStatus = mapping[stripeStatus.toLowerCase()];
|
||||
if (!mappedStatus) {
|
||||
throw new Error(`Unknown Stripe subscription status: ${stripeStatus}`);
|
||||
}
|
||||
return new SubscriptionStatus(mappedStatus);
|
||||
}
|
||||
|
||||
static active(): SubscriptionStatus {
|
||||
return new SubscriptionStatus('ACTIVE');
|
||||
}
|
||||
|
||||
static canceled(): SubscriptionStatus {
|
||||
return new SubscriptionStatus('CANCELED');
|
||||
}
|
||||
|
||||
static pastDue(): SubscriptionStatus {
|
||||
return new SubscriptionStatus('PAST_DUE');
|
||||
}
|
||||
|
||||
static trialing(): SubscriptionStatus {
|
||||
return new SubscriptionStatus('TRIALING');
|
||||
}
|
||||
|
||||
get value(): SubscriptionStatusType {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return STATUS_DETAILS[this.status].label;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return STATUS_DETAILS[this.status].description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this status allows access to the platform
|
||||
*/
|
||||
allowsAccess(): boolean {
|
||||
return STATUS_DETAILS[this.status].allowsAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this status requires user action (e.g., update payment method)
|
||||
*/
|
||||
requiresAction(): boolean {
|
||||
return STATUS_DETAILS[this.status].requiresAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a terminal state (cannot transition out)
|
||||
*/
|
||||
isTerminal(): boolean {
|
||||
return VALID_TRANSITIONS[this.status].length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the subscription is in good standing
|
||||
*/
|
||||
isInGoodStanding(): boolean {
|
||||
return this.status === 'ACTIVE' || this.status === 'TRIALING';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transition to new status is valid
|
||||
*/
|
||||
canTransitionTo(newStatus: SubscriptionStatus): boolean {
|
||||
return VALID_TRANSITIONS[this.status].includes(newStatus.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to new status if valid
|
||||
*/
|
||||
transitionTo(newStatus: SubscriptionStatus): SubscriptionStatus {
|
||||
if (!this.canTransitionTo(newStatus)) {
|
||||
throw new Error(
|
||||
`Invalid status transition from ${this.status} to ${newStatus.value}`,
|
||||
);
|
||||
}
|
||||
return newStatus;
|
||||
}
|
||||
|
||||
equals(other: SubscriptionStatus): boolean {
|
||||
return this.status === other.status;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to Stripe-compatible status string
|
||||
*/
|
||||
toStripeStatus(): string {
|
||||
return this.status.toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
}
|
||||
@ -10,3 +10,5 @@ export * from './carrier.orm-entity';
|
||||
export * from './port.orm-entity';
|
||||
export * from './rate-quote.orm-entity';
|
||||
export * from './csv-rate-config.orm-entity';
|
||||
export * from './subscription.orm-entity';
|
||||
export * from './license.orm-entity';
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* License ORM Entity (Infrastructure Layer)
|
||||
*
|
||||
* TypeORM entity for license persistence.
|
||||
* Represents user licenses linked to subscriptions.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { SubscriptionOrmEntity } from './subscription.orm-entity';
|
||||
import { UserOrmEntity } from './user.orm-entity';
|
||||
|
||||
export type LicenseStatusOrmType = 'ACTIVE' | 'REVOKED';
|
||||
|
||||
@Entity('licenses')
|
||||
@Index('idx_licenses_subscription_id', ['subscriptionId'])
|
||||
@Index('idx_licenses_user_id', ['userId'])
|
||||
@Index('idx_licenses_status', ['status'])
|
||||
@Index('idx_licenses_subscription_status', ['subscriptionId', 'status'])
|
||||
export class LicenseOrmEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'subscription_id', type: 'uuid' })
|
||||
subscriptionId: string;
|
||||
|
||||
@ManyToOne(() => SubscriptionOrmEntity, (subscription) => subscription.licenses, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'subscription_id' })
|
||||
subscription: SubscriptionOrmEntity;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: UserOrmEntity;
|
||||
|
||||
// Status
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['ACTIVE', 'REVOKED'],
|
||||
default: 'ACTIVE',
|
||||
})
|
||||
status: LicenseStatusOrmType;
|
||||
|
||||
// Timestamps
|
||||
@Column({ name: 'assigned_at', type: 'timestamp', default: () => 'NOW()' })
|
||||
assignedAt: Date;
|
||||
|
||||
@Column({ name: 'revoked_at', type: 'timestamp', nullable: true })
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Subscription ORM Entity (Infrastructure Layer)
|
||||
*
|
||||
* TypeORM entity for subscription persistence.
|
||||
* Represents organization subscriptions with plan and Stripe integration.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { OrganizationOrmEntity } from './organization.orm-entity';
|
||||
import { LicenseOrmEntity } from './license.orm-entity';
|
||||
|
||||
export type SubscriptionPlanOrmType = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||
|
||||
export type SubscriptionStatusOrmType =
|
||||
| 'ACTIVE'
|
||||
| 'PAST_DUE'
|
||||
| 'CANCELED'
|
||||
| 'INCOMPLETE'
|
||||
| 'INCOMPLETE_EXPIRED'
|
||||
| 'TRIALING'
|
||||
| 'UNPAID'
|
||||
| 'PAUSED';
|
||||
|
||||
@Entity('subscriptions')
|
||||
@Index('idx_subscriptions_organization_id', ['organizationId'])
|
||||
@Index('idx_subscriptions_stripe_customer_id', ['stripeCustomerId'])
|
||||
@Index('idx_subscriptions_stripe_subscription_id', ['stripeSubscriptionId'])
|
||||
@Index('idx_subscriptions_plan', ['plan'])
|
||||
@Index('idx_subscriptions_status', ['status'])
|
||||
export class SubscriptionOrmEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'organization_id', type: 'uuid', unique: true })
|
||||
organizationId: string;
|
||||
|
||||
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'organization_id' })
|
||||
organization: OrganizationOrmEntity;
|
||||
|
||||
// Plan information
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'],
|
||||
default: 'FREE',
|
||||
})
|
||||
plan: SubscriptionPlanOrmType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: [
|
||||
'ACTIVE',
|
||||
'PAST_DUE',
|
||||
'CANCELED',
|
||||
'INCOMPLETE',
|
||||
'INCOMPLETE_EXPIRED',
|
||||
'TRIALING',
|
||||
'UNPAID',
|
||||
'PAUSED',
|
||||
],
|
||||
default: 'ACTIVE',
|
||||
})
|
||||
status: SubscriptionStatusOrmType;
|
||||
|
||||
// Stripe integration
|
||||
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||
stripeCustomerId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'stripe_subscription_id',
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
unique: true,
|
||||
})
|
||||
stripeSubscriptionId: string | null;
|
||||
|
||||
// Billing period
|
||||
@Column({ name: 'current_period_start', type: 'timestamp', nullable: true })
|
||||
currentPeriodStart: Date | null;
|
||||
|
||||
@Column({ name: 'current_period_end', type: 'timestamp', nullable: true })
|
||||
currentPeriodEnd: Date | null;
|
||||
|
||||
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
|
||||
cancelAtPeriodEnd: boolean;
|
||||
|
||||
// Timestamps
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => LicenseOrmEntity, (license) => license.subscription)
|
||||
licenses: LicenseOrmEntity[];
|
||||
}
|
||||
@ -14,9 +14,12 @@ import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
|
||||
export class CsvBookingMapper {
|
||||
/**
|
||||
* Map ORM entity to domain entity
|
||||
*
|
||||
* Uses fromPersistence to avoid validation errors when loading legacy data
|
||||
* that might have empty documents array
|
||||
*/
|
||||
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
|
||||
return new CsvBooking(
|
||||
return CsvBooking.fromPersistence(
|
||||
ormEntity.id,
|
||||
ormEntity.userId,
|
||||
ormEntity.organizationId,
|
||||
|
||||
@ -9,3 +9,5 @@ export * from './user-orm.mapper';
|
||||
export * from './carrier-orm.mapper';
|
||||
export * from './port-orm.mapper';
|
||||
export * from './rate-quote-orm.mapper';
|
||||
export * from './subscription-orm.mapper';
|
||||
export * from './license-orm.mapper';
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* License ORM Mapper
|
||||
*
|
||||
* Maps between License domain entity and LicenseOrmEntity
|
||||
*/
|
||||
|
||||
import { License } from '@domain/entities/license.entity';
|
||||
import { LicenseOrmEntity } from '../entities/license.orm-entity';
|
||||
|
||||
export class LicenseOrmMapper {
|
||||
/**
|
||||
* Map domain entity to ORM entity
|
||||
*/
|
||||
static toOrm(domain: License): LicenseOrmEntity {
|
||||
const orm = new LicenseOrmEntity();
|
||||
const props = domain.toObject();
|
||||
|
||||
orm.id = props.id;
|
||||
orm.subscriptionId = props.subscriptionId;
|
||||
orm.userId = props.userId;
|
||||
orm.status = props.status;
|
||||
orm.assignedAt = props.assignedAt;
|
||||
orm.revokedAt = props.revokedAt;
|
||||
|
||||
return orm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ORM entity to domain entity
|
||||
*/
|
||||
static toDomain(orm: LicenseOrmEntity): License {
|
||||
return License.fromPersistence({
|
||||
id: orm.id,
|
||||
subscriptionId: orm.subscriptionId,
|
||||
userId: orm.userId,
|
||||
status: orm.status,
|
||||
assignedAt: orm.assignedAt,
|
||||
revokedAt: orm.revokedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: LicenseOrmEntity[]): License[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Subscription ORM Mapper
|
||||
*
|
||||
* Maps between Subscription domain entity and SubscriptionOrmEntity
|
||||
*/
|
||||
|
||||
import { Subscription } from '@domain/entities/subscription.entity';
|
||||
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
|
||||
|
||||
export class SubscriptionOrmMapper {
|
||||
/**
|
||||
* Map domain entity to ORM entity
|
||||
*/
|
||||
static toOrm(domain: Subscription): SubscriptionOrmEntity {
|
||||
const orm = new SubscriptionOrmEntity();
|
||||
const props = domain.toObject();
|
||||
|
||||
orm.id = props.id;
|
||||
orm.organizationId = props.organizationId;
|
||||
orm.plan = props.plan;
|
||||
orm.status = props.status;
|
||||
orm.stripeCustomerId = props.stripeCustomerId;
|
||||
orm.stripeSubscriptionId = props.stripeSubscriptionId;
|
||||
orm.currentPeriodStart = props.currentPeriodStart;
|
||||
orm.currentPeriodEnd = props.currentPeriodEnd;
|
||||
orm.cancelAtPeriodEnd = props.cancelAtPeriodEnd;
|
||||
orm.createdAt = props.createdAt;
|
||||
orm.updatedAt = props.updatedAt;
|
||||
|
||||
return orm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ORM entity to domain entity
|
||||
*/
|
||||
static toDomain(orm: SubscriptionOrmEntity): Subscription {
|
||||
return Subscription.fromPersistence({
|
||||
id: orm.id,
|
||||
organizationId: orm.organizationId,
|
||||
plan: orm.plan,
|
||||
status: orm.status,
|
||||
stripeCustomerId: orm.stripeCustomerId,
|
||||
stripeSubscriptionId: orm.stripeSubscriptionId,
|
||||
currentPeriodStart: orm.currentPeriodStart,
|
||||
currentPeriodEnd: orm.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: orm.cancelAtPeriodEnd,
|
||||
createdAt: orm.createdAt,
|
||||
updatedAt: orm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of ORM entities to domain entities
|
||||
*/
|
||||
static toDomainMany(orms: SubscriptionOrmEntity[]): Subscription[] {
|
||||
return orms.map((orm) => this.toDomain(orm));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Migration: Create Subscriptions Table
|
||||
*
|
||||
* This table stores organization subscription information including
|
||||
* plan, status, and Stripe integration data.
|
||||
*/
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSubscriptions1738000000001 implements MigrationInterface {
|
||||
name = 'CreateSubscriptions1738000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create subscription_plan enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "subscription_plan_enum" AS ENUM ('FREE', 'STARTER', 'PRO', 'ENTERPRISE')
|
||||
`);
|
||||
|
||||
// Create subscription_status enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "subscription_status_enum" AS ENUM (
|
||||
'ACTIVE',
|
||||
'PAST_DUE',
|
||||
'CANCELED',
|
||||
'INCOMPLETE',
|
||||
'INCOMPLETE_EXPIRED',
|
||||
'TRIALING',
|
||||
'UNPAID',
|
||||
'PAUSED'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create subscriptions table
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "subscriptions" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"organization_id" UUID NOT NULL,
|
||||
|
||||
-- Plan information
|
||||
"plan" subscription_plan_enum NOT NULL DEFAULT 'FREE',
|
||||
"status" subscription_status_enum NOT NULL DEFAULT 'ACTIVE',
|
||||
|
||||
-- Stripe integration
|
||||
"stripe_customer_id" VARCHAR(255) NULL,
|
||||
"stripe_subscription_id" VARCHAR(255) NULL,
|
||||
|
||||
-- Billing period
|
||||
"current_period_start" TIMESTAMP NULL,
|
||||
"current_period_end" TIMESTAMP NULL,
|
||||
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Timestamps
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT "pk_subscriptions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uq_subscriptions_organization_id" UNIQUE ("organization_id"),
|
||||
CONSTRAINT "uq_subscriptions_stripe_subscription_id" UNIQUE ("stripe_subscription_id"),
|
||||
CONSTRAINT "fk_subscriptions_organization" FOREIGN KEY ("organization_id")
|
||||
REFERENCES "organizations"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_subscriptions_organization_id" ON "subscriptions" ("organization_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_subscriptions_stripe_customer_id" ON "subscriptions" ("stripe_customer_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_subscriptions_stripe_subscription_id" ON "subscriptions" ("stripe_subscription_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_subscriptions_plan" ON "subscriptions" ("plan")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_subscriptions_status" ON "subscriptions" ("status")
|
||||
`);
|
||||
|
||||
// Add comments
|
||||
await queryRunner.query(`
|
||||
COMMENT ON TABLE "subscriptions" IS 'Organization subscriptions for licensing system'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "subscriptions"."plan" IS 'Subscription plan: FREE (2 users), STARTER (5), PRO (20), ENTERPRISE (unlimited)'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "subscriptions"."cancel_at_period_end" IS 'If true, subscription will be canceled at the end of current period'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "subscriptions" CASCADE`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS "subscription_plan_enum"`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Migration: Create Licenses Table
|
||||
*
|
||||
* This table stores user licenses linked to subscriptions.
|
||||
* Each active user in an organization consumes one license.
|
||||
*/
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateLicenses1738000000002 implements MigrationInterface {
|
||||
name = 'CreateLicenses1738000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create license_status enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "license_status_enum" AS ENUM ('ACTIVE', 'REVOKED')
|
||||
`);
|
||||
|
||||
// Create licenses table
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "licenses" (
|
||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"subscription_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
|
||||
-- Status
|
||||
"status" license_status_enum NOT NULL DEFAULT 'ACTIVE',
|
||||
|
||||
-- Timestamps
|
||||
"assigned_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
"revoked_at" TIMESTAMP NULL,
|
||||
|
||||
CONSTRAINT "pk_licenses" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uq_licenses_user_id" UNIQUE ("user_id"),
|
||||
CONSTRAINT "fk_licenses_subscription" FOREIGN KEY ("subscription_id")
|
||||
REFERENCES "subscriptions"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "fk_licenses_user" FOREIGN KEY ("user_id")
|
||||
REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_licenses_subscription_id" ON "licenses" ("subscription_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_licenses_user_id" ON "licenses" ("user_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_licenses_status" ON "licenses" ("status")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "idx_licenses_subscription_status" ON "licenses" ("subscription_id", "status")
|
||||
`);
|
||||
|
||||
// Add comments
|
||||
await queryRunner.query(`
|
||||
COMMENT ON TABLE "licenses" IS 'User licenses for subscription-based access control'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "licenses"."status" IS 'ACTIVE: license in use, REVOKED: license freed up'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
COMMENT ON COLUMN "licenses"."revoked_at" IS 'Timestamp when license was revoked, NULL if still active'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "licenses" CASCADE`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS "license_status_enum"`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Migration: Seed FREE Subscriptions for Existing Organizations
|
||||
*
|
||||
* Creates a FREE subscription for all existing organizations that don't have one,
|
||||
* and assigns licenses to all their active users.
|
||||
*/
|
||||
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SeedFreeSubscriptions1738000000003 implements MigrationInterface {
|
||||
name = 'SeedFreeSubscriptions1738000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create FREE subscription for each organization that doesn't have one
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "subscriptions" (
|
||||
"id",
|
||||
"organization_id",
|
||||
"plan",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
uuid_generate_v4(),
|
||||
o.id,
|
||||
'FREE',
|
||||
'ACTIVE',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "organizations" o
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "subscriptions" s WHERE s.organization_id = o.id
|
||||
)
|
||||
`);
|
||||
|
||||
// Assign licenses to all active users in organizations with subscriptions
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "licenses" (
|
||||
"id",
|
||||
"subscription_id",
|
||||
"user_id",
|
||||
"status",
|
||||
"assigned_at"
|
||||
)
|
||||
SELECT
|
||||
uuid_generate_v4(),
|
||||
s.id,
|
||||
u.id,
|
||||
'ACTIVE',
|
||||
NOW()
|
||||
FROM "users" u
|
||||
INNER JOIN "subscriptions" s ON s.organization_id = u.organization_id
|
||||
WHERE u.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "licenses" l WHERE l.user_id = u.id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Remove licenses created by this migration
|
||||
// Note: This is a destructive operation that cannot perfectly reverse
|
||||
// We'll delete all licenses and subscriptions with FREE plan created after a certain point
|
||||
// In practice, you wouldn't typically revert this in production
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "licenses"
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "subscriptions" WHERE "plan" = 'FREE'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -9,3 +9,5 @@ export * from './typeorm-user.repository';
|
||||
export * from './typeorm-carrier.repository';
|
||||
export * from './typeorm-port.repository';
|
||||
export * from './typeorm-rate-quote.repository';
|
||||
export * from './typeorm-subscription.repository';
|
||||
export * from './typeorm-license.repository';
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* TypeORM License Repository
|
||||
*
|
||||
* Implements LicenseRepository interface using TypeORM
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { License } from '@domain/entities/license.entity';
|
||||
import { LicenseRepository } from '@domain/ports/out/license.repository';
|
||||
import { LicenseOrmEntity } from '../entities/license.orm-entity';
|
||||
import { LicenseOrmMapper } from '../mappers/license-orm.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmLicenseRepository implements LicenseRepository {
|
||||
constructor(
|
||||
@InjectRepository(LicenseOrmEntity)
|
||||
private readonly repository: Repository<LicenseOrmEntity>,
|
||||
) {}
|
||||
|
||||
async save(license: License): Promise<License> {
|
||||
const orm = LicenseOrmMapper.toOrm(license);
|
||||
const saved = await this.repository.save(orm);
|
||||
return LicenseOrmMapper.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<License | null> {
|
||||
const orm = await this.repository.findOne({ where: { id } });
|
||||
return orm ? LicenseOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<License | null> {
|
||||
const orm = await this.repository.findOne({ where: { userId } });
|
||||
return orm ? LicenseOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findBySubscriptionId(subscriptionId: string): Promise<License[]> {
|
||||
const orms = await this.repository.find({
|
||||
where: { subscriptionId },
|
||||
order: { assignedAt: 'DESC' },
|
||||
});
|
||||
return LicenseOrmMapper.toDomainMany(orms);
|
||||
}
|
||||
|
||||
async findActiveBySubscriptionId(subscriptionId: string): Promise<License[]> {
|
||||
const orms = await this.repository.find({
|
||||
where: { subscriptionId, status: 'ACTIVE' },
|
||||
order: { assignedAt: 'DESC' },
|
||||
});
|
||||
return LicenseOrmMapper.toDomainMany(orms);
|
||||
}
|
||||
|
||||
async countActiveBySubscriptionId(subscriptionId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { subscriptionId, status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
|
||||
async countActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('license')
|
||||
.innerJoin('license.user', 'user')
|
||||
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
|
||||
.andWhere('license.status = :status', { status: 'ACTIVE' })
|
||||
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
|
||||
.getCount();
|
||||
return result;
|
||||
}
|
||||
|
||||
async findActiveBySubscriptionIdExcludingAdmins(subscriptionId: string): Promise<License[]> {
|
||||
const orms = await this.repository
|
||||
.createQueryBuilder('license')
|
||||
.innerJoin('license.user', 'user')
|
||||
.where('license.subscriptionId = :subscriptionId', { subscriptionId })
|
||||
.andWhere('license.status = :status', { status: 'ACTIVE' })
|
||||
.andWhere('user.role != :adminRole', { adminRole: 'ADMIN' })
|
||||
.orderBy('license.assignedAt', 'DESC')
|
||||
.getMany();
|
||||
return LicenseOrmMapper.toDomainMany(orms);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete({ id });
|
||||
}
|
||||
|
||||
async deleteBySubscriptionId(subscriptionId: string): Promise<void> {
|
||||
await this.repository.delete({ subscriptionId });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* TypeORM Subscription Repository
|
||||
*
|
||||
* Implements SubscriptionRepository interface using TypeORM
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subscription } from '@domain/entities/subscription.entity';
|
||||
import { SubscriptionRepository } from '@domain/ports/out/subscription.repository';
|
||||
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
|
||||
import { SubscriptionOrmMapper } from '../mappers/subscription-orm.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
|
||||
constructor(
|
||||
@InjectRepository(SubscriptionOrmEntity)
|
||||
private readonly repository: Repository<SubscriptionOrmEntity>,
|
||||
) {}
|
||||
|
||||
async save(subscription: Subscription): Promise<Subscription> {
|
||||
const orm = SubscriptionOrmMapper.toOrm(subscription);
|
||||
const saved = await this.repository.save(orm);
|
||||
return SubscriptionOrmMapper.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Subscription | null> {
|
||||
const orm = await this.repository.findOne({ where: { id } });
|
||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByOrganizationId(organizationId: string): Promise<Subscription | null> {
|
||||
const orm = await this.repository.findOne({ where: { organizationId } });
|
||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(
|
||||
stripeSubscriptionId: string,
|
||||
): Promise<Subscription | null> {
|
||||
const orm = await this.repository.findOne({ where: { stripeSubscriptionId } });
|
||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<Subscription | null> {
|
||||
const orm = await this.repository.findOne({ where: { stripeCustomerId } });
|
||||
return orm ? SubscriptionOrmMapper.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Subscription[]> {
|
||||
const orms = await this.repository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return SubscriptionOrmMapper.toDomainMany(orms);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete({ id });
|
||||
}
|
||||
}
|
||||
6
apps/backend/src/infrastructure/stripe/index.ts
Normal file
6
apps/backend/src/infrastructure/stripe/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Stripe Infrastructure Barrel Export
|
||||
*/
|
||||
|
||||
export * from './stripe.adapter';
|
||||
export * from './stripe.module';
|
||||
233
apps/backend/src/infrastructure/stripe/stripe.adapter.ts
Normal file
233
apps/backend/src/infrastructure/stripe/stripe.adapter.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Stripe Adapter
|
||||
*
|
||||
* Implementation of the StripePort interface using the Stripe SDK.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
StripePort,
|
||||
CreateCheckoutSessionInput,
|
||||
CreateCheckoutSessionOutput,
|
||||
CreatePortalSessionInput,
|
||||
CreatePortalSessionOutput,
|
||||
StripeSubscriptionData,
|
||||
StripeCheckoutSessionData,
|
||||
StripeWebhookEvent,
|
||||
} from '@domain/ports/out/stripe.port';
|
||||
import { SubscriptionPlanType } from '@domain/value-objects/subscription-plan.vo';
|
||||
|
||||
@Injectable()
|
||||
export class StripeAdapter implements StripePort {
|
||||
private readonly logger = new Logger(StripeAdapter.name);
|
||||
private readonly stripe: Stripe;
|
||||
private readonly webhookSecret: string;
|
||||
private readonly priceIdMap: Map<string, SubscriptionPlanType>;
|
||||
private readonly planPriceMap: Map<string, { monthly: string; yearly: string }>;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
||||
if (!apiKey) {
|
||||
this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe features will be disabled');
|
||||
}
|
||||
|
||||
this.stripe = new Stripe(apiKey || 'sk_test_placeholder');
|
||||
|
||||
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
|
||||
|
||||
// Map Stripe price IDs to plans
|
||||
this.priceIdMap = new Map();
|
||||
this.planPriceMap = new Map();
|
||||
|
||||
// Configure plan price IDs from environment
|
||||
const starterMonthly = this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID');
|
||||
const starterYearly = this.configService.get<string>('STRIPE_STARTER_YEARLY_PRICE_ID');
|
||||
const proMonthly = this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID');
|
||||
const proYearly = this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID');
|
||||
const enterpriseMonthly = this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID');
|
||||
const enterpriseYearly = this.configService.get<string>('STRIPE_ENTERPRISE_YEARLY_PRICE_ID');
|
||||
|
||||
if (starterMonthly) this.priceIdMap.set(starterMonthly, 'STARTER');
|
||||
if (starterYearly) this.priceIdMap.set(starterYearly, 'STARTER');
|
||||
if (proMonthly) this.priceIdMap.set(proMonthly, 'PRO');
|
||||
if (proYearly) this.priceIdMap.set(proYearly, 'PRO');
|
||||
if (enterpriseMonthly) this.priceIdMap.set(enterpriseMonthly, 'ENTERPRISE');
|
||||
if (enterpriseYearly) this.priceIdMap.set(enterpriseYearly, 'ENTERPRISE');
|
||||
|
||||
this.planPriceMap.set('STARTER', {
|
||||
monthly: starterMonthly || '',
|
||||
yearly: starterYearly || '',
|
||||
});
|
||||
this.planPriceMap.set('PRO', {
|
||||
monthly: proMonthly || '',
|
||||
yearly: proYearly || '',
|
||||
});
|
||||
this.planPriceMap.set('ENTERPRISE', {
|
||||
monthly: enterpriseMonthly || '',
|
||||
yearly: enterpriseYearly || '',
|
||||
});
|
||||
}
|
||||
|
||||
async createCheckoutSession(
|
||||
input: CreateCheckoutSessionInput,
|
||||
): Promise<CreateCheckoutSessionOutput> {
|
||||
const planPrices = this.planPriceMap.get(input.plan);
|
||||
if (!planPrices) {
|
||||
throw new Error(`No price configuration for plan: ${input.plan}`);
|
||||
}
|
||||
|
||||
const priceId = input.billingInterval === 'yearly'
|
||||
? planPrices.yearly
|
||||
: planPrices.monthly;
|
||||
|
||||
if (!priceId) {
|
||||
throw new Error(
|
||||
`No ${input.billingInterval} price configured for plan: ${input.plan}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sessionParams: Stripe.Checkout.SessionCreateParams = {
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: input.successUrl,
|
||||
cancel_url: input.cancelUrl,
|
||||
customer_email: input.customerId ? undefined : input.email,
|
||||
customer: input.customerId || undefined,
|
||||
metadata: {
|
||||
organizationId: input.organizationId,
|
||||
organizationName: input.organizationName,
|
||||
plan: input.plan,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
organizationId: input.organizationId,
|
||||
plan: input.plan,
|
||||
},
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: 'required',
|
||||
};
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create(sessionParams);
|
||||
|
||||
this.logger.log(
|
||||
`Created checkout session ${session.id} for organization ${input.organizationId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sessionUrl: session.url || '',
|
||||
};
|
||||
}
|
||||
|
||||
async createPortalSession(
|
||||
input: CreatePortalSessionInput,
|
||||
): Promise<CreatePortalSessionOutput> {
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: input.customerId,
|
||||
return_url: input.returnUrl,
|
||||
});
|
||||
|
||||
this.logger.log(`Created portal session for customer ${input.customerId}`);
|
||||
|
||||
return {
|
||||
sessionUrl: session.url,
|
||||
};
|
||||
}
|
||||
|
||||
async getSubscription(subscriptionId: string): Promise<StripeSubscriptionData | null> {
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Get the price ID from the first item
|
||||
const priceId = subscription.items.data[0]?.price.id || '';
|
||||
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
customerId: subscription.customer as string,
|
||||
status: subscription.status,
|
||||
planId: priceId,
|
||||
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as any).code === 'resource_missing') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCheckoutSession(sessionId: string): Promise<StripeCheckoutSessionData | null> {
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
customerId: session.customer as string | null,
|
||||
subscriptionId: session.subscription as string | null,
|
||||
status: session.status || 'unknown',
|
||||
metadata: (session.metadata || {}) as Record<string, string>,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as any).code === 'resource_missing') {
|
||||
return null;
|
||||
}
|
||||
this.logger.error(`Failed to retrieve checkout session ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<void> {
|
||||
await this.stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
|
||||
this.logger.log(`Scheduled subscription ${subscriptionId} for cancellation at period end`);
|
||||
}
|
||||
|
||||
async cancelSubscriptionImmediately(subscriptionId: string): Promise<void> {
|
||||
await this.stripe.subscriptions.cancel(subscriptionId);
|
||||
|
||||
this.logger.log(`Cancelled subscription ${subscriptionId} immediately`);
|
||||
}
|
||||
|
||||
async resumeSubscription(subscriptionId: string): Promise<void> {
|
||||
await this.stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
|
||||
this.logger.log(`Resumed subscription ${subscriptionId}`);
|
||||
}
|
||||
|
||||
async constructWebhookEvent(
|
||||
payload: string | Buffer,
|
||||
signature: string,
|
||||
): Promise<StripeWebhookEvent> {
|
||||
const event = this.stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
this.webhookSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
data: {
|
||||
object: event.data.object as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
mapPriceIdToPlan(priceId: string): SubscriptionPlanType | null {
|
||||
return this.priceIdMap.get(priceId) || null;
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/infrastructure/stripe/stripe.module.ts
Normal file
23
apps/backend/src/infrastructure/stripe/stripe.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Stripe Module
|
||||
*
|
||||
* NestJS module for Stripe payment integration.
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { StripeAdapter } from './stripe.adapter';
|
||||
import { STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
StripeAdapter,
|
||||
{
|
||||
provide: STRIPE_PORT,
|
||||
useExisting: StripeAdapter,
|
||||
},
|
||||
],
|
||||
exports: [STRIPE_PORT, StripeAdapter],
|
||||
})
|
||||
export class StripeModule {}
|
||||
@ -11,6 +11,8 @@ import { helmetConfig, corsConfig } from './infrastructure/security/security.con
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
bufferLogs: true,
|
||||
// Enable rawBody for Stripe webhooks signature verification
|
||||
rawBody: true,
|
||||
});
|
||||
|
||||
// Get config service
|
||||
|
||||
476
apps/frontend/app/about/page.tsx
Normal file
476
apps/frontend/app/about/page.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Ship,
|
||||
Target,
|
||||
Eye,
|
||||
Heart,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Linkedin,
|
||||
Calendar,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function AboutPage() {
|
||||
const heroRef = useRef(null);
|
||||
const missionRef = useRef(null);
|
||||
const valuesRef = useRef(null);
|
||||
const teamRef = useRef(null);
|
||||
const timelineRef = useRef(null);
|
||||
const statsRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isMissionInView = useInView(missionRef, { once: true });
|
||||
const isValuesInView = useInView(valuesRef, { once: true });
|
||||
const isTeamInView = useInView(teamRef, { once: true });
|
||||
const isTimelineInView = useInView(timelineRef, { once: true });
|
||||
const isStatsInView = useInView(statsRef, { once: true });
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Excellence',
|
||||
description:
|
||||
'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Transparence',
|
||||
description:
|
||||
'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
|
||||
color: 'from-pink-500 to-rose-500',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Collaboration',
|
||||
description:
|
||||
'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
|
||||
color: 'from-purple-500 to-indigo-500',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Innovation',
|
||||
description:
|
||||
'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
|
||||
color: 'from-orange-500 to-amber-500',
|
||||
},
|
||||
];
|
||||
|
||||
const team = [
|
||||
{
|
||||
name: 'Jean-Pierre Durand',
|
||||
role: 'CEO & Co-fondateur',
|
||||
bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
|
||||
image: '/assets/images/team/ceo.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
{
|
||||
name: 'Marie Lefebvre',
|
||||
role: 'CTO & Co-fondatrice',
|
||||
bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués',
|
||||
image: '/assets/images/team/cto.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
{
|
||||
name: 'Thomas Martin',
|
||||
role: 'COO',
|
||||
bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
|
||||
image: '/assets/images/team/coo.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
{
|
||||
name: 'Sophie Bernard',
|
||||
role: 'VP Sales',
|
||||
bio: '15 ans d\'expérience commerciale dans le secteur logistique',
|
||||
image: '/assets/images/team/vp-sales.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
{
|
||||
name: 'Alexandre Petit',
|
||||
role: 'VP Engineering',
|
||||
bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
|
||||
image: '/assets/images/team/vp-eng.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
{
|
||||
name: 'Claire Moreau',
|
||||
role: 'VP Product',
|
||||
bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
|
||||
image: '/assets/images/team/vp-product.jpg',
|
||||
linkedin: '#',
|
||||
},
|
||||
];
|
||||
|
||||
const timeline = [
|
||||
{
|
||||
year: '2021',
|
||||
title: 'Fondation',
|
||||
description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
|
||||
},
|
||||
{
|
||||
year: '2022',
|
||||
title: 'Première version',
|
||||
description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
|
||||
},
|
||||
{
|
||||
year: '2023',
|
||||
title: 'Série A',
|
||||
description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
|
||||
},
|
||||
{
|
||||
year: '2024',
|
||||
title: 'Expansion',
|
||||
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
|
||||
},
|
||||
{
|
||||
year: '2025',
|
||||
title: 'Leader européen',
|
||||
description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '500+', label: 'Clients actifs' },
|
||||
{ value: '50+', label: 'Compagnies maritimes' },
|
||||
{ value: '15', label: 'Pays couverts' },
|
||||
{ value: '100K+', label: 'Réservations/an' },
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="about" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Ship className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Notre histoire</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Révolutionner le fret maritime,
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
une réservation à la fois
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
Fondée en 2021, Xpeditis est née d'une vision simple : rendre le fret maritime aussi simple
|
||||
qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus
|
||||
grandes compagnies maritimes.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission & Vision Section */}
|
||||
<section ref={missionRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isMissionInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-12"
|
||||
>
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-gradient-to-br from-brand-turquoise/10 to-brand-turquoise/5 p-10 rounded-3xl border border-brand-turquoise/20"
|
||||
>
|
||||
<div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6">
|
||||
<Target className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Mission</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe
|
||||
qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
|
||||
tous les professionnels de la logistique.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-gradient-to-br from-brand-green/10 to-brand-green/5 p-10 rounded-3xl border border-brand-green/20"
|
||||
>
|
||||
<div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6">
|
||||
<Eye className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Vision</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire
|
||||
à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
|
||||
que mérite le commerce international.
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section ref={statsRef} className="py-16 bg-gray-50">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isStatsInView ? 'visible' : 'hidden'}
|
||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||
>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={isStatsInView ? { scale: 1 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="text-5xl lg:text-6xl font-bold text-brand-turquoise mb-2"
|
||||
>
|
||||
{stat.value}
|
||||
</motion.div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Values Section */}
|
||||
<section ref={valuesRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isValuesInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Nos Valeurs</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Les principes qui guident chacune de nos décisions
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isValuesInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
{values.map((value, index) => {
|
||||
const IconComponent = value.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -10 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div
|
||||
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${value.color} flex items-center justify-center mb-4`}
|
||||
>
|
||||
<IconComponent className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
|
||||
<p className="text-gray-600">{value.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline Section */}
|
||||
<section ref={timelineRef} className="py-20 bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isTimelineInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Parcours</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
De la startup au leader européen du fret maritime digital
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-1 h-full bg-brand-turquoise/20" />
|
||||
|
||||
<div className="space-y-12">
|
||||
{timeline.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
|
||||
animate={isTimelineInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
|
||||
>
|
||||
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}>
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<Calendar className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
|
||||
<p className="text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex items-center justify-center">
|
||||
<div className="w-6 h-6 bg-brand-turquoise rounded-full border-4 border-white shadow-lg" />
|
||||
</div>
|
||||
<div className="hidden lg:block flex-1" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team Section */}
|
||||
<section ref={teamRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isTeamInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Équipe</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Des experts passionnés par le maritime et la technologie
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isTeamInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{team.map((member, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -10 }}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-gradient-to-br from-brand-navy to-brand-navy/80 flex items-center justify-center relative overflow-hidden">
|
||||
<div className="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Users className="w-12 h-12 text-white/80" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-brand-turquoise/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<a
|
||||
href={member.linkedin}
|
||||
className="w-12 h-12 bg-white rounded-full flex items-center justify-center hover:scale-110 transition-transform"
|
||||
>
|
||||
<Linkedin className="w-6 h-6 text-brand-navy" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
|
||||
<p className="text-brand-turquoise font-medium mb-3">{member.role}</p>
|
||||
<p className="text-gray-600 text-sm">{member.bio}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
Rejoignez l'aventure Xpeditis
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-10">
|
||||
Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant
|
||||
rejoindre une équipe passionnée, nous avons hâte de vous rencontrer.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
<Link
|
||||
href="/register"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
|
||||
>
|
||||
<span>Créer un compte</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/careers"
|
||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
|
||||
>
|
||||
Voir les offres d'emploi
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
473
apps/frontend/app/blog/page.tsx
Normal file
473
apps/frontend/app/blog/page.tsx
Normal file
@ -0,0 +1,473 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Ship,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
ArrowRight,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
FileText,
|
||||
Anchor,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function BlogPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const heroRef = useRef(null);
|
||||
const articlesRef = useRef(null);
|
||||
const categoriesRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isArticlesInView = useInView(articlesRef, { once: true });
|
||||
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Tous les articles', icon: BookOpen },
|
||||
{ value: 'industry', label: 'Industrie maritime', icon: Ship },
|
||||
{ value: 'technology', label: 'Technologie', icon: TrendingUp },
|
||||
{ value: 'guides', label: 'Guides pratiques', icon: FileText },
|
||||
{ value: 'news', label: 'Actualités', icon: Globe },
|
||||
];
|
||||
|
||||
const featuredArticle = {
|
||||
id: 1,
|
||||
title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique',
|
||||
excerpt:
|
||||
'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.',
|
||||
category: 'technology',
|
||||
author: 'Marie Lefebvre',
|
||||
authorRole: 'CTO',
|
||||
date: '15 janvier 2025',
|
||||
readTime: '8 min',
|
||||
image: '/assets/images/blog/featured.jpg',
|
||||
tags: ['IA', 'Innovation', 'Logistique'],
|
||||
};
|
||||
|
||||
const articles = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Guide complet des Incoterms 2020 pour le transport maritime',
|
||||
excerpt:
|
||||
'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.',
|
||||
category: 'guides',
|
||||
author: 'Thomas Martin',
|
||||
date: '10 janvier 2025',
|
||||
readTime: '12 min',
|
||||
image: '/assets/images/blog/incoterms.jpg',
|
||||
tags: ['Incoterms', 'Guide', 'Commerce international'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Comment optimiser vos coûts de transport maritime en 2025',
|
||||
excerpt:
|
||||
'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.',
|
||||
category: 'guides',
|
||||
author: 'Sophie Bernard',
|
||||
date: '8 janvier 2025',
|
||||
readTime: '6 min',
|
||||
image: '/assets/images/blog/costs.jpg',
|
||||
tags: ['Optimisation', 'Coûts', 'Stratégie'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Les plus grands ports européens : classement 2025',
|
||||
excerpt:
|
||||
'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.',
|
||||
category: 'industry',
|
||||
author: 'Jean-Pierre Durand',
|
||||
date: '5 janvier 2025',
|
||||
readTime: '10 min',
|
||||
image: '/assets/images/blog/ports.jpg',
|
||||
tags: ['Ports', 'Europe', 'Statistiques'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Xpeditis lève 15M€ pour accélérer son expansion',
|
||||
excerpt:
|
||||
'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.',
|
||||
category: 'news',
|
||||
author: 'Jean-Pierre Durand',
|
||||
date: '3 janvier 2025',
|
||||
readTime: '4 min',
|
||||
image: '/assets/images/blog/funding.jpg',
|
||||
tags: ['Financement', 'Croissance', 'Xpeditis'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Décarbonation du transport maritime : où en sommes-nous ?',
|
||||
excerpt:
|
||||
'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.',
|
||||
category: 'industry',
|
||||
author: 'Claire Moreau',
|
||||
date: '28 décembre 2024',
|
||||
readTime: '9 min',
|
||||
image: '/assets/images/blog/green.jpg',
|
||||
tags: ['Environnement', 'Décarbonation', 'Durabilité'],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis',
|
||||
excerpt:
|
||||
'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.',
|
||||
category: 'technology',
|
||||
author: 'Alexandre Petit',
|
||||
date: '22 décembre 2024',
|
||||
readTime: '15 min',
|
||||
image: '/assets/images/blog/api.jpg',
|
||||
tags: ['API', 'Intégration', 'Technique'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Les documents essentiels pour l\'export maritime',
|
||||
excerpt:
|
||||
'Check-list complète des documents requis pour vos expéditions maritimes internationales.',
|
||||
category: 'guides',
|
||||
author: 'Thomas Martin',
|
||||
date: '18 décembre 2024',
|
||||
readTime: '7 min',
|
||||
image: '/assets/images/blog/documents.jpg',
|
||||
tags: ['Documents', 'Export', 'Douane'],
|
||||
},
|
||||
];
|
||||
|
||||
const filteredArticles = articles.filter((article) => {
|
||||
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
|
||||
const searchMatch =
|
||||
searchQuery === '' ||
|
||||
article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return categoryMatch && searchMatch;
|
||||
});
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="blog" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<BookOpen className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Blog Xpeditis</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Actualités & Insights
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
du fret maritime
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
Restez informé des dernières tendances du transport maritime, découvrez nos guides
|
||||
pratiques et suivez l'actualité de Xpeditis.
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="max-w-xl mx-auto"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un article..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section ref={categoriesRef} className="py-8 border-b border-gray-200">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isCategoriesInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = selectedCategory === category.value;
|
||||
return (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
|
||||
isActive
|
||||
? 'bg-brand-turquoise text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
<span className="font-medium">{category.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Featured Article */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Link href={`/blog/${featuredArticle.id}`}>
|
||||
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
|
||||
<Anchor className="w-48 h-48 text-white/10" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-20 p-8 lg:p-12">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
|
||||
À la une
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
|
||||
{categories.find((c) => c.value === featuredArticle.category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
|
||||
{featuredArticle.title}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-white/80 mb-6">{featuredArticle.excerpt}</p>
|
||||
|
||||
<div className="flex items-center space-x-6 text-white/60 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{featuredArticle.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{featuredArticle.date}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{featuredArticle.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>Lire l'article</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Articles Grid */}
|
||||
<section ref={articlesRef} className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isArticlesInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="flex items-center justify-between mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
|
||||
<span className="text-gray-500">{filteredArticles.length} articles</span>
|
||||
</motion.div>
|
||||
|
||||
{filteredArticles.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-600">Aucun article trouvé</h3>
|
||||
<p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isArticlesInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{filteredArticles.map((article) => (
|
||||
<motion.div key={article.id} variants={itemVariants}>
|
||||
<Link href={`/blog/${article.id}`}>
|
||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
|
||||
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative">
|
||||
<Ship className="w-16 h-16 text-brand-navy/20" />
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
|
||||
{categories.find((c) => c.value === article.category)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-brand-turquoise" />
|
||||
</div>
|
||||
<span>{article.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>{article.date}</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{article.readTime}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Load More */}
|
||||
{filteredArticles.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
|
||||
Charger plus d'articles
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Newsletter Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-white mb-6">
|
||||
Restez informé
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-10">
|
||||
Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
|
||||
du fret maritime directement dans votre boîte mail.
|
||||
</p>
|
||||
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="votre@email.com"
|
||||
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>S'abonner</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-white/50 text-sm mt-4">
|
||||
En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
621
apps/frontend/app/careers/page.tsx
Normal file
621
apps/frontend/app/careers/page.tsx
Normal file
@ -0,0 +1,621 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, useInView, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Briefcase,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
Heart,
|
||||
Zap,
|
||||
Coffee,
|
||||
GraduationCap,
|
||||
Plane,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
Search,
|
||||
Code,
|
||||
LineChart,
|
||||
Headphones,
|
||||
Megaphone,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function CareersPage() {
|
||||
const [selectedDepartment, setSelectedDepartment] = useState('all');
|
||||
const [selectedLocation, setSelectedLocation] = useState('all');
|
||||
const [expandedJob, setExpandedJob] = useState<number | null>(null);
|
||||
|
||||
const heroRef = useRef(null);
|
||||
const benefitsRef = useRef(null);
|
||||
const jobsRef = useRef(null);
|
||||
const cultureRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isBenefitsInView = useInView(benefitsRef, { once: true });
|
||||
const isJobsInView = useInView(jobsRef, { once: true });
|
||||
const isCultureInView = useInView(cultureRef, { once: true });
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Mutuelle Premium',
|
||||
description: 'Couverture santé complète pour vous et votre famille',
|
||||
},
|
||||
{
|
||||
icon: Plane,
|
||||
title: 'Télétravail Flexible',
|
||||
description: 'Travaillez d\'où vous voulez, jusqu\'à 3 jours par semaine',
|
||||
},
|
||||
{
|
||||
icon: Coffee,
|
||||
title: 'Bien-être au Travail',
|
||||
description: 'Salle de sport, fruits frais, et événements d\'équipe',
|
||||
},
|
||||
{
|
||||
icon: GraduationCap,
|
||||
title: 'Formation Continue',
|
||||
description: '2 000€/an de budget formation et conférences',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Équipe Internationale',
|
||||
description: 'Travaillez avec des talents de 15 nationalités',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Stock Options',
|
||||
description: 'Participez à la croissance de l\'entreprise',
|
||||
},
|
||||
];
|
||||
|
||||
const jobs = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Senior Frontend Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '65K - 85K €',
|
||||
description: 'Rejoignez notre équipe frontend pour développer la prochaine génération de notre plateforme.',
|
||||
requirements: [
|
||||
'5+ ans d\'expérience en développement frontend',
|
||||
'Maîtrise de React, TypeScript et Next.js',
|
||||
'Expérience avec les design systems',
|
||||
'Capacité à mentorer des développeurs juniors',
|
||||
],
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Backend Engineer (Node.js)',
|
||||
department: 'Engineering',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '55K - 75K €',
|
||||
description: 'Construisez des APIs scalables pour connecter les transitaires aux compagnies maritimes.',
|
||||
requirements: [
|
||||
'3+ ans d\'expérience en Node.js/NestJS',
|
||||
'Maîtrise de PostgreSQL et Redis',
|
||||
'Connaissance des architectures microservices',
|
||||
'Expérience avec Docker et Kubernetes appréciée',
|
||||
],
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Product Manager',
|
||||
department: 'Product',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '60K - 80K €',
|
||||
description: 'Définissez la vision produit et priorisez les fonctionnalités avec notre équipe.',
|
||||
requirements: [
|
||||
'4+ ans d\'expérience en product management B2B',
|
||||
'Expérience dans la logistique ou le shipping appréciée',
|
||||
'Capacité à analyser les données et définir les KPIs',
|
||||
'Excellentes compétences en communication',
|
||||
],
|
||||
icon: LineChart,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Account Executive',
|
||||
department: 'Sales',
|
||||
location: 'Rotterdam',
|
||||
type: 'CDI',
|
||||
remote: false,
|
||||
salary: '50K - 70K € + variable',
|
||||
description: 'Développez notre portefeuille clients aux Pays-Bas et en Belgique.',
|
||||
requirements: [
|
||||
'3+ ans d\'expérience en vente B2B',
|
||||
'Connaissance du secteur maritime/logistique',
|
||||
'Maîtrise du néerlandais et de l\'anglais',
|
||||
'Capacité à gérer des cycles de vente longs',
|
||||
],
|
||||
icon: Megaphone,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Customer Success Manager',
|
||||
department: 'Customer Success',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '45K - 60K €',
|
||||
description: 'Accompagnez nos clients dans l\'utilisation de la plateforme et maximisez leur satisfaction.',
|
||||
requirements: [
|
||||
'2+ ans d\'expérience en customer success',
|
||||
'Expérience avec les outils CRM (HubSpot, Salesforce)',
|
||||
'Excellent relationnel et sens du service',
|
||||
'Capacité à former et accompagner les utilisateurs',
|
||||
],
|
||||
icon: Headphones,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Data Analyst',
|
||||
department: 'Data',
|
||||
location: 'Hambourg',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '50K - 65K €',
|
||||
description: 'Analysez les données de shipping pour optimiser notre plateforme et nos processus.',
|
||||
requirements: [
|
||||
'3+ ans d\'expérience en data analysis',
|
||||
'Maîtrise de SQL, Python et des outils BI',
|
||||
'Expérience avec le shipping/logistics appréciée',
|
||||
'Capacité à communiquer les insights aux équipes',
|
||||
],
|
||||
icon: LineChart,
|
||||
},
|
||||
];
|
||||
|
||||
const departments = [
|
||||
{ value: 'all', label: 'Tous les départements' },
|
||||
{ value: 'Engineering', label: 'Engineering' },
|
||||
{ value: 'Product', label: 'Product' },
|
||||
{ value: 'Sales', label: 'Sales' },
|
||||
{ value: 'Customer Success', label: 'Customer Success' },
|
||||
{ value: 'Data', label: 'Data' },
|
||||
];
|
||||
|
||||
const locations = [
|
||||
{ value: 'all', label: 'Toutes les villes' },
|
||||
{ value: 'Paris', label: 'Paris' },
|
||||
{ value: 'Rotterdam', label: 'Rotterdam' },
|
||||
{ value: 'Hambourg', label: 'Hambourg' },
|
||||
];
|
||||
|
||||
const filteredJobs = jobs.filter((job) => {
|
||||
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
||||
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
||||
return departmentMatch && locationMatch;
|
||||
});
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="careers" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Briefcase className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Rejoignez-nous</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Construisons ensemble
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
le futur du maritime
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
Rejoignez une équipe passionnée qui révolutionne le fret maritime. Des défis stimulants,
|
||||
une culture bienveillante et des opportunités de croissance uniques vous attendent.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
<a
|
||||
href="#jobs"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
|
||||
>
|
||||
<span>Voir les offres</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
<Link
|
||||
href="/about"
|
||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
|
||||
>
|
||||
En savoir plus
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ value: '50+', label: 'Employés' },
|
||||
{ value: '15', label: 'Nationalités' },
|
||||
{ value: '3', label: 'Bureaux en Europe' },
|
||||
{ value: '40%', label: 'Femmes dans la tech' },
|
||||
].map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section ref={benefitsRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isBenefitsInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Pourquoi nous rejoindre ?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Nous investissons dans le bien-être et le développement de nos équipes
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{benefits.map((benefit, index) => {
|
||||
const IconComponent = benefit.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3>
|
||||
<p className="text-gray-600">{benefit.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Culture Section */}
|
||||
<section ref={cultureRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
Notre culture
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-8">
|
||||
Chez Xpeditis, nous croyons que les meilleures idées viennent d'équipes diverses
|
||||
et inclusives. Nous valorisons l'autonomie, la créativité et le feedback constructif.
|
||||
</p>
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
'Transparence totale sur les décisions et les résultats',
|
||||
'Feedback continu et culture de l\'amélioration',
|
||||
'Équilibre vie pro/perso respecté',
|
||||
'Célébration des succès collectifs',
|
||||
].map((item, index) => (
|
||||
<motion.li
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="flex items-center space-x-3 text-white/90"
|
||||
>
|
||||
<div className="w-6 h-6 bg-brand-turquoise rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<ChevronRight className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span>{item}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center"
|
||||
>
|
||||
<Users className="w-12 h-12 text-white/40" />
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Jobs Section */}
|
||||
<section ref={jobsRef} id="jobs" className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isJobsInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Nos offres d'emploi
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Trouvez le poste qui correspond à vos ambitions
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isJobsInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="flex flex-col md:flex-row items-center justify-center space-y-4 md:space-y-0 md:space-x-4 mb-12"
|
||||
>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedDepartment}
|
||||
onChange={(e) => setSelectedDepartment(e.target.value)}
|
||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||
>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.value} value={dept.value}>
|
||||
{dept.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLocation}
|
||||
onChange={(e) => setSelectedLocation(e.target.value)}
|
||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||
>
|
||||
{locations.map((loc) => (
|
||||
<option key={loc.value} value={loc.value}>
|
||||
{loc.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Job Listings */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isJobsInView ? 'visible' : 'hidden'}
|
||||
className="space-y-4"
|
||||
>
|
||||
{filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-600">Aucune offre trouvée</h3>
|
||||
<p className="text-gray-500">Essayez de modifier vos filtres</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredJobs.map((job) => {
|
||||
const IconComponent = job.icon;
|
||||
const isExpanded = expandedJob === job.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={job.id}
|
||||
variants={itemVariants}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="p-6 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpandedJob(isExpanded ? null : job.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-brand-navy">{job.title}</h3>
|
||||
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>{job.department}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{job.location}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{job.type}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
{job.remote && (
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
|
||||
Remote OK
|
||||
</span>
|
||||
)}
|
||||
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
|
||||
{job.salary}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-6 h-6 text-gray-400 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-t border-gray-100"
|
||||
>
|
||||
<div className="p-6 bg-gray-50">
|
||||
<p className="text-gray-600 mb-6">{job.description}</p>
|
||||
<h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{job.requirements.map((req, index) => (
|
||||
<li key={index} className="flex items-start space-x-2 text-gray-600">
|
||||
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href={`/careers/${job.id}`}
|
||||
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2"
|
||||
>
|
||||
<span>Postuler</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
|
||||
En savoir plus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-brand-navy mb-6">
|
||||
Pas de poste correspondant ?
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10">
|
||||
Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de
|
||||
talents passionnés pour rejoindre notre aventure.
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
|
||||
>
|
||||
<span>Candidature spontanée</span>
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
apps/frontend/app/compliance/page.tsx
Normal file
429
apps/frontend/app/compliance/page.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Shield,
|
||||
UserCheck,
|
||||
Database,
|
||||
FileText,
|
||||
Globe,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function CompliancePage() {
|
||||
const heroRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isContentInView = useInView(contentRef, { once: true });
|
||||
|
||||
const rights = [
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Droit d\'accès',
|
||||
description: 'Obtenez une copie de toutes les données personnelles que nous détenons sur vous.',
|
||||
},
|
||||
{
|
||||
icon: Edit,
|
||||
title: 'Droit de rectification',
|
||||
description: 'Faites corriger vos données personnelles si elles sont inexactes ou incomplètes.',
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
title: 'Droit à l\'effacement',
|
||||
description: 'Demandez la suppression de vos données personnelles ("droit à l\'oubli").',
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
title: 'Droit à la portabilité',
|
||||
description: 'Recevez vos données dans un format structuré, lisible par machine.',
|
||||
},
|
||||
];
|
||||
|
||||
const principles = [
|
||||
{
|
||||
icon: Database,
|
||||
title: 'Minimisation des données',
|
||||
description: 'Nous ne collectons que les données strictement nécessaires à nos services.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Limitation de conservation',
|
||||
description: 'Vos données sont conservées uniquement le temps nécessaire.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Intégrité et confidentialité',
|
||||
description: 'Vos données sont protégées contre tout accès non autorisé.',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Transparence',
|
||||
description: 'Nous vous informons clairement sur l\'utilisation de vos données.',
|
||||
},
|
||||
];
|
||||
|
||||
const measures = [
|
||||
{
|
||||
category: 'Mesures techniques',
|
||||
items: [
|
||||
'Chiffrement des données au repos et en transit',
|
||||
'Authentification multi-facteurs',
|
||||
'Journalisation des accès aux données',
|
||||
'Sauvegardes chiffrées régulières',
|
||||
'Pseudonymisation des données sensibles',
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Mesures organisationnelles',
|
||||
items: [
|
||||
'Délégué à la Protection des Données (DPO) désigné',
|
||||
'Formation régulière des employés',
|
||||
'Politiques de sécurité documentées',
|
||||
'Processus de gestion des incidents',
|
||||
'Audits de conformité réguliers',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Globe className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Conformité européenne</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Conformité
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
RGPD
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
||||
Xpeditis s'engage à respecter le Règlement Général sur la Protection des Données (RGPD)
|
||||
et à garantir vos droits en matière de protection des données personnelles.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green" />
|
||||
<span className="text-white text-sm">Conforme RGPD</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
|
||||
<UserCheck className="w-5 h-5 text-brand-green" />
|
||||
<span className="text-white text-sm">DPO désigné</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Your Rights */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Vos droits RGPD
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Le RGPD vous confère des droits renforcés sur vos données personnelles
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
{rights.map((right, index) => {
|
||||
const IconComponent = right.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
|
||||
<p className="text-gray-600">{right.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Exercise Rights CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
Accéder à mon compte
|
||||
</Link>
|
||||
<a
|
||||
href="mailto:dpo@xpeditis.com"
|
||||
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
|
||||
>
|
||||
Contacter le DPO
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Principles */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Nos principes de protection des données
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Des principes fondamentaux qui guident notre traitement des données
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{principles.map((principle, index) => {
|
||||
const IconComponent = principle.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<IconComponent className="w-6 h-6 text-brand-green" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{principle.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical & Organizational Measures */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Mesures de protection
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Des mesures techniques et organisationnelles pour assurer la sécurité de vos données
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{measures.map((measure, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-6">{measure.category}</h3>
|
||||
<ul className="space-y-4">
|
||||
{measure.items.map((item, i) => (
|
||||
<li key={i} className="flex items-center space-x-3 text-white/80">
|
||||
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Data Processing Register */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">
|
||||
Registre des traitements
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
|
||||
des données personnelles. Ce registre documente :
|
||||
</p>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>Les finalités de chaque traitement</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>Les catégories de données traitées</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>Les destinataires des données</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>Les durées de conservation</span>
|
||||
</li>
|
||||
<li className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>Les mesures de sécurité appliquées</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact DPO */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">
|
||||
Contacter notre DPO
|
||||
</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
||||
Notre Délégué à la Protection des Données est à votre disposition pour toute question
|
||||
relative au traitement de vos données personnelles ou à l'exercice de vos droits.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<a
|
||||
href="mailto:dpo@xpeditis.com"
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>dpo@xpeditis.com</span>
|
||||
</a>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
|
||||
>
|
||||
Politique de confidentialité
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
565
apps/frontend/app/contact/page.tsx
Normal file
565
apps/frontend/app/contact/page.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Clock,
|
||||
Send,
|
||||
MessageSquare,
|
||||
Headphones,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const heroRef = useRef(null);
|
||||
const formRef = useRef(null);
|
||||
const contactRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isFormInView = useInView(formRef, { once: true });
|
||||
const isContactInView = useInView(contactRef, { once: true });
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate form submission
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const contactMethods = [
|
||||
{
|
||||
icon: Mail,
|
||||
title: 'Email',
|
||||
description: 'Envoyez-nous un email',
|
||||
value: 'contact@xpeditis.com',
|
||||
link: 'mailto:contact@xpeditis.com',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
icon: Phone,
|
||||
title: 'Téléphone',
|
||||
description: 'Appelez-nous',
|
||||
value: '+33 1 23 45 67 89',
|
||||
link: 'tel:+33123456789',
|
||||
color: 'from-green-500 to-emerald-500',
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: 'Chat en direct',
|
||||
description: 'Discutez avec notre équipe',
|
||||
value: 'Disponible 24/7',
|
||||
link: '#chat',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
},
|
||||
{
|
||||
icon: Headphones,
|
||||
title: 'Support',
|
||||
description: 'Centre d\'aide',
|
||||
value: 'support.xpeditis.com',
|
||||
link: 'https://support.xpeditis.com',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
},
|
||||
];
|
||||
|
||||
const offices = [
|
||||
{
|
||||
city: 'Paris',
|
||||
address: '123 Avenue des Champs-Élysées',
|
||||
postalCode: '75008 Paris, France',
|
||||
phone: '+33 1 23 45 67 89',
|
||||
email: 'paris@xpeditis.com',
|
||||
isHQ: true,
|
||||
},
|
||||
{
|
||||
city: 'Rotterdam',
|
||||
address: 'Wilhelminakade 123',
|
||||
postalCode: '3072 AP Rotterdam, Netherlands',
|
||||
phone: '+31 10 123 4567',
|
||||
email: 'rotterdam@xpeditis.com',
|
||||
isHQ: false,
|
||||
},
|
||||
{
|
||||
city: 'Hambourg',
|
||||
address: 'Am Sandtorkai 50',
|
||||
postalCode: '20457 Hamburg, Germany',
|
||||
phone: '+49 40 123 4567',
|
||||
email: 'hamburg@xpeditis.com',
|
||||
isHQ: false,
|
||||
},
|
||||
];
|
||||
|
||||
const subjects = [
|
||||
{ value: '', label: 'Sélectionnez un sujet' },
|
||||
{ value: 'demo', label: 'Demande de démonstration' },
|
||||
{ value: 'pricing', label: 'Questions sur les tarifs' },
|
||||
{ value: 'partnership', label: 'Partenariat' },
|
||||
{ value: 'support', label: 'Support technique' },
|
||||
{ value: 'press', label: 'Relations presse' },
|
||||
{ value: 'careers', label: 'Recrutement' },
|
||||
{ value: 'other', label: 'Autre' },
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="contact" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Mail className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Nous contacter</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Une question ?
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Nous sommes là pour vous
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
Notre équipe est disponible pour répondre à toutes vos questions sur notre plateforme,
|
||||
nos services et nos tarifs. N'hésitez pas à nous contacter !
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Methods */}
|
||||
<section ref={contactRef} className="py-16 bg-gray-50">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContactInView ? 'visible' : 'hidden'}
|
||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{contactMethods.map((method, index) => {
|
||||
const IconComponent = method.icon;
|
||||
return (
|
||||
<motion.a
|
||||
key={index}
|
||||
href={method.link}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
|
||||
<p className="text-gray-500 text-sm mb-2">{method.description}</p>
|
||||
<p className="text-brand-turquoise font-medium">{method.value}</p>
|
||||
</motion.a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Contact Form & Info */}
|
||||
<section ref={formRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">Envoyez-nous un message</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Remplissez le formulaire ci-dessous et nous vous répondrons dans les plus brefs délais.
|
||||
</p>
|
||||
|
||||
{isSubmitted ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-green-50 border border-green-200 rounded-2xl p-8 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-green-800 mb-2">Message envoyé !</h3>
|
||||
<p className="text-green-700 mb-6">
|
||||
Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
}}
|
||||
className="text-brand-turquoise font-medium hover:underline"
|
||||
>
|
||||
Envoyer un autre message
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Prénom *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
required
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
placeholder="Jean"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nom *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
required
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
placeholder="Dupont"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
placeholder="jean.dupont@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Téléphone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Entreprise
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
placeholder="Votre entreprise"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sujet *
|
||||
</label>
|
||||
<select
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
>
|
||||
{subjects.map((subject) => (
|
||||
<option key={subject.value} value={subject.value}>
|
||||
{subject.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Message *
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none"
|
||||
placeholder="Comment pouvons-nous vous aider ?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold text-lg flex items-center justify-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Envoi en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
<span>Envoyer le message</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Offices */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={isFormInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">Nos bureaux</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Retrouvez-nous dans nos bureaux à travers l'Europe ou contactez-nous par email.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{offices.map((office, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-white p-6 rounded-2xl border-2 transition-all ${
|
||||
office.isHQ
|
||||
? 'border-brand-turquoise shadow-lg'
|
||||
: 'border-gray-200 hover:border-brand-turquoise/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
office.isHQ ? 'bg-brand-turquoise' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Building2 className={`w-6 h-6 ${office.isHQ ? 'text-white' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-xl font-bold text-brand-navy">{office.city}</h3>
|
||||
{office.isHQ && (
|
||||
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full">
|
||||
Siège social
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<span>{office.address}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-400 ml-6">{office.postalCode}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
<a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
|
||||
{office.phone}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
<a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
|
||||
{office.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Clock className="w-6 h-6 text-brand-turquoise" />
|
||||
<h3 className="text-lg font-bold text-brand-navy">Horaires d'ouverture</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Lundi - Vendredi</span>
|
||||
<span className="font-medium">9h00 - 18h00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Samedi</span>
|
||||
<span className="font-medium">10h00 - 14h00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Dimanche</span>
|
||||
<span className="font-medium text-gray-400">Fermé</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
* Support technique disponible 24/7 pour les clients Enterprise
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Map Section */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre présence en Europe</h2>
|
||||
<p className="text-gray-600">
|
||||
Des bureaux stratégiquement situés pour mieux vous servir
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="aspect-[21/9] bg-gradient-to-br from-brand-navy/5 to-brand-turquoise/5 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="w-16 h-16 text-brand-turquoise mx-auto mb-4" />
|
||||
<p className="text-gray-500">Carte interactive bientôt disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
apps/frontend/app/cookies/page.tsx
Normal file
291
apps/frontend/app/cookies/page.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail } from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function CookiesPage() {
|
||||
const heroRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isContentInView = useInView(contentRef, { once: true });
|
||||
|
||||
const cookieTypes = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Cookies essentiels',
|
||||
description: 'Nécessaires au fonctionnement du site',
|
||||
required: true,
|
||||
cookies: [
|
||||
{ name: 'session_id', purpose: 'Maintien de votre session de connexion', duration: 'Session' },
|
||||
{ name: 'csrf_token', purpose: 'Protection contre les attaques CSRF', duration: 'Session' },
|
||||
{ name: 'cookie_consent', purpose: 'Mémorisation de vos préférences cookies', duration: '1 an' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Cookies analytiques',
|
||||
description: 'Nous aident à améliorer notre plateforme',
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: '_ga', purpose: 'Google Analytics - Identification des visiteurs', duration: '2 ans' },
|
||||
{ name: '_gid', purpose: 'Google Analytics - Identification des sessions', duration: '24 heures' },
|
||||
{ name: '_gat', purpose: 'Google Analytics - Limitation du taux de requêtes', duration: '1 minute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Cookies marketing',
|
||||
description: 'Permettent de personnaliser les publicités',
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: '_fbp', purpose: 'Facebook Pixel - Suivi des conversions', duration: '3 mois' },
|
||||
{ name: 'li_fat_id', purpose: 'LinkedIn Insight - Attribution marketing', duration: '30 jours' },
|
||||
{ name: 'hubspotutk', purpose: 'HubSpot - Identification des visiteurs', duration: '13 mois' },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Cookies fonctionnels',
|
||||
description: 'Améliorent votre expérience utilisateur',
|
||||
required: false,
|
||||
cookies: [
|
||||
{ name: 'language', purpose: 'Mémorisation de votre langue préférée', duration: '1 an' },
|
||||
{ name: 'theme', purpose: 'Mémorisation du thème (clair/sombre)', duration: '1 an' },
|
||||
{ name: 'recent_searches', purpose: 'Historique de vos recherches récentes', duration: '30 jours' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Cookie className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Transparence</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Politique de
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Cookies
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
||||
Découvrez comment nous utilisons les cookies pour améliorer votre expérience
|
||||
sur Xpeditis et comment vous pouvez gérer vos préférences.
|
||||
</p>
|
||||
|
||||
<p className="text-white/60 text-sm">
|
||||
Dernière mise à jour : Janvier 2025
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Introduction */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-brand-navy mb-4">Qu'est-ce qu'un cookie ?</h2>
|
||||
<p className="text-gray-600 leading-relaxed mb-4">
|
||||
Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone)
|
||||
lorsque vous visitez un site web. Les cookies permettent au site de mémoriser vos actions et
|
||||
préférences sur une période donnée.
|
||||
</p>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Les cookies ne contiennent pas d'informations personnellement identifiables et ne peuvent pas
|
||||
accéder aux données stockées sur votre appareil.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cookie Types Section */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
|
||||
<p className="text-gray-600">
|
||||
Nous utilisons différents types de cookies sur notre plateforme
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="space-y-8"
|
||||
>
|
||||
{cookieTypes.map((type, index) => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{type.required ? (
|
||||
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
|
||||
Requis
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ToggleLeft className="w-8 h-8 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Optionnel</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Nom</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{type.cookies.map((cookie, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 last:border-0">
|
||||
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
|
||||
<td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
|
||||
<td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* How to manage cookies */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">Comment gérer vos cookies ?</h3>
|
||||
<div className="space-y-4 text-gray-600">
|
||||
<p>
|
||||
Vous pouvez à tout moment modifier vos préférences en matière de cookies :
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Via notre bandeau de consentement accessible en bas de chaque page</li>
|
||||
<li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
|
||||
<li>En utilisant des outils tiers de gestion des cookies</li>
|
||||
</ul>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">Des questions sur les cookies ?</h3>
|
||||
<p className="text-white/80 mb-6">
|
||||
Notre équipe est disponible pour répondre à toutes vos questions
|
||||
concernant l'utilisation des cookies sur notre plateforme.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:privacy@xpeditis.com"
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>privacy@xpeditis.com</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
981
apps/frontend/app/dashboard/documents/page.tsx
Normal file
981
apps/frontend/app/dashboard/documents/page.tsx
Normal file
@ -0,0 +1,981 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
type: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt?: Date;
|
||||
// Legacy fields for compatibility
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface DocumentWithBooking extends Document {
|
||||
bookingId: string;
|
||||
quoteNumber: string;
|
||||
route: string;
|
||||
status: string;
|
||||
carrierName: string;
|
||||
fileType?: string;
|
||||
}
|
||||
|
||||
export default function UserDocumentsPage() {
|
||||
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
||||
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Modal state for adding documents
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Modal state for replacing documents
|
||||
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
||||
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
||||
const [replacingFile, setReplacingFile] = useState(false);
|
||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Dropdown menu state
|
||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||
|
||||
// Helper function to get formatted quote number
|
||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
||||
};
|
||||
|
||||
// Get file extension and type
|
||||
const getFileType = (fileName: string): string => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const typeMap: Record<string, string> = {
|
||||
pdf: 'PDF',
|
||||
doc: 'Word',
|
||||
docx: 'Word',
|
||||
xls: 'Excel',
|
||||
xlsx: 'Excel',
|
||||
jpg: 'Image',
|
||||
jpeg: 'Image',
|
||||
png: 'Image',
|
||||
gif: 'Image',
|
||||
txt: 'Text',
|
||||
csv: 'CSV',
|
||||
};
|
||||
return typeMap[ext] || ext.toUpperCase();
|
||||
};
|
||||
|
||||
const fetchBookingsAndDocuments = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Fetch all user's bookings (paginated, get all pages)
|
||||
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
||||
const allBookings = response.bookings || [];
|
||||
setBookings(allBookings);
|
||||
|
||||
// Extract all documents from all bookings
|
||||
const allDocuments: DocumentWithBooking[] = [];
|
||||
|
||||
allBookings.forEach((booking: CsvBookingResponse) => {
|
||||
if (booking.documents && booking.documents.length > 0) {
|
||||
booking.documents.forEach((doc: any, index: number) => {
|
||||
// Use the correct field names from the backend
|
||||
const actualFileName = doc.fileName || doc.name || 'document';
|
||||
const actualFilePath = doc.filePath || doc.url || '';
|
||||
const actualMimeType = doc.mimeType || doc.type || '';
|
||||
|
||||
// Extract clean file type from mimeType or fileName
|
||||
let fileType = '';
|
||||
if (actualMimeType.includes('/')) {
|
||||
const parts = actualMimeType.split('/');
|
||||
fileType = getFileType(parts[1]);
|
||||
} else {
|
||||
fileType = getFileType(actualFileName);
|
||||
}
|
||||
|
||||
allDocuments.push({
|
||||
id: doc.id || `${booking.id}-doc-${index}`,
|
||||
fileName: actualFileName,
|
||||
filePath: actualFilePath,
|
||||
type: doc.type || '',
|
||||
mimeType: actualMimeType,
|
||||
size: doc.size || 0,
|
||||
uploadedAt: doc.uploadedAt,
|
||||
bookingId: booking.id,
|
||||
quoteNumber: getQuoteNumber(booking),
|
||||
route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`,
|
||||
status: booking.status,
|
||||
carrierName: booking.carrierName || 'N/A',
|
||||
fileType: fileType,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setDocuments(allDocuments);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erreur lors du chargement des documents');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookingsAndDocuments();
|
||||
}, [fetchBookingsAndDocuments]);
|
||||
|
||||
// Filter documents
|
||||
const filteredDocuments = documents.filter(doc => {
|
||||
const matchesSearch =
|
||||
searchTerm === '' ||
|
||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
doc.route.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
doc.carrierName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = filterStatus === 'all' || doc.status === filterStatus;
|
||||
|
||||
const matchesQuote =
|
||||
filterQuoteNumber === '' ||
|
||||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
||||
|
||||
return matchesSearch && matchesStatus && matchesQuote;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
||||
|
||||
const getDocumentIcon = (type: string) => {
|
||||
const typeLower = type.toLowerCase();
|
||||
const icons: Record<string, string> = {
|
||||
'application/pdf': '📄',
|
||||
'image/jpeg': '🖼️',
|
||||
'image/png': '🖼️',
|
||||
'image/jpg': '🖼️',
|
||||
pdf: '📄',
|
||||
jpeg: '🖼️',
|
||||
jpg: '🖼️',
|
||||
png: '🖼️',
|
||||
gif: '🖼️',
|
||||
image: '🖼️',
|
||||
word: '📝',
|
||||
doc: '📝',
|
||||
docx: '📝',
|
||||
excel: '📊',
|
||||
xls: '📊',
|
||||
xlsx: '📊',
|
||||
csv: '📊',
|
||||
text: '📄',
|
||||
txt: '📄',
|
||||
};
|
||||
return icons[typeLower] || '📎';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
ACCEPTED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
PENDING: 'En attente',
|
||||
ACCEPTED: 'Accepté',
|
||||
REJECTED: 'Refusé',
|
||||
CANCELLED: 'Annulé',
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, fileName: string) => {
|
||||
try {
|
||||
// Try direct download first
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// If direct download doesn't work, try fetch with blob
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link2 = document.createElement('a');
|
||||
link2.href = blobUrl;
|
||||
link2.download = fileName;
|
||||
document.body.appendChild(link2);
|
||||
link2.click();
|
||||
document.body.removeChild(link2);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (fetchError) {
|
||||
console.error('Fetch download failed:', fetchError);
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
alert(
|
||||
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique bookings for add document modal
|
||||
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
||||
|
||||
const handleAddDocumentClick = () => {
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowAddModal(false);
|
||||
setSelectedBookingId(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
||||
alert('Veuillez sélectionner une réservation et au moins un fichier');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingFiles(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const files = fileInputRef.current.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('documents', files[i]);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de l\'ajout des documents');
|
||||
}
|
||||
|
||||
alert('Documents ajoutés avec succès!');
|
||||
handleCloseModal();
|
||||
fetchBookingsAndDocuments(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error('Error uploading documents:', error);
|
||||
alert(
|
||||
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||
);
|
||||
} finally {
|
||||
setUploadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle dropdown menu
|
||||
const toggleDropdown = (docId: string) => {
|
||||
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setOpenDropdownId(null);
|
||||
};
|
||||
|
||||
if (openDropdownId) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [openDropdownId]);
|
||||
|
||||
// Replace document handlers
|
||||
const handleReplaceClick = (doc: DocumentWithBooking) => {
|
||||
setOpenDropdownId(null);
|
||||
setDocumentToReplace(doc);
|
||||
setShowReplaceModal(true);
|
||||
};
|
||||
|
||||
const handleCloseReplaceModal = () => {
|
||||
setShowReplaceModal(false);
|
||||
setDocumentToReplace(null);
|
||||
if (replaceFileInputRef.current) {
|
||||
replaceFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceDocument = async () => {
|
||||
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
||||
alert('Veuillez sélectionner un fichier de remplacement');
|
||||
return;
|
||||
}
|
||||
|
||||
setReplacingFile(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('document', replaceFileInputRef.current.files[0]);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
|
||||
}
|
||||
|
||||
alert('Document remplacé avec succès!');
|
||||
handleCloseReplaceModal();
|
||||
fetchBookingsAndDocuments(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error('Error replacing document:', error);
|
||||
alert(
|
||||
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||
);
|
||||
} finally {
|
||||
setReplacingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Chargement des documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Gérez tous les documents de vos réservations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddDocumentClick}
|
||||
disabled={bookingsWithPendingStatus.length === 0}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Ajouter un document
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Total Documents</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Réservations avec Documents</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Documents Filtrés</div>
|
||||
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nom, type, route, transporteur..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ex: #F2CAD5E1"
|
||||
value={filterQuoteNumber}
|
||||
onChange={e => setFilterQuoteNumber(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="all">Tous les statuts</option>
|
||||
<option value="PENDING">En attente</option>
|
||||
<option value="ACCEPTED">Accepté</option>
|
||||
<option value="REJECTED">Refusé</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Nom du Document
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° de Devis
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Route
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Transporteur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedDocuments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||||
{documents.length === 0
|
||||
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
|
||||
: 'Aucun document ne correspond aux filtres sélectionnés.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedDocuments.map((doc, index) => (
|
||||
<tr key={`${doc.bookingId}-${doc.id}-${index}`} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{doc.fileName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<span className="text-2xl mr-2">
|
||||
{getDocumentIcon(doc.fileType || doc.type)}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{doc.route}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
||||
>
|
||||
{getStatusLabel(doc.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div className="relative inline-block text-left">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="Actions"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="12" cy="19" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenDropdownId(null);
|
||||
handleDownload(doc.filePath || doc.url || '', doc.fileName);
|
||||
}}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-3 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReplaceClick(doc)}
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-3 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Remplacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredDocuments.length > 0 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(endIndex, filteredDocuments.length)}
|
||||
</span>{' '}
|
||||
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700">Par page:</label>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={e => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Précédent</span>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
currentPage === pageNum
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Suivant</span>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Document Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Ajouter un document
|
||||
</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sélectionner une réservation (en attente)
|
||||
</label>
|
||||
<select
|
||||
value={selectedBookingId || ''}
|
||||
onChange={e => setSelectedBookingId(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Choisir une réservation --</option>
|
||||
{bookingsWithPendingStatus.map(booking => (
|
||||
<option key={booking.id} value={booking.id}>
|
||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fichiers à ajouter
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileUpload}
|
||||
disabled={uploadingFiles || !selectedBookingId}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{uploadingFiles ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : (
|
||||
'Ajouter'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replace Document Modal */}
|
||||
{showReplaceModal && documentToReplace && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={handleCloseReplaceModal}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Remplacer le document
|
||||
</h3>
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Current document info */}
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-500">Document actuel:</p>
|
||||
<p className="text-sm font-medium text-gray-900 mt-1">
|
||||
{documentToReplace.fileName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nouveau fichier
|
||||
</label>
|
||||
<input
|
||||
ref={replaceFileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Formats acceptés: PDF, Word, Excel, Images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReplaceDocument}
|
||||
disabled={replacingFile}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replacingFile ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Remplacement en cours...
|
||||
</>
|
||||
) : (
|
||||
'Remplacer'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseReplaceModal}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -24,6 +24,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
|
||||
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
|
||||
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
|
||||
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||
// ADMIN and MANAGER only navigation items
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import { getOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||
import type { OrganizationResponse } from '@/types/api';
|
||||
import SubscriptionTab from '@/components/organization/SubscriptionTab';
|
||||
import LicensesTab from '@/components/organization/LicensesTab';
|
||||
|
||||
interface OrganizationForm {
|
||||
name: string;
|
||||
@ -17,11 +20,22 @@ interface OrganizationForm {
|
||||
address_country: string;
|
||||
}
|
||||
|
||||
type TabType = 'information' | 'address';
|
||||
type TabType = 'information' | 'address' | 'subscription' | 'licenses';
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('information');
|
||||
|
||||
// Auto-switch to subscription tab if coming back from Stripe (only for ADMIN/MANAGER)
|
||||
useEffect(() => {
|
||||
const isSuccess = searchParams.get('success') === 'true';
|
||||
const isCanceled = searchParams.get('canceled') === 'true';
|
||||
const canAccessBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
if ((isSuccess || isCanceled) && canAccessBilling) {
|
||||
setActiveTab('subscription');
|
||||
}
|
||||
}, [searchParams, user?.role]);
|
||||
const [organization, setOrganization] = useState<OrganizationResponse | null>(null);
|
||||
const [formData, setFormData] = useState<OrganizationForm>({
|
||||
name: '',
|
||||
@ -152,16 +166,62 @@ export default function OrganizationSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user can view subscription and licenses (only ADMIN and MANAGER)
|
||||
const canViewBilling = user?.role === 'ADMIN' || user?.role === 'MANAGER';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'information' as TabType,
|
||||
label: 'Informations',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'address' as TabType,
|
||||
label: 'Adresse',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// Only show subscription and licenses tabs for ADMIN and MANAGER roles
|
||||
...(canViewBilling ? [
|
||||
{
|
||||
id: 'subscription' as TabType,
|
||||
label: 'Abonnement',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'licenses' as TabType,
|
||||
label: 'Licences',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Paramètres de l'organisation</h1>
|
||||
<p className="text-gray-600 mt-2">Gérez les informations de votre organisation</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
|
||||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-green-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -173,7 +233,7 @@ export default function OrganizationSettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
{error && (activeTab === 'information' || activeTab === 'address') && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -185,13 +245,13 @@ export default function OrganizationSettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Read-only warning for USER role */}
|
||||
{!canEdit && (
|
||||
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-blue-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
|
||||
<p className="text-blue-800 font-medium">Mode lecture seule - Seuls les administrateurs et managers peuvent modifier l'organisation</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -199,38 +259,23 @@ export default function OrganizationSettingsPage() {
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab('information')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'information'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Informations</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('address')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'address'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>Adresse</span>
|
||||
</div>
|
||||
</button>
|
||||
<nav className="flex -mb-px overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -258,7 +303,7 @@ export default function OrganizationSettingsPage() {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SIREN
|
||||
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
|
||||
<span className="ml-2 text-xs text-gray-500">(Système d'Identification du Répertoire des Entreprises)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -393,10 +438,14 @@ export default function OrganizationSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscription' && canViewBilling && <SubscriptionTab />}
|
||||
|
||||
{activeTab === 'licenses' && canViewBilling && <LicensesTab />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && (
|
||||
{/* Actions (only for information and address tabs) */}
|
||||
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
||||
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
31
apps/frontend/app/dashboard/settings/subscription/page.tsx
Normal file
31
apps/frontend/app/dashboard/settings/subscription/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Subscription Management Page
|
||||
*
|
||||
* Redirects to Organization settings with Subscription tab
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve any query parameters (success, canceled) from Stripe redirects
|
||||
const params = searchParams.toString();
|
||||
const redirectUrl = `/dashboard/settings/organization${params ? `?${params}` : ''}`;
|
||||
router.replace(redirectUrl);
|
||||
}, [router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
|
||||
<p className="text-gray-600">Redirection...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,9 +9,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { listUsers, updateUser, deleteUser } from '@/lib/api';
|
||||
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
||||
import { createInvitation } from '@/lib/api/invitations';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
const router = useRouter();
|
||||
@ -34,6 +35,12 @@ export default function UsersManagementPage() {
|
||||
queryFn: () => listUsers(),
|
||||
});
|
||||
|
||||
// Check license availability
|
||||
const { data: licenseStatus } = useQuery({
|
||||
queryKey: ['canInvite'],
|
||||
queryFn: () => canInviteUser(),
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: typeof inviteForm) => {
|
||||
return createInvitation({
|
||||
@ -45,6 +52,7 @@ export default function UsersManagementPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({
|
||||
@ -82,6 +90,7 @@ export default function UsersManagementPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('User status updated successfully');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
@ -95,6 +104,7 @@ export default function UsersManagementPage() {
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('User deleted successfully');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
@ -159,19 +169,79 @@ export default function UsersManagementPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* License Warning */}
|
||||
{licenseStatus && !licenseStatus.canInvite && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
||||
Upgrade your subscription to invite more users.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
||||
>
|
||||
Upgrade Subscription
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Usage Info */}
|
||||
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-800">
|
||||
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Manage Subscription
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<span className="mr-2">➕</span>
|
||||
Invite User
|
||||
</button>
|
||||
{licenseStatus?.canInvite ? (
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Invite User
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Upgrade to Invite
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
@ -319,13 +389,23 @@ export default function UsersManagementPage() {
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
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"
|
||||
>
|
||||
<span className="mr-2">➕</span>
|
||||
Invite User
|
||||
</button>
|
||||
{licenseStatus?.canInvite ? (
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
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"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Invite User
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
Upgrade to Invite
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
287
apps/frontend/app/dashboard/track-trace/page.tsx
Normal file
287
apps/frontend/app/dashboard/track-trace/page.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Track & Trace Page
|
||||
*
|
||||
* Allows users to track their shipments by entering tracking numbers
|
||||
* and selecting the carrier. Redirects to carrier's tracking page.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Carrier tracking URLs - the tracking number will be appended
|
||||
const carriers = [
|
||||
{
|
||||
id: 'maersk',
|
||||
name: 'Maersk',
|
||||
logo: '🚢',
|
||||
trackingUrl: 'https://www.maersk.com/tracking/',
|
||||
placeholder: 'Ex: MSKU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'msc',
|
||||
name: 'MSC',
|
||||
logo: '🛳️',
|
||||
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
||||
placeholder: 'Ex: MSCU1234567',
|
||||
description: 'Container, B/L or Booking number',
|
||||
},
|
||||
{
|
||||
id: 'cma-cgm',
|
||||
name: 'CMA CGM',
|
||||
logo: '⚓',
|
||||
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
||||
placeholder: 'Ex: CMAU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'hapag-lloyd',
|
||||
name: 'Hapag-Lloyd',
|
||||
logo: '🔷',
|
||||
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
||||
placeholder: 'Ex: HLCU1234567',
|
||||
description: 'Container number',
|
||||
},
|
||||
{
|
||||
id: 'cosco',
|
||||
name: 'COSCO',
|
||||
logo: '🌊',
|
||||
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
||||
placeholder: 'Ex: COSU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'one',
|
||||
name: 'ONE (Ocean Network Express)',
|
||||
logo: '🟣',
|
||||
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
||||
placeholder: 'Ex: ONEU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'evergreen',
|
||||
name: 'Evergreen',
|
||||
logo: '🌲',
|
||||
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
||||
placeholder: 'Ex: EGHU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'yangming',
|
||||
name: 'Yang Ming',
|
||||
logo: '🟡',
|
||||
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
||||
placeholder: 'Ex: YMLU1234567',
|
||||
description: 'Container number',
|
||||
},
|
||||
{
|
||||
id: 'zim',
|
||||
name: 'ZIM',
|
||||
logo: '🔵',
|
||||
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
||||
placeholder: 'Ex: ZIMU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
{
|
||||
id: 'hmm',
|
||||
name: 'HMM (Hyundai)',
|
||||
logo: '🟠',
|
||||
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
||||
placeholder: 'Ex: HDMU1234567',
|
||||
description: 'Container or B/L number',
|
||||
},
|
||||
];
|
||||
|
||||
export default function TrackTracePage() {
|
||||
const [trackingNumber, setTrackingNumber] = useState('');
|
||||
const [selectedCarrier, setSelectedCarrier] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleTrack = () => {
|
||||
// Validation
|
||||
if (!trackingNumber.trim()) {
|
||||
setError('Veuillez entrer un numéro de tracking');
|
||||
return;
|
||||
}
|
||||
if (!selectedCarrier) {
|
||||
setError('Veuillez sélectionner un transporteur');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
// Find the carrier and build the tracking URL
|
||||
const carrier = carriers.find(c => c.id === selectedCarrier);
|
||||
if (carrier) {
|
||||
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
|
||||
// Open in new tab
|
||||
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTrack();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<Card className="bg-white shadow-lg border-blue-100">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<span className="text-2xl">🔍</span>
|
||||
Rechercher une expédition
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Carrier Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Sélectionnez le transporteur
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{carriers.map(carrier => (
|
||||
<button
|
||||
key={carrier.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCarrier(carrier.id);
|
||||
setError('');
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
|
||||
selectedCarrier === carrier.id
|
||||
? 'border-blue-500 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl mb-1">{carrier.logo}</span>
|
||||
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracking Number Input */}
|
||||
<div>
|
||||
<label htmlFor="tracking-number" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de tracking
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="tracking-number"
|
||||
type="text"
|
||||
value={trackingNumber}
|
||||
onChange={e => {
|
||||
setTrackingNumber(e.target.value.toUpperCase());
|
||||
setError('');
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
|
||||
className="text-lg font-mono border-gray-300 focus:border-blue-500"
|
||||
/>
|
||||
{selectedCarrierData && (
|
||||
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTrack}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
|
||||
>
|
||||
<span className="mr-2">🔍</span>
|
||||
Rechercher
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Help Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📦</span>
|
||||
Numéro de conteneur
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">
|
||||
Format standard: 4 lettres + 7 chiffres (ex: MSKU1234567).
|
||||
Le préfixe indique généralement le propriétaire du conteneur.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
Connaissement (B/L)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">
|
||||
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
|
||||
Format variable selon le carrier.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span>📝</span>
|
||||
Référence de booking
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">
|
||||
Numéro de réservation attribué par le transporteur lors de la réservation initiale de l'espace sur le navire.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">💡</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Cette fonctionnalité vous redirige vers le site officiel du transporteur pour obtenir les informations
|
||||
de suivi les plus récentes. Les données affichées proviennent directement du système du transporteur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/frontend/app/dashboard/wiki/assurance/page.tsx
Normal file
210
apps/frontend/app/dashboard/wiki/assurance/page.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Assurance Maritime - Wiki Page
|
||||
*
|
||||
* Protection des marchandises en transit
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const clausesICC = [
|
||||
{
|
||||
code: 'ICC A',
|
||||
name: 'All Risks',
|
||||
coverage: 'Tous risques',
|
||||
description: 'Couverture la plus complète. Couvre tous les risques de perte ou dommage sauf exclusions spécifiques.',
|
||||
includes: ['Vol', 'Casse', 'Mouille', 'Manquants', 'Chute', 'Écrasement'],
|
||||
excludes: ['Vice propre', 'Emballage insuffisant', 'Guerre', 'Grèves'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
code: 'ICC B',
|
||||
name: 'With Average',
|
||||
coverage: 'Risques majeurs',
|
||||
description: 'Couverture intermédiaire incluant les événements majeurs de transport.',
|
||||
includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Jet à la mer', 'Avarie commune'],
|
||||
excludes: ['Vol', 'Mouille', 'Manquants (hors avarie commune)', 'Casse isolée'],
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
code: 'ICC C',
|
||||
name: 'Free of Particular Average',
|
||||
coverage: 'Risques minimaux',
|
||||
description: 'Couverture de base pour les sinistres majeurs uniquement.',
|
||||
includes: ['Incendie', 'Naufrage', 'Échouement', 'Collision', 'Avarie commune'],
|
||||
excludes: ['Vol', 'Mouille', 'Casse', 'Manquants', 'Perte partielle'],
|
||||
recommended: false,
|
||||
},
|
||||
];
|
||||
|
||||
const extensionsGaranties = [
|
||||
{ name: 'Guerre et grèves', description: 'Extension pour couvrir les risques de guerre, grèves, émeutes.' },
|
||||
{ name: 'Magasin à magasin', description: 'Couverture étendue incluant les phases de stockage.' },
|
||||
{ name: 'Frais de réexpédition', description: 'Couvre les frais en cas de changement de destination.' },
|
||||
{ name: 'Pertes financières', description: 'Perte de marge, frais supplémentaires liés au sinistre.' },
|
||||
{ name: 'Transport frigorifique', description: 'Risques spécifiques liés au froid (panne, variation).' },
|
||||
];
|
||||
|
||||
export default function AssurancePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">🛡️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
L'assurance transport maritime protège les marchandises contre les risques de perte ou de dommage
|
||||
pendant le transit. Elle est régie par les Institute Cargo Clauses (ICC) de l'Institute of London Underwriters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Why insure */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi s'assurer ?</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-blue-800">
|
||||
<li><strong>Responsabilité limitée du transporteur</strong> : Maximum ~2 DTS/kg (Convention de Bruxelles)</li>
|
||||
<li><strong>Délai de réclamation court</strong> : 3 jours pour les réserves au transporteur</li>
|
||||
<li><strong>Preuves difficiles</strong> : Charge de la preuve souvent sur l'expéditeur</li>
|
||||
<li><strong>Exigence bancaire</strong> : Souvent requise pour les lettres de crédit (CIF, CIP)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ICC Clauses */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
|
||||
<div className="space-y-4">
|
||||
{clausesICC.map((clause) => (
|
||||
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 text-white rounded-md font-mono ${clause.recommended ? 'bg-green-600' : 'bg-gray-600'}`}>
|
||||
{clause.code}
|
||||
</span>
|
||||
<span className="text-lg">{clause.name}</span>
|
||||
{clause.recommended && (
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">Recommandé</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{clause.description}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">✓ Couvert</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{clause.includes.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">✗ Non couvert</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{clause.excludes.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valeur assurée */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||
Valeur assurée = (CIF + 10%) × Taux de change
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium">CIF</h4>
|
||||
<p className="text-gray-600">Coût + Assurance + Fret jusqu'au port de destination</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">+ 10%</h4>
|
||||
<p className="text-gray-600">Majoration standard pour couvrir le profit espéré</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">Taux de prime</h4>
|
||||
<p className="text-gray-600">0.1% à 1% selon marchandise, trajet, clause</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Extensions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">➕ Extensions de Garantie</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{extensionsGaranties.map((ext) => (
|
||||
<Card key={ext.name} className="bg-white">
|
||||
<CardContent className="pt-4">
|
||||
<h4 className="font-medium text-gray-900">{ext.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{ext.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
|
||||
<ol className="list-decimal list-inside space-y-3 text-gray-700">
|
||||
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
|
||||
<li><strong>Préserver</strong> : Ne pas modifier l'état des marchandises (photos, témoins)</li>
|
||||
<li><strong>Notifier</strong> : Informer l'assureur sous 5 jours ouvrés</li>
|
||||
<li><strong>Documenter</strong> : Rassembler tous les documents (B/L, facture, expertise)</li>
|
||||
<li><strong>Réclamer</strong> : Déposer une réclamation formelle avec justificatifs</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
|
||||
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>
|
||||
<li>Photographier la marchandise avant expédition</li>
|
||||
<li>Conserver tous les documents originaux</li>
|
||||
<li>Ne jamais signer "reçu conforme" sans avoir vérifié</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
apps/frontend/app/dashboard/wiki/calcul-fret/page.tsx
Normal file
269
apps/frontend/app/dashboard/wiki/calcul-fret/page.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Calcul du Fret Maritime - Wiki Page
|
||||
*
|
||||
* Comment sont calculés les coûts de transport
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const surcharges = [
|
||||
{
|
||||
code: 'BAF',
|
||||
name: 'Bunker Adjustment Factor',
|
||||
description: 'Surcharge carburant liée aux fluctuations du prix du fuel maritime.',
|
||||
variation: 'Mensuelle',
|
||||
},
|
||||
{
|
||||
code: 'CAF',
|
||||
name: 'Currency Adjustment Factor',
|
||||
description: 'Ajustement monétaire pour compenser les variations de taux de change.',
|
||||
variation: 'Mensuelle',
|
||||
},
|
||||
{
|
||||
code: 'THC',
|
||||
name: 'Terminal Handling Charges',
|
||||
description: 'Frais de manutention au terminal portuaire (chargement/déchargement).',
|
||||
variation: 'Par port',
|
||||
},
|
||||
{
|
||||
code: 'ISPS',
|
||||
name: 'International Ship & Port Security',
|
||||
description: 'Surcharge sécurité conforme au code ISPS (post 11 septembre).',
|
||||
variation: 'Fixe',
|
||||
},
|
||||
{
|
||||
code: 'LSS',
|
||||
name: 'Low Sulphur Surcharge',
|
||||
description: 'Surcharge carburant bas soufre (réglementation IMO 2020).',
|
||||
variation: 'Mensuelle',
|
||||
},
|
||||
{
|
||||
code: 'PSS',
|
||||
name: 'Peak Season Surcharge',
|
||||
description: 'Surcharge haute saison (généralement août-octobre vers l\'Europe).',
|
||||
variation: 'Saisonnière',
|
||||
},
|
||||
{
|
||||
code: 'GRI',
|
||||
name: 'General Rate Increase',
|
||||
description: 'Augmentation générale des tarifs, généralement annoncée à l\'avance.',
|
||||
variation: 'Variable',
|
||||
},
|
||||
{
|
||||
code: 'EBS',
|
||||
name: 'Emergency Bunker Surcharge',
|
||||
description: 'Surcharge d\'urgence en cas de hausse brutale du carburant.',
|
||||
variation: 'Exceptionnelle',
|
||||
},
|
||||
];
|
||||
|
||||
const fraisAdditionnels = [
|
||||
{ name: 'Documentation Fee', description: 'Frais d\'émission du B/L ou autres documents', typical: '35-75 USD' },
|
||||
{ name: 'Seal Fee', description: 'Coût du plomb de sécurité du conteneur', typical: '10-25 USD' },
|
||||
{ name: 'VGM Fee', description: 'Frais de certification du poids vérifié', typical: '25-50 USD' },
|
||||
{ name: 'Container Cleaning', description: 'Nettoyage du conteneur si requis', typical: '50-150 USD' },
|
||||
{ name: 'Customs Clearance', description: 'Frais de dédouanement (honoraires)', typical: '50-200 USD' },
|
||||
{ name: 'Inspection Fee', description: 'Frais d\'inspection (scanner, phytosanitaire)', typical: '50-300 USD' },
|
||||
];
|
||||
|
||||
export default function CalculFretPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">🧮</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Le coût du transport maritime se compose du fret de base et de nombreuses surcharges.
|
||||
Comprendre ces éléments est essentiel pour bien estimer ses coûts logistiques.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Base Calculation */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Prix forfaitaire par conteneur (20', 40', 40'HC), indépendant du poids (dans les limites).
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-2">Ex: 1,500 USD/20' Shanghai → Rotterdam</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">LCL (Groupage)</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Prix au mètre cube (CBM) ou à la tonne, selon le plus avantageux pour le transporteur.
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-2">Ex: 45 USD/CBM (minimum 1 CBM)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Weight Calculation */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">⚖️ Poids Taxable (LCL)</h3>
|
||||
<div className="bg-white p-4 rounded-lg border mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||
Poids taxable = MAX (Volume CBM × 1000, Poids brut kg)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium">Exemple 1 : Marchandise légère</h4>
|
||||
<ul className="text-gray-600 mt-2 space-y-1">
|
||||
<li>Volume : 2 CBM</li>
|
||||
<li>Poids : 500 kg</li>
|
||||
<li>Volume équivalent : 2 × 1000 = 2000 kg</li>
|
||||
<li className="font-medium text-blue-700">→ Taxé sur 2 CBM (ou 2 tonnes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium">Exemple 2 : Marchandise lourde</h4>
|
||||
<ul className="text-gray-600 mt-2 space-y-1">
|
||||
<li>Volume : 1 CBM</li>
|
||||
<li>Poids : 2000 kg</li>
|
||||
<li>Volume équivalent : 1 × 1000 = 1000 kg</li>
|
||||
<li className="font-medium text-blue-700">→ Taxé sur 2 tonnes (poids réel)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Ratio standard : 1 CBM = 1 tonne (1000 kg). Certaines compagnies utilisent 1 CBM = 333 kg pour l'aérien.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Surcharges */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{surcharges.map((sur) => (
|
||||
<Card key={sur.code} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-purple-600 text-white rounded-md font-mono text-sm">
|
||||
{sur.code}
|
||||
</span>
|
||||
<span className="text-base">{sur.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm">{sur.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Variation : {sur.variation}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional fees */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 font-medium">Frais</th>
|
||||
<th className="text-left py-2 font-medium">Description</th>
|
||||
<th className="text-right py-2 font-medium">Montant typique</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fraisAdditionnels.map((frais) => (
|
||||
<tr key={frais.name} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-900">{frais.name}</td>
|
||||
<td className="py-3 text-gray-600">{frais.description}</td>
|
||||
<td className="py-3 text-right font-mono text-gray-700">{frais.typical}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Example calculation */}
|
||||
<Card className="mt-8 bg-green-50 border-green-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="text-sm text-gray-600 mb-3">Conteneur 40' Shanghai → Le Havre</p>
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Ocean Freight (base)</span>
|
||||
<span>1,800 USD</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>BAF</span>
|
||||
<span>450 USD</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>LSS</span>
|
||||
<span>180 USD</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>THC Origin</span>
|
||||
<span>150 USD</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>THC Destination</span>
|
||||
<span>280 EUR</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>ISPS</span>
|
||||
<span>12 USD</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Documentation</span>
|
||||
<span>45 USD</span>
|
||||
</div>
|
||||
<div className="border-t pt-2 flex justify-between font-bold">
|
||||
<span>Total estimé</span>
|
||||
<span>~2,637 USD + 280 EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Demandez des devis "All-in" pour éviter les surprises de surcharges</li>
|
||||
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
||||
<li>Anticipez la haute saison (septembre-novembre) avec des réservations précoces</li>
|
||||
<li>Optimisez le remplissage des conteneurs pour réduire le coût unitaire</li>
|
||||
<li>Vérifiez les surcharges qui peuvent changer entre devis et facture</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
apps/frontend/app/dashboard/wiki/conteneurs/page.tsx
Normal file
315
apps/frontend/app/dashboard/wiki/conteneurs/page.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Conteneurs et Types de Cargo - Wiki Page
|
||||
*
|
||||
* Complete guide to container types
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const containers = [
|
||||
{
|
||||
type: '20\' Standard (20\' DRY)',
|
||||
code: '20DC',
|
||||
dimensions: {
|
||||
external: '6.06m x 2.44m x 2.59m',
|
||||
internal: '5.90m x 2.35m x 2.39m',
|
||||
door: '2.34m x 2.28m',
|
||||
},
|
||||
capacity: {
|
||||
volume: '33 m³',
|
||||
payload: '28,200 kg',
|
||||
tare: '2,300 kg',
|
||||
},
|
||||
usage: 'Marchandises générales sèches',
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: '40\' Standard (40\' DRY)',
|
||||
code: '40DC',
|
||||
dimensions: {
|
||||
external: '12.19m x 2.44m x 2.59m',
|
||||
internal: '12.03m x 2.35m x 2.39m',
|
||||
door: '2.34m x 2.28m',
|
||||
},
|
||||
capacity: {
|
||||
volume: '67 m³',
|
||||
payload: '26,680 kg',
|
||||
tare: '3,800 kg',
|
||||
},
|
||||
usage: 'Marchandises générales, cargo volumineux',
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: '40\' High Cube (40\' HC)',
|
||||
code: '40HC',
|
||||
dimensions: {
|
||||
external: '12.19m x 2.44m x 2.90m',
|
||||
internal: '12.03m x 2.35m x 2.69m',
|
||||
door: '2.34m x 2.58m',
|
||||
},
|
||||
capacity: {
|
||||
volume: '76 m³',
|
||||
payload: '26,460 kg',
|
||||
tare: '4,020 kg',
|
||||
},
|
||||
usage: 'Cargo léger mais volumineux',
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
type: 'Reefer (Réfrigéré)',
|
||||
code: '20RF / 40RF',
|
||||
dimensions: {
|
||||
external: 'Comme standard',
|
||||
internal: 'Légèrement réduit (isolation)',
|
||||
door: 'Standard',
|
||||
},
|
||||
capacity: {
|
||||
volume: '28 m³ (20\') / 60 m³ (40\')',
|
||||
payload: '27,400 kg / 26,500 kg',
|
||||
temperature: '-30°C à +30°C',
|
||||
},
|
||||
usage: 'Produits périssables, pharmaceutiques',
|
||||
icon: '❄️',
|
||||
},
|
||||
{
|
||||
type: 'Open Top',
|
||||
code: '20OT / 40OT',
|
||||
dimensions: {
|
||||
external: 'Comme standard',
|
||||
internal: 'Comme standard',
|
||||
door: 'Toit amovible + portes arrière',
|
||||
},
|
||||
capacity: {
|
||||
volume: 'Comme standard',
|
||||
payload: '28,100 kg / 26,400 kg',
|
||||
tare: '2,400 kg / 4,100 kg',
|
||||
},
|
||||
usage: 'Cargo hors gabarit en hauteur, machinerie',
|
||||
icon: '📭',
|
||||
},
|
||||
{
|
||||
type: 'Flat Rack',
|
||||
code: '20FR / 40FR',
|
||||
dimensions: {
|
||||
external: 'Comme standard (sans parois)',
|
||||
internal: 'Plateau sans côtés',
|
||||
door: 'N/A',
|
||||
},
|
||||
capacity: {
|
||||
volume: 'N/A',
|
||||
payload: '31,000 kg / 40,000 kg',
|
||||
tare: '2,700 kg / 4,700 kg',
|
||||
},
|
||||
usage: 'Cargo très lourd ou surdimensionné',
|
||||
icon: '🚛',
|
||||
},
|
||||
{
|
||||
type: 'Tank Container',
|
||||
code: '20TK',
|
||||
dimensions: {
|
||||
external: 'Cadre standard 20\'',
|
||||
internal: 'Citerne 21,000-26,000 L',
|
||||
door: 'Valves et trappes',
|
||||
},
|
||||
capacity: {
|
||||
volume: '21,000-26,000 litres',
|
||||
payload: '26,000 kg',
|
||||
tare: '3,500 kg',
|
||||
},
|
||||
usage: 'Liquides, gaz, produits chimiques',
|
||||
icon: '🛢️',
|
||||
},
|
||||
];
|
||||
|
||||
const specialEquipment = [
|
||||
{ name: 'Flexitank', desc: 'Poche flexible pour liquides non dangereux dans un 20\' standard', capacity: '16,000-24,000 L' },
|
||||
{ name: 'Garment on Hanger (GOH)', desc: 'Barres pour vêtements suspendus', capacity: 'Variable' },
|
||||
{ name: 'Ventilated Container', desc: 'Aération naturelle pour café, cacao, oignons', capacity: 'Standard' },
|
||||
{ name: 'Insulated Container', desc: 'Isolation thermique sans réfrigération active', capacity: 'Standard' },
|
||||
];
|
||||
|
||||
export default function ConteneursPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">📦</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Les conteneurs maritimes sont standardisés selon les normes ISO. Comprendre les différents
|
||||
types permet de choisir le conteneur adapté à votre marchandise et d'optimiser les coûts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Reference */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Codes ISO Courants</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ code: '20DC', name: '20\' Dry' },
|
||||
{ code: '40DC', name: '40\' Dry' },
|
||||
{ code: '40HC', name: '40\' High Cube' },
|
||||
{ code: '45HC', name: '45\' High Cube' },
|
||||
{ code: '20RF', name: '20\' Reefer' },
|
||||
{ code: '40RF', name: '40\' Reefer' },
|
||||
{ code: '20OT', name: '20\' Open Top' },
|
||||
{ code: '20FR', name: '20\' Flat Rack' },
|
||||
].map((item) => (
|
||||
<div key={item.code} className="bg-white p-2 rounded text-center">
|
||||
<span className="font-mono font-bold text-blue-700">{item.code}</span>
|
||||
<p className="text-xs text-gray-600">{item.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Types */}
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Types de Conteneurs</h2>
|
||||
<div className="space-y-4">
|
||||
{containers.map((container) => (
|
||||
<Card key={container.code} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{container.icon}</span>
|
||||
<div>
|
||||
<span className="text-lg">{container.type}</span>
|
||||
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
|
||||
{container.code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{container.usage}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Dimensions</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-gray-500">Externe:</span>
|
||||
<span className="text-gray-900">{container.dimensions.external}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-gray-500">Interne:</span>
|
||||
<span className="text-gray-900">{container.dimensions.internal}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-gray-500">Porte:</span>
|
||||
<span className="text-gray-900">{container.dimensions.door}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Capacité</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(container.capacity).map(([key, value]) => (
|
||||
<li key={key} className="flex justify-between">
|
||||
<span className="text-gray-500 capitalize">{key}:</span>
|
||||
<span className="text-gray-900">{value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Special Equipment */}
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Équipements Spéciaux</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{specialEquipment.map((equip) => (
|
||||
<div key={equip.name} className="p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-semibold text-gray-900">{equip.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{equip.desc}</p>
|
||||
<p className="text-sm text-blue-600 mt-2">Capacité: {equip.capacity}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Selection Guide */}
|
||||
<Card className="mt-8 bg-green-50 border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-green-900">Guide de Sélection</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📦</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Marchandises générales</p>
|
||||
<p className="text-sm text-green-800">→ 20' ou 40' Standard (DRY)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">❄️</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
|
||||
<p className="text-sm text-green-800">→ Reefer 20' ou 40'</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📭</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
|
||||
<p className="text-sm text-green-800">→ Open Top</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">🚛</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
|
||||
<p className="text-sm text-green-800">→ Flat Rack</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">🛢️</span>
|
||||
<div>
|
||||
<p className="font-medium text-green-900">Liquides en vrac</p>
|
||||
<p className="text-sm text-green-800">→ Tank Container ou Flexitank</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Un 40' HC offre 30% de volume en plus qu'un 20' mais coûte rarement le double</li>
|
||||
<li>Les Reefer nécessitent une alimentation électrique au port et sur le navire</li>
|
||||
<li>Les conteneurs spéciaux (OT, FR, Tank) ont une disponibilité limitée - réservez à l'avance</li>
|
||||
<li>Vérifiez le poids maximum autorisé sur les routes du pays de destination</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
apps/frontend/app/dashboard/wiki/documents-transport/page.tsx
Normal file
249
apps/frontend/app/dashboard/wiki/documents-transport/page.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Documents de Transport Maritime - Wiki Page
|
||||
*
|
||||
* Essential documents for maritime shipping
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const documents = [
|
||||
{
|
||||
name: 'Bill of Lading (B/L)',
|
||||
french: 'Connaissement',
|
||||
description: 'Document principal du transport maritime. Il fait preuve du contrat de transport, accuse réception des marchandises et constitue un titre de propriété négociable.',
|
||||
types: [
|
||||
{ name: 'B/L à ordre', desc: 'Négociable, peut être endossé' },
|
||||
{ name: 'B/L nominatif', desc: 'Au nom d\'un destinataire précis' },
|
||||
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
|
||||
],
|
||||
importance: 'Critique',
|
||||
icon: '📄',
|
||||
},
|
||||
{
|
||||
name: 'Sea Waybill',
|
||||
french: 'Lettre de transport maritime',
|
||||
description: 'Document non négociable servant de preuve du contrat de transport et de reçu. Plus simple que le B/L car pas besoin de présenter l\'original pour retirer les marchandises.',
|
||||
types: [
|
||||
{ name: 'Standard', desc: 'Pour expéditions entre parties de confiance' },
|
||||
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
|
||||
],
|
||||
importance: 'Important',
|
||||
icon: '📋',
|
||||
},
|
||||
{
|
||||
name: 'Manifest',
|
||||
french: 'Manifeste de cargaison',
|
||||
description: 'Liste complète de toutes les marchandises chargées à bord d\'un navire. Utilisé par les autorités douanières et portuaires.',
|
||||
types: [
|
||||
{ name: 'Cargo Manifest', desc: 'Liste détaillée des marchandises' },
|
||||
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
|
||||
],
|
||||
importance: 'Obligatoire',
|
||||
icon: '📑',
|
||||
},
|
||||
{
|
||||
name: 'Packing List',
|
||||
french: 'Liste de colisage',
|
||||
description: 'Document détaillant le contenu de chaque colis, ses dimensions et son poids. Essentiel pour le dédouanement.',
|
||||
types: [
|
||||
{ name: 'Simple', desc: 'Liste basique des contenus' },
|
||||
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
|
||||
],
|
||||
importance: 'Important',
|
||||
icon: '📦',
|
||||
},
|
||||
{
|
||||
name: 'Commercial Invoice',
|
||||
french: 'Facture commerciale',
|
||||
description: 'Facture établie par le vendeur décrivant les marchandises, leur valeur et les conditions de vente. Base pour le calcul des droits de douane.',
|
||||
types: [
|
||||
{ name: 'Proforma', desc: 'Avant expédition, pour cotation' },
|
||||
{ name: 'Définitive', desc: 'Document final de facturation' },
|
||||
],
|
||||
importance: 'Critique',
|
||||
icon: '🧾',
|
||||
},
|
||||
{
|
||||
name: 'Certificate of Origin',
|
||||
french: 'Certificat d\'origine',
|
||||
description: 'Document certifiant le pays de fabrication ou de transformation des marchandises. Requis pour les préférences tarifaires.',
|
||||
types: [
|
||||
{ name: 'EUR.1', desc: 'Pour les échanges UE' },
|
||||
{ name: 'Form A', desc: 'Système de préférences généralisées' },
|
||||
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
|
||||
],
|
||||
importance: 'Selon destination',
|
||||
icon: '🏭',
|
||||
},
|
||||
];
|
||||
|
||||
const additionalDocs = [
|
||||
{
|
||||
name: 'Dangerous Goods Declaration',
|
||||
description: 'Obligatoire pour les marchandises dangereuses (IMDG)',
|
||||
},
|
||||
{
|
||||
name: 'Phytosanitary Certificate',
|
||||
description: 'Pour les produits végétaux et alimentaires',
|
||||
},
|
||||
{
|
||||
name: 'Insurance Certificate',
|
||||
description: 'Preuve de couverture d\'assurance cargo',
|
||||
},
|
||||
{
|
||||
name: 'Inspection Certificate',
|
||||
description: 'Rapport de contrôle qualité avant embarquement',
|
||||
},
|
||||
{
|
||||
name: 'VGM Declaration',
|
||||
description: 'Déclaration du poids vérifié du conteneur (SOLAS)',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumentsTransportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">📋</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Le transport maritime international nécessite une documentation précise et complète.
|
||||
Ces documents servent de preuve de propriété, de contrat de transport et sont essentiels
|
||||
pour le passage en douane.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Importance Warning */}
|
||||
<Card className="bg-red-50 border-red-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-red-900 mb-2">Important</h3>
|
||||
<p className="text-red-800">
|
||||
Une erreur ou un manque de document peut entraîner des retards considérables, des frais
|
||||
de surestaries (demurrage) et même le refus d'entrée des marchandises dans le pays de destination.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Documents */}
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Documents Principaux</h2>
|
||||
<div className="space-y-4">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.name} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{doc.icon}</span>
|
||||
<div>
|
||||
<span className="text-lg">{doc.name}</span>
|
||||
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs font-medium rounded-full ${
|
||||
doc.importance === 'Critique' ? 'bg-red-100 text-red-800' :
|
||||
doc.importance === 'Obligatoire' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{doc.importance}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{doc.description}</p>
|
||||
<div className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Types:</p>
|
||||
<ul className="space-y-1">
|
||||
{doc.types.map((type) => (
|
||||
<li key={type.name} className="text-sm flex">
|
||||
<span className="font-medium text-gray-900 min-w-[120px]">{type.name}:</span>
|
||||
<span className="text-gray-600">{type.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bill of Lading Detail */}
|
||||
<Card className="mt-8 bg-blue-50 border-blue-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-900">Focus: Le Bill of Lading (B/L)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-blue-800 mb-4">
|
||||
Le B/L remplit trois fonctions essentielles:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">1. Reçu</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Confirme que le transporteur a reçu les marchandises dans l'état décrit
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">2. Contrat</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Preuve du contrat de transport entre le chargeur et le transporteur
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">3. Titre</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Document de titre permettant le transfert de propriété des marchandises
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Documents */}
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Documents Complémentaires</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{additionalDocs.map((doc) => (
|
||||
<div key={doc.name} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-green-600">✓</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{doc.name}</p>
|
||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Vérifiez la cohérence entre tous les documents (noms, adresses, descriptions)</li>
|
||||
<li>Conservez des copies de tous les originaux</li>
|
||||
<li>Anticipez les délais d'obtention des certificats (origine, sanitaire, etc.)</li>
|
||||
<li>En cas de L/C, les documents doivent correspondre exactement aux exigences</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
apps/frontend/app/dashboard/wiki/douanes/page.tsx
Normal file
218
apps/frontend/app/dashboard/wiki/douanes/page.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Procédures Douanières - Wiki Page
|
||||
*
|
||||
* Guide des formalités douanières import/export
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const regimesDouaniers = [
|
||||
{
|
||||
code: 'IM4',
|
||||
name: 'Mise en libre pratique',
|
||||
description: 'Importation définitive avec paiement des droits et taxes. La marchandise entre dans le circuit économique.',
|
||||
},
|
||||
{
|
||||
code: 'IM5',
|
||||
name: 'Admission temporaire',
|
||||
description: 'Importation temporaire avec suspension des droits. Pour les marchandises réexportées en l\'état.',
|
||||
},
|
||||
{
|
||||
code: 'IM6',
|
||||
name: 'Perfectionnement actif',
|
||||
description: 'Importation pour transformation et réexportation. Suspension des droits sur les intrants.',
|
||||
},
|
||||
{
|
||||
code: 'IM7',
|
||||
name: 'Entrepôt douanier',
|
||||
description: 'Stockage sous douane sans paiement des droits jusqu\'à la mise en consommation.',
|
||||
},
|
||||
{
|
||||
code: 'EX1',
|
||||
name: 'Exportation définitive',
|
||||
description: 'Sortie définitive des marchandises du territoire douanier.',
|
||||
},
|
||||
{
|
||||
code: 'EX2',
|
||||
name: 'Exportation temporaire',
|
||||
description: 'Sortie temporaire avec réimportation prévue en l\'état.',
|
||||
},
|
||||
{
|
||||
code: 'EX3',
|
||||
name: 'Perfectionnement passif',
|
||||
description: 'Exportation pour transformation à l\'étranger et réimportation.',
|
||||
},
|
||||
];
|
||||
|
||||
const documentsDouane = [
|
||||
{
|
||||
name: 'DAU (Document Administratif Unique)',
|
||||
description: 'Formulaire standard pour toutes les déclarations douanières dans l\'UE.',
|
||||
obligatoire: true,
|
||||
},
|
||||
{
|
||||
name: 'Facture commerciale',
|
||||
description: 'Document de base indiquant la valeur des marchandises.',
|
||||
obligatoire: true,
|
||||
},
|
||||
{
|
||||
name: 'Liste de colisage',
|
||||
description: 'Détail du contenu de chaque colis (poids, dimensions, contenu).',
|
||||
obligatoire: true,
|
||||
},
|
||||
{
|
||||
name: 'Certificat d\'origine',
|
||||
description: 'Atteste l\'origine des marchandises pour les accords préférentiels.',
|
||||
obligatoire: false,
|
||||
},
|
||||
{
|
||||
name: 'Licence d\'importation/exportation',
|
||||
description: 'Autorisation pour certaines marchandises réglementées.',
|
||||
obligatoire: false,
|
||||
},
|
||||
{
|
||||
name: 'Certificat sanitaire/phytosanitaire',
|
||||
description: 'Pour les produits alimentaires, animaux et végétaux.',
|
||||
obligatoire: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DouanesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">🛃</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Les formalités douanières sont obligatoires pour toute marchandise traversant une frontière.
|
||||
Elles permettent le contrôle des échanges, la perception des droits et taxes, et l'application
|
||||
des réglementations commerciales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Concepts */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Concepts Clés</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
||||
<div>
|
||||
<h4 className="font-medium">Valeur en douane</h4>
|
||||
<p className="text-sm">Base de calcul des droits, généralement la valeur CIF (coût + assurance + fret).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">Code SH / NC</h4>
|
||||
<p className="text-sm">Classification tarifaire harmonisée des marchandises (6 ou 8 chiffres).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">Origine</h4>
|
||||
<p className="text-sm">Pays de fabrication ou de transformation substantielle de la marchandise.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">OEA (Opérateur Économique Agréé)</h4>
|
||||
<p className="text-sm">Statut de confiance accordé par les douanes pour des procédures simplifiées.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Régimes douaniers */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{regimesDouaniers.map((regime) => (
|
||||
<Card key={regime.code} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-green-600 text-white rounded-md font-mono text-sm">
|
||||
{regime.code}
|
||||
</span>
|
||||
<span className="text-lg">{regime.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">{regime.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents requis */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{documentsDouane.map((doc) => (
|
||||
<div key={doc.name} className="flex items-start gap-3 pb-4 border-b last:border-0">
|
||||
<span className={`px-2 py-1 text-xs rounded ${doc.obligatoire ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||
{doc.obligatoire ? 'Obligatoire' : 'Selon cas'}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{doc.name}</h4>
|
||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Droits et taxes */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Droits de douane</h4>
|
||||
<p className="text-sm text-gray-600">Pourcentage appliqué sur la valeur en douane selon le code SH.</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Ex: 0% à 17% selon les produits</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">TVA import</h4>
|
||||
<p className="text-sm text-gray-600">Taxe sur la valeur ajoutée calculée sur (valeur + droits).</p>
|
||||
<p className="text-xs text-gray-500 mt-2">France: 20%, 10%, 5.5% ou 2.1%</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Droits antidumping</h4>
|
||||
<p className="text-sm text-gray-600">Droits additionnels pour protéger contre la concurrence déloyale.</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Variable selon origine et produit</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">⚠️ Points d'Attention</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
||||
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
||||
<li>Anticiper les contrôles : certificats, licences, normes</li>
|
||||
<li>Utiliser les accords de libre-échange pour réduire les droits</li>
|
||||
<li>Attention aux marchandises à double usage (exportation)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
apps/frontend/app/dashboard/wiki/imdg/page.tsx
Normal file
312
apps/frontend/app/dashboard/wiki/imdg/page.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Marchandises Dangereuses (IMDG) - Wiki Page
|
||||
*
|
||||
* Transport de matières dangereuses par mer
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const classesIMDG = [
|
||||
{
|
||||
class: '1',
|
||||
name: 'Explosifs',
|
||||
description: 'Matières et objets explosibles',
|
||||
examples: 'Munitions, feux d\'artifice, détonateurs',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
class: '2',
|
||||
name: 'Gaz',
|
||||
description: 'Gaz comprimés, liquéfiés ou dissous',
|
||||
examples: 'Propane, aérosols, oxygène, extincteurs',
|
||||
color: 'bg-green-500',
|
||||
subdivisions: ['2.1 Inflammables', '2.2 Non inflammables', '2.3 Toxiques'],
|
||||
},
|
||||
{
|
||||
class: '3',
|
||||
name: 'Liquides inflammables',
|
||||
description: 'Liquides à point d\'éclair bas',
|
||||
examples: 'Essence, peintures, alcools, solvants',
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
{
|
||||
class: '4',
|
||||
name: 'Solides inflammables',
|
||||
description: 'Solides facilement inflammables ou auto-réactifs',
|
||||
examples: 'Allumettes, soufre, métaux en poudre',
|
||||
color: 'bg-red-400',
|
||||
subdivisions: ['4.1 Inflammables', '4.2 Auto-inflammables', '4.3 Réagissent avec l\'eau'],
|
||||
},
|
||||
{
|
||||
class: '5',
|
||||
name: 'Comburants et peroxydes',
|
||||
description: 'Matières qui favorisent la combustion',
|
||||
examples: 'Engrais, peroxydes, agents de blanchiment',
|
||||
color: 'bg-yellow-500',
|
||||
subdivisions: ['5.1 Comburants', '5.2 Peroxydes organiques'],
|
||||
},
|
||||
{
|
||||
class: '6',
|
||||
name: 'Matières toxiques et infectieuses',
|
||||
description: 'Matières nocives pour la santé',
|
||||
examples: 'Pesticides, échantillons médicaux, cyanures',
|
||||
color: 'bg-purple-500',
|
||||
subdivisions: ['6.1 Toxiques', '6.2 Infectieuses'],
|
||||
},
|
||||
{
|
||||
class: '7',
|
||||
name: 'Matières radioactives',
|
||||
description: 'Matières émettant des radiations',
|
||||
examples: 'Isotopes médicaux, sources industrielles',
|
||||
color: 'bg-yellow-300',
|
||||
},
|
||||
{
|
||||
class: '8',
|
||||
name: 'Matières corrosives',
|
||||
description: 'Matières attaquant les tissus ou métaux',
|
||||
examples: 'Acide sulfurique, soude caustique, batteries',
|
||||
color: 'bg-gray-700',
|
||||
},
|
||||
{
|
||||
class: '9',
|
||||
name: 'Matières dangereuses diverses',
|
||||
description: 'Autres matières présentant un danger',
|
||||
examples: 'Batteries lithium, amiante, glace carbonique',
|
||||
color: 'bg-gray-400',
|
||||
},
|
||||
];
|
||||
|
||||
const documentsRequired = [
|
||||
{ name: 'DGD (Dangerous Goods Declaration)', description: 'Déclaration obligatoire signée par l\'expéditeur' },
|
||||
{ name: 'Multimodal Dangerous Goods Form', description: 'Formulaire standard OMI/OIT pour le transport multimodal' },
|
||||
{ name: 'Fiche de Données de Sécurité (FDS/SDS)', description: 'Document technique détaillant les risques et mesures' },
|
||||
{ name: 'Certificat d\'empotage', description: 'Attestation de bon chargement du conteneur' },
|
||||
{ name: 'Approval/Exemption', description: 'Autorisations spécifiques pour certaines matières' },
|
||||
];
|
||||
|
||||
const packagingGroups = [
|
||||
{ group: 'I', danger: 'Élevé', description: 'Matières très dangereuses' },
|
||||
{ group: 'II', danger: 'Moyen', description: 'Matières moyennement dangereuses' },
|
||||
{ group: 'III', danger: 'Faible', description: 'Matières légèrement dangereuses' },
|
||||
];
|
||||
|
||||
export default function IMDGPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">⚠️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Le Code IMDG (International Maritime Dangerous Goods) est le référentiel international pour
|
||||
le transport maritime de marchandises dangereuses. Publié par l'OMI, il est mis à jour tous
|
||||
les deux ans et est obligatoire depuis janvier 2004.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Info */}
|
||||
<Card className="bg-red-50 border-red-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Responsabilités de l'Expéditeur</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-red-800">
|
||||
<li>Classer correctement la marchandise selon le Code IMDG</li>
|
||||
<li>Utiliser des emballages homologués UN</li>
|
||||
<li>Étiqueter et marquer conformément aux exigences</li>
|
||||
<li>Remplir la déclaration de marchandises dangereuses (DGD)</li>
|
||||
<li>S'assurer de la formation du personnel impliqué</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Classes */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{classesIMDG.map((cls) => (
|
||||
<Card key={cls.class} className="bg-white overflow-hidden">
|
||||
<div className={`h-2 ${cls.color}`}></div>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<span className={`w-10 h-10 flex items-center justify-center text-white font-bold rounded ${cls.color}`}>
|
||||
{cls.class}
|
||||
</span>
|
||||
<span className="text-base">{cls.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm mb-2">{cls.description}</p>
|
||||
<p className="text-xs text-gray-500">Ex: {cls.examples}</p>
|
||||
{cls.subdivisions && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-xs text-gray-400">Subdivisions:</p>
|
||||
<ul className="text-xs text-gray-500">
|
||||
{cls.subdivisions.map((sub) => (
|
||||
<li key={sub}>• {sub}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UN Number */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
|
||||
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="font-mono text-lg text-blue-600">UN 1203</p>
|
||||
<p className="text-gray-600">Essence</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="font-mono text-lg text-blue-600">UN 2794</p>
|
||||
<p className="text-gray-600">Batteries acide/plomb</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="font-mono text-lg text-blue-600">UN 3481</p>
|
||||
<p className="text-gray-600">Batteries lithium-ion</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Packaging Groups */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d'Emballage</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{packagingGroups.map((pg) => (
|
||||
<div key={pg.group} className="flex items-center gap-4 p-3 rounded-lg bg-gray-50">
|
||||
<span className={`w-12 h-12 flex items-center justify-center rounded-full font-bold text-white ${
|
||||
pg.group === 'I' ? 'bg-red-600' : pg.group === 'II' ? 'bg-orange-500' : 'bg-yellow-500'
|
||||
}`}>
|
||||
{pg.group}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Groupe {pg.group} - Danger {pg.danger}</p>
|
||||
<p className="text-sm text-gray-600">{pg.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Documents */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
{documentsRequired.map((doc) => (
|
||||
<div key={doc.name} className="flex items-start gap-3 pb-3 border-b last:border-0">
|
||||
<span className="mt-1 w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{doc.name}</h4>
|
||||
<p className="text-sm text-gray-600">{doc.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Labeling */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🏷️ Marquage et Étiquetage</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Colis</h4>
|
||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
||||
<li>• Étiquette(s) de danger (losanges)</li>
|
||||
<li>• Numéro ONU précédé de "UN"</li>
|
||||
<li>• Nom technique de la matière</li>
|
||||
<li>• Marque d'homologation UN de l'emballage</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Conteneur</h4>
|
||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
||||
<li>• Plaques-étiquettes (4 faces)</li>
|
||||
<li>• Numéro ONU en grands caractères</li>
|
||||
<li>• Certificat d'empotage affiché</li>
|
||||
<li>• Marine Pollutant si applicable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Segregation */}
|
||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
|
||||
<p className="text-orange-800 mb-3">
|
||||
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
|
||||
Le Code IMDG définit des règles strictes de ségrégation :
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<p className="font-medium">1</p>
|
||||
<p className="text-xs text-gray-600">Away from</p>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<p className="font-medium">2</p>
|
||||
<p className="text-xs text-gray-600">Separated from</p>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<p className="font-medium">3</p>
|
||||
<p className="text-xs text-gray-600">Separated by compartment</p>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded text-center">
|
||||
<p className="font-medium">4</p>
|
||||
<p className="text-xs text-gray-600">Separated longitudinally</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
||||
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
||||
<li>Former le personnel aux procédures d'urgence</li>
|
||||
<li>Utiliser un transitaire spécialisé en marchandises dangereuses</li>
|
||||
<li>Consulter les réglementations locales (certains ports ont des restrictions)</li>
|
||||
<li>Batteries lithium : attention aux réglementations très strictes (UN 3480, 3481)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
apps/frontend/app/dashboard/wiki/incoterms/page.tsx
Normal file
224
apps/frontend/app/dashboard/wiki/incoterms/page.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Incoterms 2020 - Wiki Page
|
||||
*
|
||||
* Detailed information about international commercial terms
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const incoterms = [
|
||||
{
|
||||
code: 'EXW',
|
||||
name: 'Ex Works',
|
||||
description: 'Le vendeur met la marchandise à disposition dans ses locaux. L\'acheteur assume tous les risques et coûts.',
|
||||
risk: 'Acheteur',
|
||||
transport: 'Tous modes',
|
||||
category: 'Départ',
|
||||
},
|
||||
{
|
||||
code: 'FCA',
|
||||
name: 'Free Carrier',
|
||||
description: 'Le vendeur livre la marchandise au transporteur désigné par l\'acheteur.',
|
||||
risk: 'Transfert à la livraison au transporteur',
|
||||
transport: 'Tous modes',
|
||||
category: 'Départ',
|
||||
},
|
||||
{
|
||||
code: 'CPT',
|
||||
name: 'Carriage Paid To',
|
||||
description: 'Le vendeur paie le transport jusqu\'à destination. Le risque est transféré à la remise au transporteur.',
|
||||
risk: 'Transfert à la livraison au transporteur',
|
||||
transport: 'Tous modes',
|
||||
category: 'Arrivée',
|
||||
},
|
||||
{
|
||||
code: 'CIP',
|
||||
name: 'Carriage and Insurance Paid To',
|
||||
description: 'Comme CPT mais le vendeur doit aussi assurer la marchandise.',
|
||||
risk: 'Transfert à la livraison au transporteur',
|
||||
transport: 'Tous modes',
|
||||
category: 'Arrivée',
|
||||
},
|
||||
{
|
||||
code: 'DAP',
|
||||
name: 'Delivered at Place',
|
||||
description: 'Le vendeur livre la marchandise prête à être déchargée au lieu convenu.',
|
||||
risk: 'Vendeur jusqu\'à destination',
|
||||
transport: 'Tous modes',
|
||||
category: 'Arrivée',
|
||||
},
|
||||
{
|
||||
code: 'DPU',
|
||||
name: 'Delivered at Place Unloaded',
|
||||
description: 'Le vendeur livre et décharge la marchandise au lieu de destination.',
|
||||
risk: 'Vendeur jusqu\'au déchargement',
|
||||
transport: 'Tous modes',
|
||||
category: 'Arrivée',
|
||||
},
|
||||
{
|
||||
code: 'DDP',
|
||||
name: 'Delivered Duty Paid',
|
||||
description: 'Le vendeur assume tous les risques et coûts, y compris les droits de douane.',
|
||||
risk: 'Vendeur (maximum)',
|
||||
transport: 'Tous modes',
|
||||
category: 'Arrivée',
|
||||
},
|
||||
{
|
||||
code: 'FAS',
|
||||
name: 'Free Alongside Ship',
|
||||
description: 'Le vendeur livre la marchandise le long du navire au port d\'embarquement.',
|
||||
risk: 'Transfert le long du navire',
|
||||
transport: 'Maritime uniquement',
|
||||
category: 'Maritime',
|
||||
},
|
||||
{
|
||||
code: 'FOB',
|
||||
name: 'Free On Board',
|
||||
description: 'Le vendeur livre la marchandise à bord du navire. Très utilisé en maritime.',
|
||||
risk: 'Transfert à bord du navire',
|
||||
transport: 'Maritime uniquement',
|
||||
category: 'Maritime',
|
||||
},
|
||||
{
|
||||
code: 'CFR',
|
||||
name: 'Cost and Freight',
|
||||
description: 'Le vendeur paie le fret jusqu\'au port de destination. Le risque passe à bord.',
|
||||
risk: 'Transfert à bord du navire',
|
||||
transport: 'Maritime uniquement',
|
||||
category: 'Maritime',
|
||||
},
|
||||
{
|
||||
code: 'CIF',
|
||||
name: 'Cost, Insurance and Freight',
|
||||
description: 'Comme CFR mais le vendeur doit aussi assurer la marchandise.',
|
||||
risk: 'Transfert à bord du navire',
|
||||
transport: 'Maritime uniquement',
|
||||
category: 'Maritime',
|
||||
},
|
||||
];
|
||||
|
||||
export default function IncotermsPage() {
|
||||
const categories = ['Départ', 'Arrivée', 'Maritime'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">📜</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Les Incoterms (International Commercial Terms) sont des règles publiées par la Chambre de Commerce
|
||||
Internationale (ICC) qui définissent les responsabilités des vendeurs et acheteurs dans les transactions
|
||||
internationales. La version 2020 est entrée en vigueur le 1er janvier 2020.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Points */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Points Clés</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-blue-800">
|
||||
<li>11 Incoterms au total : 7 multimodaux et 4 maritimes</li>
|
||||
<li>Définissent le transfert des risques et des coûts</li>
|
||||
<li>Ne définissent PAS le transfert de propriété</li>
|
||||
<li>Doivent être suivis de la version (ex: FOB Incoterms 2020)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Incoterms by category */}
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
|
||||
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{incoterms
|
||||
.filter((term) => term.category === category)
|
||||
.map((term) => (
|
||||
<Card key={term.code} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-blue-600 text-white rounded-md font-mono">
|
||||
{term.code}
|
||||
</span>
|
||||
<span className="text-lg">{term.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-3">{term.description}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Risque:</span>
|
||||
<span className="font-medium text-gray-900">{term.risk}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Transport:</span>
|
||||
<span className="font-medium text-gray-900">{term.transport}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Visual diagram placeholder */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Transfert des Risques - Schéma</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">Vendeur</span>
|
||||
<span className="font-medium">Acheteur</span>
|
||||
</div>
|
||||
<div className="relative h-8 bg-gradient-to-r from-blue-500 to-green-500 rounded-full">
|
||||
<div className="absolute inset-0 flex items-center justify-around text-white text-xs font-medium">
|
||||
<span>EXW</span>
|
||||
<span>FCA</span>
|
||||
<span>FOB</span>
|
||||
<span>CFR/CIF</span>
|
||||
<span>DAP</span>
|
||||
<span>DDP</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
Plus on va vers la droite, plus le vendeur assume de responsabilités
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Utilisez FOB ou CIF pour le maritime, FCA ou CIP pour le multimodal</li>
|
||||
<li>Précisez toujours le lieu exact (ex: FOB Shanghai Port)</li>
|
||||
<li>Vérifiez la cohérence entre l'Incoterm et la lettre de crédit</li>
|
||||
<li>EXW et DDP sont les termes extrêmes - à utiliser avec précaution</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
apps/frontend/app/dashboard/wiki/lcl-vs-fcl/page.tsx
Normal file
283
apps/frontend/app/dashboard/wiki/lcl-vs-fcl/page.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* LCL vs FCL - Wiki Page
|
||||
*
|
||||
* Comparison between Less than Container Load and Full Container Load
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
export default function LclVsFclPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">⚖️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Choisir entre LCL (Less than Container Load) et FCL (Full Container Load) est une décision
|
||||
cruciale qui impacte les coûts, les délais et la sécurité de vos marchandises.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Definitions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-900 flex items-center gap-2">
|
||||
<span className="text-2xl">📦</span>
|
||||
LCL - Groupage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-blue-800 mb-4">
|
||||
<strong>Less than Container Load</strong> - Vos marchandises partagent un conteneur
|
||||
avec d'autres expéditeurs. Vous payez uniquement pour l'espace utilisé.
|
||||
</p>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-900 mb-2">Caractéristiques:</p>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Volume: généralement < 15 m³</li>
|
||||
<li>• Consolidation en entrepôt (CFS)</li>
|
||||
<li>• Facturation au m³ ou tonne</li>
|
||||
<li>• Délai transit plus long (+3-7 jours)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-green-900 flex items-center gap-2">
|
||||
<span className="text-2xl">🚛</span>
|
||||
FCL - Complet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-green-800 mb-4">
|
||||
<strong>Full Container Load</strong> - Vous réservez un conteneur entier, même s'il
|
||||
n'est pas plein. Vos marchandises ne sont pas mélangées.
|
||||
</p>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-green-900 mb-2">Caractéristiques:</p>
|
||||
<ul className="text-sm text-green-800 space-y-1">
|
||||
<li>• Volume: 20', 40' ou 40'HC</li>
|
||||
<li>• Chargement direct porte-à-porte</li>
|
||||
<li>• Facturation par conteneur</li>
|
||||
<li>• Transit direct (plus rapide)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-8 mb-4">Comparaison Détaillée</h2>
|
||||
<Card className="bg-white overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Critère</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-blue-700">LCL</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-green-700">FCL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Coût</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Payez au m³/tonne</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Prix fixe par conteneur</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Seuil de rentabilité</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">< 10-15 m³</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">> 15 m³</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Transit time</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">+3-7 jours (consolidation)</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Direct, plus rapide</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Manutention</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Multiple (risque de dommage)</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Minimale</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Sécurité</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Marchandises mélangées</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Conteneur scellé</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Flexibilité</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Haute (petits volumes)</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Moyenne</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">Documentation</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Plus complexe</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">Simplifiée</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Calculation */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900">Calcul du Seuil de Rentabilité</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Le point où FCL devient plus économique que LCL dépend de la route et des tarifs.
|
||||
Voici un exemple de calcul:
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-3 border rounded-lg">
|
||||
<p className="font-medium text-blue-700">LCL</p>
|
||||
<p className="text-sm text-gray-600">Tarif: 80 €/m³</p>
|
||||
<p className="text-sm text-gray-600">Pour 15 m³: 15 × 80 = <strong>1,200 €</strong></p>
|
||||
</div>
|
||||
<div className="p-3 border rounded-lg">
|
||||
<p className="font-medium text-green-700">FCL 20'</p>
|
||||
<p className="text-sm text-gray-600">Tarif conteneur: 1,100 €</p>
|
||||
<p className="text-sm text-gray-600">Capacité: ~28 m³ pour <strong>1,100 €</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 bg-amber-50 p-3 rounded">
|
||||
<strong>Conclusion:</strong> Dans cet exemple, le FCL devient rentable à partir de ~14 m³.
|
||||
Demandez toujours des cotations LCL et FCL pour comparer.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* When to Choose */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-blue-900">Choisissez LCL si:</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600">✓</span>
|
||||
<span className="text-blue-800">Volume inférieur à 10-15 m³</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600">✓</span>
|
||||
<span className="text-blue-800">Expéditions régulières de petites quantités</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600">✓</span>
|
||||
<span className="text-blue-800">Test de nouveaux marchés</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600">✓</span>
|
||||
<span className="text-blue-800">Marchandises non fragiles</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600">✓</span>
|
||||
<span className="text-blue-800">Flexibilité sur les délais</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-green-900">Choisissez FCL si:</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-green-800">Volume supérieur à 15 m³</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-green-800">Marchandises de valeur ou fragiles</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-green-800">Délais de livraison critiques</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-green-800">Sécurité renforcée requise</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-600">✓</span>
|
||||
<span className="text-green-800">Marchandises incompatibles avec d'autres</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* LCL Process */}
|
||||
<Card className="mt-8 bg-white">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-gray-900">Processus LCL (Groupage)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{[
|
||||
{ step: '1', title: 'Collecte', desc: 'Enlèvement chez l\'expéditeur' },
|
||||
{ step: '2', title: 'CFS Origine', desc: 'Consolidation en entrepôt' },
|
||||
{ step: '3', title: 'Transport', desc: 'Acheminement maritime' },
|
||||
{ step: '4', title: 'CFS Destination', desc: 'Dégroupage' },
|
||||
{ step: '5', title: 'Livraison', desc: 'Livraison finale' },
|
||||
].map((item, index) => (
|
||||
<div key={item.step} className="flex flex-col md:flex-row items-center">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-2">
|
||||
{item.step}
|
||||
</div>
|
||||
<p className="font-medium text-sm">{item.title}</p>
|
||||
<p className="text-xs text-gray-500">{item.desc}</p>
|
||||
</div>
|
||||
{index < 4 && (
|
||||
<div className="hidden md:block text-gray-400 mx-2">→</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Demandez toujours des cotations LCL ET FCL pour comparer</li>
|
||||
<li>En LCL, emballez solidement car vos marchandises seront manipulées plusieurs fois</li>
|
||||
<li>Vérifiez les frais additionnels (CFS, manutention) qui peuvent surprendre en LCL</li>
|
||||
<li>Un conteneur 40' n'est pas le double du prix d'un 20' - parfois 20-30% plus cher seulement</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
apps/frontend/app/dashboard/wiki/lettre-credit/page.tsx
Normal file
298
apps/frontend/app/dashboard/wiki/lettre-credit/page.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Lettre de Crédit (L/C) - Wiki Page
|
||||
*
|
||||
* Instrument de paiement international sécurisé
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const typesLC = [
|
||||
{
|
||||
type: 'Irrévocable',
|
||||
description: 'Ne peut être modifiée ou annulée sans l\'accord de toutes les parties',
|
||||
usage: 'Standard depuis UCP 600',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
type: 'Confirmée',
|
||||
description: 'Une banque (généralement dans le pays du vendeur) ajoute sa garantie',
|
||||
usage: 'Recommandé pour les pays à risque',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
type: 'Transférable',
|
||||
description: 'Peut être transférée à un second bénéficiaire (sous-traitant)',
|
||||
usage: 'Négoce, intermédiaires',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
type: 'Revolving',
|
||||
description: 'Se reconstitue automatiquement après chaque utilisation',
|
||||
usage: 'Commandes répétitives',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
type: 'Stand-by',
|
||||
description: 'Garantie de paiement en cas de défaillance (rarement utilisée)',
|
||||
usage: 'Garantie bancaire',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
type: 'Red/Green Clause',
|
||||
description: 'Permet un paiement anticipé avant expédition',
|
||||
usage: 'Préfinancement du vendeur',
|
||||
recommended: false,
|
||||
},
|
||||
];
|
||||
|
||||
const parties = [
|
||||
{ role: 'Donneur d\'ordre (Applicant)', description: 'L\'acheteur/importateur qui demande l\'ouverture de la L/C' },
|
||||
{ role: 'Bénéficiaire', description: 'Le vendeur/exportateur qui recevra le paiement' },
|
||||
{ role: 'Banque émettrice', description: 'Banque de l\'acheteur qui émet la L/C' },
|
||||
{ role: 'Banque notificatrice', description: 'Banque du vendeur qui notifie la L/C (sans engagement)' },
|
||||
{ role: 'Banque confirmatrice', description: 'Banque qui ajoute sa propre garantie (optionnel)' },
|
||||
];
|
||||
|
||||
const documentsTypiques = [
|
||||
{ document: 'Facture commerciale', obligatoire: true },
|
||||
{ document: 'Bill of Lading (connaissement)', obligatoire: true },
|
||||
{ document: 'Liste de colisage', obligatoire: true },
|
||||
{ document: 'Certificat d\'origine', obligatoire: false },
|
||||
{ document: 'Certificat d\'assurance', obligatoire: false },
|
||||
{ document: 'Certificat d\'inspection', obligatoire: false },
|
||||
{ document: 'Certificat phytosanitaire', obligatoire: false },
|
||||
];
|
||||
|
||||
const erreursFrequentes = [
|
||||
'Nom du bénéficiaire mal orthographié',
|
||||
'Dates d\'expédition ou d\'expiration dépassées',
|
||||
'Description des marchandises ne correspondant pas exactement',
|
||||
'Documents présentés en retard (au-delà du délai de présentation)',
|
||||
'Incoterm incohérent avec les documents',
|
||||
'Montant des documents différent de la L/C',
|
||||
'Connaissement avec réserves (claused B/L)',
|
||||
];
|
||||
|
||||
export default function LettreCreditPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">💳</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
La lettre de crédit documentaire (L/C ou Documentary Credit) est un engagement de paiement
|
||||
émis par une banque pour le compte de l'acheteur, garantissant au vendeur d'être payé
|
||||
contre présentation de documents conformes. Elle est régie par les Règles UCP 600 de la CCI.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
|
||||
{ step: '2', title: 'Émission', desc: 'Banque émettrice envoie la L/C' },
|
||||
{ step: '3', title: 'Notification', desc: 'Banque du vendeur notifie la L/C' },
|
||||
{ step: '4', title: 'Expédition', desc: 'Vendeur expédie et présente les documents' },
|
||||
{ step: '5', title: 'Paiement', desc: 'Banque paie après vérification' },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="text-center">
|
||||
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center mx-auto font-bold">
|
||||
{item.step}
|
||||
</div>
|
||||
<p className="font-medium text-blue-900 mt-2">{item.title}</p>
|
||||
<p className="text-xs text-blue-700">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Parties */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{parties.map((p) => (
|
||||
<div key={p.role} className="flex items-start gap-3 pb-4 border-b last:border-0">
|
||||
<span className="mt-1 w-3 h-3 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{p.role}</h4>
|
||||
<p className="text-sm text-gray-600">{p.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Types */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{typesLC.map((lc) => (
|
||||
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">{lc.type}</span>
|
||||
{lc.recommended && (
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">Recommandé</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm">{lc.description}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Usage : {lc.usage}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{documentsTypiques.map((doc) => (
|
||||
<div key={doc.document} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className={`px-2 py-1 text-xs rounded ${doc.obligatoire ? 'bg-red-100 text-red-700' : 'bg-gray-200 text-gray-700'}`}>
|
||||
{doc.obligatoire ? 'Toujours' : 'Selon L/C'}
|
||||
</span>
|
||||
<span className="text-gray-900">{doc.document}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Key Dates */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Date d'expédition</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Date limite pour expédier les marchandises (Latest Shipment Date)
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Délai de présentation</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Généralement 21 jours après expédition pour présenter les documents
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Date de validité</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Date limite absolue de la L/C (Expiry Date)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Costs */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium">Côté Acheteur</h4>
|
||||
<ul className="text-gray-600 mt-2 space-y-1">
|
||||
<li>• Commission d'ouverture : 0.1% - 0.5%</li>
|
||||
<li>• Frais de modification : fixes</li>
|
||||
<li>• Commission d'engagement : si non utilisée</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">Côté Vendeur</h4>
|
||||
<ul className="text-gray-600 mt-2 space-y-1">
|
||||
<li>• Frais de notification : fixes</li>
|
||||
<li>• Commission de confirmation : 0.1% - 2%+</li>
|
||||
<li>• Frais de négociation : variable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Note : Les frais de confirmation peuvent être très élevés pour les pays à risque.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Common Errors */}
|
||||
<Card className="mt-8 bg-red-50 border-red-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-red-900 mb-3">⚠️ Erreurs Fréquentes (Réserves)</h3>
|
||||
<p className="text-red-800 mb-3">
|
||||
Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-red-700">
|
||||
{erreursFrequentes.map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* UCP 600 */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
|
||||
<p className="text-gray-600">
|
||||
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
|
||||
régissent les lettres de crédit documentaires depuis 2007. Points clés :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700 mt-3">
|
||||
<li>Délai standard d'examen des documents : 5 jours ouvrés bancaires</li>
|
||||
<li>La L/C est irrévocable par défaut (même si non mentionné)</li>
|
||||
<li>Les banques examinent les documents, pas les marchandises</li>
|
||||
<li>Les documents doivent être "conformes en apparence"</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
|
||||
<li>Demander des modifications AVANT expédition si nécessaire</li>
|
||||
<li>Préparer les documents exactement comme demandé (virgules, orthographe)</li>
|
||||
<li>Respecter les délais avec marge de sécurité</li>
|
||||
<li>Travailler avec un transitaire expérimenté en documentaire</li>
|
||||
<li>Conserver des copies de tous les documents</li>
|
||||
<li>Envisager une L/C confirmée pour les nouveaux clients ou pays risqués</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
apps/frontend/app/dashboard/wiki/page.tsx
Normal file
149
apps/frontend/app/dashboard/wiki/page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Wiki Page - Maritime Import/Export Knowledge Base
|
||||
*
|
||||
* Main page displaying cards for all wiki topics
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
|
||||
const wikiTopics = [
|
||||
{
|
||||
title: 'Incoterms 2020',
|
||||
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
|
||||
icon: '📜',
|
||||
href: '/dashboard/wiki/incoterms',
|
||||
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
||||
},
|
||||
{
|
||||
title: 'Documents de Transport',
|
||||
description: 'Les documents essentiels pour le transport maritime',
|
||||
icon: '📋',
|
||||
href: '/dashboard/wiki/documents-transport',
|
||||
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
||||
},
|
||||
{
|
||||
title: 'Conteneurs et Types de Cargo',
|
||||
description: 'Guide complet des types de conteneurs maritimes',
|
||||
icon: '📦',
|
||||
href: '/dashboard/wiki/conteneurs',
|
||||
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
|
||||
},
|
||||
{
|
||||
title: 'LCL vs FCL',
|
||||
description: 'Différences entre groupage et conteneur complet',
|
||||
icon: '⚖️',
|
||||
href: '/dashboard/wiki/lcl-vs-fcl',
|
||||
tags: ['Groupage', 'Complet', 'Coûts'],
|
||||
},
|
||||
{
|
||||
title: 'Procédures Douanières',
|
||||
description: 'Guide des formalités douanières import/export',
|
||||
icon: '🛃',
|
||||
href: '/dashboard/wiki/douanes',
|
||||
tags: ['Déclaration', 'Tarifs', 'Régimes'],
|
||||
},
|
||||
{
|
||||
title: 'Assurance Maritime',
|
||||
description: 'Protection des marchandises en transit',
|
||||
icon: '🛡️',
|
||||
href: '/dashboard/wiki/assurance',
|
||||
tags: ['ICC A', 'ICC B', 'ICC C'],
|
||||
},
|
||||
{
|
||||
title: 'Calcul du Fret Maritime',
|
||||
description: 'Comment sont calculés les coûts de transport',
|
||||
icon: '🧮',
|
||||
href: '/dashboard/wiki/calcul-fret',
|
||||
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
||||
},
|
||||
{
|
||||
title: 'Ports et Routes Maritimes',
|
||||
description: 'Les principales routes commerciales mondiales',
|
||||
icon: '🌍',
|
||||
href: '/dashboard/wiki/ports-routes',
|
||||
tags: ['Hub', 'Détroits', 'Canaux'],
|
||||
},
|
||||
{
|
||||
title: 'VGM (Verified Gross Mass)',
|
||||
description: 'Obligation de pesée des conteneurs (SOLAS)',
|
||||
icon: '⚓',
|
||||
href: '/dashboard/wiki/vgm',
|
||||
tags: ['SOLAS', 'Pesée', 'Certification'],
|
||||
},
|
||||
{
|
||||
title: 'Marchandises Dangereuses (IMDG)',
|
||||
description: 'Transport de matières dangereuses par mer',
|
||||
icon: '⚠️',
|
||||
href: '/dashboard/wiki/imdg',
|
||||
tags: ['Classes', 'Étiquetage', 'Sécurité'],
|
||||
},
|
||||
{
|
||||
title: 'Lettre de Crédit (L/C)',
|
||||
description: 'Instrument de paiement international sécurisé',
|
||||
icon: '💳',
|
||||
href: '/dashboard/wiki/lettre-credit',
|
||||
tags: ['Banque', 'Paiement', 'Sécurité'],
|
||||
},
|
||||
{
|
||||
title: 'Transit Time et Délais',
|
||||
description: 'Comprendre les délais en transport maritime',
|
||||
icon: '⏱️',
|
||||
href: '/dashboard/wiki/transit-time',
|
||||
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function WikiPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Wiki Maritime</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Base de connaissances sur l'import/export maritime. Cliquez sur un sujet pour en savoir plus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{wikiTopics.map((topic) => (
|
||||
<Link key={topic.href} href={topic.href} className="block group">
|
||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-4xl">{topic.icon}</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
||||
{topic.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-600">
|
||||
{topic.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topic.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<span className="font-semibold">Besoin d'aide ?</span> Ces guides sont régulièrement mis à jour avec les dernières réglementations et bonnes pratiques du secteur maritime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
apps/frontend/app/dashboard/wiki/ports-routes/page.tsx
Normal file
301
apps/frontend/app/dashboard/wiki/ports-routes/page.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Ports et Routes Maritimes - Wiki Page
|
||||
*
|
||||
* Les principales routes commerciales mondiales
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const majorRoutes = [
|
||||
{
|
||||
name: 'Asie - Europe',
|
||||
description: 'La plus importante route commerciale mondiale',
|
||||
transitTime: '28-35 jours',
|
||||
volume: '~24 millions TEU/an',
|
||||
keyPorts: ['Shanghai', 'Singapour', 'Rotterdam', 'Hambourg', 'Anvers'],
|
||||
passages: ['Canal de Suez', 'Détroit de Malacca'],
|
||||
},
|
||||
{
|
||||
name: 'Asie - Amérique du Nord (Pacifique)',
|
||||
description: 'Deuxième route la plus importante',
|
||||
transitTime: '12-18 jours',
|
||||
volume: '~26 millions TEU/an',
|
||||
keyPorts: ['Shanghai', 'Busan', 'Los Angeles', 'Long Beach', 'Seattle'],
|
||||
passages: ['Traversée Pacifique directe'],
|
||||
},
|
||||
{
|
||||
name: 'Europe - Amérique du Nord (Atlantique)',
|
||||
description: 'Route transatlantique historique',
|
||||
transitTime: '8-12 jours',
|
||||
volume: '~8 millions TEU/an',
|
||||
keyPorts: ['Rotterdam', 'Anvers', 'New York', 'Savannah', 'Charleston'],
|
||||
passages: ['Traversée Atlantique directe'],
|
||||
},
|
||||
{
|
||||
name: 'Asie - Méditerranée',
|
||||
description: 'Variante de la route Asie-Europe via Med',
|
||||
transitTime: '18-25 jours',
|
||||
volume: '~6 millions TEU/an',
|
||||
keyPorts: ['Shanghai', 'Pirée', 'Gênes', 'Barcelone', 'Tanger Med'],
|
||||
passages: ['Canal de Suez', 'Détroit de Gibraltar'],
|
||||
},
|
||||
];
|
||||
|
||||
const strategicPassages = [
|
||||
{
|
||||
name: 'Canal de Suez',
|
||||
location: 'Égypte',
|
||||
opened: '1869',
|
||||
length: '193.3 km',
|
||||
transitTime: '12-16 heures',
|
||||
traffic: '~20,000 navires/an',
|
||||
importance: 'Relie la Méditerranée à la Mer Rouge. Raccourcit de 7,000 km la route Europe-Asie.',
|
||||
},
|
||||
{
|
||||
name: 'Canal de Panama',
|
||||
location: 'Panama',
|
||||
opened: '1914',
|
||||
length: '82 km',
|
||||
transitTime: '8-10 heures',
|
||||
traffic: '~14,000 navires/an',
|
||||
importance: 'Relie l\'Atlantique au Pacifique. Crucial pour le commerce Asie-Côte Est USA.',
|
||||
},
|
||||
{
|
||||
name: 'Détroit de Malacca',
|
||||
location: 'Malaisie/Indonésie',
|
||||
opened: 'Naturel',
|
||||
length: '800 km',
|
||||
transitTime: '12 heures',
|
||||
traffic: '~90,000 navires/an',
|
||||
importance: 'Point de passage obligé entre Océan Indien et Mer de Chine. 25% du commerce mondial.',
|
||||
},
|
||||
{
|
||||
name: 'Détroit de Gibraltar',
|
||||
location: 'Espagne/Maroc',
|
||||
opened: 'Naturel',
|
||||
length: '60 km',
|
||||
transitTime: '2-3 heures',
|
||||
traffic: '~100,000 navires/an',
|
||||
importance: 'Entrée en Méditerranée depuis l\'Atlantique.',
|
||||
},
|
||||
];
|
||||
|
||||
const majorPorts = [
|
||||
{ name: 'Shanghai', country: 'Chine', volume: '47.0', rank: 1 },
|
||||
{ name: 'Singapour', country: 'Singapour', volume: '37.2', rank: 2 },
|
||||
{ name: 'Ningbo-Zhoushan', country: 'Chine', volume: '33.0', rank: 3 },
|
||||
{ name: 'Shenzhen', country: 'Chine', volume: '30.0', rank: 4 },
|
||||
{ name: 'Guangzhou', country: 'Chine', volume: '24.2', rank: 5 },
|
||||
{ name: 'Busan', country: 'Corée du Sud', volume: '22.7', rank: 6 },
|
||||
{ name: 'Qingdao', country: 'Chine', volume: '22.0', rank: 7 },
|
||||
{ name: 'Rotterdam', country: 'Pays-Bas', volume: '14.5', rank: 8 },
|
||||
{ name: 'Dubai/Jebel Ali', country: 'EAU', volume: '14.1', rank: 9 },
|
||||
{ name: 'Tianjin', country: 'Chine', volume: '14.0', rank: 10 },
|
||||
];
|
||||
|
||||
export default function PortsRoutesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">🌍</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Le transport maritime assure plus de 80% du commerce mondial en volume.
|
||||
Comprendre les grandes routes et les ports stratégiques est essentiel pour optimiser sa supply chain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Stats */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">80%</p>
|
||||
<p className="text-sm text-blue-600">du commerce mondial (volume)</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">~800M</p>
|
||||
<p className="text-sm text-blue-600">TEU transportés/an</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">~60,000</p>
|
||||
<p className="text-sm text-blue-600">navires marchands</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-blue-700">~14B</p>
|
||||
<p className="text-sm text-blue-600">tonnes de marchandises/an</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Major Routes */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳️ Routes Commerciales Majeures</h2>
|
||||
<div className="space-y-4">
|
||||
{majorRoutes.map((route) => (
|
||||
<Card key={route.name} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">{route.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-3">{route.description}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Transit time:</span>
|
||||
<p className="font-medium">{route.transitTime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Volume annuel:</span>
|
||||
<p className="font-medium">{route.volume}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Ports clés:</span>
|
||||
<p className="font-medium">{route.keyPorts.slice(0, 3).join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Passages:</span>
|
||||
<p className="font-medium">{route.passages.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategic Passages */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚓ Passages Stratégiques</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{strategicPassages.map((passage) => (
|
||||
<Card key={passage.name} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-lg">{passage.name}</span>
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{passage.location}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm mb-3">{passage.importance}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-gray-500">Longueur:</span>
|
||||
<p className="font-medium">{passage.length}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-gray-500">Transit:</span>
|
||||
<p className="font-medium">{passage.transitTime}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-gray-500">Trafic:</span>
|
||||
<p className="font-medium">{passage.traffic}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-2 rounded">
|
||||
<span className="text-gray-500">Ouverture:</span>
|
||||
<p className="font-medium">{passage.opened}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Ports */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 font-medium">Rang</th>
|
||||
<th className="text-left py-2 font-medium">Port</th>
|
||||
<th className="text-left py-2 font-medium">Pays</th>
|
||||
<th className="text-right py-2 font-medium">Volume (M TEU)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{majorPorts.map((port) => (
|
||||
<tr key={port.name} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-3">
|
||||
<span className={`inline-block w-6 h-6 text-center rounded-full text-white text-xs leading-6 ${
|
||||
port.rank <= 3 ? 'bg-yellow-500' : 'bg-gray-400'
|
||||
}`}>
|
||||
{port.rank}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 font-medium text-gray-900">{port.name}</td>
|
||||
<td className="py-3 text-gray-600">{port.country}</td>
|
||||
<td className="py-3 text-right font-mono">{port.volume}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Source: World Shipping Council, données approximatives</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Hub Ports Info */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Grands ports où les conteneurs sont transférés entre navires mères et feeders.
|
||||
Ex: Singapour, Tanger Med, Algésiras.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Avantage: Desserte mondiale, fréquence élevée</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Ports Régionaux (Gateway)</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Ports desservant directement un hinterland économique.
|
||||
Ex: Le Havre, Marseille, Hambourg.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Avantage: Proximité du marché final, moins de manutention</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
|
||||
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>
|
||||
<li>Surveillez les perturbations géopolitiques (Canal de Suez, Détroit d'Ormuz)</li>
|
||||
<li>Comparez les transbordements vs les services directs selon vos priorités (coût vs délai)</li>
|
||||
<li>Vérifiez les connexions ferroviaires/fluviales depuis les ports</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
apps/frontend/app/dashboard/wiki/transit-time/page.tsx
Normal file
354
apps/frontend/app/dashboard/wiki/transit-time/page.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Transit Time et Délais - Wiki Page
|
||||
*
|
||||
* Comprendre les délais en transport maritime
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const etapesTimeline = [
|
||||
{
|
||||
etape: 'Booking',
|
||||
description: 'Réservation de l\'espace sur le navire',
|
||||
delai: '1-7 jours avant cut-off',
|
||||
responsable: 'Transitaire/Exportateur',
|
||||
},
|
||||
{
|
||||
etape: 'Container pickup',
|
||||
description: 'Enlèvement du conteneur vide au dépôt',
|
||||
delai: '2-5 jours avant cut-off',
|
||||
responsable: 'Transporteur terrestre',
|
||||
},
|
||||
{
|
||||
etape: 'Empotage (Stuffing)',
|
||||
description: 'Chargement des marchandises dans le conteneur',
|
||||
delai: '1-3 jours avant cut-off',
|
||||
responsable: 'Exportateur',
|
||||
},
|
||||
{
|
||||
etape: 'Documentation cut-off',
|
||||
description: 'Date limite pour soumettre les documents (B/L, VGM)',
|
||||
delai: '24-48h avant ETD',
|
||||
responsable: 'Transitaire',
|
||||
},
|
||||
{
|
||||
etape: 'Cargo cut-off',
|
||||
description: 'Date limite de dépôt du conteneur au terminal',
|
||||
delai: '24-48h avant ETD',
|
||||
responsable: 'Transporteur terrestre',
|
||||
},
|
||||
{
|
||||
etape: 'ETD (Estimated Time of Departure)',
|
||||
description: 'Départ estimé du navire du port d\'origine',
|
||||
delai: 'Jour J',
|
||||
responsable: 'Compagnie maritime',
|
||||
},
|
||||
{
|
||||
etape: 'Transit maritime',
|
||||
description: 'Traversée maritime (variable selon route)',
|
||||
delai: '10-45 jours',
|
||||
responsable: 'Compagnie maritime',
|
||||
},
|
||||
{
|
||||
etape: 'ETA (Estimated Time of Arrival)',
|
||||
description: 'Arrivée estimée au port de destination',
|
||||
delai: 'Jour J + transit',
|
||||
responsable: 'Compagnie maritime',
|
||||
},
|
||||
{
|
||||
etape: 'Déchargement',
|
||||
description: 'Déchargement du navire et mise à quai',
|
||||
delai: '1-3 jours après ETA',
|
||||
responsable: 'Terminal portuaire',
|
||||
},
|
||||
{
|
||||
etape: 'Dédouanement',
|
||||
description: 'Formalités douanières à destination',
|
||||
delai: '1-5 jours',
|
||||
responsable: 'Commissionnaire en douane',
|
||||
},
|
||||
{
|
||||
etape: 'Livraison',
|
||||
description: 'Acheminement final au destinataire',
|
||||
delai: '1-5 jours',
|
||||
responsable: 'Transporteur terrestre',
|
||||
},
|
||||
];
|
||||
|
||||
const fraisRetard = [
|
||||
{
|
||||
nom: 'Demurrage',
|
||||
definition: 'Frais pour le conteneur resté au terminal au-delà du free time',
|
||||
taux: '50-150 USD/jour/conteneur',
|
||||
lieu: 'Terminal portuaire',
|
||||
},
|
||||
{
|
||||
nom: 'Detention',
|
||||
definition: 'Frais pour le conteneur gardé hors terminal au-delà du free time',
|
||||
taux: '30-100 USD/jour/conteneur',
|
||||
lieu: 'Chez l\'importateur',
|
||||
},
|
||||
{
|
||||
nom: 'Storage',
|
||||
definition: 'Frais de stockage au terminal (séparés du demurrage)',
|
||||
taux: 'Variable selon port',
|
||||
lieu: 'Terminal portuaire',
|
||||
},
|
||||
{
|
||||
nom: 'Per Diem',
|
||||
definition: 'Frais journaliers combinés (parfois utilisé pour demurrage+detention)',
|
||||
taux: '50-200 USD/jour',
|
||||
lieu: 'Variable',
|
||||
},
|
||||
];
|
||||
|
||||
const transitTimes = [
|
||||
{ route: 'Shanghai → Rotterdam', temps: '28-32 jours', via: 'Suez' },
|
||||
{ route: 'Shanghai → Le Havre', temps: '30-35 jours', via: 'Suez' },
|
||||
{ route: 'Shanghai → Los Angeles', temps: '12-15 jours', via: 'Direct Pacifique' },
|
||||
{ route: 'Shanghai → New York', temps: '35-40 jours', via: 'Suez ou Panama' },
|
||||
{ route: 'Rotterdam → New York', temps: '10-14 jours', via: 'Direct Atlantique' },
|
||||
{ route: 'Mumbai → Rotterdam', temps: '18-22 jours', via: 'Suez' },
|
||||
{ route: 'Santos → Rotterdam', temps: '18-22 jours', via: 'Direct Atlantique' },
|
||||
];
|
||||
|
||||
export default function TransitTimePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">⏱️</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
La gestion des délais est cruciale en transport maritime. Comprendre les différentes étapes,
|
||||
les cut-off dates et les frais de retard permet d'optimiser sa supply chain et d'éviter
|
||||
les coûts supplémentaires.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key Terms */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">ETD</h4>
|
||||
<p className="text-sm text-blue-700">Estimated Time of Departure - Départ estimé</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">ETA</h4>
|
||||
<p className="text-sm text-blue-700">Estimated Time of Arrival - Arrivée estimée</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">Cut-off</h4>
|
||||
<p className="text-sm text-blue-700">Date/heure limite de dépôt</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">Free time</h4>
|
||||
<p className="text-sm text-blue-700">Jours gratuits avant frais de retard</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d'une Expédition FCL</h2>
|
||||
<div className="space-y-3">
|
||||
{etapesTimeline.map((item, index) => (
|
||||
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-gray-900">{item.etape}</h4>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{item.delai}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{item.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Responsable : {item.responsable}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transit Times */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 font-medium">Route</th>
|
||||
<th className="text-center py-2 font-medium">Transit Time</th>
|
||||
<th className="text-right py-2 font-medium">Via</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transitTimes.map((tt) => (
|
||||
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-3 text-gray-900">{tt.route}</td>
|
||||
<td className="py-3 text-center font-mono text-blue-600">{tt.temps}</td>
|
||||
<td className="py-3 text-right text-gray-500">{tt.via}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Free Time */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">⏰ Free Time (Jours Gratuits)</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur
|
||||
sans frais supplémentaires.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Free time standard</h4>
|
||||
<p className="text-2xl font-bold text-blue-600">7-14 jours</p>
|
||||
<p className="text-xs text-gray-500">Selon compagnie et port</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Demurrage start</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Commence après le free time au terminal
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Detention start</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Commence à la sortie du terminal (gate-out)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Late Fees */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fraisRetard.map((frais) => (
|
||||
<Card key={frais.nom} className="bg-white border-red-200">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg text-red-700">{frais.nom}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 text-sm mb-2">{frais.definition}</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Taux indicatif :</span>
|
||||
<span className="font-mono text-red-600">{frais.taux}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-1">
|
||||
<span className="text-gray-500">Lieu :</span>
|
||||
<span className="text-gray-700">{frais.lieu}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Factors affecting transit */}
|
||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-orange-900 mb-3">⚡ Facteurs Impactant les Délais</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
|
||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
||||
<li>• Congestion portuaire (Los Angeles, Rotterdam)</li>
|
||||
<li>• Conditions météorologiques (typhons, tempêtes)</li>
|
||||
<li>• Fermeture de canaux (Suez, Panama)</li>
|
||||
<li>• Inspection douanière (scanner, contrôle)</li>
|
||||
<li>• Blank sailings (annulation de rotation)</li>
|
||||
<li>• Grèves (dockers, transporteurs)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">Variations saisonnières</h4>
|
||||
<ul className="text-sm text-orange-700 mt-2 space-y-1">
|
||||
<li>• Nouvel An Chinois (février) : +2-3 semaines</li>
|
||||
<li>• Golden Week (octobre) : congestion Asie</li>
|
||||
<li>• Peak Season (août-octobre) : surcharges, retards</li>
|
||||
<li>• Fêtes de fin d'année : rush avant Christmas</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roll-over */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
|
||||
<p className="text-gray-600 mb-3">
|
||||
Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté
|
||||
sur le prochain départ.
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Causes fréquentes :</h4>
|
||||
<ul className="text-sm text-gray-600 mt-2 space-y-1">
|
||||
<li>• Navire plein (overbooking)</li>
|
||||
<li>• Conteneur arrivé après le cargo cut-off</li>
|
||||
<li>• Documents manquants ou incorrects</li>
|
||||
<li>• VGM non transmis à temps</li>
|
||||
<li>• Problème avec la marchandise (DG, inspection)</li>
|
||||
</ul>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Impact : Généralement +7 jours de délai (service hebdomadaire)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Réserver tôt, surtout en haute saison (2-3 semaines d'avance)</li>
|
||||
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
||||
<li>Préparer les documents en parallèle de l'empotage</li>
|
||||
<li>Négocier du free time supplémentaire pour les volumes importants</li>
|
||||
<li>Tracker activement les navires (AIS, portails compagnies)</li>
|
||||
<li>Anticiper le dédouanement (pré-clearing si possible)</li>
|
||||
<li>Avoir un plan B en cas de roll-over (service alternatif)</li>
|
||||
<li>Éviter les expéditions critiques pendant les périodes à risque</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
apps/frontend/app/dashboard/wiki/vgm/page.tsx
Normal file
268
apps/frontend/app/dashboard/wiki/vgm/page.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
/**
|
||||
* VGM (Verified Gross Mass) - Wiki Page
|
||||
*
|
||||
* Obligation de pesée des conteneurs (SOLAS)
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
|
||||
const methodesPesee = [
|
||||
{
|
||||
method: 'Méthode 1',
|
||||
name: 'Pesée du conteneur complet',
|
||||
description: 'Pesée du conteneur chargé et scellé sur une balance étalonnée.',
|
||||
process: [
|
||||
'Empotage du conteneur',
|
||||
'Scellage du conteneur',
|
||||
'Pesée sur pont-bascule certifié',
|
||||
'Transmission du VGM',
|
||||
],
|
||||
advantages: ['Plus précis', 'Moins de calculs'],
|
||||
disadvantages: ['Nécessite un pont-bascule', 'Conteneur déjà scellé'],
|
||||
},
|
||||
{
|
||||
method: 'Méthode 2',
|
||||
name: 'Calcul par addition',
|
||||
description: 'Addition de la tare du conteneur et du poids de tous les éléments chargés.',
|
||||
process: [
|
||||
'Pesée de chaque colis individuellement',
|
||||
'Addition de tous les poids',
|
||||
'Ajout des matériaux d\'arrimage',
|
||||
'Addition de la tare conteneur',
|
||||
],
|
||||
advantages: ['Pas besoin de pont-bascule', 'Peut être fait progressivement'],
|
||||
disadvantages: ['Plus complexe', 'Risque d\'erreur cumulative'],
|
||||
},
|
||||
];
|
||||
|
||||
const sanctions = [
|
||||
{ region: 'France', sanction: 'Amende jusqu\'à 7,500€ et refus d\'embarquement' },
|
||||
{ region: 'USA', sanction: 'Refus d\'embarquement, amende par la garde côtière' },
|
||||
{ region: 'Chine', sanction: 'Refus d\'embarquement, pénalités portuaires' },
|
||||
{ region: 'Union Européenne', sanction: 'Application variable selon pays membre' },
|
||||
];
|
||||
|
||||
const elementsVGM = [
|
||||
{ element: 'Tare conteneur', description: 'Poids à vide du conteneur (inscrit sur la porte)', example: '2,200 kg (20\')' },
|
||||
{ element: 'Marchandises', description: 'Poids brut de toutes les marchandises', example: 'Variable' },
|
||||
{ element: 'Emballages', description: 'Palettes, cartons, film plastique...', example: '200-500 kg' },
|
||||
{ element: 'Matériaux d\'arrimage', description: 'Bois de calage, sangles, airbags...', example: '50-200 kg' },
|
||||
];
|
||||
|
||||
export default function VGMPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link
|
||||
href="/dashboard/wiki"
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour au Wiki
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl">⚓</span>
|
||||
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
|
||||
</div>
|
||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||
Depuis le 1er juillet 2016, la Convention SOLAS (Safety of Life at Sea) exige que le poids
|
||||
vérifié de tout conteneur soit transmis avant embarquement. Cette obligation vise à prévenir
|
||||
les accidents liés aux conteneurs mal déclarés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Why VGM */}
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
||||
<div>
|
||||
<h4 className="font-medium">🛡️ Sécurité</h4>
|
||||
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">⚖️ Stabilité du navire</h4>
|
||||
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">🏗️ Équipements portuaires</h4>
|
||||
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium">🚛 Transport terrestre</h4>
|
||||
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* VGM Components */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
|
||||
<p className="text-center font-mono text-lg">
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">VGM</span> = Tare + Marchandises + Emballages + Arrimage
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{elementsVGM.map((item) => (
|
||||
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{item.element}</h4>
|
||||
<p className="text-sm text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded">{item.example}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Methods */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
|
||||
<div className="space-y-4">
|
||||
{methodesPesee.map((method) => (
|
||||
<Card key={method.method} className="bg-white">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<span className="px-3 py-1 bg-green-600 text-white rounded-md">
|
||||
{method.method}
|
||||
</span>
|
||||
<span className="text-lg">{method.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{method.description}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Processus</h4>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
{method.process.map((step, idx) => (
|
||||
<li key={idx}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-700 mb-2">✓ Avantages</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{method.advantages.map((adv) => (
|
||||
<li key={adv} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full"></span>
|
||||
{adv}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-red-700 mb-2">✗ Inconvénients</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{method.disadvantages.map((dis) => (
|
||||
<li key={dis} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
|
||||
{dis}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responsibility */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Responsable légal du VGM. Doit obtenir, certifier et transmettre le poids vérifié.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Transitaire</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Peut transmettre le VGM pour le compte de l'expéditeur. Reste un intermédiaire.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<h4 className="font-medium text-gray-900">Compagnie maritime</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Ne peut embarquer un conteneur sans VGM. Peut refuser un VGM manifestement erroné.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tolerances */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<p className="text-gray-600 mb-3">
|
||||
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Tolérance typique :</span>
|
||||
<p className="text-gray-600">± 5% du poids déclaré ou ± 500 kg (le plus petit des deux)</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Conséquence si dépassement :</span>
|
||||
<p className="text-gray-600">Nouvelle pesée à la charge de l'expéditeur, retard possible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sanctions */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">⚠️ Sanctions par Région</h2>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
{sanctions.map((s) => (
|
||||
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0">
|
||||
<span className="font-medium text-gray-900">{s.region}</span>
|
||||
<span className="text-sm text-red-600">{s.sanction}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
|
||||
<li>Utiliser des balances étalonnées et certifiées</li>
|
||||
<li>Conserver les preuves de pesée pendant 3 ans minimum</li>
|
||||
<li>Vérifier les exigences spécifiques de chaque compagnie maritime</li>
|
||||
<li>Former le personnel aux procédures VGM</li>
|
||||
<li>Ne jamais sous-estimer le poids intentionnellement</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion, useInView, useScroll, useTransform } from 'framer-motion';
|
||||
import {
|
||||
Ship,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
Shield,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Calculator,
|
||||
MapPin,
|
||||
Package,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
@ -21,14 +17,24 @@ import {
|
||||
Anchor,
|
||||
Container,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Users,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function LandingPage() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
const heroRef = useRef(null);
|
||||
const featuresRef = useRef(null);
|
||||
const statsRef = useRef(null);
|
||||
const toolsRef = useRef(null);
|
||||
const pricingRef = useRef(null);
|
||||
const testimonialsRef = useRef(null);
|
||||
const ctaRef = useRef(null);
|
||||
|
||||
@ -36,99 +42,98 @@ export default function LandingPage() {
|
||||
const isFeaturesInView = useInView(featuresRef, { once: true });
|
||||
const isStatsInView = useInView(statsRef, { once: true });
|
||||
const isToolsInView = useInView(toolsRef, { once: true });
|
||||
const isPricingInView = useInView(pricingRef, { once: true });
|
||||
const isTestimonialsInView = useInView(testimonialsRef, { once: true });
|
||||
const isCtaInView = useInView(ctaRef, { once: true });
|
||||
|
||||
const { scrollYProgress } = useScroll();
|
||||
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Recherche Intelligente',
|
||||
description:
|
||||
'Comparez instantanément les tarifs de plus de 50 compagnies maritimes en temps réel.',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Réservation Rapide',
|
||||
description: 'Réservez vos containers LCL/FCL en quelques clics avec confirmation immédiate.',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Tableau de Bord',
|
||||
description: 'Suivez tous vos envois en temps réel avec des KPIs détaillés et des analytics.',
|
||||
title: 'Dashboard Analytics',
|
||||
description:
|
||||
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
|
||||
color: 'from-blue-500 to-cyan-500',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Gestion des Bookings',
|
||||
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
|
||||
color: 'from-purple-500 to-pink-500',
|
||||
link: '/dashboard/bookings',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Documents Maritimes',
|
||||
description: 'Centralisez tous vos documents : B/L, factures, certificats et documents douaniers.',
|
||||
color: 'from-orange-500 to-red-500',
|
||||
link: '/dashboard/documents',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: '10 000+ Ports',
|
||||
icon: Search,
|
||||
title: 'Track & Trace',
|
||||
description:
|
||||
'Accédez à un réseau mondial de ports avec des données actualisées quotidiennement.',
|
||||
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
|
||||
color: 'from-green-500 to-emerald-500',
|
||||
link: '/dashboard/track-trace',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Meilleurs Prix',
|
||||
icon: BookOpen,
|
||||
title: 'Wiki Maritime',
|
||||
description:
|
||||
'Optimisation automatique des tarifs pour vous garantir les prix les plus compétitifs.',
|
||||
'Base de connaissances complète : Incoterms, documents, procédures douanières et plus encore.',
|
||||
color: 'from-yellow-500 to-orange-500',
|
||||
link: '/dashboard/wiki',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Sécurisé',
|
||||
icon: Bell,
|
||||
title: 'Notifications Temps Réel',
|
||||
description:
|
||||
'Plateforme conforme aux standards internationaux avec chiffrement de bout en bout.',
|
||||
'Restez informé avec des alertes instantanées sur vos bookings, documents et mises à jour.',
|
||||
color: 'from-indigo-500 to-purple-500',
|
||||
link: '/dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
const tools = [
|
||||
{
|
||||
icon: Calculator,
|
||||
title: 'Calculateur de Fret',
|
||||
description: 'Estimez vos coûts de transport en temps réel',
|
||||
link: '/tools/calculator',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Distance & Temps',
|
||||
description: 'Calculez la distance et le temps entre ports',
|
||||
link: '/tools/distance',
|
||||
icon: LayoutDashboard,
|
||||
title: 'Dashboard',
|
||||
description: 'Vue d\'ensemble de votre activité maritime',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: 'Optimiseur de Chargement',
|
||||
description: "Maximisez l'utilisation de vos containers",
|
||||
link: '/tools/load-optimizer',
|
||||
},
|
||||
{
|
||||
icon: Ship,
|
||||
title: 'Suivi en Temps Réel',
|
||||
description: 'Trackez vos envois partout dans le monde',
|
||||
link: '/tracking',
|
||||
title: 'Mes Bookings',
|
||||
description: 'Gérez toutes vos réservations en un seul endroit',
|
||||
link: '/dashboard/bookings',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Documents Maritimes',
|
||||
description: 'Générez automatiquement vos documents',
|
||||
link: '/tools/documents',
|
||||
title: 'Documents',
|
||||
description: 'Accédez à tous vos documents maritimes',
|
||||
link: '/dashboard/documents',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Index des Tarifs',
|
||||
description: 'Suivez les tendances du marché maritime',
|
||||
link: '/tools/freight-index',
|
||||
icon: Search,
|
||||
title: 'Track & Trace',
|
||||
description: 'Suivez vos conteneurs en temps réel',
|
||||
link: '/dashboard/track-trace',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Wiki Maritime',
|
||||
description: 'Base de connaissances du fret maritime',
|
||||
link: '/dashboard/wiki',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Mon Profil',
|
||||
description: 'Gérez vos informations personnelles',
|
||||
link: '/dashboard/profile',
|
||||
},
|
||||
];
|
||||
|
||||
@ -139,6 +144,63 @@ export default function LandingPage() {
|
||||
{ value: '99.5%', label: 'Disponibilité', icon: CheckCircle2 },
|
||||
];
|
||||
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: 'Gratuit',
|
||||
period: '',
|
||||
description: 'Idéal pour découvrir la plateforme',
|
||||
features: [
|
||||
{ text: 'Jusqu\'à 5 bookings/mois', included: true },
|
||||
{ text: 'Track & Trace illimité', included: true },
|
||||
{ text: 'Wiki maritime complet', included: true },
|
||||
{ text: 'Dashboard basique', included: true },
|
||||
{ text: 'Support par email', included: true },
|
||||
{ text: 'Gestion des documents', included: false },
|
||||
{ text: 'Notifications temps réel', included: false },
|
||||
{ text: 'API access', included: false },
|
||||
],
|
||||
cta: 'Commencer gratuitement',
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
price: '99€',
|
||||
period: '/mois',
|
||||
description: 'Pour les transitaires en croissance',
|
||||
features: [
|
||||
{ text: 'Bookings illimités', included: true },
|
||||
{ text: 'Track & Trace illimité', included: true },
|
||||
{ text: 'Wiki maritime complet', included: true },
|
||||
{ text: 'Dashboard avancé + KPIs', included: true },
|
||||
{ text: 'Support prioritaire', included: true },
|
||||
{ text: 'Gestion des documents', included: true },
|
||||
{ text: 'Notifications temps réel', included: true },
|
||||
{ text: 'API access', included: false },
|
||||
],
|
||||
cta: 'Essai gratuit 14 jours',
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: 'Sur mesure',
|
||||
period: '',
|
||||
description: 'Pour les grandes entreprises',
|
||||
features: [
|
||||
{ text: 'Tout Professional +', included: true },
|
||||
{ text: 'API access complet', included: true },
|
||||
{ text: 'Intégrations personnalisées', included: true },
|
||||
{ text: 'Account manager dédié', included: true },
|
||||
{ text: 'SLA garanti 99.9%', included: true },
|
||||
{ text: 'Formation sur site', included: true },
|
||||
{ text: 'Multi-organisations', included: true },
|
||||
{ text: 'Audit & conformité', included: true },
|
||||
],
|
||||
cta: 'Contactez-nous',
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
@ -186,61 +248,7 @@ export default function LandingPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Navigation */}
|
||||
<motion.nav
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled ? 'bg-brand-navy/95 backdrop-blur-md shadow-lg' : 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Image
|
||||
src="/assets/logos/logo-white.png"
|
||||
alt="Xpeditis"
|
||||
width={70}
|
||||
height={80}
|
||||
priority
|
||||
className="h-auto"
|
||||
/>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<Link
|
||||
href="#features"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Fonctionnalités
|
||||
</Link>
|
||||
<Link
|
||||
href="#tools"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Outils
|
||||
</Link>
|
||||
<Link
|
||||
href="#pricing"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Tarifs
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Connexion
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-6 py-2.5 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-lg font-medium"
|
||||
>
|
||||
Commencer Gratuitement
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.nav>
|
||||
<LandingHeader transparentOnTop={true} />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative min-h-screen flex items-center overflow-hidden">
|
||||
@ -310,19 +318,31 @@ export default function LandingPage() {
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6 mb-12"
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Créer un compte gratuit</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-50 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto"
|
||||
>
|
||||
Voir la démo
|
||||
</Link>
|
||||
{isAuthenticated && user ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Accéder au Dashboard</span>
|
||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Créer un compte gratuit</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-50 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto"
|
||||
>
|
||||
Voir la démo
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -417,15 +437,23 @@ export default function LandingPage() {
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ scale: 1.05, y: -10 }}
|
||||
className="group bg-white p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all border border-gray-100 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
|
||||
<Link
|
||||
href={feature.link}
|
||||
className="group block bg-white p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all border border-gray-100"
|
||||
>
|
||||
<IconComponent className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
<div
|
||||
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
<IconComponent className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
<div className="mt-4 flex items-center text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>Découvrir</span>
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
@ -546,6 +574,103 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section
|
||||
ref={pricingRef}
|
||||
id="pricing"
|
||||
className="py-20 lg:py-32 bg-gradient-to-br from-gray-50 to-white"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Tarifs simples et transparents
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Choisissez le plan adapté à vos besoins. Évoluez à tout moment.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isPricingInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||
>
|
||||
{pricingPlans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -10 }}
|
||||
className={`relative bg-white rounded-2xl shadow-lg border-2 transition-all ${
|
||||
plan.highlighted
|
||||
? 'border-brand-turquoise shadow-2xl scale-105'
|
||||
: 'border-gray-200 hover:border-brand-turquoise/50'
|
||||
}`}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<span className="bg-brand-turquoise text-white text-sm font-bold px-4 py-1 rounded-full">
|
||||
Populaire
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-8">
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-2">{plan.name}</h3>
|
||||
<p className="text-gray-600 mb-6">{plan.description}</p>
|
||||
<div className="mb-6">
|
||||
<span className="text-5xl font-bold text-brand-navy">{plan.price}</span>
|
||||
<span className="text-gray-500">{plan.period}</span>
|
||||
</div>
|
||||
<ul className="space-y-3 mb-8">
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<li key={featureIndex} className="flex items-center">
|
||||
{feature.included ? (
|
||||
<Check className="w-5 h-5 text-brand-green mr-3 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={plan.name === 'Enterprise' ? '/contact' : '/register'}
|
||||
className={`block w-full text-center py-3 px-6 rounded-lg font-semibold transition-all ${
|
||||
plan.highlighted
|
||||
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg hover:shadow-xl'
|
||||
: 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<p className="text-gray-600">
|
||||
Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Des questions ? <Link href="/contact" className="text-brand-turquoise hover:underline">Contactez notre équipe commerciale</Link>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section className="py-20 lg:py-32 bg-gradient-to-br from-brand-navy to-brand-navy/95 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
@ -703,19 +828,31 @@ export default function LandingPage() {
|
||||
variants={itemVariants}
|
||||
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Créer un compte gratuit</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto"
|
||||
>
|
||||
Se connecter
|
||||
</Link>
|
||||
{isAuthenticated && user ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Accéder au Dashboard</span>
|
||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<span>Créer un compte gratuit</span>
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto"
|
||||
>
|
||||
Se connecter
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -738,165 +875,7 @@ export default function LandingPage() {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-brand-navy text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 mb-12">
|
||||
{/* Company Info */}
|
||||
<div className="lg:col-span-2">
|
||||
<Image
|
||||
src="/assets/logos/logo-white.png"
|
||||
alt="Xpeditis"
|
||||
width={160}
|
||||
height={55}
|
||||
className="h-auto mb-6"
|
||||
/>
|
||||
<p className="text-white/70 text-sm mb-6 leading-relaxed">
|
||||
Xpeditis est la plateforme B2B leader pour le fret maritime en Europe. Nous
|
||||
connectons les transitaires avec les plus grandes compagnies maritimes mondiales.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 bg-white/10 hover:bg-brand-turquoise rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<span className="sr-only">LinkedIn</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 bg-white/10 hover:bg-brand-turquoise rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<span className="sr-only">Twitter</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Produits</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="#features" className="hover:text-brand-turquoise transition-colors">
|
||||
Fonctionnalités
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="#tools" className="hover:text-brand-turquoise transition-colors">
|
||||
Outils & Calculateurs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="#pricing" className="hover:text-brand-turquoise transition-colors">
|
||||
Tarifs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/api" className="hover:text-brand-turquoise transition-colors">
|
||||
API Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="hover:text-brand-turquoise transition-colors"
|
||||
>
|
||||
Intégrations
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Entreprise</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="/about" className="hover:text-brand-turquoise transition-colors">
|
||||
À propos
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="hover:text-brand-turquoise transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/careers" className="hover:text-brand-turquoise transition-colors">
|
||||
Carrières
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/press" className="hover:text-brand-turquoise transition-colors">
|
||||
Presse
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4">Légal</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="/privacy" className="hover:text-brand-turquoise transition-colors">
|
||||
Politique de confidentialité
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/terms" className="hover:text-brand-turquoise transition-colors">
|
||||
Conditions générales
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/cookies" className="hover:text-brand-turquoise transition-colors">
|
||||
Politique de cookies
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/security" className="hover:text-brand-turquoise transition-colors">
|
||||
Sécurité
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
||||
Conformité RGPD
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Footer */}
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="text-white/50 text-sm">
|
||||
© 2025 Xpeditis SAS. Tous droits réservés.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-sm text-white/50">
|
||||
<span className="flex items-center space-x-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Paris, France</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>50+ Pays</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
578
apps/frontend/app/press/page.tsx
Normal file
578
apps/frontend/app/press/page.tsx
Normal file
@ -0,0 +1,578 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Newspaper,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
Mail,
|
||||
Phone,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Award,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Globe,
|
||||
ArrowRight,
|
||||
Quote,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function PressPage() {
|
||||
const heroRef = useRef(null);
|
||||
const newsRef = useRef(null);
|
||||
const resourcesRef = useRef(null);
|
||||
const contactRef = useRef(null);
|
||||
const milestonesRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isNewsInView = useInView(newsRef, { once: true });
|
||||
const isResourcesInView = useInView(resourcesRef, { once: true });
|
||||
const isContactInView = useInView(contactRef, { once: true });
|
||||
const isMilestonesInView = useInView(milestonesRef, { once: true });
|
||||
|
||||
const pressReleases = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Xpeditis lève 15 millions d\'euros pour accélérer son expansion européenne',
|
||||
date: '15 janvier 2025',
|
||||
excerpt:
|
||||
'La startup française de fret maritime digital annonce une levée de fonds Série A menée par Partech et Eurazeo.',
|
||||
category: 'Financement',
|
||||
pdfUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Xpeditis franchit le cap des 500 clients actifs',
|
||||
date: '8 janvier 2025',
|
||||
excerpt:
|
||||
'La plateforme B2B de fret maritime confirme sa position de leader en Europe avec une croissance de 200% en 2024.',
|
||||
category: 'Croissance',
|
||||
pdfUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Partenariat stratégique avec CMA CGM',
|
||||
date: '15 décembre 2024',
|
||||
excerpt:
|
||||
'Xpeditis intègre l\'offre complète du groupe CMA CGM, offrant encore plus de choix à ses utilisateurs.',
|
||||
category: 'Partenariat',
|
||||
pdfUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Lancement de nouvelles fonctionnalités de tracking en temps réel',
|
||||
date: '1er décembre 2024',
|
||||
excerpt:
|
||||
'La plateforme enrichit son offre avec un système de suivi des conteneurs amélioré et des notifications proactives.',
|
||||
category: 'Produit',
|
||||
pdfUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Ouverture du bureau de Rotterdam',
|
||||
date: '15 novembre 2024',
|
||||
excerpt:
|
||||
'Xpeditis poursuit son expansion avec l\'ouverture d\'un nouveau bureau aux Pays-Bas pour servir le marché Benelux.',
|
||||
category: 'Expansion',
|
||||
pdfUrl: '#',
|
||||
},
|
||||
];
|
||||
|
||||
const mediaCoverage = [
|
||||
{
|
||||
outlet: 'Les Echos',
|
||||
title: 'Xpeditis, la startup qui digitalise le fret maritime',
|
||||
date: '12 janvier 2025',
|
||||
url: '#',
|
||||
logo: '/assets/logos/media/les-echos.png',
|
||||
},
|
||||
{
|
||||
outlet: 'BFM Business',
|
||||
title: 'Comment l\'IA révolutionne la logistique maritime',
|
||||
date: '10 janvier 2025',
|
||||
url: '#',
|
||||
logo: '/assets/logos/media/bfm.png',
|
||||
},
|
||||
{
|
||||
outlet: 'Journal de la Marine Marchande',
|
||||
title: 'Les plateformes digitales au service des transitaires',
|
||||
date: '5 janvier 2025',
|
||||
url: '#',
|
||||
logo: '/assets/logos/media/jmm.png',
|
||||
},
|
||||
{
|
||||
outlet: 'Tech.eu',
|
||||
title: 'French logistics startup Xpeditis raises €15M',
|
||||
date: '15 janvier 2025',
|
||||
url: '#',
|
||||
logo: '/assets/logos/media/tech-eu.png',
|
||||
},
|
||||
];
|
||||
|
||||
const pressKitItems = [
|
||||
{
|
||||
icon: ImageIcon,
|
||||
title: 'Logos & Visuels',
|
||||
description: 'Logos en haute résolution (PNG, SVG) et visuels de la plateforme',
|
||||
downloadUrl: '#',
|
||||
format: 'ZIP - 12 MB',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Dossier de presse',
|
||||
description: 'Présentation complète de Xpeditis, chiffres clés et biographies des fondateurs',
|
||||
downloadUrl: '#',
|
||||
format: 'PDF - 4 MB',
|
||||
},
|
||||
{
|
||||
icon: Video,
|
||||
title: 'Vidéos & B-Roll',
|
||||
description: 'Vidéos de présentation et séquences B-Roll pour vos reportages',
|
||||
downloadUrl: '#',
|
||||
format: 'MP4 - 250 MB',
|
||||
},
|
||||
];
|
||||
|
||||
const milestones = [
|
||||
{ year: '2021', event: 'Création de Xpeditis', icon: Award },
|
||||
{ year: '2022', event: 'Première version lancée', icon: TrendingUp },
|
||||
{ year: '2023', event: 'Levée Seed de 3M€', icon: TrendingUp },
|
||||
{ year: '2024', event: '500 clients actifs', icon: Users },
|
||||
{ year: '2025', event: 'Série A - 15M€', icon: Globe },
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="press" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Newspaper className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Espace Presse</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Espace Presse
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Xpeditis
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
|
||||
Retrouvez tous nos communiqués de presse, ressources médias et informations
|
||||
pour les journalistes. Notre équipe communication est à votre disposition.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
<a
|
||||
href="#resources"
|
||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Kit Presse</span>
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
|
||||
>
|
||||
Contact Presse
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Stats */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ value: '15M€', label: 'Levée de fonds' },
|
||||
{ value: '500+', label: 'Clients actifs' },
|
||||
{ value: '50+', label: 'Compagnies maritimes' },
|
||||
{ value: '15', label: 'Pays couverts' },
|
||||
].map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Press Releases */}
|
||||
<section ref={newsRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isNewsInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Communiqués de Presse
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Les dernières actualités officielles de Xpeditis
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isNewsInView ? 'visible' : 'hidden'}
|
||||
className="space-y-6"
|
||||
>
|
||||
{pressReleases.map((release) => (
|
||||
<motion.div
|
||||
key={release.id}
|
||||
variants={itemVariants}
|
||||
className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
|
||||
{release.category}
|
||||
</span>
|
||||
<span className="flex items-center space-x-1 text-gray-500 text-sm">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{release.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
|
||||
{release.title}
|
||||
</h3>
|
||||
<p className="text-gray-600">{release.excerpt}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<a
|
||||
href={release.pdfUrl}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>PDF</span>
|
||||
</a>
|
||||
<a
|
||||
href={`/press/${release.id}`}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors"
|
||||
>
|
||||
<span>Lire</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Media Coverage */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Ils parlent de nous
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Retrouvez les articles de presse mentionnant Xpeditis
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{mediaCoverage.map((article, index) => (
|
||||
<motion.a
|
||||
key={index}
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Newspaper className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-brand-turquoise">{article.outlet}</span>
|
||||
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-brand-turquoise transition-colors" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
|
||||
{article.title}
|
||||
</h3>
|
||||
<span className="text-gray-500 text-sm">{article.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Press Kit Resources */}
|
||||
<section ref={resourcesRef} id="resources" className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isResourcesInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Kit Presse
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Téléchargez tous les éléments dont vous avez besoin pour vos articles
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isResourcesInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
||||
>
|
||||
{pressKitItems.map((item, index) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -10 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6">
|
||||
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
|
||||
<p className="text-gray-600 mb-4">{item.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">{item.format}</span>
|
||||
<a
|
||||
href={item.downloadUrl}
|
||||
className="flex items-center space-x-2 text-brand-turquoise font-medium hover:underline"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Télécharger</span>
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Milestones */}
|
||||
<section ref={milestonesRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isMilestonesInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-4">
|
||||
Notre Parcours
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 max-w-2xl mx-auto">
|
||||
Les grandes étapes de l'histoire de Xpeditis
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-8">
|
||||
{milestones.map((milestone, index) => {
|
||||
const IconComponent = milestone.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={isMilestonesInView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-20 h-20 bg-brand-turquoise rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<IconComponent className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-brand-turquoise mb-1">{milestone.year}</div>
|
||||
<div className="text-white/80 max-w-[150px]">{milestone.event}</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Press Contact */}
|
||||
<section ref={contactRef} id="contact" className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContactInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
Contact Presse
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Notre équipe communication est à votre disposition pour toute demande d'interview,
|
||||
d'information ou de partenariat média.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContactInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="bg-white rounded-3xl shadow-xl border border-gray-100 p-8 lg:p-12"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-6">Relations Presse</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Email</div>
|
||||
<a
|
||||
href="mailto:presse@xpeditis.com"
|
||||
className="text-lg font-medium text-brand-navy hover:text-brand-turquoise transition-colors"
|
||||
>
|
||||
presse@xpeditis.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
|
||||
<Phone className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Téléphone</div>
|
||||
<a
|
||||
href="tel:+33123456790"
|
||||
className="text-lg font-medium text-brand-navy hover:text-brand-turquoise transition-colors"
|
||||
>
|
||||
+33 1 23 45 67 90
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-6">Responsable Communication</h3>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-brand-navy">Camille Dumont</div>
|
||||
<div className="text-brand-turquoise font-medium mb-2">Directrice Communication</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Disponible pour les interviews, demandes d'information et partenariats médias.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-100">
|
||||
<div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl">
|
||||
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-gray-600 italic mb-4">
|
||||
"Nous sommes toujours ravis d'échanger avec les journalistes sur notre mission de
|
||||
révolutionner le fret maritime. N'hésitez pas à nous contacter pour toute demande."
|
||||
</p>
|
||||
<div className="text-sm">
|
||||
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
|
||||
<span className="text-gray-500"> - CEO & Co-fondateur</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
apps/frontend/app/privacy/page.tsx
Normal file
229
apps/frontend/app/privacy/page.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { Shield, Eye, Lock, UserCheck, Database, Globe, Mail, Calendar } from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
const heroRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isContentInView = useInView(contentRef, { once: true });
|
||||
|
||||
const sections = [
|
||||
{
|
||||
icon: Database,
|
||||
title: '1. Données collectées',
|
||||
content: `Nous collectons les données suivantes :
|
||||
|
||||
• **Données d'identification** : nom, prénom, adresse email professionnelle, numéro de téléphone
|
||||
• **Données professionnelles** : nom de l'entreprise, fonction, numéro SIRET
|
||||
• **Données de connexion** : adresse IP, logs de connexion, données de navigation
|
||||
• **Données de transaction** : historique des réservations, devis, factures
|
||||
• **Données de communication** : échanges avec notre service client`,
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: '2. Utilisation des données',
|
||||
content: `Vos données sont utilisées pour :
|
||||
|
||||
• Fournir et améliorer nos services de réservation de fret maritime
|
||||
• Gérer votre compte et vos préférences
|
||||
• Traiter vos demandes de devis et réservations
|
||||
• Vous envoyer des communications commerciales (avec votre consentement)
|
||||
• Assurer la sécurité de notre plateforme
|
||||
• Respecter nos obligations légales et réglementaires`,
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: '3. Protection des données',
|
||||
content: `Nous mettons en œuvre des mesures de sécurité robustes :
|
||||
|
||||
• Chiffrement SSL/TLS pour toutes les communications
|
||||
• Chiffrement des données sensibles au repos (AES-256)
|
||||
• Authentification à deux facteurs disponible
|
||||
• Audits de sécurité réguliers
|
||||
• Formation continue de nos équipes
|
||||
• Hébergement sur des serveurs certifiés ISO 27001`,
|
||||
},
|
||||
{
|
||||
icon: UserCheck,
|
||||
title: '4. Vos droits',
|
||||
content: `Conformément au RGPD, vous disposez des droits suivants :
|
||||
|
||||
• **Droit d'accès** : obtenir une copie de vos données personnelles
|
||||
• **Droit de rectification** : corriger vos données inexactes
|
||||
• **Droit à l'effacement** : demander la suppression de vos données
|
||||
• **Droit à la portabilité** : recevoir vos données dans un format structuré
|
||||
• **Droit d'opposition** : vous opposer au traitement de vos données
|
||||
• **Droit de limitation** : limiter le traitement de vos données
|
||||
|
||||
Pour exercer ces droits, contactez-nous à : privacy@xpeditis.com`,
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: '5. Transferts internationaux',
|
||||
content: `Vos données peuvent être transférées vers des pays hors UE dans le cadre de nos services de fret maritime international. Ces transferts sont encadrés par :
|
||||
|
||||
• Des clauses contractuelles types approuvées par la Commission européenne
|
||||
• Des certifications adéquates (ex: Privacy Shield pour certains prestataires)
|
||||
• Le consentement explicite pour certains transferts spécifiques`,
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: '6. Conservation des données',
|
||||
content: `Nous conservons vos données selon les durées suivantes :
|
||||
|
||||
• **Données de compte** : durée de la relation commerciale + 3 ans
|
||||
• **Données de transaction** : 10 ans (obligations comptables)
|
||||
• **Données de connexion** : 1 an
|
||||
• **Données marketing** : 3 ans après le dernier contact
|
||||
|
||||
À l'expiration de ces délais, vos données sont supprimées ou anonymisées.`,
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Protection des données</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Politique de
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Confidentialité
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
||||
Chez Xpeditis, la protection de vos données personnelles est une priorité absolue.
|
||||
Cette politique explique comment nous collectons, utilisons et protégeons vos informations.
|
||||
</p>
|
||||
|
||||
<p className="text-white/60 text-sm">
|
||||
Dernière mise à jour : Janvier 2025
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="space-y-12"
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
const IconComponent = section.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-brand-navy mb-4">{section.title}</h2>
|
||||
<div className="text-gray-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
|
||||
{section.content.split('**').map((part, i) =>
|
||||
i % 2 === 1 ? <strong key={i}>{part}</strong> : part
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">Des questions ?</h3>
|
||||
<p className="text-white/80 mb-6">
|
||||
Pour toute question concernant notre politique de confidentialité ou vos données personnelles,
|
||||
contactez notre Délégué à la Protection des Données.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:privacy@xpeditis.com"
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>privacy@xpeditis.com</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
apps/frontend/app/security/page.tsx
Normal file
335
apps/frontend/app/security/page.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
Shield,
|
||||
Lock,
|
||||
Server,
|
||||
Eye,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Key,
|
||||
FileCheck,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function SecurityPage() {
|
||||
const heroRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isContentInView = useInView(contentRef, { once: true });
|
||||
|
||||
const securityFeatures = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: 'Chiffrement de bout en bout',
|
||||
description:
|
||||
'Toutes les communications sont chiffrées avec TLS 1.3. Vos données sensibles sont chiffrées au repos avec AES-256.',
|
||||
},
|
||||
{
|
||||
icon: Key,
|
||||
title: 'Authentification sécurisée',
|
||||
description:
|
||||
'Authentification à deux facteurs (2FA) disponible. Mots de passe hashés avec Argon2id, le standard le plus robuste.',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: 'Infrastructure certifiée',
|
||||
description:
|
||||
'Hébergement sur AWS avec certifications ISO 27001, SOC 2 Type II et conformité RGPD.',
|
||||
},
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Surveillance 24/7',
|
||||
description:
|
||||
'Monitoring continu de notre infrastructure. Détection et réponse aux incidents en temps réel.',
|
||||
},
|
||||
{
|
||||
icon: FileCheck,
|
||||
title: 'Audits réguliers',
|
||||
description:
|
||||
'Tests de pénétration annuels par des experts indépendants. Programme de bug bounty actif.',
|
||||
},
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
title: 'Plan de continuité',
|
||||
description:
|
||||
'Sauvegardes automatiques quotidiennes. Plan de reprise d\'activité testé régulièrement.',
|
||||
},
|
||||
];
|
||||
|
||||
const certifications = [
|
||||
{ name: 'ISO 27001', description: 'Système de management de la sécurité de l\'information' },
|
||||
{ name: 'SOC 2 Type II', description: 'Contrôles de sécurité, disponibilité et confidentialité' },
|
||||
{ name: 'RGPD', description: 'Conformité au Règlement Général sur la Protection des Données' },
|
||||
{ name: 'PCI DSS', description: 'Norme de sécurité des données de paiement' },
|
||||
];
|
||||
|
||||
const practices = [
|
||||
{
|
||||
title: 'Développement sécurisé',
|
||||
items: [
|
||||
'Revue de code systématique',
|
||||
'Analyse statique du code (SAST)',
|
||||
'Tests de sécurité automatisés',
|
||||
'Gestion des dépendances',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Protection des données',
|
||||
items: [
|
||||
'Minimisation des données collectées',
|
||||
'Pseudonymisation des données',
|
||||
'Contrôle d\'accès granulaire',
|
||||
'Journalisation des accès',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Réponse aux incidents',
|
||||
items: [
|
||||
'Équipe de sécurité dédiée',
|
||||
'Procédures de notification',
|
||||
'Analyse post-incident',
|
||||
'Amélioration continue',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<Shield className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Sécurité maximale</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Votre sécurité,
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
Notre priorité
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
||||
Chez Xpeditis, nous mettons en œuvre les meilleures pratiques de sécurité
|
||||
pour protéger vos données et garantir la continuité de vos opérations.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Features */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Mesures de sécurité
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Une infrastructure robuste pour protéger vos données
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{securityFeatures.map((feature, index) => {
|
||||
const IconComponent = feature.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
whileHover={{ y: -5 }}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
|
||||
>
|
||||
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Certifications */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Certifications & Conformité
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Nos engagements validés par des organismes indépendants
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{certifications.map((cert, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 bg-brand-green/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-brand-green" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{cert.name}</h3>
|
||||
<p className="text-gray-600 text-sm">{cert.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Security Practices */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
Nos pratiques de sécurité
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
Une approche proactive de la sécurité à chaque étape
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{practices.map((practice, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-6">{practice.title}</h3>
|
||||
<ul className="space-y-4">
|
||||
{practice.items.map((item, i) => (
|
||||
<li key={i} className="flex items-center space-x-3 text-white/80">
|
||||
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Report Vulnerability */}
|
||||
<section className="py-20 bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<AlertTriangle className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">
|
||||
Signaler une vulnérabilité
|
||||
</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
||||
Vous avez découvert une faille de sécurité ? Nous prenons très au sérieux
|
||||
la sécurité de notre plateforme. Contactez notre équipe de sécurité pour
|
||||
un signalement responsable.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:security@xpeditis.com"
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>security@xpeditis.com</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
apps/frontend/app/terms/page.tsx
Normal file
240
apps/frontend/app/terms/page.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { FileText, Users, CreditCard, AlertTriangle, Scale, Gavel, Mail } from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
export default function TermsPage() {
|
||||
const heroRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const isHeroInView = useInView(heroRef, { once: true });
|
||||
const isContentInView = useInView(contentRef, { once: true });
|
||||
|
||||
const sections = [
|
||||
{
|
||||
icon: Users,
|
||||
title: '1. Objet et acceptation',
|
||||
content: `Les présentes Conditions Générales d'Utilisation (CGU) régissent l'utilisation de la plateforme Xpeditis, accessible à l'adresse www.xpeditis.com.
|
||||
|
||||
En accédant à notre plateforme et en utilisant nos services, vous acceptez sans réserve les présentes CGU. Si vous n'acceptez pas ces conditions, vous ne devez pas utiliser nos services.
|
||||
|
||||
Xpeditis se réserve le droit de modifier ces CGU à tout moment. Les utilisateurs seront informés de toute modification par email et/ou notification sur la plateforme.`,
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '2. Description des services',
|
||||
content: `Xpeditis propose une plateforme B2B de réservation de fret maritime permettant :
|
||||
|
||||
• La recherche et comparaison de tarifs de transport maritime
|
||||
• La réservation de conteneurs auprès de compagnies maritimes partenaires
|
||||
• Le suivi en temps réel des expéditions
|
||||
• La gestion documentaire (Bill of Lading, certificats, etc.)
|
||||
• L'accès à un tableau de bord analytique
|
||||
|
||||
Xpeditis agit en qualité d'intermédiaire technologique et ne se substitue pas aux transporteurs maritimes dans l'exécution des prestations de transport.`,
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: '3. Inscription et compte utilisateur',
|
||||
content: `**Conditions d'inscription**
|
||||
L'inscription est réservée aux professionnels du transport et de la logistique (transitaires, commissionnaires, etc.). L'utilisateur doit fournir des informations exactes et complètes.
|
||||
|
||||
**Responsabilité du compte**
|
||||
L'utilisateur est responsable de la confidentialité de ses identifiants et de toutes les activités effectuées sous son compte. Il s'engage à notifier immédiatement Xpeditis en cas d'utilisation non autorisée.
|
||||
|
||||
**Suspension et résiliation**
|
||||
Xpeditis peut suspendre ou résilier un compte en cas de violation des CGU, d'activité frauduleuse ou de non-paiement.`,
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: '4. Tarification et paiement',
|
||||
content: `**Tarifs affichés**
|
||||
Les tarifs affichés sur la plateforme sont indicatifs et peuvent varier selon la disponibilité et les conditions du marché. Le tarif définitif est confirmé lors de la validation de la réservation.
|
||||
|
||||
**Modalités de paiement**
|
||||
Les paiements s'effectuent par virement bancaire ou carte bancaire. Les factures sont payables selon les conditions convenues (généralement 30 jours).
|
||||
|
||||
**Frais supplémentaires**
|
||||
Des frais additionnels peuvent s'appliquer (surcharges carburant, frais portuaires, etc.). Ces frais sont clairement indiqués avant validation.`,
|
||||
},
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
title: '5. Responsabilité',
|
||||
content: `**Limitation de responsabilité**
|
||||
Xpeditis agit en tant qu'intermédiaire technologique. La responsabilité du transport incombe aux compagnies maritimes selon leurs conditions générales et les conventions internationales applicables.
|
||||
|
||||
**Force majeure**
|
||||
Xpeditis ne peut être tenue responsable des retards ou annulations dus à des cas de force majeure (intempéries, grèves, pandémies, etc.).
|
||||
|
||||
**Disponibilité de la plateforme**
|
||||
Xpeditis s'efforce de maintenir la plateforme disponible 24h/24. Cependant, des interruptions peuvent survenir pour maintenance ou raisons techniques.`,
|
||||
},
|
||||
{
|
||||
icon: Scale,
|
||||
title: '6. Propriété intellectuelle',
|
||||
content: `Tous les éléments de la plateforme Xpeditis (logo, design, textes, logiciels, bases de données) sont protégés par les droits de propriété intellectuelle.
|
||||
|
||||
L'utilisateur s'engage à :
|
||||
• Ne pas reproduire, modifier ou distribuer le contenu de la plateforme
|
||||
• Ne pas utiliser les données à des fins concurrentielles
|
||||
• Ne pas tenter de désassembler ou décompiler le logiciel
|
||||
• Respecter les marques et logos de Xpeditis et de ses partenaires`,
|
||||
},
|
||||
{
|
||||
icon: Gavel,
|
||||
title: '7. Droit applicable et litiges',
|
||||
content: `**Droit applicable**
|
||||
Les présentes CGU sont régies par le droit français.
|
||||
|
||||
**Résolution des litiges**
|
||||
En cas de litige, les parties s'engagent à rechercher une solution amiable. À défaut, les tribunaux de Paris seront seuls compétents.
|
||||
|
||||
**Médiation**
|
||||
Conformément aux dispositions du Code de la consommation, l'utilisateur peut recourir gratuitement au service de médiation auquel Xpeditis est adhérent.`,
|
||||
},
|
||||
];
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-white/90 text-sm font-medium">Cadre juridique</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Conditions Générales
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
||||
d'Utilisation
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
|
||||
Ces conditions régissent votre utilisation de la plateforme Xpeditis.
|
||||
Veuillez les lire attentivement avant d'utiliser nos services.
|
||||
</p>
|
||||
|
||||
<p className="text-white/60 text-sm">
|
||||
Dernière mise à jour : Janvier 2025
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section ref={contentRef} className="py-20">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="space-y-12"
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
const IconComponent = section.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-brand-navy mb-4">{section.title}</h2>
|
||||
<div className="text-gray-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
|
||||
{section.content.split('**').map((part, i) =>
|
||||
i % 2 === 1 ? <strong key={i}>{part}</strong> : part
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">Besoin de précisions ?</h3>
|
||||
<p className="text-white/80 mb-6">
|
||||
Pour toute question concernant nos conditions générales d'utilisation,
|
||||
notre équipe juridique est à votre disposition.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:legal@xpeditis.com"
|
||||
className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
<span>legal@xpeditis.com</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
apps/frontend/src/components/layout/LandingFooter.tsx
Normal file
155
apps/frontend/src/components/layout/LandingFooter.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { MapPin, Globe } from 'lucide-react';
|
||||
|
||||
export function LandingFooter() {
|
||||
return (
|
||||
<footer className="bg-brand-navy text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 mb-12">
|
||||
{/* Company Info */}
|
||||
<div className="lg:col-span-2">
|
||||
<Image
|
||||
src="/assets/logos/logo-white.png"
|
||||
alt="Xpeditis"
|
||||
width={160}
|
||||
height={55}
|
||||
className="h-auto mb-6"
|
||||
/>
|
||||
<p className="text-white/70 text-sm mb-6 leading-relaxed">
|
||||
Xpeditis est la plateforme B2B leader pour le fret maritime en Europe. Nous
|
||||
connectons les transitaires avec les plus grandes compagnies maritimes mondiales.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 bg-white/10 hover:bg-brand-turquoise rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<span className="sr-only">LinkedIn</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 bg-white/10 hover:bg-brand-turquoise rounded-full flex items-center justify-center transition-colors"
|
||||
>
|
||||
<span className="sr-only">Twitter</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4 text-white">Produits</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="/#features" className="hover:text-brand-turquoise transition-colors">
|
||||
Fonctionnalités
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/#tools" className="hover:text-brand-turquoise transition-colors">
|
||||
Outils & Calculateurs
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/#pricing" className="hover:text-brand-turquoise transition-colors">
|
||||
Tarifs
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4 text-white">Entreprise</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="/about" className="hover:text-brand-turquoise transition-colors">
|
||||
À propos
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="hover:text-brand-turquoise transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/careers" className="hover:text-brand-turquoise transition-colors">
|
||||
Carrières
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/press" className="hover:text-brand-turquoise transition-colors">
|
||||
Presse
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-4 text-white">Légal</h4>
|
||||
<ul className="space-y-3 text-white/70 text-sm">
|
||||
<li>
|
||||
<Link href="/privacy" className="hover:text-brand-turquoise transition-colors">
|
||||
Politique de confidentialité
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/terms" className="hover:text-brand-turquoise transition-colors">
|
||||
Conditions générales
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/cookies" className="hover:text-brand-turquoise transition-colors">
|
||||
Politique de cookies
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/security" className="hover:text-brand-turquoise transition-colors">
|
||||
Sécurité
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
||||
Conformité RGPD
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Footer */}
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="text-white/50 text-sm">
|
||||
© 2025 Xpeditis SAS. Tous droits réservés.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 text-sm text-white/50">
|
||||
<span className="flex items-center space-x-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Paris, France</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>50+ Pays</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
214
apps/frontend/src/components/layout/LandingHeader.tsx
Normal file
214
apps/frontend/src/components/layout/LandingHeader.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronDown,
|
||||
Briefcase,
|
||||
Newspaper,
|
||||
Mail,
|
||||
Info,
|
||||
BookOpen,
|
||||
LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
|
||||
interface LandingHeaderProps {
|
||||
transparentOnTop?: boolean;
|
||||
activePage?: 'about' | 'contact' | 'careers' | 'blog' | 'press';
|
||||
}
|
||||
|
||||
export function LandingHeader({ transparentOnTop = false, activePage }: LandingHeaderProps) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isCompanyMenuOpen, setIsCompanyMenuOpen] = useState(false);
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
|
||||
const companyMenuItems = [
|
||||
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' },
|
||||
{ href: '/contact', label: 'Contact', icon: Mail, description: 'Nous contacter' },
|
||||
{ href: '/careers', label: 'Carrières', icon: Briefcase, description: 'Rejoignez-nous' },
|
||||
{ href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' },
|
||||
{ href: '/press', label: 'Presse', icon: Newspaper, description: 'Espace presse' },
|
||||
];
|
||||
|
||||
const getUserInitials = () => {
|
||||
if (!user) return '';
|
||||
const firstInitial = user.firstName?.charAt(0)?.toUpperCase() || '';
|
||||
const lastInitial = user.lastName?.charAt(0)?.toUpperCase() || '';
|
||||
return firstInitial + lastInitial || user.email?.charAt(0)?.toUpperCase() || '?';
|
||||
};
|
||||
|
||||
const getFullName = () => {
|
||||
if (!user) return '';
|
||||
if (user.firstName && user.lastName) {
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
}
|
||||
return user.email || '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!transparentOnTop) {
|
||||
setIsScrolled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [transparentOnTop]);
|
||||
|
||||
const isActiveLink = (href: string) => {
|
||||
if (!activePage) return false;
|
||||
return href === `/${activePage}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled ? 'bg-brand-navy/95 backdrop-blur-md shadow-lg' : 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<Image
|
||||
src="/assets/logos/logo-white.png"
|
||||
alt="Xpeditis"
|
||||
width={70}
|
||||
height={80}
|
||||
priority
|
||||
className="h-auto"
|
||||
/>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
<Link
|
||||
href="/#features"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Fonctionnalités
|
||||
</Link>
|
||||
<Link
|
||||
href="/#tools"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Outils
|
||||
</Link>
|
||||
<Link
|
||||
href="/#pricing"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Tarifs
|
||||
</Link>
|
||||
|
||||
{/* Menu Entreprise */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsCompanyMenuOpen(true)}
|
||||
onMouseLeave={() => setIsCompanyMenuOpen(false)}
|
||||
>
|
||||
<button
|
||||
className={`flex items-center space-x-1 transition-colors font-medium ${
|
||||
activePage ? 'text-brand-turquoise' : 'text-white hover:text-brand-turquoise'
|
||||
}`}
|
||||
>
|
||||
<span>Entreprise</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${isCompanyMenuOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCompanyMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-full left-1/2 -translate-x-1/2 pt-4 z-50"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden min-w-[280px]">
|
||||
<div className="p-2">
|
||||
{companyMenuItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-colors group ${
|
||||
isActive ? 'bg-brand-turquoise/10' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-turquoise/20'
|
||||
: 'bg-brand-turquoise/10 group-hover:bg-brand-turquoise/20'
|
||||
}`}>
|
||||
<IconComponent className="w-5 h-5 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-brand-turquoise'
|
||||
: 'text-brand-navy group-hover:text-brand-turquoise'
|
||||
}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Affichage conditionnel: connecté vs non connecté */}
|
||||
{loading ? (
|
||||
<div className="w-8 h-8 rounded-full bg-white/20 animate-pulse" />
|
||||
) : isAuthenticated && user ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center space-x-3 group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-brand-turquoise flex items-center justify-center text-white font-semibold text-sm border-2 border-white/30 group-hover:border-white transition-colors">
|
||||
{getUserInitials()}
|
||||
</div>
|
||||
<span className="text-white font-medium group-hover:text-brand-turquoise transition-colors max-w-[150px] truncate">
|
||||
{getFullName()}
|
||||
</span>
|
||||
<LayoutDashboard className="w-5 h-5 text-white/70 group-hover:text-brand-turquoise transition-colors" />
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-white hover:text-brand-turquoise transition-colors font-medium"
|
||||
>
|
||||
Connexion
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-6 py-2.5 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-lg font-medium"
|
||||
>
|
||||
Commencer Gratuitement
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.nav>
|
||||
);
|
||||
}
|
||||
2
apps/frontend/src/components/layout/index.ts
Normal file
2
apps/frontend/src/components/layout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { LandingHeader } from './LandingHeader';
|
||||
export { LandingFooter } from './LandingFooter';
|
||||
360
apps/frontend/src/components/organization/LicensesTab.tsx
Normal file
360
apps/frontend/src/components/organization/LicensesTab.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Licenses Tab Component
|
||||
*
|
||||
* Manages user licenses within the organization
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
|
||||
|
||||
export default function LicensesTab() {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const { data: subscription, isLoading } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: getSubscriptionOverview,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const licenses = subscription?.licenses || [];
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'ACTIVE');
|
||||
const revokedLicenses = licenses.filter((l) => l.status === 'REVOKED');
|
||||
|
||||
const usagePercentage = subscription
|
||||
? subscription.maxLicenses === -1
|
||||
? 0
|
||||
: (subscription.usedLicenses / subscription.maxLicenses) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Summary */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Résumé des licences</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-500">Licences utilisées</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{subscription?.usedLicenses || 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-500">Licences disponibles</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{subscription?.availableLicenses === -1
|
||||
? 'Illimité'
|
||||
: subscription?.availableLicenses || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||
<p className="text-sm text-gray-500">Licences totales</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{subscription?.maxLicenses === -1
|
||||
? 'Illimité'
|
||||
: subscription?.maxLicenses || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Bar */}
|
||||
{subscription && subscription.maxLicenses !== -1 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Utilisation</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{Math.round(usagePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${
|
||||
usagePercentage >= 90
|
||||
? 'bg-red-600'
|
||||
: usagePercentage >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Licenses */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Licences actives ({activeLicenses.length})
|
||||
</h3>
|
||||
</div>
|
||||
{activeLicenses.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
Aucune licence active
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Utilisateur
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Rôle
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Assignée le
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Licence
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{activeLicenses.map((license) => {
|
||||
const isAdmin = license.userRole === 'ADMIN';
|
||||
return (
|
||||
<tr key={license.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{license.userName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">{license.userEmail}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isAdmin
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{license.userRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isAdmin ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-amber-100 text-amber-800">
|
||||
Illimité
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revoked Licenses (History) */}
|
||||
{revokedLicenses.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Historique des licences révoquées ({revokedLicenses.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Utilisateur
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Rôle
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Assignée le
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Révoquée le
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Statut
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{revokedLicenses.map((license) => (
|
||||
<tr key={license.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{license.userName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">{license.userEmail}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
license.userRole === 'ADMIN'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{license.userRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(license.assignedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{license.revokedAt
|
||||
? new Date(license.revokedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600">
|
||||
Révoquée
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
Comment fonctionnent les licences ?
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
Chaque utilisateur actif de votre organisation consomme une licence
|
||||
</li>
|
||||
<li>
|
||||
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
|
||||
</li>
|
||||
<li>
|
||||
Les licences sont automatiquement assignées lors de l'ajout d'un
|
||||
utilisateur
|
||||
</li>
|
||||
<li>
|
||||
Les licences sont libérées lorsqu'un utilisateur est désactivé ou
|
||||
supprimé
|
||||
</li>
|
||||
<li>
|
||||
Pour ajouter plus d'utilisateurs, passez à un plan supérieur dans
|
||||
l'onglet Abonnement
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
418
apps/frontend/src/components/organization/SubscriptionTab.tsx
Normal file
418
apps/frontend/src/components/organization/SubscriptionTab.tsx
Normal file
@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Subscription Tab Component
|
||||
*
|
||||
* Manages subscription plan, billing, and upgrade flows
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getSubscriptionOverview,
|
||||
getAllPlans,
|
||||
createCheckoutSession,
|
||||
createPortalSession,
|
||||
syncSubscriptionFromStripe,
|
||||
formatPrice,
|
||||
getPlanBadgeColor,
|
||||
getStatusBadgeColor,
|
||||
type SubscriptionPlan,
|
||||
type BillingInterval,
|
||||
} from '@/lib/api/subscriptions';
|
||||
|
||||
export default function SubscriptionTab() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [billingInterval, setBillingInterval] = useState<BillingInterval>('monthly');
|
||||
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { data: subscription, isLoading: loadingSubscription, refetch: refetchSubscription } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: getSubscriptionOverview,
|
||||
// Refetch more frequently when we're waiting for webhook
|
||||
refetchInterval: isRefreshing ? 2000 : false,
|
||||
});
|
||||
|
||||
const { data: plansData, isLoading: loadingPlans } = useQuery({
|
||||
queryKey: ['plans'],
|
||||
queryFn: getAllPlans,
|
||||
});
|
||||
|
||||
// Handle success/cancel from Stripe redirect
|
||||
const handleStripeRedirect = useCallback(async () => {
|
||||
const isSuccess = searchParams.get('success') === 'true';
|
||||
const isCanceled = searchParams.get('canceled') === 'true';
|
||||
const sessionId = searchParams.get('session_id') || undefined;
|
||||
|
||||
if (isSuccess) {
|
||||
setSuccess('Votre abonnement a été mis à jour avec succès !');
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
// Sync from Stripe using the session ID (works even without webhooks)
|
||||
// The session ID allows us to retrieve the subscription from the checkout session
|
||||
console.log('Syncing subscription with sessionId:', sessionId);
|
||||
await syncSubscriptionFromStripe(sessionId);
|
||||
|
||||
// Then invalidate and refetch to get fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
await refetchSubscription();
|
||||
|
||||
// Wait a bit and refetch again to ensure data is up to date
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
await syncSubscriptionFromStripe(sessionId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
await refetchSubscription();
|
||||
} catch (err) {
|
||||
console.error('Error syncing subscription:', err);
|
||||
// Fallback: just refetch
|
||||
await refetchSubscription();
|
||||
}
|
||||
|
||||
setIsRefreshing(false);
|
||||
|
||||
// Clear the URL params after processing
|
||||
router.replace('/dashboard/settings/organization', { scroll: false });
|
||||
|
||||
setTimeout(() => setSuccess(''), 5000);
|
||||
} else if (isCanceled) {
|
||||
setError('Le paiement a été annulé. Votre abonnement n\'a pas été modifié.');
|
||||
router.replace('/dashboard/settings/organization', { scroll: false });
|
||||
setTimeout(() => setError(''), 5000);
|
||||
}
|
||||
}, [searchParams, queryClient, refetchSubscription, router]);
|
||||
|
||||
useEffect(() => {
|
||||
handleStripeRedirect();
|
||||
}, [handleStripeRedirect]);
|
||||
|
||||
const checkoutMutation = useMutation({
|
||||
mutationFn: (plan: SubscriptionPlan) =>
|
||||
createCheckoutSession({ plan, billingInterval }),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.sessionUrl;
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message || 'Erreur lors de la création de la session de paiement');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
const portalMutation = useMutation({
|
||||
mutationFn: () => createPortalSession(),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.sessionUrl;
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message || 'Erreur lors de l\'ouverture du portail de facturation');
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpgrade = (plan: SubscriptionPlan) => {
|
||||
if (plan === 'FREE') return;
|
||||
setSelectedPlan(plan);
|
||||
checkoutMutation.mutate(plan);
|
||||
};
|
||||
|
||||
const handleManageBilling = () => {
|
||||
portalMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Sync from Stripe first
|
||||
await syncSubscriptionFromStripe();
|
||||
// Then invalidate and refetch
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
await refetchSubscription();
|
||||
} catch (err) {
|
||||
console.error('Error syncing subscription:', err);
|
||||
// Fallback: just refetch
|
||||
await refetchSubscription();
|
||||
}
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
const isCurrentPlan = (plan: SubscriptionPlan): boolean => {
|
||||
return subscription?.plan === plan;
|
||||
};
|
||||
|
||||
const canUpgrade = (plan: SubscriptionPlan): boolean => {
|
||||
if (!subscription) return false;
|
||||
const planOrder: SubscriptionPlan[] = ['FREE', 'STARTER', 'PRO', 'ENTERPRISE'];
|
||||
return planOrder.indexOf(plan) > planOrder.indexOf(subscription.plan);
|
||||
};
|
||||
|
||||
const isLoading = loadingSubscription || loadingPlans;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercentage = subscription
|
||||
? subscription.maxLicenses === -1
|
||||
? 0
|
||||
: (subscription.usedLicenses / subscription.maxLicenses) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg flex items-center justify-between">
|
||||
<span>{success}</span>
|
||||
{isRefreshing && (
|
||||
<span className="text-sm text-green-600 flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Mise à jour...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Plan */}
|
||||
{subscription && (
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Plan actuel</h3>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<svg className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{isRefreshing ? 'Actualisation...' : 'Actualiser'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${getPlanBadgeColor(subscription.plan)}`}
|
||||
>
|
||||
{subscription.planDetails.name}
|
||||
</span>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusBadgeColor(subscription.status)}`}
|
||||
>
|
||||
{subscription.status === 'ACTIVE' ? 'Actif' : subscription.status}
|
||||
</span>
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
|
||||
Annulation prévue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subscription.plan !== 'FREE' && (
|
||||
<button
|
||||
onClick={handleManageBilling}
|
||||
disabled={portalMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{portalMutation.isPending ? 'Chargement...' : 'Gérer la facturation'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* License Usage */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Utilisation des licences</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{subscription.usedLicenses} /{' '}
|
||||
{subscription.maxLicenses === -1 ? 'Illimité' : subscription.maxLicenses}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className={`h-2.5 rounded-full ${
|
||||
usagePercentage >= 90
|
||||
? 'bg-red-600'
|
||||
: usagePercentage >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
style={{
|
||||
width:
|
||||
subscription.maxLicenses === -1
|
||||
? '10%'
|
||||
: `${Math.min(usagePercentage, 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
{subscription.availableLicenses !== -1 && subscription.availableLicenses <= 2 && (
|
||||
<p className="mt-2 text-sm text-amber-600">
|
||||
{subscription.availableLicenses === 0
|
||||
? 'Aucune licence disponible. Passez à un plan supérieur pour ajouter des utilisateurs.'
|
||||
: `Plus que ${subscription.availableLicenses} licence${subscription.availableLicenses === 1 ? '' : 's'} disponible${subscription.availableLicenses === 1 ? '' : 's'}.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Billing Period */}
|
||||
{subscription.currentPeriodEnd && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
Période actuelle : jusqu'au{' '}
|
||||
{new Date(subscription.currentPeriodEnd).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Plans disponibles</h3>
|
||||
<div className="flex items-center gap-2 bg-gray-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Mensuel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('yearly')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition ${
|
||||
billingInterval === 'yearly'
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Annuel
|
||||
<span className="ml-1 text-xs text-green-600">-20%</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{plansData?.plans.map((plan) => (
|
||||
<div
|
||||
key={plan.plan}
|
||||
className={`bg-white rounded-lg border-2 p-5 ${
|
||||
isCurrentPlan(plan.plan)
|
||||
? 'border-blue-500 shadow-md'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:shadow transition'
|
||||
}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
|
||||
<div className="mt-3">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{plan.plan === 'ENTERPRISE'
|
||||
? 'Sur devis'
|
||||
: formatPrice(
|
||||
billingInterval === 'yearly'
|
||||
? plan.yearlyPriceEur
|
||||
: plan.monthlyPriceEur,
|
||||
)}
|
||||
</span>
|
||||
{plan.plan !== 'ENTERPRISE' && plan.plan !== 'FREE' && (
|
||||
<span className="text-gray-500 text-sm">
|
||||
/{billingInterval === 'yearly' ? 'an' : 'mois'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{plan.maxLicenses === -1
|
||||
? 'Utilisateurs illimités'
|
||||
: `Jusqu'à ${plan.maxLicenses} utilisateurs`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{plan.features.slice(0, 4).map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5">
|
||||
{isCurrentPlan(plan.plan) ? (
|
||||
<button
|
||||
disabled
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-500 bg-gray-100 rounded-lg cursor-not-allowed"
|
||||
>
|
||||
Plan actuel
|
||||
</button>
|
||||
) : plan.plan === 'ENTERPRISE' ? (
|
||||
<a
|
||||
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
|
||||
className="block w-full px-4 py-2 text-sm font-medium text-center text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition"
|
||||
>
|
||||
Nous contacter
|
||||
</a>
|
||||
) : canUpgrade(plan.plan) ? (
|
||||
<button
|
||||
onClick={() => handleUpgrade(plan.plan)}
|
||||
disabled={
|
||||
checkoutMutation.isPending && selectedPlan === plan.plan
|
||||
}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
|
||||
>
|
||||
{checkoutMutation.isPending && selectedPlan === plan.plan
|
||||
? 'Chargement...'
|
||||
: 'Passer à ce plan'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 rounded-lg cursor-not-allowed"
|
||||
>
|
||||
Rétrograder via Facturation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -136,6 +136,26 @@ export {
|
||||
type DashboardAlert,
|
||||
} from './dashboard';
|
||||
|
||||
// Subscriptions (5 endpoints)
|
||||
export {
|
||||
getSubscriptionOverview,
|
||||
getAllPlans,
|
||||
canInviteUser,
|
||||
createCheckoutSession,
|
||||
createPortalSession,
|
||||
formatPrice,
|
||||
getPlanBadgeColor,
|
||||
getStatusBadgeColor,
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionStatus,
|
||||
type BillingInterval,
|
||||
type PlanDetails,
|
||||
type LicenseResponse,
|
||||
type SubscriptionOverviewResponse,
|
||||
type CanInviteResponse,
|
||||
type AllPlansResponse,
|
||||
} from './subscriptions';
|
||||
|
||||
// Re-export as API objects for backward compatibility
|
||||
import * as bookingsModule from './bookings';
|
||||
import * as ratesModule from './rates';
|
||||
@ -146,6 +166,7 @@ import * as notificationsModule from './notifications';
|
||||
import * as auditModule from './audit';
|
||||
import * as webhooksModule from './webhooks';
|
||||
import * as gdprModule from './gdpr';
|
||||
import * as subscriptionsModule from './subscriptions';
|
||||
|
||||
export const bookingsApi = bookingsModule;
|
||||
export const ratesApi = ratesModule;
|
||||
@ -156,3 +177,4 @@ export const notificationsApi = notificationsModule;
|
||||
export const auditApi = auditModule;
|
||||
export const webhooksApi = webhooksModule;
|
||||
export const gdprApi = gdprModule;
|
||||
export const subscriptionsApi = subscriptionsModule;
|
||||
|
||||
226
apps/frontend/src/lib/api/subscriptions.ts
Normal file
226
apps/frontend/src/lib/api/subscriptions.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Subscriptions API Client
|
||||
*
|
||||
* API functions for subscription and license management
|
||||
*/
|
||||
|
||||
import { get, post } from './client';
|
||||
|
||||
/**
|
||||
* Subscription plan types
|
||||
*/
|
||||
export type SubscriptionPlan = 'FREE' | 'STARTER' | 'PRO' | 'ENTERPRISE';
|
||||
|
||||
/**
|
||||
* Subscription status types
|
||||
*/
|
||||
export type SubscriptionStatus =
|
||||
| 'ACTIVE'
|
||||
| 'PAST_DUE'
|
||||
| 'CANCELED'
|
||||
| 'INCOMPLETE'
|
||||
| 'INCOMPLETE_EXPIRED'
|
||||
| 'TRIALING'
|
||||
| 'UNPAID'
|
||||
| 'PAUSED';
|
||||
|
||||
/**
|
||||
* Billing interval types
|
||||
*/
|
||||
export type BillingInterval = 'monthly' | 'yearly';
|
||||
|
||||
/**
|
||||
* Plan details
|
||||
*/
|
||||
export interface PlanDetails {
|
||||
plan: SubscriptionPlan;
|
||||
name: string;
|
||||
maxLicenses: number;
|
||||
monthlyPriceEur: number;
|
||||
yearlyPriceEur: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* License response
|
||||
*/
|
||||
export interface LicenseResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
userRole: string;
|
||||
status: 'ACTIVE' | 'REVOKED';
|
||||
assignedAt: string;
|
||||
revokedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription overview response
|
||||
*/
|
||||
export interface SubscriptionOverviewResponse {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
plan: SubscriptionPlan;
|
||||
planDetails: PlanDetails;
|
||||
status: SubscriptionStatus;
|
||||
usedLicenses: number;
|
||||
maxLicenses: number;
|
||||
availableLicenses: number;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
currentPeriodStart?: string;
|
||||
currentPeriodEnd?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
licenses: LicenseResponse[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Can invite response
|
||||
*/
|
||||
export interface CanInviteResponse {
|
||||
canInvite: boolean;
|
||||
availableLicenses: number;
|
||||
usedLicenses: number;
|
||||
maxLicenses: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All plans response
|
||||
*/
|
||||
export interface AllPlansResponse {
|
||||
plans: PlanDetails[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout session request
|
||||
*/
|
||||
export interface CreateCheckoutSessionRequest {
|
||||
plan: SubscriptionPlan;
|
||||
billingInterval: BillingInterval;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout session response
|
||||
*/
|
||||
export interface CheckoutSessionResponse {
|
||||
sessionId: string;
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal session request
|
||||
*/
|
||||
export interface CreatePortalSessionRequest {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal session response
|
||||
*/
|
||||
export interface PortalSessionResponse {
|
||||
sessionUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription overview for current organization
|
||||
*/
|
||||
export async function getSubscriptionOverview(): Promise<SubscriptionOverviewResponse> {
|
||||
return get<SubscriptionOverviewResponse>('/api/v1/subscriptions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available plans
|
||||
*/
|
||||
export async function getAllPlans(): Promise<AllPlansResponse> {
|
||||
return get<AllPlansResponse>('/api/v1/subscriptions/plans');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if organization can invite more users
|
||||
*/
|
||||
export async function canInviteUser(): Promise<CanInviteResponse> {
|
||||
return get<CanInviteResponse>('/api/v1/subscriptions/can-invite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout session for subscription upgrade
|
||||
*/
|
||||
export async function createCheckoutSession(
|
||||
data: CreateCheckoutSessionRequest,
|
||||
): Promise<CheckoutSessionResponse> {
|
||||
return post<CheckoutSessionResponse>('/api/v1/subscriptions/checkout', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal session
|
||||
*/
|
||||
export async function createPortalSession(
|
||||
data?: CreatePortalSessionRequest,
|
||||
): Promise<PortalSessionResponse> {
|
||||
return post<PortalSessionResponse>('/api/v1/subscriptions/portal', data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync subscription from Stripe
|
||||
* Useful when webhooks are not available (e.g., local development)
|
||||
* @param sessionId - Optional Stripe checkout session ID (pass after checkout completes)
|
||||
*/
|
||||
export async function syncSubscriptionFromStripe(sessionId?: string): Promise<SubscriptionOverviewResponse> {
|
||||
return post<SubscriptionOverviewResponse>('/api/v1/subscriptions/sync', { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display
|
||||
*/
|
||||
export function formatPrice(amount: number, currency = 'EUR'): string {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan badge color class
|
||||
*/
|
||||
export function getPlanBadgeColor(plan: SubscriptionPlan): string {
|
||||
switch (plan) {
|
||||
case 'FREE':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'STARTER':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'PRO':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'ENTERPRISE':
|
||||
return 'bg-amber-100 text-amber-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color class
|
||||
*/
|
||||
export function getStatusBadgeColor(status: SubscriptionStatus): string {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
case 'TRIALING':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'PAST_DUE':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'CANCELED':
|
||||
case 'INCOMPLETE_EXPIRED':
|
||||
case 'UNPAID':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'INCOMPLETE':
|
||||
case 'PAUSED':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
@ -1,388 +0,0 @@
|
||||
/**
|
||||
* Privacy Policy Page
|
||||
* GDPR Compliant
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Privacy Policy | Xpeditis</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Privacy Policy for Xpeditis - GDPR compliant data protection"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-6">Privacy Policy</h1>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Last Updated: October 14, 2025
|
||||
<br />
|
||||
GDPR Compliant
|
||||
</p>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">1. Introduction</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Xpeditis ("we," "our," or "us") is committed to protecting your privacy. This
|
||||
Privacy Policy explains how we collect, use, disclose, and safeguard your
|
||||
information when you use our maritime freight booking platform.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">
|
||||
This policy complies with the General Data Protection Regulation (GDPR) and other
|
||||
applicable data protection laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">2. Data Controller</h2>
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<p className="text-gray-700">
|
||||
<strong>Company Name:</strong> Xpeditis
|
||||
<br />
|
||||
<strong>Email:</strong> privacy@xpeditis.com
|
||||
<br />
|
||||
<strong>Address:</strong> [Company Address]
|
||||
<br />
|
||||
<strong>DPO Email:</strong> dpo@xpeditis.com
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
3. Information We Collect
|
||||
</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">3.1 Personal Information</h3>
|
||||
<p className="text-gray-700 mb-4">We collect the following personal information:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Account Information:</strong> Name, email address, phone number, company
|
||||
name, job title
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication Data:</strong> Password (hashed), OAuth tokens, 2FA
|
||||
credentials
|
||||
</li>
|
||||
<li>
|
||||
<strong>Booking Information:</strong> Shipper/consignee details, cargo
|
||||
descriptions, container specifications
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment Information:</strong> Billing address (payment card data is
|
||||
processed by third-party processors)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Communication Data:</strong> Support tickets, emails, chat messages
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
3.2 Technical Information
|
||||
</h3>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Log Data:</strong> IP address, browser type, device information, operating
|
||||
system
|
||||
</li>
|
||||
<li>
|
||||
<strong>Usage Data:</strong> Pages visited, features used, time spent, click
|
||||
patterns
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies:</strong> Session cookies, preference cookies, analytics cookies
|
||||
</li>
|
||||
<li>
|
||||
<strong>Performance Data:</strong> Error logs, crash reports, API response times
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
4. Legal Basis for Processing (GDPR)
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We process your data based on the following legal grounds:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Contract Performance:</strong> To provide booking and shipment services
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legitimate Interests:</strong> Platform security, fraud prevention,
|
||||
service improvement
|
||||
</li>
|
||||
<li>
|
||||
<strong>Legal Obligation:</strong> Tax compliance, anti-money laundering, data
|
||||
retention laws
|
||||
</li>
|
||||
<li>
|
||||
<strong>Consent:</strong> Marketing communications, optional analytics, cookies
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
5. How We Use Your Information
|
||||
</h2>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>Provide, operate, and maintain the Platform</li>
|
||||
<li>Process bookings and manage shipments</li>
|
||||
<li>Communicate with you about your account and services</li>
|
||||
<li>Send transactional emails (booking confirmations, notifications)</li>
|
||||
<li>Provide customer support</li>
|
||||
<li>Detect and prevent fraud, abuse, and security incidents</li>
|
||||
<li>Analyze usage patterns and improve the Platform</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Send marketing communications (with your consent)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
6. Data Sharing and Disclosure
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">We may share your information with:</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">6.1 Service Providers</h3>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Shipping Carriers:</strong> Maersk, MSC, CMA CGM, etc. (for booking
|
||||
execution)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cloud Infrastructure:</strong> AWS/GCP (data hosting)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email Services:</strong> SendGrid/AWS SES (transactional emails)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Analytics:</strong> Sentry (error tracking), Google Analytics (usage
|
||||
analytics)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment Processors:</strong> Stripe (payment processing)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">6.2 Legal Requirements</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We may disclose your information if required by law, court order, or government
|
||||
request, or to protect our rights, property, or safety.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">6.3 Business Transfers</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
In the event of a merger, acquisition, or sale of assets, your information may be
|
||||
transferred to the acquiring entity.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
7. International Data Transfers
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Your data may be transferred to and processed in countries outside the European
|
||||
Economic Area (EEA). We ensure adequate protection through:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>Standard Contractual Clauses (SCCs)</li>
|
||||
<li>EU-US Data Privacy Framework</li>
|
||||
<li>Adequacy decisions by the European Commission</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">8. Data Retention</h2>
|
||||
<p className="text-gray-700 mb-4">We retain your data for the following periods:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Account Data:</strong> Until account deletion + 30 days
|
||||
</li>
|
||||
<li>
|
||||
<strong>Booking Data:</strong> 7 years (for legal and tax compliance)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Audit Logs:</strong> 2 years
|
||||
</li>
|
||||
<li>
|
||||
<strong>Analytics Data:</strong> 26 months
|
||||
</li>
|
||||
<li>
|
||||
<strong>Marketing Consent:</strong> Until withdrawal + 30 days
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
9. Your Data Protection Rights (GDPR)
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">You have the following rights:</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">9.1 Right to Access</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can request a copy of all personal data we hold about you.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.2 Right to Rectification
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">You can correct inaccurate or incomplete data.</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.3 Right to Erasure ("Right to be Forgotten")
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can request deletion of your data, subject to legal retention requirements.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.4 Right to Data Portability
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can receive your data in a structured, machine-readable format (JSON/CSV).
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">9.5 Right to Object</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can object to processing based on legitimate interests or for marketing
|
||||
purposes.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.6 Right to Restrict Processing
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can request limitation of processing in certain circumstances.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.7 Right to Withdraw Consent
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can withdraw consent for marketing or optional data processing at any time.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
9.8 Right to Lodge a Complaint
|
||||
</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can file a complaint with your local data protection authority.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mt-4">
|
||||
<p className="text-blue-900">
|
||||
<strong>To exercise your rights:</strong> Email privacy@xpeditis.com or use the
|
||||
"Data Export" / "Delete Account" features in your account settings.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">10. Security Measures</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We implement industry-standard security measures:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Encryption:</strong> TLS 1.3 for data in transit, AES-256 for data at rest
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication:</strong> Password hashing (bcrypt), JWT tokens, 2FA
|
||||
support
|
||||
</li>
|
||||
<li>
|
||||
<strong>Access Control:</strong> Role-based access control (RBAC), principle of
|
||||
least privilege
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monitoring:</strong> Security logging, intrusion detection, regular audits
|
||||
</li>
|
||||
<li>
|
||||
<strong>Compliance:</strong> OWASP Top 10 protection, regular penetration testing
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
11. Cookies and Tracking
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">We use the following types of cookies:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>
|
||||
<strong>Essential Cookies:</strong> Required for authentication and security
|
||||
(cannot be disabled)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Functional Cookies:</strong> Remember your preferences and settings
|
||||
</li>
|
||||
<li>
|
||||
<strong>Analytics Cookies:</strong> Help us understand how you use the Platform
|
||||
(optional)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Marketing Cookies:</strong> Used for targeted advertising (optional,
|
||||
requires consent)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You can manage cookie preferences in your browser settings or through our cookie
|
||||
consent banner.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">12. Children's Privacy</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The Platform is not intended for users under 18 years of age. We do not knowingly
|
||||
collect personal information from children.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
13. Changes to This Policy
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We may update this Privacy Policy from time to time. We will notify you of
|
||||
significant changes via email or platform notification. Continued use after changes
|
||||
constitutes acceptance.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">14. Contact Us</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
For privacy-related questions or to exercise your data protection rights:
|
||||
</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-gray-700">
|
||||
<strong>Email:</strong> privacy@xpeditis.com
|
||||
<br />
|
||||
<strong>DPO Email:</strong> dpo@xpeditis.com
|
||||
<br />
|
||||
<strong>Address:</strong> [Company Address]
|
||||
<br />
|
||||
<strong>Phone:</strong> [Company Phone]
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,233 +0,0 @@
|
||||
/**
|
||||
* Terms & Conditions Page
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Terms & Conditions | Xpeditis</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Terms and Conditions for Xpeditis maritime freight booking platform"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-6">Terms & Conditions</h1>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-8">Last Updated: October 14, 2025</p>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">1. Acceptance of Terms</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
By accessing and using Xpeditis ("the Platform"), you accept and agree to be bound
|
||||
by the terms and provision of this agreement. If you do not agree to abide by the
|
||||
above, please do not use this service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
2. Description of Service
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Xpeditis is a B2B SaaS platform that provides maritime freight booking and
|
||||
management services, including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>Real-time shipping rate search and comparison</li>
|
||||
<li>Online container booking</li>
|
||||
<li>Shipment tracking and management</li>
|
||||
<li>Document management</li>
|
||||
<li>Integration with carrier APIs</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">3. User Accounts</h2>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">3.1 Registration</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
To use the Platform, you must register for an account and provide accurate, current,
|
||||
and complete information. You are responsible for maintaining the confidentiality of
|
||||
your account credentials.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">3.2 Account Security</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You are responsible for all activities that occur under your account. You must
|
||||
immediately notify us of any unauthorized use of your account.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">3.3 Account Termination</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We reserve the right to suspend or terminate your account if you violate these Terms
|
||||
or engage in fraudulent, abusive, or illegal activity.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">4. Booking and Payments</h2>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">4.1 Booking Process</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
All bookings made through the Platform are subject to availability and confirmation
|
||||
by the carrier. Xpeditis acts as an intermediary and does not guarantee booking
|
||||
acceptance.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">4.2 Pricing</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Rates displayed on the Platform are provided by carriers and may change. Final
|
||||
pricing is confirmed upon booking acceptance. All prices are subject to applicable
|
||||
surcharges, taxes, and fees.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">4.3 Payment Terms</h3>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Payment terms are established between you and the carrier. Xpeditis may facilitate
|
||||
payment processing but is not responsible for payment disputes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">5. User Obligations</h2>
|
||||
<p className="text-gray-700 mb-4">You agree to:</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>Provide accurate and complete booking information</li>
|
||||
<li>Comply with all applicable laws and regulations</li>
|
||||
<li>Not use the Platform for illegal or unauthorized purposes</li>
|
||||
<li>Not interfere with or disrupt the Platform's operation</li>
|
||||
<li>Not attempt to gain unauthorized access to any part of the Platform</li>
|
||||
<li>Not transmit viruses, malware, or malicious code</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
6. Intellectual Property
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
All content, features, and functionality of the Platform, including but not limited
|
||||
to text, graphics, logos, icons, images, audio clips, and software, are the
|
||||
exclusive property of Xpeditis and protected by copyright, trademark, and other
|
||||
intellectual property laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
7. Limitation of Liability
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, XPEDITIS SHALL NOT BE LIABLE FOR ANY
|
||||
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT
|
||||
LIMITED TO LOSS OF PROFITS, DATA, USE, OR GOODWILL, ARISING OUT OF OR IN CONNECTION
|
||||
WITH YOUR USE OF THE PLATFORM.
|
||||
</p>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Xpeditis acts as an intermediary between freight forwarders and carriers. We are not
|
||||
responsible for:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-700 mb-4">
|
||||
<li>Carrier performance, delays, or cancellations</li>
|
||||
<li>Cargo damage, loss, or theft</li>
|
||||
<li>Customs issues or regulatory compliance</li>
|
||||
<li>Force majeure events</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">8. Indemnification</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
You agree to indemnify, defend, and hold harmless Xpeditis and its officers,
|
||||
directors, employees, and agents from any claims, losses, damages, liabilities, and
|
||||
expenses arising out of your use of the Platform or violation of these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
9. Data Protection and Privacy
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Your use of the Platform is also governed by our Privacy Policy. By using the
|
||||
Platform, you consent to the collection, use, and disclosure of your information as
|
||||
described in the Privacy Policy.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
10. Third-Party Services
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
The Platform may contain links to third-party websites or services. Xpeditis is not
|
||||
responsible for the content, privacy policies, or practices of third-party sites.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
11. Service Availability
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We strive to provide continuous service availability but do not guarantee that the
|
||||
Platform will be uninterrupted, secure, or error-free. We reserve the right to
|
||||
suspend or discontinue any part of the Platform at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
|
||||
12. Modifications to Terms
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
We reserve the right to modify these Terms at any time. Changes will be effective
|
||||
immediately upon posting. Your continued use of the Platform after changes
|
||||
constitutes acceptance of the modified Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">13. Governing Law</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
These Terms shall be governed by and construed in accordance with the laws of
|
||||
[Jurisdiction], without regard to its conflict of law provisions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">14. Dispute Resolution</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
Any disputes arising out of or relating to these Terms or the Platform shall be
|
||||
resolved through binding arbitration in accordance with the rules of [Arbitration
|
||||
Body].
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-4">15. Contact Information</h2>
|
||||
<p className="text-gray-700 mb-4">
|
||||
If you have any questions about these Terms, please contact us at:
|
||||
</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-gray-700">
|
||||
<strong>Email:</strong> legal@xpeditis.com
|
||||
<br />
|
||||
<strong>Address:</strong> [Company Address]
|
||||
<br />
|
||||
<strong>Phone:</strong> [Company Phone]
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
docs/STRIPE_SETUP.md
Normal file
219
docs/STRIPE_SETUP.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Configuration Stripe pour Xpeditis
|
||||
|
||||
Ce guide explique comment configurer Stripe pour le système de licences et d'abonnements.
|
||||
|
||||
## 1. Prérequis
|
||||
|
||||
- Compte Stripe (https://dashboard.stripe.com)
|
||||
- Accès aux clés API Stripe
|
||||
|
||||
## 2. Configuration du Dashboard Stripe
|
||||
|
||||
### 2.1 Créer les Produits
|
||||
|
||||
Dans le Dashboard Stripe, allez dans **Products** et créez les produits suivants :
|
||||
|
||||
| Produit | Description |
|
||||
|---------|-------------|
|
||||
| **Xpeditis Starter** | Plan Starter - Jusqu'à 5 utilisateurs |
|
||||
| **Xpeditis Pro** | Plan Pro - Jusqu'à 20 utilisateurs |
|
||||
| **Xpeditis Enterprise** | Plan Enterprise - Utilisateurs illimités |
|
||||
|
||||
### 2.2 Créer les Prix
|
||||
|
||||
Pour chaque produit, créez 2 prix (mensuel et annuel) :
|
||||
|
||||
#### Starter
|
||||
| Type | Prix | Récurrence |
|
||||
|------|------|------------|
|
||||
| Mensuel | 49 EUR | /mois |
|
||||
| Annuel | 470 EUR | /an (~20% de réduction) |
|
||||
|
||||
#### Pro
|
||||
| Type | Prix | Récurrence |
|
||||
|------|------|------------|
|
||||
| Mensuel | 149 EUR | /mois |
|
||||
| Annuel | 1430 EUR | /an (~20% de réduction) |
|
||||
|
||||
#### Enterprise
|
||||
| Type | Prix | Récurrence |
|
||||
|------|------|------------|
|
||||
| Mensuel | Prix personnalisé | /mois |
|
||||
| Annuel | Prix personnalisé | /an |
|
||||
|
||||
### 2.3 Récupérer les Price IDs
|
||||
|
||||
Après avoir créé les prix, notez les **Price IDs** (format: `price_xxxxx`) pour chaque prix.
|
||||
|
||||
## 3. Configuration du Webhook
|
||||
|
||||
### 3.1 Créer le Webhook Endpoint
|
||||
|
||||
1. Allez dans **Developers > Webhooks**
|
||||
2. Cliquez sur **Add endpoint**
|
||||
3. Configurez :
|
||||
- **Endpoint URL**: `https://votre-domaine.com/api/v1/subscriptions/webhook`
|
||||
- **Events to send**: Sélectionnez les événements suivants :
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `invoice.payment_failed`
|
||||
|
||||
4. Cliquez sur **Add endpoint**
|
||||
5. Notez le **Webhook signing secret** (format: `whsec_xxxxx`)
|
||||
|
||||
### 3.2 Test Local avec Stripe CLI
|
||||
|
||||
Pour tester les webhooks en local :
|
||||
|
||||
```bash
|
||||
# Installer Stripe CLI
|
||||
brew install stripe/stripe-cli/stripe
|
||||
|
||||
# Se connecter à Stripe
|
||||
stripe login
|
||||
|
||||
# Écouter les webhooks et les transférer en local
|
||||
stripe listen --forward-to localhost:4000/api/v1/subscriptions/webhook
|
||||
|
||||
# Le CLI affichera le webhook secret à utiliser localement
|
||||
# > Ready! Your webhook signing secret is whsec_xxxxx
|
||||
```
|
||||
|
||||
## 4. Variables d'environnement
|
||||
|
||||
Ajoutez ces variables dans votre fichier `.env` du backend :
|
||||
|
||||
```bash
|
||||
# Clés API Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxx # Clé secrète (test ou live)
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret du webhook
|
||||
|
||||
# Price IDs (créés dans le Dashboard)
|
||||
STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxxxx
|
||||
STRIPE_STARTER_YEARLY_PRICE_ID=price_xxxxx
|
||||
STRIPE_PRO_MONTHLY_PRICE_ID=price_xxxxx
|
||||
STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx
|
||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx
|
||||
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx
|
||||
|
||||
# URL Frontend (pour les redirections)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## 5. Configurer le Customer Portal
|
||||
|
||||
Le Customer Portal permet aux clients de gérer leur abonnement (changer de plan, annuler, mettre à jour le paiement).
|
||||
|
||||
1. Allez dans **Settings > Billing > Customer portal**
|
||||
2. Activez les options souhaitées :
|
||||
- [x] Allow customers to update their payment methods
|
||||
- [x] Allow customers to update subscriptions
|
||||
- [x] Allow customers to cancel subscriptions
|
||||
- [x] Show invoice history
|
||||
|
||||
3. Configurez les produits autorisés dans le portal
|
||||
|
||||
## 6. Mode Test vs Production
|
||||
|
||||
### Mode Test (Développement)
|
||||
- Utilisez `sk_test_xxxxx` comme clé secrète
|
||||
- Les paiements ne sont pas réels
|
||||
- Utilisez les cartes de test Stripe :
|
||||
- Succès: `4242 4242 4242 4242`
|
||||
- Échec: `4000 0000 0000 0002`
|
||||
- 3D Secure: `4000 0025 0000 3155`
|
||||
|
||||
### Mode Production
|
||||
- Utilisez `sk_live_xxxxx` comme clé secrète
|
||||
- Activez le mode live dans le Dashboard
|
||||
- Assurez-vous d'avoir complété la vérification de compte
|
||||
|
||||
## 7. Flux d'abonnement
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Page Subscription│
|
||||
│ (Frontend) │
|
||||
└────────┬────────┘
|
||||
│ Clic "Upgrade"
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ POST /checkout │
|
||||
│ (Backend) │
|
||||
└────────┬────────┘
|
||||
│ Crée Checkout Session
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Stripe Checkout │
|
||||
│ (Stripe) │
|
||||
└────────┬────────┘
|
||||
│ Paiement réussi
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Webhook │
|
||||
│ checkout. │
|
||||
│ session.completed│
|
||||
└────────┬────────┘
|
||||
│ Met à jour la subscription
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Base de données │
|
||||
│ (PostgreSQL) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 8. Gestion des erreurs
|
||||
|
||||
### Paiement échoué
|
||||
- Le webhook `invoice.payment_failed` est déclenché
|
||||
- L'abonnement passe en statut `PAST_DUE`
|
||||
- L'utilisateur est informé et peut mettre à jour son moyen de paiement
|
||||
|
||||
### Annulation
|
||||
- Via le Customer Portal ou l'API
|
||||
- L'abonnement reste actif jusqu'à la fin de la période
|
||||
- À la fin de la période, le webhook `customer.subscription.deleted` est déclenché
|
||||
- L'organisation repasse au plan FREE
|
||||
|
||||
## 9. Vérification
|
||||
|
||||
### Checklist de configuration
|
||||
|
||||
- [ ] Produits créés dans Stripe Dashboard
|
||||
- [ ] Prix créés (mensuel + annuel pour chaque plan)
|
||||
- [ ] Webhook endpoint configuré
|
||||
- [ ] Customer Portal configuré
|
||||
- [ ] Variables d'environnement ajoutées au `.env`
|
||||
- [ ] Test avec Stripe CLI en local
|
||||
- [ ] Test d'un paiement complet (checkout → webhook)
|
||||
|
||||
### Test manuel
|
||||
|
||||
1. Lancez le backend et le frontend
|
||||
2. Connectez-vous en tant qu'ADMIN
|
||||
3. Allez dans Settings > Subscription
|
||||
4. Cliquez sur "Upgrade" sur un plan payant
|
||||
5. Utilisez la carte de test `4242 4242 4242 4242`
|
||||
6. Vérifiez que le plan est mis à jour dans la base de données
|
||||
7. Vérifiez que les licences sont correctement comptées
|
||||
|
||||
## 10. Commandes utiles
|
||||
|
||||
```bash
|
||||
# Voir les webhooks reçus
|
||||
stripe events list --limit 10
|
||||
|
||||
# Déclencher un webhook manuellement
|
||||
stripe trigger checkout.session.completed
|
||||
|
||||
# Voir les logs
|
||||
stripe logs tail
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Pour toute question sur Stripe :
|
||||
- Documentation Stripe : https://stripe.com/docs
|
||||
- Support Stripe : https://support.stripe.com
|
||||
Loading…
Reference in New Issue
Block a user