---------------------------------------------------- server/server.js // server/server.js const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const dotenv = require('dotenv'); const path = require('path'); const fs = require('fs'); dotenv.config(); const app = express(); const PORT = process.env.PORT || 3000; // ================================================================ // MIDDLEWARES // ================================================================ app.set('trust proxy', true); app.use(helmet({ contentSecurityPolicy: false })); app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Logger app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); next(); }); // ================================================================ // GESTION DU MULTILINGUE ET FICHIERS STATIQUES (CORRIGÉ) // ================================================================ const rootDir = path.join(__dirname, '..'); // 1. Servir les pages d'accueil statiques pour le SEO (fr/, de/, it/, en/) // Ces routes sont spécifiquement pour les bots qui visitent les URLs de base. app.use('/fr', express.static(path.join(rootDir, 'public/fr'))); // [cite: 9] (adapté) app.use('/de', express.static(path.join(rootDir, 'public/de'))); // [cite: 9] (adapté) app.use('/it', express.static(path.join(rootDir, 'public/it'))); // [cite: 9] (adapté) app.use('/en', express.static(path.join(rootDir, 'public/en'))); // [cite: 9] (adapté) // 2. Servir les fichiers statiques communs (CSS, JS, images, etc.) app.use(express.static(path.join(rootDir, 'public'))); // [cite: 10] // ================================================================ // CHARGER LES CONFIGS // ================================================================ let appConfigs = {}; try { appConfigs.countries = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/countries.json'), 'utf8')); appConfigs.currencies = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/currencies.json'), 'utf8')); // [cite: 13] appConfigs.taresRules = JSON.parse(fs.readFileSync(path.join(__dirname, '../config/tares_codes.json'), 'utf8')); // ... reste du chargement de config ... [cite: 14, 15] app.appConfigs = appConfigs; console.log('✓ Fichiers de configuration chargés'); // [cite: 16] } catch (error) { console.error('⚠ Erreur chargement config:', error.message); // [cite: 17] } // ================================================================ // ROUTES DE L'API // ================================================================ const apiRoutes = require('./routes/api'); app.use('/api', apiRoutes); // ================================================================ // ROUTE FALLBACK POUR L'APPLICATION ANGULAR (Single Page App) // Doit être la dernière route. Elle intercepte TOUT ce qui n'est pas // un fichier statique ou une route API. // ================================================================ app.get('*', (req, res) => { const indexPath = path.join(rootDir, 'public/index.html'); fs.readFile(indexPath, 'utf8', (err, html) => { if (err) { return res.status(500).send('Erreur serveur.'); } let finalHtml = html.replace(/\{\{STRIPE_PUBLIC_KEY\}\}/g, process.env.STRIPE_PUBLIC_KEY || ''); finalHtml = finalHtml.replace(/\{\{STRIPE_BUY_BUTTON_ID\}\}/g, process.env.STRIPE_BUY_BUTTON_ID || ''); res.send(finalHtml); }); }); // ================================================================ // GESTION D'ERREURS (inchangée) // ================================================================ app.use((err, req, res, next) => { // ... votre gestionnaire d'erreurs }); app.use((req, res) => { res.status(404).json({ error: 'Route non trouvée: ' + req.path }); }); // ================================================================ // DÉMARRAGE (inchangé) // ================================================================ app.listen(PORT, '127.0.0.1', () => { console.log(`╔════════════════════════════════════════════════════════════╗`); console.log(`║ Backend e-dec démarré sur http://127.0.0.1:${PORT} ║`); console.log(`╚════════════════════════════════════════════════════════════╝`); }); ---------------------------------------------------- server/routes/api.js const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); const { pool } = require('../db'); const { v4: uuidv4 } = require('uuid'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); // Services const ocrService = require('../services/ocrService'); const taresClassifier = require('../services/taresClassifier'); const edecGenerator = require('../services/edecGenerator'); const exchangeRateService = require('../services/exchangeRateService'); const declarationService = require('../services/declarationService'); const edecSubmissionService = require('../services/edecSubmissionService'); const DOWNLOAD_DIR = path.join(__dirname, '../../edecpdf'); const EXPIRATION_DAYS = 30; // Configuration Multer const upload = multer({ dest: path.join(__dirname, '../../public/uploads'), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowed = ['image/jpeg', 'image/png', 'application/pdf']; if (allowed.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Formats supportés: JPG, PNG, PDF')); } } }); // ========================================================================= // ROUTES PAIEMENT STRIPE CHECKOUT // ========================================================================= /** * ÉTAPE 1 : Préparation de la déclaration (AVANT paiement) * - Valide les données * - Génère le XML * - Crée l'enregistrement DB avec status 'pending_payment' * - Retourne le sessionToken pour tracking */ router.post('/prepare-declaration', async (req, res) => { const { type, vehicle, parties, declaration, declarantType } = req.body; if (!type || !vehicle || !parties || !declaration) { return res.status(400).json({ success: false, message: 'Données manquantes' }); } const txId = uuidv4(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); console.log(`[PREPARE-DECLARATION] Début préparation ${type} pour UUID: ${txId}`); // Helper pour formater les noms const getFullName = (name, firstname, ide) => { const isCompany = !!ide && ide.trim() !== 'CHE222251936'; if (isCompany) return name || ''; return [firstname, name].filter(p => p && p.trim()).join(' '); }; let data = {}; if (type === 'import') { const imp = parties.importer || {}; const cons = parties.consignee || {}; let declarantData = {}; if ((declarantType || 'importer') === 'importer') declarantData = imp; else if (declarantType === 'consignee') declarantData = cons; else declarantData = parties.declarant || {}; data = { ...vehicle, ...declaration, user_name: getFullName(declarantData.name, declarantData.firstname, declarantData.ide), user_firstname: declarantData.firstname || null, user_address: declarantData.address || null, user_zip: declarantData.zip || '', user_city: declarantData.city || '', user_ide: declarantData.ide || null, user_country: 'CH', importer_name: getFullName(imp.name, imp.firstname, imp.ide), importer_address: imp.address || null, importer_zip: imp.zip || '', importer_city: imp.city || '', importer_ide: imp.ide || null, consignee_name: getFullName(cons.name, cons.firstname, cons.ide), consignee_address: cons.address || null, consignee_zip: cons.zip || '', consignee_city: cons.city || '', consignee_ide: cons.ide || null, language: declaration.language || 'fr' }; // Classification et enrichissement const classification = await taresClassifier.classify(data, req.app.appConfigs.taresRules); data.commodity_code = data.commodity_code || classification.commodity_code; data.statistical_key = data.statistical_key || classification.statistical_key || '911'; data.origin_country = await taresClassifier.getCountryFromVIN_DB(data.vin) || data.dispatch_country || 'FR'; data.brand_code = data.brand_code || await declarationService.getBrandCodeByBrandName(data.brand); const sum = Number(data.purchase_price || 0) + Number(data.transport_cost || 0) + Number(data.other_costs || 0); data.statistical_value_chf = await exchangeRateService.convert(Number(data.purchase_price || 0), data.purchase_currency, 'CHF'); data.vat_value_chf = await exchangeRateService.convert(sum, data.purchase_currency, 'CHF'); // Créer la déclaration en DB (sans payment_id pour l'instant) const declarationId = await declarationService.createFullImportDeclaration(conn, txId, data, null); // Générer le XML const xml = await edecGenerator.generateImport(data); const xmlFilePath = await declarationService.saveXmlFile(conn, txId, xml, declarationId); // Mettre le status à pending_payment await conn.execute( `UPDATE declarations SET status = 'pending_payment' WHERE uuid = ?`, [txId] ); await conn.commit(); console.log(`[PREPARE-DECLARATION] ✓ Import préparé: ${txId}`); return res.json({ success: true, sessionToken: txId, message: 'Déclaration prête pour le paiement' }); } else if (type === 'export') { const consignor = parties.consignor || {}; const consignee = parties.consignee || {}; let declarantData = {}; if ((declarantType || 'consignor') === 'consignor') declarantData = consignor; else if (declarantType === 'consignee') declarantData = consignee; else declarantData = parties.declarant || {}; data = { ...vehicle, ...declaration, user_name: getFullName(declarantData.name, declarantData.firstname, declarantData.ide), user_firstname: declarantData.firstname || null, user_address: declarantData.address || null, user_zip: declarantData.zip || '', user_city: declarantData.city || '', user_ide: declarantData.ide || null, user_country: 'CH', consignor_name: getFullName(consignor.name, consignor.firstname, consignor.ide), consignor_address: consignor.address || null, consignor_zip: consignor.zip || '', consignor_city: consignor.city || '', consignor_ide: consignor.ide || null, consignee_name: getFullName(consignee.name, consignee.firstname, consignee.ide), consignee_address: consignee.address || null, consignee_zip: consignee.zip || '', consignee_city: consignee.city || '', consignee_ide: consignee.ide || null, language: declaration.language || 'fr' }; const classification = await taresClassifier.classify(data, req.app.appConfigs.taresRules); data.commodity_code = data.commodity_code || classification.commodity_code; data.statistical_key = data.statistical_key || classification.statistical_key || '911'; data.brand_code = data.brand_code || await declarationService.getBrandCodeByBrandName(data.brand); const declarationId = await declarationService.createFullExportDeclaration(conn, txId, data, null); const xml = await edecGenerator.generateExport(data); const xmlFilePath = await declarationService.saveXmlFile(conn, txId, xml, declarationId); await conn.execute( `UPDATE declarations SET status = 'pending_payment' WHERE uuid = ?`, [txId] ); await conn.commit(); console.log(`[PREPARE-DECLARATION] ✓ Export préparé: ${txId}`); return res.json({ success: true, sessionToken: txId, message: 'Déclaration prête pour le paiement' }); } return res.status(400).json({ success: false, message: 'Type de déclaration inconnu' }); } catch (e) { await conn.rollback(); console.error('[PREPARE-DECLARATION] Erreur:', e); return res.status(500).json({ success: false, message: e.message }); } finally { conn.release(); } }); /** * ÉTAPE 2 : Vérification du paiement Stripe Checkout (APRÈS retour de Stripe) * - Récupère la Checkout Session depuis Stripe * - Vérifie que le paiement est bien 'paid' * - Met à jour la DB avec le payment_id * - Lance la soumission e-dec */ router.post('/verify-checkout-session', async (req, res) => { const { sessionToken, stripeSessionId } = req.body; if (!sessionToken || !stripeSessionId) { return res.status(400).json({ success: false, message: 'Paramètres manquants' }); } const conn = await pool.getConnection(); try { console.log(`[VERIFY-CHECKOUT] Vérification pour ${sessionToken}, Stripe Session: ${stripeSessionId}`); // Vérifier la session Stripe const session = await stripe.checkout.sessions.retrieve(stripeSessionId); if (session.payment_status !== 'paid') { return res.json({ success: false, message: 'Paiement non finalisé', paymentStatus: session.payment_status }); } console.log(`[VERIFY-CHECKOUT] ✓ Paiement confirmé pour ${sessionToken}`); // Vérifier que le sessionToken existe et est en pending_payment const [rows] = await conn.execute( `SELECT id, xml_file_path FROM declarations WHERE uuid = ? AND status = 'pending_payment'`, [sessionToken] ); if (rows.length === 0) { return res.status(404).json({ success: false, message: 'Déclaration non trouvée ou déjà traitée' }); } const declaration = rows[0]; const xmlFilePath = declaration.xml_file_path; // Mettre à jour avec le payment_id await conn.execute( `UPDATE declarations SET payment_id = ?, status = 'payment_completed' WHERE uuid = ?`, [stripeSessionId, sessionToken] ); console.log(`[VERIFY-CHECKOUT] ✓ DB mise à jour, lancement soumission pour ${sessionToken}`); // Lancer la soumission e-dec en arrière-plan setTimeout(() => { edecSubmissionService.submitDeclaration(xmlFilePath, sessionToken).catch(err => { console.error(`[SUBMISSION-BACKGROUND] Erreur fatale pour ${sessionToken}:`, err); }); }, 100); return res.json({ success: true, sessionToken: sessionToken, message: 'Paiement validé, soumission démarrée' }); } catch (error) { console.error('[VERIFY-CHECKOUT] Erreur:', error); return res.status(500).json({ success: false, message: 'Erreur lors de la vérification du paiement' }); } finally { conn.release(); } }); // ========================================================================= // ROUTES DE SERVICE (INCHANGÉES) // ========================================================================= router.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), service: 'e-dec Node.js Backend', version: '1.4.0' }); }); router.get('/countries', (req, res) => res.json(req.app.appConfigs.countries || {})); router.get('/currencies', (req, res) => res.json(req.app.appConfigs.currencies || {})); router.get('/exchange-rates', async (req, res) => { try { const rates = await exchangeRateService.getCurrentRates(); res.json(rates); } catch (e) { res.status(500).json({ error: e.message }); } }); router.post('/convert-currency', [ body('amount').isFloat({ min: 0 }), body('from').isString().isLength({ min: 3, max: 3 }), body('to').optional().isString().isLength({ min: 3, max: 3 }) ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Paramètres invalides', details: errors.array() }); } try { const { amount, from, to = 'CHF' } = req.body; const converted = await exchangeRateService.convert(Number(amount), from, to); res.json({ converted }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ========================================================================= // ROUTES MÉTIER (INCHANGÉES) // ========================================================================= router.post('/ocr', upload.single('registration_card'), async (req, res) => { const filePath = req.file?.path; if (!filePath) { return res.status(400).json({ error: 'Aucun fichier fourni' }); } try { // Récupération du contexte envoyé depuis le frontend (import/export) const context = req.body.context || 'import'; // Passage du contexte au service OCR const extractedData = await ocrService.extractFromFile(filePath, req.file.mimetype, context); res.json(extractedData); } catch (error) { console.error('Erreur OCR complète:', error); res.status(500).json({ error: 'Erreur OCR', details: error.message, hint: 'Vérifiez que votre clé API OpenRouter est valide et que le fichier est lisible.' }); } finally { if (filePath && fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (_) {} } } }); router.post('/validate-vin', [ body('vin').isString().isLength({ min: 17, max: 17 }) ], async (req, res) => { const { vin } = req.body; const valid = /^[A-HJ-NPR-Z0-9]{17}$/.test((vin || '').toUpperCase()); let country = null; try { if (valid) { country = await taresClassifier.getCountryFromVIN_DB(vin); } res.json({ valid, country }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.post('/classify-vehicle', async (req, res) => { try { const classification = await taresClassifier.classify(req.body, req.app.appConfigs.taresRules); res.json(classification); } catch (error) { console.error('Erreur classification:', error); res.status(500).json({ error: error.message }); } }); router.post('/brand-codes/lookup', [ body('brand').trim().notEmpty().withMessage("Le nom de la marque est requis.") ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Champs invalides', details: errors.array() }); } try { const { brand } = req.body; const brandCode = await declarationService.getBrandCodeByBrandName(brand); if (brandCode) { return res.json({ brandCode }); } const options = await declarationService.getAllBrandCodes(); if (options.length === 0) { return res.status(404).json({ message: "Liste des codes de marque non disponible." }); } return res.json({ options }); } catch (error) { console.error('Erreur lors de la recherche du code de marque:', error); return res.status(500).json({ error: "Erreur interne du serveur lors de la recherche." }); } }); // ========================================================================= // SUIVI ET TÉLÉCHARGEMENT (INCHANGÉS) // ========================================================================= router.get('/submission-status-sse/:sessionToken', async (req, res) => { const { sessionToken } = req.params; console.log(`[SSE] Nouvelle connexion pour ${sessionToken}`); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); const sendStatusUpdate = (data) => { try { if (res.finished) return; res.write(`data: ${JSON.stringify(data)}\n\n`); if (data.status === 'submitted' || data.status === 'submission_error') { setTimeout(() => { if (!res.finished) res.end(); }, 100); } } catch (error) { console.error(`[SSE] Erreur envoi données pour ${sessionToken}:`, error); } }; edecSubmissionService.registerListener(sessionToken, sendStatusUpdate); try { const [rows] = await pool.execute(` SELECT status, declaration_number, liste_importation_path, bulletin_delivrance_path, COALESCE(error_message, '') as error_message, submission_date FROM declarations WHERE uuid = ?`, [sessionToken]); if (rows.length > 0) { const declaration = rows[0]; const { progress, step } = declarationService.getSubmissionProgress(declaration.status); sendStatusUpdate({ status: declaration.status, progress, step, declarationNumber: declaration.declaration_number, listePath: declaration.liste_importation_path, bulletinPath: declaration.bulletin_delivrance_path, submissionDate: declaration.submission_date, error: declaration.error_message }); } } catch (error) { console.error(`[SSE] Erreur vérification état initial ${sessionToken}:`, error); } const cleanup = () => { if (!res.finished) { console.log(`[SSE] Connexion fermée pour ${sessionToken} - Nettoyage`); edecSubmissionService.removeListener(sessionToken); res.end(); } }; req.on('close', cleanup); req.on('error', cleanup); }); router.get('/submission-status/:sessionToken', async (req, res) => { const { sessionToken } = req.params; try { const [rows] = await pool.execute( `SELECT status, declaration_number, liste_importation_path, bulletin_delivrance_path, COALESCE(error_message, '') as error_message, submission_date FROM declarations WHERE uuid = ?`, [sessionToken] ); if (rows.length === 0) { return res.status(404).json({ error: 'Session non trouvée' }); } const declaration = rows[0]; const { progress, step } = declarationService.getSubmissionProgress(declaration.status); res.json({ status: declaration.status, progress, step, declarationNumber: declaration.declaration_number, listePath: declaration.liste_importation_path, bulletinPath: declaration.bulletin_delivrance_path, submissionDate: declaration.submission_date, error: declaration.error_message }); } catch (error) { console.error('[SUBMISSION-STATUS] Erreur:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); router.get('/download-pdf', async (req, res) => { const rawFilePath = req.query.path; if (!rawFilePath) { return res.status(400).json({ error: 'Paramètre de chemin manquant' }); } const safeFilePath = path.resolve(rawFilePath); if (!safeFilePath.startsWith(DOWNLOAD_DIR)) { console.warn(`[DOWNLOAD-SECURITY] Tentative d'accès illégal : ${rawFilePath}`); return res.status(403).json({ error: 'Accès refusé. Chemin non autorisé.' }); } if (!fs.existsSync(safeFilePath)) { return res.status(404).json({ error: 'Fichier non trouvé sur le serveur' }); } const sessionToken = path.basename(safeFilePath).split('_')[0]; try { const [rows] = await pool.execute( `SELECT submission_date FROM declarations WHERE uuid = ? AND status = 'submitted'`, [sessionToken] ); if (rows.length === 0 || !rows[0].submission_date) { return res.status(404).json({ error: 'Déclaration non trouvée ou non finalisée.' }); } const submissionDate = new Date(rows[0].submission_date); const expirationDate = new Date(submissionDate.getTime() + EXPIRATION_DAYS * 24 * 60 * 60 * 1000); if (new Date() > expirationDate) { return res.status(410).json({ error: `Le lien de téléchargement a expiré (limite de ${EXPIRATION_DAYS} jours).` }); } res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${path.basename(safeFilePath)}"`); fs.createReadStream(safeFilePath).pipe(res); } catch (error) { console.error('[DOWNLOAD-PDF] Erreur:', error); res.status(500).json({ error: 'Erreur serveur.' }); } }); router.post('/submission-error/report', async (req, res) => { const { sessionToken, email } = req.body; // Validation simple if (!sessionToken || !email) { return res.status(400).json({ success: false, error: 'Session token et email requis' }); } // Validation format email const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ success: false, error: 'Format d\'email invalide' }); } try { // Mise à jour de l'email dans la table declarations const [result] = await pool.execute( `UPDATE declarations SET user_email = ? WHERE uuid = ? AND status = 'submission_error'`, [email, sessionToken] ); if (result.affectedRows === 0) { return res.status(404).json({ success: false, error: 'Déclaration non trouvée ou statut incorrect' }); } console.log(`[ERROR-REPORT] Email ${email} enregistré pour la session ${sessionToken}`); res.json({ success: true, message: 'Votre email a été enregistré. Notre équipe vous contactera.' }); } catch (error) { console.error('[ERROR-REPORT] Erreur:', error); res.status(500).json({ success: false, error: 'Erreur lors de l\'enregistrement de l\'email.' }); } }); module.exports = router; ---------------------------------------------------- server/services/edecGenerator.js const edecGeneratorImport = require('./edecGeneratorImport'); const edecGeneratorExport = require('./edecGeneratorExport'); class EdecGenerator { generateImport(data) { return edecGeneratorImport.generateImport(data); } generateExport(data) { return edecGeneratorExport.generateExport(data); } } module.exports = new EdecGenerator(); ---------------------------------------------------- server/services/edecSubmissionService.js const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); const { pool } = require('../db'); class EdecSubmissionService { constructor() { this.baseUrl = 'https://e-dec-web.ezv.admin.ch/webdec/main.xhtml'; this.downloadDir = path.join(__dirname, '../../edecpdf'); this.screenshotDir = path.join(__dirname, '../../screenshots'); this.statusListeners = new Map(); [this.downloadDir, this.screenshotDir].forEach(dir => { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } }); } // ============= GESTION SSE & STATUS ============= registerListener(sessionToken, listener) { this.statusListeners.set(sessionToken, listener); console.log(`[SSE] Listener enregistré pour ${sessionToken}`); } removeListener(sessionToken) { this.statusListeners.delete(sessionToken); console.log(`[SSE] Listener supprimé pour ${sessionToken}`); } async updateStatus(sessionToken, status, step = null, progress = null) { const conn = await pool.getConnection(); try { console.log(`[STATUS] ${sessionToken} -> ${status} (${progress}%)`); const errorMessage = status.includes('error') ? String(status) : null; await conn.execute( `UPDATE declarations SET status = ?, error_message = ? WHERE uuid = ?`, [status, errorMessage, sessionToken] ); const listener = this.statusListeners.get(sessionToken); if (listener) { listener({ status, step: step || 'En cours...', progress: progress || 0 }); } } catch (error) { console.error(`[STATUS-ERROR] ${sessionToken}:`, error); throw error; } finally { conn.release(); } } // ============= UTILITAIRES ============= async takeScreenshot(page, name) { const screenshotPath = path.join(this.screenshotDir, `${name}_${Date.now()}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); console.log(`[SCREENSHOT] ${screenshotPath}`); return screenshotPath; } async clickById(page, elementId) { console.log(`[CLICK] Tentative de clic sur #${elementId}`); const clicked = await page.evaluate((id) => { const element = document.getElementById(id); if (element) { element.click(); return true; } return false; }, elementId); if (!clicked) { throw new Error(`Élément #${elementId} introuvable`); } console.log(`[CLICK] ✓ Clic réussi sur #${elementId}`); } async checkCheckbox(page, checkboxId) { console.log(`[CHECKBOX] Tentative de cocher #${checkboxId}`); const result = await page.evaluate((id) => { const checkbox = document.getElementById(id); if (checkbox && checkbox.type === 'checkbox') { checkbox.checked = true; return checkbox.checked; } return false; }, checkboxId); if (!result) { throw new Error(`Impossible de cocher la checkbox #${checkboxId}`); } console.log(`[CHECKBOX] ✓ Checkbox cochée #${checkboxId}`); } async isPopupVisible(page, popupId) { return await page.evaluate((id) => { const popup = document.getElementById(id); return popup && popup.style.visibility === 'visible'; }, popupId); } async waitForUrlChange(page, originalUrl, timeout = 10000) { try { await page.waitForFunction( (expectedUrl) => window.location.href !== expectedUrl, originalUrl, { timeout } ); console.log(`[URL] ✓ Changement détecté: ${page.url()}`); return true; } catch { console.log(`[URL] ✗ Aucun changement après ${timeout}ms`); return false; } } // ============= ÉTAPE 1: UPLOAD XML ============= async uploadXML(page, xmlFilePath, sessionToken) { console.log('[ÉTAPE 1] Début upload XML'); await this.updateStatus(sessionToken, 'submitting', 'Ouverture formulaire...', 15); // Clic sur "Zollanmeldung laden" console.log('[EDEC Submit] Clic sur "Zollanmeldung laden"...'); await page.click('#mainform\\:loadDeclaration'); await page.waitForTimeout(2000); console.log('[EDEC Submit] Attente de la popup de chargement...'); await page.waitForSelector('#mainform\\:declarationLoadPopup', { state: 'visible', timeout: 10000 }); console.log('[EDEC Submit] Recherche de l\'iframe d\'upload...'); const iframeElement = await page.waitForSelector( 'iframe#mainform\\:inputFileComponent\\:uploadFrame', { state: 'attached', timeout: 10000 } ); const frame = await iframeElement.contentFrame(); if (!frame) { throw new Error('Impossible d\'accéder au contenu de l\'iframe'); } // Upload du XML await this.updateStatus(sessionToken, 'submitting', 'Upload du fichier XML...', 30); console.log('[EDEC Submit] Upload du fichier XML...'); const fileInput = await frame.waitForSelector('input[type="file"]', { state: 'attached', timeout: 10000 }); await fileInput.setInputFiles(xmlFilePath); // Sauvegarde de l'URL avant confirmation const urlBeforeConfirm = page.url(); console.log(`[URL-BEFORE-CONFIRM] ${urlBeforeConfirm}`); console.log('[EDEC Submit] Clic sur le bouton OK...'); await page.click('a#mainform\\:confirmButton'); // Vérification que l'upload a réussi (changement d'URL ou élément visible) console.log('[EDEC Submit] Vérification succès upload...'); const uploadSuccess = await Promise.race([ this.waitForUrlChange(page, urlBeforeConfirm, 10000), page.waitForSelector('a#j_id49\\:j_id69', { timeout: 10000 }).then(() => 'button_found'), page.waitForSelector('span.iceOutFrmt', { timeout: 10000 }).then(() => 'confirmation_found') ]); if (!uploadSuccess) { await this.takeScreenshot(page, `upload_failed_${sessionToken}`); const currentUrl = page.url(); console.log(`[UPLOAD-ERROR] Aucun changement détecté. URL actuelle: ${currentUrl}`); throw new Error('Échec de l\'upload du XML - aucune confirmation détectée'); } console.log('[EDEC Submit] ✓ Upload XML réussi'); console.log(`[LANDING PAGE 2] URL actuelle: ${page.url()}`); } // ============= ÉTAPE 2: ENVOI & GESTION ERREURS ============= async submitAndHandleErrors(page, sessionToken) { console.log('[ÉTAPE 2] Début envoi et gestion erreurs'); await this.updateStatus(sessionToken, 'processing', 'Envoi de la déclaration...', 50); let maxAttempts = 3; let attempt = 0; while (attempt < maxAttempts) { attempt++; console.log(`[ENVOI] Tentative ${attempt}/${maxAttempts}`); // Clic sur le bouton Senden const urlBeforeSend = page.url(); await this.clickById(page, 'j_id49:j_id69'); // Attendre 4 secondes pour laisser le temps à la page de réagir await page.waitForTimeout(4000); // IMPORTANT: Vérifier d'abord si l'URL a changé (succès direct) const currentUrl = page.url(); if (currentUrl !== urlBeforeSend) { console.log('[ENVOI] ✓ URL changée immédiatement après Senden - Succès!'); console.log(`[ENVOI] Ancienne URL: ${urlBeforeSend}`); console.log(`[ENVOI] Nouvelle URL: ${currentUrl}`); return true; } // Si pas de changement d'URL, vérifier si popup d'invraisemblance apparaît const inplausibleVisible = await this.isPopupVisible(page, 'mainform:inplausibleDeclarationPopup'); if (inplausibleVisible) { console.log('[ENVOI] Popup d\'invraisemblance détectée, fermeture...'); await this.clickById(page, 'mainform:cancelButton'); await page.waitForTimeout(2000); // Vérifier les erreurs de validation await this.updateStatus(sessionToken, 'processing', 'Correction des erreurs...', 60 + (attempt * 5)); const errorResult = await this.checkAndFixValidationErrors(page, sessionToken); if (!errorResult.fixed) { throw new Error('Erreurs de validation non résolues'); } // Si l'URL a changé pendant la correction = SUCCÈS! if (errorResult.urlChanged) { console.log('[ÉTAPE 2] ✓ URL changée pendant la correction, passage à l\'étape 3'); return true; } // Réessayer l'envoi (continue = retour au début de la boucle while) console.log('[ENVOI] Erreurs corrigées, retour au début de la boucle...'); continue; } // Si on arrive ici sans popup et sans changement d'URL, réessayer console.log('[ENVOI] Pas de popup ni de changement d\'URL, nouvelle tentative...'); } throw new Error(`Échec après ${maxAttempts} tentatives d'envoi`); } // ============= GESTION DES ERREURS DE VALIDATION ============= async checkAndFixValidationErrors(page, sessionToken) { console.log('[ERREURS] Vérification erreurs de validation...'); const errorIconSelector = '#mainform\\:tree-d-1-0-c img[src*="exclamation.gif"][title*="Maske nicht korrekt ausgefüllt"]'; await page.waitForTimeout(3000); const errorIcons = await page.$$(errorIconSelector); if (!errorIcons || errorIcons.length === 0) { console.log('[ERREURS] ✓ Aucune erreur détectée'); return { fixed: true, urlChanged: false }; } console.log(`[ERREURS] ${errorIcons.length} erreur(s) détectée(s)`); await this.takeScreenshot(page, `errors_detected_${sessionToken}`); // FIX ICI: Utiliser une boucle au lieu de .map() const errorIds = []; for (const icon of errorIcons) { const id = await icon.getAttribute('id'); if (id) errorIds.push(id); } // Traiter chaque erreur for (let i = 0; i < errorIds.length; i++) { const errorId = errorIds[i]; console.log(`[ERREURS] Traitement erreur ${i + 1}/${errorIds.length}: ${errorId}`); const result = await this.fixErrorById(page, errorId, sessionToken, i); // Si l'URL a changé pendant la correction, c'est un succès direct! if (result === 'SUCCESS_URL_CHANGED') { console.log('[ERREURS] ✓ Correction réussie avec changement d\'URL!'); return { fixed: true, urlChanged: true }; } if (!result) { console.log(`[ERREURS] ✗ Impossible de corriger ${errorId}`); return { fixed: false, urlChanged: false }; } console.log(`[ERREURS] ✓ Correction de ${errorId} terminée`); await page.waitForTimeout(1000); } console.log('[ERREURS] ✓ Toutes les erreurs traitées'); return { fixed: true, urlChanged: false }; } async fixErrorById(page, errorId, sessionToken, errorIndex) { // Switch case basé sur l'ID de l'erreur switch (errorId) { case 'mainform:tree:n-1-0-1:j_id121': return await this.fixR125bError(page, sessionToken, errorIndex); // Ajouter d'autres cas ici pour d'autres types d'erreurs default: console.log(`[ERREURS] Type d'erreur non géré: ${errorId}`); return false; } } async fixR125bError(page, sessionToken, errorIndex) { try { console.log('[R125b] Début correction erreur R125b'); // 1️⃣ Clic sur le lien de l'erreur await this.clickById(page, 'mainform:tree:n-1-0-1:link'); // 2️⃣ Attendre que le bouton "ouvrir popup" soit prêt console.log('[R125b] Attente du bouton de détail...'); const detailButtonSelector = '#mainform\\:j_id224'; await page.waitForSelector(detailButtonSelector, { state: 'visible', timeout: 15000 }) .catch(() => { throw new Error('Le bouton de détail n’est pas apparu dans le délai imparti.'); }); console.log('[R125b] Bouton de détail détecté, clic...'); await page.locator(detailButtonSelector).click({ timeout: 5000 }); // 3️⃣ Attendre que le popup apparaisse const popupSelector = '#mainform\\:plausiPopup'; console.log('[R125b] Attente de l’apparition du popup de plausibilité...'); await page.waitForSelector(popupSelector, { state: 'visible', timeout: 20000 }); console.log('[R125b] Popup de plausibilité détecté et visible.'); // 4️⃣ Cocher la checkbox de correction await this.checkCheckbox(page, 'mainform:j_id367'); // 5️⃣ Fermer le popup await this.clickById(page, 'mainform:plausiPopupCloseButton'); // 6️⃣ Attendre que le popup soit bien fermé await page.waitForSelector(popupSelector, { state: 'hidden', timeout: 15000 }); console.log('[R125b] Popup de plausibilité fermé.'); // 7️⃣ Screenshot await this.takeScreenshot(page, `r125b_fixed_${sessionToken}_${errorIndex}`); // 8️⃣ Envoi (Senden) const urlBeforeClick = page.url(); console.log('[R125b] Clic sur Senden après correction...'); await this.clickById(page, 'j_id9:j_id69'); //a corriger empêche de continuer vraie valeur j_id49:j_id69 // Attendre changement d’URL ou timeout await page.waitForURL( (url) => url.href !== urlBeforeClick, { timeout: 10000 } ).catch(() => { console.log("[R125b] L'URL n'a pas changé après le clic sur Senden. Le processus continue."); }); const currentUrl = page.url(); if (currentUrl !== urlBeforeClick) { console.log('[R125b] ✓ URL changée après correction! Passage direct à l\'étape 3'); return 'SUCCESS_URL_CHANGED'; } console.log('[R125b] ✓ Erreur R125b corrigée, pas de changement d\'URL.'); return true; } catch (error) { console.error(`[R125b] ✗ Échec de la procédure de correction: ${error.message}`); // Retente une fois si c’est une erreur d’élément introuvable (souvent un timing issue) if (error.message.includes('introuvable') || error.message.includes('Timeout')) { console.log('[R125b] Tentative de relance de la correction après 2s...'); await page.waitForTimeout(2000); return await this.fixR125bError(page, sessionToken, errorIndex); } await this.takeScreenshot(page, `r125b_error_${sessionToken}_${errorIndex}`); return false; } } // ============= ÉTAPE 3: RÉCUPÉRATION DES PDF ============= async downloadPDFs(page, sessionToken, declarationType) { console.log(`[ÉTAPE 3] Récupération des PDF (Type: ${declarationType})`); await this.updateStatus(sessionToken, 'processing', 'Téléchargement des PDF...', 90); // Extraction du numéro de déclaration let declarationNumber = null; try { // Attendre que les spans soient chargés await page.waitForSelector('span.iceOutFrmt', { timeout: 10000 }); // Récupérer TOUS les spans pour trouver celui avec le numéro const allSpans = await page.$$('span.iceOutFrmt'); console.log(`[PDF] ${allSpans.length} span.iceOutFrmt trouvés`); for (const span of allSpans) { const spanText = await span.textContent(); console.log(`[PDF] Analyse span: ${spanText.substring(0, 100)}`); // Chercher le pattern CHWI ou CHWE const match = spanText.match(/(\d{2}CHW[EI]\d+)/); if (match) { declarationNumber = match[1]; console.log(`[PDF] ✓ Numéro de déclaration trouvé: ${declarationNumber}`); break; } } if (!declarationNumber) { console.warn('[PDF] ⚠ Aucun numéro de déclaration trouvé dans les spans'); } } catch (error) { console.warn(`[PDF] Impossible d'extraire le numéro: ${error.message}`); } // Téléchargement des PDF selon le type await page.waitForSelector('a[href*=".pdf"]', { timeout: 15000 }); const pdfLinks = await page.$$('a.downloadLink[href*=".pdf"]'); console.log(`[PDF] ${pdfLinks.length} lien(s) PDF trouvé(s)`); let listePath = null; let bulletinPath = null; if (declarationType === 'EXPORT') { // EXPORT : 1 seul PDF (Liste d'exportation) if (pdfLinks.length < 1) { await this.takeScreenshot(page, `insufficient_pdfs_export_${sessionToken}`); throw new Error(`Aucun PDF trouvé pour l'export`); } listePath = path.join(this.downloadDir, `${sessionToken}_liste_exportation.pdf`); console.log('[PDF] Téléchargement Liste d\'exportation...'); const downloadPromise = page.waitForEvent('download', { timeout: 30000 }); await pdfLinks[0].click(); const download = await downloadPromise; await download.saveAs(listePath); console.log(`[PDF] ✓ Liste d'exportation: ${listePath}`); } else { // IMPORT : 2 PDF (Liste d'importation + Bulletin de délivrance) if (pdfLinks.length < 2) { await this.takeScreenshot(page, `insufficient_pdfs_import_${sessionToken}`); throw new Error(`Seulement ${pdfLinks.length} PDF trouvé(s) pour l'import, 2 attendus`); } listePath = path.join(this.downloadDir, `${sessionToken}_liste_importation.pdf`); bulletinPath = path.join(this.downloadDir, `${sessionToken}_bulletin_delivrance.pdf`); // PDF 1: Liste d'importation console.log('[PDF] Téléchargement Liste d\'importation...'); const downloadPromise1 = page.waitForEvent('download', { timeout: 30000 }); await pdfLinks[0].click(); const download1 = await downloadPromise1; await download1.saveAs(listePath); console.log(`[PDF] ✓ Liste d'importation: ${listePath}`); // PDF 2: Bulletin de délivrance console.log('[PDF] Téléchargement Bulletin de délivrance...'); await page.waitForTimeout(1000); const downloadPromise2 = page.waitForEvent('download', { timeout: 30000 }); await pdfLinks[1].click(); const download2 = await downloadPromise2; await download2.saveAs(bulletinPath); console.log(`[PDF] ✓ Bulletin de délivrance: ${bulletinPath}`); } // Sauvegarde en base de données await this.saveToDatabase(sessionToken, declarationNumber || 'UNKNOWN', listePath, bulletinPath); console.log('[ÉTAPE 3] ✓ PDF récupérés avec succès'); return { success: true, declarationNumber: declarationNumber || 'UNKNOWN', listePath, bulletinPath }; } async saveToDatabase(sessionToken, declarationNumber, listePath, bulletinPath) { const conn = await pool.getConnection(); try { await conn.execute(` UPDATE declarations SET declaration_number = ?, liste_importation_path = ?, bulletin_delivrance_path = ?, status = 'submitted', submission_date = NOW(), error_message = NULL WHERE uuid = ? `, [declarationNumber, listePath, bulletinPath, sessionToken]); const listener = this.statusListeners.get(sessionToken); if (listener) { listener({ status: 'submitted', step: 'Déclaration soumise avec succès', progress: 100, declarationNumber, listePath, bulletinPath }); } } finally { conn.release(); } } // ============= MÉTHODE PRINCIPALE ============= async submitDeclaration(xmlFilePath, sessionToken) { let browser = null; let page = null; try { console.log(`[DÉBUT] Soumission pour ${sessionToken}`); await this.updateStatus(sessionToken, 'submitting', 'Initialisation...', 5); // NOUVEAU : Récupérer le type de déclaration depuis la BD const conn = await pool.getConnection(); let declarationType = 'IMPORT'; // Valeur par défaut try { const [rows] = await conn.execute( 'SELECT declaration_type FROM declarations WHERE uuid = ?', [sessionToken] ); if (rows && rows.length > 0) { declarationType = rows[0].declaration_type; console.log(`[TYPE] Déclaration type: ${declarationType}`); } } finally { conn.release(); } // Lancement du navigateur browser = await chromium.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ] }); const context = await browser.newContext({ acceptDownloads: true, userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', viewport: { width: 1280, height: 720 } }); page = await context.newPage(); page.on('console', msg => console.log('[BROWSER]', msg.text())); page.on('pageerror', error => console.log('[BROWSER-ERROR]', error)); // Navigation initiale console.log('[NAVIGATION] Accès au portail...'); await page.goto(this.baseUrl, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2000); // ÉTAPE 1: Upload XML await this.uploadXML(page, xmlFilePath, sessionToken); // ÉTAPE 2: Envoi avec gestion des erreurs await this.submitAndHandleErrors(page, sessionToken); // ÉTAPE 3: Récupération des PDF (AVEC LE TYPE) const result = await this.downloadPDFs(page, sessionToken, declarationType); await this.takeScreenshot(page, `success_${sessionToken}`); console.log('[FIN] ✓ Soumission terminée avec succès'); return result; } catch (error) { console.error('[ERREUR FATALE]', error); if (page) { await this.takeScreenshot(page, `fatal_error_${sessionToken}`); } await this.updateStatus( sessionToken, 'submission_error', `Échec: ${error.message}`, 100 ); throw error; } finally { if (browser) { await browser.close(); } } } } module.exports = new EdecSubmissionService(); ---------------------------------------------------- server/services/edecGeneratorImport.js function escapeXml(value) { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') .replace(/'/g, '''); } // invoiceCurrencyType mapping: // 1=CHF, 2=EUR, 3=USD, 4=Autre monnaie UE, 5=Autre monnaie function mapInvoiceCurrencyType(currency) { if (currency === 'CHF') return 1; if (currency === 'EUR') return 2; if (currency === 'USD') return 3; // Liste des pays de l'UE qui n'utilisent pas l'Euro (BGN, CZK, DKK, HUF, PLN, RON, SEK) const euNonEuroCurrencies = ['BGN', 'CZK', 'DKK', 'HUF', 'PLN', 'RON', 'SEK']; if (euNonEuroCurrencies.includes(currency)) { return 4; // Autre monnaie UE } return 5; // Autre monnaie } class EdecGeneratorImport { generateImport(data) { // CORRECTION: Remplace 'T' par un espace et 'Z' par ' UTC' pour le format e-dec (YYYY-MM-DD HH:mm:ss.sss UTC) const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC'); const lang = (data.language || 'fr').toLowerCase(); const dispatchCountry = data.dispatch_country; const transportMode = data.transport_mode; // Le nom (raison sociale ou Prénom Nom) est désormais formaté par le controller client const importerName = escapeXml(data.user_name); const importerStreet = escapeXml(data.user_address); const importerZip = escapeXml(data.user_zip); const importerCity = escapeXml(data.user_city); const importerCountry = 'CH'; const ide = escapeXml(data.user_ide || 'CHE222251936'); // Particulier par défaut const consigneeName = escapeXml(data.consignee_name || data.user_name); const consigneeStreet = escapeXml(data.consignee_address || data.user_address); const consigneeZip = escapeXml(data.consignee_zip || data.user_zip); const consigneeCity = escapeXml(data.consignee_city || data.user_city); const consigneeIde = escapeXml(data.consignee_ide || ide); const declarantName = escapeXml(data.declarant_name || data.user_name); const declarantStreet = escapeXml(data.declarant_address || data.user_address); const declarantZip = escapeXml(data.declarant_zip || data.user_zip); const declarantCity = escapeXml(data.declarant_city || data.user_city); const declarantIde = escapeXml(data.declarant_ide); const description = escapeXml(`${data.brand} ${data.model} ${data.year}`.trim()); const commodityCode = escapeXml(data.commodity_code); const statisticalCode = escapeXml(data.statistical_key); // Les masses doivent être des nombres entiers (arrondi par précaution) const grossMass = Math.round(Number(data.weight_empty)); // Mettre le poids a vide meme dans le poids brut Math.round(Number(data.weight_total || 0)); const netMass = Math.round(Number(data.weight_empty)); // Correction: La valeur statistique et la base TVA sont arrondies à l'ENTIER. // statisticalValue (Prix d'achat hors coûts annexes) const statisticalValue = Math.round(Number(data.statistical_value_chf || data.statistical_value || 0)); // vatValue (Prix d'achat + coûts annexes) const vatValue = Math.round(Number(data.vat_value_chf || data.vatValue || statisticalValue)); const vin = escapeXml(data.vin || ''); const brandCode = escapeXml(data.brand_code || ''); const originCountry = escapeXml(data.origin_country || data.originCountry || dispatchCountry || 'FR'); const preference = 0; // pas de tarif préférentiel par défaut // TVA code: 1 normal, 3 déménagement const vatCode = data.is_relocation ? '3' : '1'; // Additional tax (IVA) RG 660: key 001 (assujetti) ou 002 (exonéré) const additionalTaxKey = data.is_iva_exempt ? '002' : '001'; // Assure que la quantité pour la taxe additionnelle est un entier const additionalTaxQty = statisticalValue; // Utilisation de data.purchase_currency pour déduire le type de monnaie const invoiceCurrencyType = mapInvoiceCurrencyType(data.purchase_currency); // Construction des détails de l'article de marchandise (VIN et Brand Code) let goodsItemDetailsXml = ''; if (vin || brandCode) { goodsItemDetailsXml = ` 2 ${vin} ${brandCode ? ` 1 ${brandCode} ` : ''} `; } // Ajout de l'entête XML et utilisation de la variable now corrigée const xml = ` 1 1 ${lang} ${dispatchCountry} ${transportMode} 0 ${importerName} ${importerStreet ? `${importerStreet}` : ''} ${importerZip} ${importerCity} ${importerCountry} ${ide} ${consigneeName} ${consigneeStreet ? `${consigneeStreet}` : ''} ${consigneeZip} ${consigneeCity} ${importerCountry} ${consigneeIde} ${declarantIde ? `${declarantIde}` : ''} ${declarantName} ${declarantStreet ? `${declarantStreet}` : ''} ${declarantZip} ${declarantCity} ${importerCountry} 0 0 0 ${invoiceCurrencyType} 0 ${description} ${commodityCode} ${statisticalCode} ${grossMass} ${netMass} 1 0 2 1 1 ${statisticalValue} 0 ${originCountry} ${preference} ${goodsItemDetailsXml} VN 1 ${description} 0 ${vatValue} ${vatCode} 660 ${additionalTaxKey} ${additionalTaxQty} `; return xml; } } module.exports = new EdecGeneratorImport(); ---------------------------------------------------- server/services/declarationService.js const { pool } = require('../db'); const path = require('path'); const fs = require('fs'); class DeclarationService { async createDeclarationRecord(conn, uuid, type, data, paymentId = null) { const [result] = await conn.execute( `INSERT INTO declarations (uuid, declaration_type, user_name, user_firstname, user_address, user_zip, user_city, user_country, user_ide, language, payment_id, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'CH', ?, ?, ?, 'draft')`, [ uuid, type.toUpperCase(), data.user_name || '', data.user_firstname || null, data.user_address || null, data.user_zip || '', data.user_city || '', data.user_ide || null, (data.language || 'fr').toLowerCase(), paymentId ] ); return result.insertId; } async createImportDetailsRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO import_details (declaration_id, dispatch_country, transport_mode, is_relocation, purchase_price, purchase_currency, transport_cost, other_costs, statistical_value, vat_value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ declarationId, data.dispatch_country, data.transport_mode, data.is_relocation ? 1 : 0, Number(data.purchase_price || 0), data.purchase_currency, Number(data.transport_cost || 0), Number(data.other_costs || 0), data.statistical_value_chf, data.vat_value_chf ] ); } async createExportDetailsRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO export_details (declaration_id, destination_country, taxation_code) VALUES (?, ?, ?)`, [ declarationId, data.destination_country, data.taxation_code || null ] ); } async createVehicleRecord(conn, declarationId, data) { return conn.execute( `INSERT INTO vehicles (declaration_id, vin, brand, model, year, cylinder_capacity, fuel_type, weight_empty, weight_total, tares_code, statistical_key, country_origin, brand_code, matriculation_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ declarationId, (data.vin || '').toUpperCase(), data.brand, data.model, data.year || null, data.cylinder_capacity || null, data.fuel_type || null, data.weight_empty || null, data.weight_total || null, data.commodity_code || (data.declaration_type === 'EXPORT' ? '8703.9060' : null), data.statistical_key || '911', data.origin_country || null, data.brand_code || null, data.matriculation_number || null ] ); } async createFullImportDeclaration(conn, txId, data, paymentId) { const declarationId = await this.createDeclarationRecord(conn, txId, 'IMPORT', data, paymentId); await this.createImportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'IMPORT' }); return declarationId; } async createFullExportDeclaration(conn, txId, data, paymentId) { const declarationId = await this.createDeclarationRecord(conn, txId, 'EXPORT', data, paymentId); await this.createExportDetailsRecord(conn, declarationId, data); await this.createVehicleRecord(conn, declarationId, { ...data, declaration_type: 'EXPORT' }); return declarationId; } async saveXmlFile(conn, txId, xml, declarationId) { const xmlDir = path.join(__dirname, '../../xml'); if (!fs.existsSync(xmlDir)) { fs.mkdirSync(xmlDir, { recursive: true }); } const xmlFilePath = path.join(xmlDir, `${txId}.xml`); fs.writeFileSync(xmlFilePath, xml, 'utf8'); await conn.execute( `UPDATE declarations SET xml_content = ?, xml_file_path = ?, status = 'generated' WHERE id = ?`, [xml, xmlFilePath, declarationId] ); return xmlFilePath; } getSubmissionProgress(status) { let progress = 0; let step = ''; switch (status) { case 'pending_payment': progress = 20; step = 'En attente de paiement'; break; case 'payment_completed': progress = 40; step = 'Paiement validé, préparation de la soumission'; break; case 'generated': progress = 25; step = 'Déclaration générée, en attente de soumission'; break; case 'submitting': progress = 50; step = 'Connexion au portail e-dec'; break; case 'processing': progress = 75; step = 'Traitement par la douane'; break; case 'submitted': progress = 100; step = 'Soumission terminée avec succès'; break; case 'submission_error': progress = 100; step = 'Erreur de soumission'; break; default: progress = 10; step = 'Initialisation'; } return { progress, step }; } async getBrandCodeByBrandName(brandName) { if (!brandName) return null; const cleanedBrandName = brandName.trim().toUpperCase(); const query = `SELECT brand_code FROM brand_codes WHERE brand_name = ?`; const [rows] = await pool.execute(query, [cleanedBrandName]); return rows.length > 0 ? rows[0].brand_code : null; } async getAllBrandCodes() { const query = `SELECT brand_code, brand_name FROM brand_codes ORDER BY brand_name`; const [rows] = await pool.execute(query); return rows; } } module.exports = new DeclarationService(); ---------------------------------------------------- server/services/ocrService.js const axios = require('axios'); const fs = require('fs'); const sharp = require('sharp'); const path = require('path'); class OcrService { constructor() { this.apiKey = process.env.OPENROUTER_API_KEY; this.model = process.env.OPENROUTER_MODEL || 'google/gemini-2.5-flash'; console.log('[OCR Service] Initialisé avec:', { hasApiKey: !!this.apiKey, apiKeyPreview: this.apiKey ? this.apiKey.substring(0, 10) + '...' : 'MANQUANT', model: this.model }); } /** * Construit le prompt pour l'API en fonction du contexte (import ou export). * @param {string} context - Le contexte de l'opération ('import' ou 'export'). * @returns {string} Le prompt formaté. */ buildExtractionPrompt(context) { const actualContext = context || 'import'; const basePrompt = `Tu es un expert en extraction de données de certificats d'immatriculation (cartes grises) européens. Analyse l'image ou le document fourni et retourne UNIQUEMENT un objet JSON strict correspondant au format: {"vin":"VF3LCYHZPFS123456","brand":"Peugeot","model":"308","year":2015,"cylinder_capacity":1560,"fuel_type":"diesel","weight_empty":1245,"weight_total":1870,"type_approval":"e112007/460009*15","paper_origin":"FRANCE","matriculation_number":null} RÈGLES ET TERMES DE RECHERCHE PAR CHAMP: - vin: 17 caractères alphanumériques. Cherche les champs E, 23, FIN, Chassis N., Fahrgestell-Nr, Telaio n., ou VIN. Supprime les espaces internes. IMPORTANT: Les lettres I, O et Q ne sont JAMAIS utilisées dans un VIN. Si tu rencontres un I, considère-le comme un 1. Si tu rencontres un O ou un Q, considère-les comme des 0. - brand: D.1, Marke. - model: D.3, Modèle, Typ. - year: L'année de la première mise en circulation. Cherche B, 1. Inverkehrsetzung, 1ère mise en circulation, Data di prima immatricolazione. Isole UNIQUEMENT l'année (YYYY). - cylinder_capacity: P.1, Hubraum, Cilindrata, Cylindrée. (Int en cm³) - fuel_type: P.3, type de carburant, Carburant, Kraftstoff, Alimentazione. (essence|diesel|electrique|hybride|hybride_plugin) - weight_empty: G, Leergewicht, Poids à vide, massa a vuoto. (Int en kg) - weight_total: F.2, Gesamtgewicht, Poids total autorisé en charge, Massa massima ammissibile a pieno carico. (Int en kg) - type_approval: K, Réception par type, Typengenehmigung, Approvazione del tipo. - paper_origin: Retourne le pays de la carte grise EN FRANCAIS! Après avoir analyser Le titre du document, les inscriptions, La langue utilisée, l'adresse et la ville, la structure des identifiants, la ligne D.1 ou tout autre information contenue dans la carte grise pour déterminer le pays d'emission du document.`; const matriculationInstruction = `- matriculation_number: Numéro de matricule ou plaque d'immatriculation. En Suisse, cherche le point 18 (Stammnumer, N° matricule ). Format typique: XXX.XXX.XXX. Si absent ou illisible, retourne null.`; if (actualContext === 'import') { return `${basePrompt} Pour les documents d'importation, le champ matriculation_number sera null. Contexte: le document provient d'un véhicule importé. Retourne strictement du JSON valide sans commentaire ni texte. Si une valeur est totalement inconnue, utilise: null.`; } else if (actualContext === 'export') { return `${basePrompt} ${matriculationInstruction} Contexte: le document provient d'un véhicule Suisse destiné à l'export. paper_origin est donc "suisse". Retourne strictement du JSON valide sans commentaire ni texte. Si une valeur est totalement inconnue, utilise: null.`; } // Retour par défaut en cas de contexte non reconnu, bien que le code en amont assigne 'import' par défaut. return basePrompt; } // NOTE CORRECTION: Le bloc "if (context === 'import') { ... }" qui se trouvait ici a été supprimé. // Il était en dehors de toute méthode, ce qui causait une SyntaxError. // La logique est déjà correctement gérée dans la méthode `buildExtractionPrompt` ci-dessus. validateVIN(vin) { if (!vin) return null; let clean = vin.replace(/\s/g, '').toUpperCase().trim(); const corrected = clean .replace(/I/g, '1') .replace(/[OQ]/g, '0'); if (clean !== corrected) { console.warn('[OCR] VIN corrigé automatiquement:', { original: vin, corrected }); } return /^[A-HJ-NPR-Z0-9]{17}$/.test(corrected) ? corrected : null; } validateYear(year) { if (!year) return null; const y = parseInt(year, 10); return (y >= 1900 && y <= new Date().getFullYear() + 1) ? y : null; } validateInteger(value) { if (value === null || value === undefined || value === '') return null; const int = parseInt(value, 10); return Number.isFinite(int) && int > 0 ? int : null; } normalizeFuelType(fuelType) { if (!fuelType) return null; const normalized = fuelType.toLowerCase().trim(); if (normalized.includes('plug')) return 'hybride_plugin'; if (normalized.includes('hybr')) return 'hybride'; if (normalized.includes('elec') || normalized.includes('élec')) return 'electrique'; if (normalized.includes('dies')) return 'diesel'; if (normalized.includes('ess') || normalized.includes('petrol') || normalized.includes('gasoline')) return 'essence'; return null; // Retourne null si le type n'est pas reconnu pour éviter les valeurs non standard } parseGeminiJSON(response) { console.log('[OCR] ÉTAPE 7a - Parsing JSON, réponse brute:', response); const clean = response.replace(/```json|```/g, '').trim(); console.log('[OCR] ÉTAPE 7b - Après nettoyage:', clean); try { const data = JSON.parse(clean); console.log('[OCR] ÉTAPE 7c - JSON parsé:', data); return { vin: this.validateVIN(data.vin), brand: data.brand || null, model: data.model || null, year: this.validateYear(data.year), cylinder_capacity: this.validateInteger(data.cylinder_capacity), fuel_type: this.normalizeFuelType(data.fuel_type), weight_empty: this.validateInteger(data.weight_empty), weight_total: this.validateInteger(data.weight_total), type_approval: data.type_approval || null, // NOTE OPTIMISATION: Ajout du champ 'paper_origin' qui était demandé dans le prompt mais manquant ici. paper_origin: data.paper_origin || null, matriculation_number: data.matriculation_number || null }; } catch (err) { console.error('[OCR] ERREUR ÉTAPE 7 - Parsing JSON:', err); throw new Error('Format de réponse OCR invalide: ' + err.message); } } async extractFromFile(filePath, mimeType, context) { console.log('[OCR] ÉTAPE 1 - Début extraction:', { filePath, mimeType, context }); if (!this.apiKey) { throw new Error('OPENROUTER_API_KEY manquante dans .env'); } const safeMime = ['image/jpeg', 'image/png', 'application/pdf'].includes(mimeType) ? mimeType : 'image/jpeg'; console.log('[OCR] ÉTAPE 2 - Type MIME validé:', safeMime); let base64Data; let contentType; try { let buffer; if (safeMime === 'application/pdf') { console.log('[OCR] ÉTAPE 3a - Traitement PDF'); buffer = fs.readFileSync(filePath); contentType = 'application/pdf'; console.log('[OCR] PDF lu, taille:', buffer.length, 'bytes'); } else { console.log('[OCR] ÉTAPE 3b - Traitement Image'); buffer = await sharp(filePath) .rotate() .resize({ width: 2048, withoutEnlargement: true }) .jpeg({ quality: 85 }) .toBuffer(); contentType = 'image/jpeg'; console.log('[OCR] Image optimisée, taille:', buffer.length, 'bytes'); } base64Data = buffer.toString('base64'); console.log('[OCR] Fichier converti en base64, longueur:', base64Data.length); const dataSizeMB = base64Data.length * 0.75 / (1024 * 1024); console.log(`[OCR] ÉTAPE 3d - Taille du contenu à envoyer: ${dataSizeMB.toFixed(2)} MB`); if (dataSizeMB > 15) { console.warn('[OCR] AVERTISSEMENT: La taille du contenu est supérieure à 15MB, risque d\'échec API.'); } } catch (error) { console.error('[OCR] ERREUR ÉTAPE 3 - Lecture/conversion fichier:', error); throw new Error('Impossible de lire ou de traiter le fichier: ' + error.message); } const prompt = this.buildExtractionPrompt(context); console.log('[OCR] ÉTAPE 4 - Prompt construit.'); const dataUrl = `data:${contentType};base64,${base64Data}`; console.log('[OCR] ÉTAPE 5 - Envoi requête à OpenRouter'); try { const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', { model: this.model, messages: [{ role: 'user', content: [ { type: 'text', text: prompt }, { type: 'image_url', image_url: { url: dataUrl } } ] }], temperature: 0.1, max_tokens: 4096, // Augmenté pour être sûr response_format: { type: "json_object" } // Demande explicite de JSON si le modèle le supporte }, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': process.env.REFERER_URL || 'http://localhost:3000', 'X-Title': 'e-dec Vehicles' }, timeout: 45000 // Augmenté pour les fichiers plus lourds }); console.log('[OCR] ÉTAPE 6 - Réponse API reçue.'); const extractedText = response.data.choices?.[0]?.message?.content || ''; if (!extractedText) { throw new Error("La réponse de l'API est vide."); } console.log('[OCR] ÉTAPE 7 - Contenu extrait (aperçu):', extractedText.substring(0, 200)); const parsed = this.parseGeminiJSON(extractedText); console.log('[OCR] ÉTAPE 8 - JSON parsé et validé avec succès:', parsed); return parsed; } catch (error) { const status = error.response?.status || 'N/A'; const code = error.code || 'N/A'; const errorData = error.response?.data; console.error('[OCR] ERREUR CRITIQUE - Requête API:', { message: error.message, status: status, code: code, details: errorData }); if (status === 401) { throw new Error('Clé API OpenRouter invalide ou expirée (401)'); } if (status === 429) { throw new Error('Quota OpenRouter dépassé (429)'); } if (code === 'ECONNABORTED' || error.message.includes('timeout')) { throw new Error('Timeout: OpenRouter n\'a pas répondu dans le temps imparti (45 secondes)'); } const apiErrorMessage = errorData?.error?.message || error.message; throw new Error(`Erreur API OpenRouter inattendue: ${apiErrorMessage}`); } } } module.exports = new OcrService(); ---------------------------------------------------- server/services/edecGeneratorExport.js function escapeXml(value) { if (value === null || value === undefined) return ''; return String(value) .replace(/&/g, '&') // Note: & doit être échappé en premier .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Ligne corrigée } class EdecGeneratorExport { generateExport(data) { const now = new Date().toISOString().replace('T', ' ').replace('Z', ' UTC'); const lang = (data.language || 'fr').toLowerCase(); // Consignor (expéditeur CH) - équivalent importateur const consignorName = escapeXml(data.consignor_name || data.user_name); const consignorStreet = escapeXml(data.consignor_address || data.user_address); const consignorZip = escapeXml(data.consignor_zip || data.user_zip); const consignorCity = escapeXml(data.consignor_city || data.user_city); const consignorCountry = 'CH'; const consignorIde = escapeXml(data.consignor_ide || data.user_ide || 'CHE222251936'); // Consignee (destinataire étranger) const consigneeName = escapeXml(data.consignee_name); const consigneeStreet = escapeXml(data.consignee_address); const consigneeZip = escapeXml(data.consignee_zip); const consigneeCity = escapeXml(data.consignee_city); const consigneeCountry = escapeXml(data.destination_country); // Declarant const declarantName = escapeXml(data.declarant_name || data.user_name); const declarantStreet = escapeXml(data.declarant_address || data.user_address); const declarantZip = escapeXml(data.declarant_zip || data.user_zip); const declarantCity = escapeXml(data.declarant_city || data.user_city); const declarantIde = escapeXml(data.declarant_ide); const declarantCountry = escapeXml(data.declarant_country || consigneeCountry); const description = escapeXml(`${data.brand} ${data.model} ${data.year}`.trim()); const commodityCode = escapeXml(data.commodity_code); const statisticalCode = escapeXml(data.statistical_key); const vin = escapeXml(data.vin); const brandCode = escapeXml(data.brand_code); const matriculationNumber = escapeXml(data.matriculation_number); const grossMass = Math.round(Number(data.weight_total || 0)); const statisticalValue = Math.round(Number(data.statistical_value || data.purchase_price || 0)); // Construction des détails du véhicule let goodsItemDetailsXml = ''; if (brandCode || vin || matriculationNumber) { goodsItemDetailsXml = ` `; if (brandCode) { goodsItemDetailsXml += ` 1 ${brandCode} `; } if (vin) { goodsItemDetailsXml += ` 2 ${vin} `; } if (matriculationNumber) { goodsItemDetailsXml += ` 3 ${matriculationNumber} `; } goodsItemDetailsXml += ` `; } const xml = ` 2 1 ${lang} ${consigneeCountry} 9 0 ${consignorName} ${consignorStreet ? `${consignorStreet}` : ''} ${consignorZip} ${consignorCity} ${consignorCountry} ${consignorIde} ${consigneeName} ${consigneeStreet ? `${consigneeStreet}` : ''} ${consigneeZip} ${consigneeCity} ${consigneeCountry} ${declarantIde ? `${declarantIde}` : ''} ${declarantName} ${declarantStreet ? `${declarantStreet}` : ''} ${declarantZip} ${declarantCity} ${declarantCountry} 0 1 0 ${description} ${commodityCode} ${statisticalCode} ${grossMass > 0 ? `${grossMass}` : ''} ${grossMass > 0 ? `${grossMass}` : ''} 1 2 2 1 1 ${statisticalValue > 0 ? `${statisticalValue}` : ''} 0 ${goodsItemDetailsXml} VN 1 ${description} `; return xml; } } module.exports = new EdecGeneratorExport(); ---------------------------------------------------- server/services/exchangeRateService.js const mysql = require('mysql2/promise'); class ExchangeRateService { constructor() { this.dbConfig = { host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME }; } async getCurrentRates() { const connection = await mysql.createConnection(this.dbConfig); try { const [rows] = await connection.execute(` SELECT currency_code, rate_to_chf, rate_date FROM daily_exchange_rates WHERE rate_date = ( SELECT MAX(rate_date) FROM daily_exchange_rates ) ORDER BY currency_code `); const rates = {}; rows.forEach(r => rates[r.currency_code] = { rate: parseFloat(r.rate_to_chf), date: r.rate_date }); return rates; } finally { await connection.end(); } } async getRate(currency) { if (!currency) return 1.0; if (currency === 'CHF') return 1.0; const connection = await mysql.createConnection(this.dbConfig); try { const [rows] = await connection.execute(` SELECT rate_to_chf FROM daily_exchange_rates WHERE currency_code = ? ORDER BY rate_date DESC LIMIT 1 `, [currency]); return rows.length > 0 ? parseFloat(rows[0].rate_to_chf) : 1.0; } finally { await connection.end(); } } async convert(amount, fromCurrency, toCurrency = 'CHF') { const amt = Number(amount || 0); if (!amt) return 0; if (fromCurrency === toCurrency) return Math.round(amt * 100) / 100; const rateFrom = await this.getRate(fromCurrency); const rateTo = await this.getRate(toCurrency); const amountInCHF = amt * rateFrom; const converted = amountInCHF / rateTo; return Math.round(converted * 100) / 100; } } module.exports = new ExchangeRateService(); ---------------------------------------------------- server/services/exchangeRateCronService.js const axios = require('axios'); const { pool } = require('../db'); const cron = require('node-cron'); class ExchangeRateCronService { constructor() { this.pdfUrl = 'https://www.backend-rates.bazg.admin.ch/pdf?activeSearchType=yesterday&locale=fr'; this.cronSchedule = '0 2 * * 1-5'; // Lundi à vendredi à 2h00 } /** * Parse le PDF des taux de change et extrait les données */ async fetchAndParseRates() { try { console.log('[EXCHANGE-CRON] Début récupération des taux de change...'); // Télécharger le PDF const response = await axios.get(this.pdfUrl, { responseType: 'arraybuffer', timeout: 30000 }); const pdfBuffer = Buffer.from(response.data); console.log('[EXCHANGE-CRON] PDF téléchargé, taille:', pdfBuffer.length, 'bytes'); // Parser le PDF avec pdf-parse const pdfParse = require('pdf-parse'); const pdfData = await pdfParse(pdfBuffer); const text = pdfData.text; console.log('[EXCHANGE-CRON] PDF parsé, extraction des taux...'); // Extraction des taux via regex // Format attendu: "EUR 1.0458" ou "USD 0.9123" etc. const rateRegex = /([A-Z]{3})\s+([\d.]+)/g; const rates = []; let match; while ((match = rateRegex.exec(text)) !== null) { const currency = match[1]; const rate = parseFloat(match[2]); // Filtrer les faux positifs (codes qui ne sont pas des devises) if (rate > 0 && rate < 1000 && currency.length === 3) { rates.push({ currency, rate }); } } console.log(`[EXCHANGE-CRON] ${rates.length} taux extraits`); if (rates.length === 0) { throw new Error('Aucun taux de change extrait du PDF'); } // Sauvegarder en base de données await this.saveRatesToDatabase(rates); console.log('[EXCHANGE-CRON] ✓ Mise à jour des taux terminée avec succès'); return rates; } catch (error) { console.error('[EXCHANGE-CRON] Erreur:', error); throw error; } } /** * Sauvegarde les taux en base de données (UPDATE ou INSERT) */ async saveRatesToDatabase(rates) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); for (const { currency, rate } of rates) { // UPDATE si existe, sinon INSERT await conn.execute(` INSERT INTO daily_exchange_rates (currency_code, rate_to_chf, rate_date) VALUES (?, ?, CURDATE()) ON DUPLICATE KEY UPDATE rate_to_chf = VALUES(rate_to_chf), rate_date = VALUES(rate_date) `, [currency, rate]); } await conn.commit(); console.log(`[EXCHANGE-CRON] ${rates.length} taux sauvegardés en BDD`); } catch (error) { await conn.rollback(); console.error('[EXCHANGE-CRON] Erreur sauvegarde BDD:', error); throw error; } finally { conn.release(); } } /** * Démarre le CRON job */ start() { console.log('[EXCHANGE-CRON] Démarrage du CRON (lun-ven à 2h00)'); cron.schedule(this.cronSchedule, async () => { console.log('[EXCHANGE-CRON] Déclenchement du CRON'); try { await this.fetchAndParseRates(); } catch (error) { console.error('[EXCHANGE-CRON] Échec du CRON:', error.message); } }); // Exécution immédiate au démarrage (optionnel, commentez si non souhaité) // this.fetchAndParseRates().catch(err => console.error('[EXCHANGE-CRON] Erreur init:', err)); } } module.exports = new ExchangeRateCronService(); ---------------------------------------------------- server/services/cleanupService.old const { pool } = require('../db'); // Ajouter cette ligne class CleanupService { constructor() { this.stuckDeclarations = new Set(); } async checkForStuckSubmissions() { try { const conn = await pool.getConnection(); try { // Trouver les déclarations en processing depuis plus de 5 minutes const [rows] = await conn.execute(` SELECT uuid, created_at FROM declarations WHERE status = 'processing' AND created_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE) `); for (const row of rows) { console.log(`[CLEANUP] Déclaration bloquée détectée: ${row.uuid}`); await conn.execute( `UPDATE declarations SET status = 'timeout', error_message = ? WHERE uuid = ?`, ['Processus interrompu - timeout système', row.uuid] ); this.stuckDeclarations.add(row.uuid); } if (rows.length > 0) { console.log(`[CLEANUP] ${rows.length} déclaration(s) bloquée(s) nettoyée(s)`); } } finally { conn.release(); } } catch (error) { console.error('[CLEANUP-ERROR]:', error); } } startCleanupInterval() { // Vérifier toutes les 5 minutes setInterval(() => { this.checkForStuckSubmissions(); }, 5 * 60 * 1000); } } module.exports = new CleanupService(); ---------------------------------------------------- server/services/taresClassifier.js const fs = require('fs'); const path = require('path'); // Assurez-vous que le chemin d'accès à votre pool de connexion DB est correct const { pool } = require('../db'); /** * Gère la logique de classification des véhicules selon les règles TARES * et la validation/lookup des VIN. * * NOTE: Les règles TARES (taresRules) et la classification s'attendent à être passées * par le contrôleur ou lues à l'initialisation du serveur, * car la classe n'est plus chargée statiquement par le constructeur. */ class TaresClassifier { // Suppression du constructeur qui lisait statiquement les fichiers. normalizeFuelType(fuelType) { if (!fuelType) return 'essence'; const normalized = fuelType.toLowerCase().trim(); // La nouvelle logique de normalisation est plus simple et utilise des sous-chaînes if (normalized.includes('plug')) return 'hybride_plugin'; if (normalized.includes('hybr')) return 'hybride'; if (normalized.includes('elec') || normalized.includes('élec')) return 'electrique'; if (normalized.includes('dies')) return 'diesel'; if (normalized.includes('ess') || normalized.includes('petrol') || normalized.includes('gasoline')) return 'essence'; return normalized; } matchesRule(fuel, cylinder, weightEmpty, rule) { if (rule.fuel && rule.fuel !== fuel) return false; if (rule.cylinder_min !== undefined && cylinder < rule.cylinder_min) return false; if (rule.cylinder_max !== undefined && cylinder > rule.cylinder_max) return false; // Utilisation de weightEmpty (poids à vide) pour la classification if (rule.weight_min !== undefined && weightEmpty < rule.weight_min) return false; if (rule.weight_max !== undefined && weightEmpty > rule.weight_max) return false; return true; } /** * Tente de classer le véhicule en utilisant les règles de configuration fournies. * Si aucune correspondance n'est trouvée, retourne une erreur. * @param {object} vehicleData - Données du véhicule (fuel_type, cc, weight, etc.) * @param {object} taresRules - Les règles TARES lues depuis le fichier de configuration (passées par l'appelant) */ async classify(vehicleData, taresRules = {}) { const fuelType = this.normalizeFuelType(vehicleData.fuel_type || 'essence'); const cc = parseInt(vehicleData.cylinder_capacity || 0, 10); const wEmpty = parseInt(vehicleData.weight_empty || 0, 10); const wTotal = parseInt(vehicleData.weight_total || 0, 10); // 1. Tente d'appliquer les règles de configuration (Règles TARES) for (const [code, rule] of Object.entries(taresRules || {})) { if (this.matchesRule(fuelType, cc, wEmpty, rule)) { const { statistical_keys = { '911': 'Automobile' }, description = 'Véhicule' } = rule; const { key, needs_user_selection, description: skDesc, available_keys } = this.selectStatisticalKey(statistical_keys, wTotal); return { commodity_code: code, description, statistical_key: needs_user_selection ? null : key, statistical_key_description: needs_user_selection ? null : (skDesc || statistical_keys[key]), needs_user_selection, available_keys: needs_user_selection ? statistical_keys : null }; } } // RETOUR D'ERREUR si aucune correspondance n'est trouvée dans les règles TARES return { error: "Correspondance non trouvée", message: "Aucune règle TARES fournie ne correspond aux données de ce véhicule." }; } selectStatisticalKey(keys, weightTotal) { if (!keys || Object.keys(keys).length === 0) return { key: '911', description: 'Automobile', needs_user_selection: false, available_keys: null }; // Cas 1: Une seule clé disponible if (Object.keys(keys).length === 1) { const k = Object.keys(keys)[0]; return { key: k, description: keys[k], needs_user_selection: false, available_keys: null }; } // Cas 2: Sélection automatique basée sur le Poids Total (ex: 921 > 3500kg, 923 <= 3500kg) if (keys['921'] && keys['923']) { return weightTotal > 3500 ? { key: '921', description: keys['921'], needs_user_selection: false, available_keys: null } : { key: '923', description: keys['923'], needs_user_selection: false, available_keys: null }; } // Ajout d'une gestion pour les codes électriques 926/927 if (keys['926'] && keys['927']) { return weightTotal > 3500 ? { key: '926', description: keys['926'], needs_user_selection: false, available_keys: null } : { key: '927', description: keys['927'], needs_user_selection: false, available_keys: null }; } // Cas 3: Sélection manuelle requise (si plus d'une clé n'ayant pas de logique d'auto-sélection) return { key: null, description: null, needs_user_selection: true, available_keys: keys }; } /** * Récupère le pays de fabrication (WMI) à partir du VIN en utilisant la base de données. * @param {string} vin - Numéro d'identification du véhicule. * @returns {Promise} - Code pays (ex: 'FR') ou null. */ async getCountryFromVIN_DB(vin) { if (!vin || vin.length < 2) return null; const wmi = vin.substring(0, 2).toUpperCase(); // Utilisation du pool pour la connexion à la base de données const conn = await pool.getConnection(); try { const [rows] = await conn.execute(` SELECT country_code FROM vin_wmi_country_codes WHERE ? BETWEEN wmi_start AND wmi_end LIMIT 1 `, [wmi]); // Assurez-vous d'avoir des colonnes 'wmi_start', 'wmi_end' et 'country_code' dans votre table SQL. return rows?.[0]?.country_code || null; } catch (error) { console.error("Erreur DB WMI:", error.message); return null; } finally { if (conn) conn.release(); } } } // Exportation en tant qu'instance unique (Singleton) module.exports = new TaresClassifier(); ---------------------------------------------------- server/db.js const mysql = require('mysql2/promise'); const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }); module.exports = { pool, getConnection: () => pool.getConnection() }; ---------------------------------------------------- server/controllers/homeController.js // Obsolète (SPA), conservé pour compatibilité si utilisé ailleurs const path = require('path'); const router = require('express').Router(); router.get('/', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); router.get('/:lang', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); module.exports = router; ---------------------------------------------------- server/controllers/importController.js // Obsolète, les routes actuelles sont dans /server/routes/api.js const router = require('express').Router(); router.get('/ping', (req, res) => res.json({ ok: true })); module.exports = router; ---------------------------------------------------- server/controllers/exportController.js // Obsolète, les routes actuelles sont dans /server/routes/api.js module.exports = { submit: (req, res) => { const xml = ` 2 `; res.set('Content-Type', 'application/xml'); res.set('Content-Disposition', `attachment; filename="edec-export-${Date.now()}.xml"`); res.send(xml); } }; ---------------------------------------------------- public/en/index.html Import & Export Car in Switzerland - Simplified Customs Declaration

Vehicle Customs Declaration in Switzerland: Simple and Fast

Import or export your car without stress. Get your official e-dec form in 5 minutes for only 50 CHF.

Car customs clearance, simplified.

The official e-dec portal of the Swiss customs is powerful but often complex and intimidating for newcomers. Freight forwarders are expensive and slow. MyAutoImport.ch is the ideal solution: we guide you through a simple form, asking only the essential questions. No more confusion, get your customs declaration in record time.

Why choose us?

MyAutoImport.ch

50 CHF

~ 5 minutes

  • ✅ Simple and guided
  • ✅ Fast and 100% online
  • ✅ Cost-effective

Traditional Forwarder

~ 100 CHF

~ 45 minutes (or more)

  • ❌ Waiting times
  • ❌ More expensive
  • ❌ Travel sometimes required

e-dec (Official Site)

Free

Very variable

  • ❌ Very complex
  • ❌ Risk of costly mistakes
  • ❌ No support
---------------------------------------------------- public/js/controllers/homeController.js angular.module('edecApp') // Le contrôleur hérite automatiquement de $scope.currentLang et $scope.t du MainController. .controller('HomeController', ['$scope', '$location', function($scope, $location) { // Log pour confirmer le chargement du contrôleur et la langue active console.log('HomeController chargé, langue:', $scope.currentLang); // Vos fonctionnalités existantes $scope.features = [ { icon: '⚡', titleKey: 'feature_fast_title', descriptionKey: 'feature_fast_desc' }, { icon: '💰', titleKey: 'feature_eco_title', descriptionKey: 'feature_eco_desc' }, { icon: '✅', titleKey: 'feature_simple_title', descriptionKey: 'feature_simple_desc' }, { icon: '🤖', titleKey: 'feature_ai_title', descriptionKey: 'feature_ai_desc' } ]; }]); ---------------------------------------------------- public/js/controllers/importController.js angular.module('edecApp') .controller('ImportController', function($scope, $http, $window, $rootScope, $timeout, $location, ConfigService, TranslationService) { function initScope() { $scope.step = 1; $scope.method = null; $scope.vehicle = {}; $scope.parties = { importer: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, consignee: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' }, declarant: { name: '', firstname: '', address: '', zip: '', city: '', ide: '' } }; $scope.declarantType = 'importer'; $scope.declaration = { transport_mode: '', purchase_currency: '', purchase_price: 0, transport_cost: 0, other_costs: 0, dispatch_country: '', language: $rootScope.lang || 'fr' }; $scope.classification = null; $scope.ocrLoading = false; $scope.preparing = false; $scope.ocrAttempted = false; $scope.ocrCountdownActive = false; $scope.vinValid = null; $scope.classificationError = null; // <-- NOUVEAU: Stocke le message d'erreur de classification $scope.reporting = false; // <-- NOUVEAU: Flag de chargement pour le signalement $scope.statisticalValueCHF = 0; $scope.vatValueCHF = 0; $scope.isCompany = false; $scope.sessionToken = null; $scope.submissionProgress = 0; $scope.submissionStep = ''; $scope.submissionResult = null; $scope.showProgress = false; $scope.eventSource = null; $scope.showStripeButton = false; $scope.tooltips = { transportMode: false, relocation: false, ivaExempt: false }; $scope.ocrCountdown = 0; $scope.ocrCountdownActive = false; } const SESSION_STORAGE_KEY = 'edec_import_form'; let saveTimeout = null; // Nouvelle fonction pour gérer le chargement du script Stripe function loadStripeBuyButtonScript() { $timeout(function() { if (!document.getElementById('stripe-buy-button-script')) { const script = document.createElement('script'); script.id = 'stripe-buy-button-script'; script.src = 'https://js.stripe.com/v3/buy-button.js'; script.async = true; document.head.appendChild(script); } }, 100); } function loadStripeBuyButtonScript() { $timeout(function() { if (!document.getElementById('stripe-buy-button-script')) { const script = document.createElement('script'); script.id = 'stripe-buy-button-script'; script.src = 'https://js.stripe.com/v3/buy-button.js'; script.async = true; document.head.appendChild(script); } }, 100); } // Fonction pour mettre en évidence TOUS les champs obligatoires manquants $scope.highlightInvalidFields = function(formName) { const form = $scope[formName]; if (!form) return false; let hasErrors = false; // Remove validation-error class from all form fields const formElement = document.querySelector('form[name="' + formName + '"]'); if (formElement) { const allFields = formElement.querySelectorAll('input, select'); allFields.forEach(field => field.classList.remove('validation-error')); } // Marquer tous les champs invalides comme "touched" et "dirty" angular.forEach(form, function(field, fieldName) { if (fieldName.indexOf('$') !== 0) { // Ignorer les propriétés internes d'AngularJS if (field && field.$invalid) { field.$setDirty(); field.$setTouched(); // Add validation-error class to the DOM element const element = document.querySelector('[name="' + fieldName + '"]'); if (element) { element.classList.add('validation-error'); } hasErrors = true; } } }); // Forcer un digest pour mettre à jour l'affichage if (!$scope.$$phase) { $scope.$apply(); } // Scroll vers le premier champ invalide après un court délai $timeout(function() { const firstInvalid = document.querySelector('.validation-error'); if (firstInvalid) { firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Focus sur le champ si c'est un input/select if (firstInvalid.tagName === 'INPUT' || firstInvalid.tagName === 'SELECT') { firstInvalid.focus(); } } }, 200); return hasErrors; }; function saveState() { $timeout.cancel(saveTimeout); saveTimeout = $timeout(function() { const dataToSave = { step: Math.min($scope.step || 1, 3), method: $scope.method, vehicle: angular.copy($scope.vehicle), parties: angular.copy($scope.parties), declarantType: $scope.declarantType, declaration: angular.copy($scope.declaration), sessionToken: $scope.sessionToken }; sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dataToSave)); }, 500); } function loadSavedState() { const savedData = sessionStorage.getItem(SESSION_STORAGE_KEY); if (savedData) { try { const parsedData = JSON.parse(savedData); $scope.step = parsedData.step || 1; $scope.method = parsedData.method || null; angular.extend($scope.vehicle, parsedData.vehicle || {}); angular.extend($scope.parties, parsedData.parties || { importer: {}, consignee: {}, declarant: {} }); $scope.declarantType = parsedData.declarantType || 'importer'; angular.extend($scope.declaration, parsedData.declaration || {}); $scope.sessionToken = parsedData.sessionToken || null; $scope.isCompany = !!$scope.parties.importer.ide && $scope.parties.importer.ide.trim() !== 'CHE222251936'; // Si on a un sessionToken, on peut afficher le bouton Stripe if ($scope.sessionToken && $scope.step === 3) { $scope.showStripeButton = true; loadStripeBuyButtonScript(); } $rootScope.showNotification($rootScope.t('data_restored'), 'info'); return true; } catch (e) { sessionStorage.removeItem(SESSION_STORAGE_KEY); return false; } } return false; } // Gestion du retour de paiement annulé const urlParams = new URLSearchParams($window.location.search); if (urlParams.get('payment_cancelled') === 'true') { $rootScope.showNotification('Paiement annulé. Vous pouvez réessayer.', 'warning'); // Nettoyer l'URL $location.search('payment_cancelled', null); } initScope(); loadSavedState(); ConfigService.getCurrencies().then(data => $scope.currencies = data); // Initialiser vide $scope.transportModes = []; // Attendre que les traductions soient chargées TranslationService.getPromise.then(function() { // Fonction pour générer les modes de transport traduits $scope.getTransportModes = function() { return [ { value: '9', label: $rootScope.t('transport_mode_9') }, { value: '3', label: $rootScope.t('transport_mode_3') }, { value: '2', label: $rootScope.t('transport_mode_2') }, { value: '4', label: $rootScope.t('transport_mode_4') }, { value: '8', label: $rootScope.t('transport_mode_8') }, { value: '7', label: $rootScope.t('transport_mode_7') } ]; }; // Initialiser les modes de transport $scope.transportModes = $scope.getTransportModes(); // Mettre à jour quand la langue change $scope.$watch(() => $rootScope.lang, function(newLang) { if (newLang) { $scope.transportModes = $scope.getTransportModes(); } }); }); $scope.$watch('parties.importer.ide', function(newVal) { $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; }); $scope.$watch('vehicle', saveState, true); $scope.$watch('parties', saveState, true); $scope.$watch('declaration', saveState, true); $scope.$watch('declarantType', saveState); $scope.$watch('step', saveState); $scope.$watch('method', saveState); // Gestion de l'exclusivité IDE vs Prénom pour tous les parties $scope.$watch('parties.importer.ide', function(newVal) { $scope.importerHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.importer.firstname', function(newVal) { $scope.importerHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.consignee.ide', function(newVal) { $scope.consigneeHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.consignee.firstname', function(newVal) { $scope.consigneeHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.declarant.ide', function(newVal) { $scope.declarantHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.declarant.firstname', function(newVal) { $scope.declarantHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('submissionResult', function(newVal) { if (newVal) { sessionStorage.removeItem(SESSION_STORAGE_KEY); } }); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.triggerFileInput = () => document.getElementById('file-upload').click(); $scope.handleFileSelect = function(event, context) { const files = event.target.files; if (!files || files.length === 0) return; $scope.$apply(() => { $scope.ocrLoading = true; $scope.ocrAttempted = true; }); const formData = new FormData(); formData.append('registration_card', files[0]); $http.post('/api/ocr', formData, { transformRequest: angular.identity, headers: { 'Content-Type': undefined } }).then(function(response) { const ocrData = response.data; // Pour import: utiliser paper_origin pour dispatch_country if (context === 'import' && ocrData.paper_origin && $rootScope.countries) { const ocrCountryName = ocrData.paper_origin.toLowerCase().trim(); const countryCode = Object.keys($rootScope.countries).find(code => { const fullCountryName = $rootScope.countries[code].toLowerCase().trim(); const mainCountryName = fullCountryName.split('(')[0].trim(); return mainCountryName === ocrCountryName; }); if (countryCode) { $scope.declaration.dispatch_country = countryCode; } } delete ocrData.paper_origin; angular.extend($scope.vehicle, ocrData); if ($scope.vehicle.vin) $scope.validateVIN(); $scope.updateClassification(); $rootScope.showNotification($rootScope.t('ocr_success'), 'success'); $scope.startOcrCountdown(); }).catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; $rootScope.showNotification('Erreur OCR: ' + details, 'error'); }).finally(() => { $scope.ocrLoading = false; }); }; $scope.startOcrCountdown = function() { $scope.ocrCountdown = 30; $scope.ocrCountdownActive = true; const interval = setInterval(function() { $scope.$apply(function() { $scope.ocrCountdown--; if ($scope.ocrCountdown <= 0) { $scope.ocrCountdownActive = false; clearInterval(interval); } }); }, 1000); }; $scope.validateVIN = function() { if (!$scope.vehicle.vin || $scope.vehicle.vin.length !== 17) { $scope.vinValid = false; return; } $http.post('/api/validate-vin', { vin: $scope.vehicle.vin }) .then(function(response) { $scope.vinValid = response.data.valid; if (response.data.valid && response.data.country && !$scope.declaration.dispatch_country) { $scope.declaration.dispatch_country = response.data.country; } }).catch(function() { $scope.vinValid = false; }); }; $scope.updateClassification = function() { // Réinitialisation des erreurs précédentes avant le nouvel appel $scope.classification = null; $scope.classificationError = null; // <-- IMPORTANT: Réinitialiser l'état d'erreur if (!$scope.vehicle.fuel_type || !$scope.vehicle.weight_empty) return; $http.post('/api/classify-vehicle', $scope.vehicle) .then(function(response) { // 🛑 GESTION DE L'ERREUR DE CLASSIFICATION (NOUVEAU) if (response.data.error) { // Le backend a retourné un objet d'erreur $scope.classificationError = response.data.message; // Stocke le message $rootScope.showNotification(response.data.message, 'error'); return; // Stoppe l'exécution, on ne classe rien. } // Cas de succès (Logique originale) $scope.classification = response.data; $scope.vehicle.commodity_code = response.data.commodity_code; if (!response.data.needs_user_selection) { $scope.vehicle.statistical_key = response.data.statistical_key; } }) .catch(function(error) { // Gestion des erreurs de connexion/serveur HTTP const message = error.data?.message || 'Erreur réseau lors de la classification.'; $scope.classificationError = message; $rootScope.showNotification(message, 'error'); }); }; /** * NOUVELLE FONCTION : Signale les données du véhicule non classées. * Envoie les données à un endpoint pour enregistrement en DB. */ $scope.reportClassificationError = function() { if ($scope.reporting) return; $scope.reporting = true; // Active le flag de chargement const payload = { vehicle: $scope.vehicle, classification_error: $scope.classificationError || 'Classification non trouvée (signalement manuel)', // Ajouter des informations de contexte utiles importer_ide: $scope.parties.importer.ide || null, user_lang: $scope.declaration.language }; $http.post('/api/report-classification-error', payload) .then(function(response) { if (response.data.success) { // Vous pouvez choisir de remettre à zéro le formulaire ou de laisser l'utilisateur corriger $rootScope.showNotification($rootScope.t('error_reported_success') + ' - Merci pour votre aide!', 'success'); } else { $rootScope.showNotification(response.data.message || $rootScope.t('error_reporting_failed'), 'error'); } }) .catch(function(error) { $rootScope.showNotification($rootScope.t('error_reporting_network') + ': ' + (error.data?.message || 'Erreur réseau'), 'error'); }) .finally(() => { $scope.reporting = false; }); }; $scope.calculateTotal = function() { const price = parseFloat($scope.declaration.purchase_price) || 0; const transport = parseFloat($scope.declaration.transport_cost) || 0; const other = parseFloat($scope.declaration.other_costs) || 0; const currency = $scope.declaration.purchase_currency; ConfigService.getExchangeRates().then(function(rates) { if (!rates || !rates[currency]) return; const rate = rates[currency].rate; $scope.statisticalValueCHF = Math.round(price * rate); $scope.vatValueCHF = Math.round((price + transport + other) * rate); }); }; $scope.nextStep = function() { if ($scope.classificationError) { $rootScope.showNotification($rootScope.t('classification_error_block'), 'error'); return; } if ($scope.step === 2) { const hasErrors = $scope.highlightInvalidFields('vehicleForm'); if (hasErrors || !$scope.vehicle.vin || !$scope.vehicle.brand || !$scope.vehicle.model || !$scope.vehicle.fuel_type || !$scope.vehicle.weight_empty || !$scope.vehicle.weight_total) { $rootScope.showNotification($rootScope.t('fill_required_vehicle'), 'error'); return; } } $scope.step++; $timeout(function() { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 100); }; $scope.prevStep = () => $scope.step--; $scope.copyConsigneeToImporter = function() { $scope.parties.importer = angular.copy($scope.parties.consignee); $rootScope.showNotification($rootScope.t('info_copied'), 'success'); }; $scope.showTooltip = function(tooltip, show) { $scope.tooltips[tooltip] = show; }; $scope.toggleTooltip = function(tooltip) { $scope.tooltips[tooltip] = !$scope.tooltips[tooltip]; }; $scope.copyLinkToClipboard = function(link) { const tempInput = document.createElement('input'); tempInput.style.position = 'absolute'; tempInput.style.left = '-9999px'; tempInput.value = 'myautoimport.ch' + link; document.body.appendChild(tempInput); tempInput.select(); document.execCommand('copy'); document.body.removeChild(tempInput); $rootScope.showNotification($rootScope.t('link_copied'), 'success'); }; /** * NOUVELLE FONCTION : Préparer la déclaration (génération XML AVANT paiement) */ // CORRECTION 3: Remplacer prepareDeclaration dans importController.js $scope.prepareDeclaration = function() { if ($scope.preparing) return; const hasErrors = $scope.highlightInvalidFields('valuationForm'); const imp = $scope.parties.importer; if (hasErrors || !imp.name || !imp.zip || !imp.city || !$scope.declaration.dispatch_country) { $rootScope.showNotification($rootScope.t('fill_required_importer'), 'error'); return; } if (!$scope.declaration.purchase_price || $scope.declaration.purchase_price <= 0) { $rootScope.showNotification($rootScope.t('fill_valid_price'), 'error'); // Highlight the purchase_price field const priceElement = document.querySelector('[name="purchase_price"]'); if (priceElement) { priceElement.classList.add('validation-error'); } return; } if ($scope.declaration.purchase_price < 2000 && !$scope.declaration.price_confirmation) { $rootScope.showNotification($rootScope.t('confirm_low_price'), 'error'); return; } $scope.preparing = true; const payload = { type: 'import', vehicle: $scope.vehicle, parties: $scope.parties, declaration: $scope.declaration, declarantType: $scope.declarantType }; $http.post('/api/prepare-declaration', payload) .then(function(response) { if (response.data.success) { $scope.sessionToken = response.data.sessionToken; $scope.showStripeButton = true; saveState(); loadStripeBuyButtonScript(); $rootScope.showNotification('Déclaration prête ! Procédez au paiement.', 'success'); } else { $rootScope.showNotification(response.data.message || 'Erreur de préparation', 'error'); } }) .catch(function(error) { $rootScope.showNotification('Erreur: ' + (error.data?.message || 'Erreur réseau'), 'error'); }) .finally(() => { $scope.preparing = false; }); }; /** * Écoute du statut de soumission après paiement validé */ $scope.listenForSubmissionStatus = function() { if (!$scope.sessionToken) return; if ($scope.eventSource) { $scope.eventSource.close(); } $scope.step = 4; $scope.showProgress = true; const source = new EventSource('/api/submission-status-sse/' + $scope.sessionToken); $scope.eventSource = source; console.log('[SSE-CLIENT] Connexion SSE établie pour:', $scope.sessionToken); // Fallback polling au cas où SSE ne reçoit pas l'événement final let fallbackTimeout = setTimeout(() => { console.log('[SSE-CLIENT] Timeout SSE - Activation du polling de secours'); $scope.checkSubmissionStatusFallback(); }, 120000); // 2 minutes source.onopen = function() { console.log('[SSE-CLIENT] Connexion SSE ouverte'); }; source.onmessage = function(event) { console.log('[SSE-CLIENT] Message SSE reçu:', event.data); $scope.$apply(() => { try { const status = JSON.parse(event.data); $scope.submissionProgress = status.progress; $scope.submissionStep = status.step; if (status.status === 'submitted') { console.log('[SSE-CLIENT] Statut "submitted" reçu - Succès!'); clearTimeout(fallbackTimeout); $scope.submissionResult = status; $scope.showProgress = false; source.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); $rootScope.showNotification($rootScope.t('submission_success'), 'success'); } else if (status.status === 'submission_error') { console.log('[SSE-CLIENT] Statut "submission_error" reçu'); clearTimeout(fallbackTimeout); $scope.showProgress = false; source.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification($rootScope.t('submission_error_detected'), 'error'); } } catch (e) { console.error('[SSE-CLIENT] Erreur parsing:', e); } }); }; source.onerror = function(error) { console.error('[SSE-CLIENT] Erreur de connexion SSE:', error); $scope.$apply(() => { $scope.submissionStep = 'Erreur de connexion SSE...'; }); }; source.onclose = function() { console.log('[SSE-CLIENT] Connexion SSE fermée'); }; }; // Fallback polling si SSE échoue $scope.checkSubmissionStatusFallback = function() { if (!$scope.sessionToken) return; console.log('[FALLBACK] Vérification du statut via HTTP polling'); $http.get('/api/submission-status/' + $scope.sessionToken) .then(function(response) { const status = response.data; console.log('[FALLBACK] Statut récupéré:', status); if (status.status === 'submitted') { console.log('[FALLBACK] Statut final "submitted" détecté'); $scope.submissionResult = status; $scope.showProgress = false; if ($scope.eventSource) $scope.eventSource.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); $rootScope.showNotification($rootScope.t('submission_success'), 'success'); } else if (status.status === 'submission_error') { console.log('[FALLBACK] Erreur de soumission détectée'); $scope.showProgress = false; if ($scope.eventSource) $scope.eventSource.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification($rootScope.t('submission_error_detected'), 'error'); } else { // Si pas encore terminé, revérifier dans 5 secondes setTimeout(() => $scope.checkSubmissionStatusFallback(), 5000); } }) .catch(function(error) { console.error('[FALLBACK] Erreur HTTP:', error); // Réessayer dans 10 secondes en cas d'erreur setTimeout(() => $scope.checkSubmissionStatusFallback(), 10000); }); }; $scope.downloadPDF = function(path) { if (!path) return; $window.open('/api/download-pdf?path=' + encodeURIComponent(path), '_blank'); }; $scope.clearValidationError = function(element) { angular.element(element).removeClass('validation-error'); }; $scope.resetForm = function() { if ($scope.eventSource) $scope.eventSource.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); // Remove validation-error classes from all forms const forms = ['vehicleForm', 'valuationForm']; forms.forEach(formName => { const formElement = document.querySelector('form[name="' + formName + '"]'); if (formElement) { const allFields = formElement.querySelectorAll('input, select'); allFields.forEach(field => field.classList.remove('validation-error')); } }); initScope(); $rootScope.showNotification($rootScope.t('form_reset'), 'info'); }; // Gestion du retour de Stripe (depuis payment-success) $scope.checkPaymentReturn = function() { const returnData = sessionStorage.getItem('stripe_payment_return'); if (returnData) { try { const data = JSON.parse(returnData); if (data.sessionToken === $scope.sessionToken) { sessionStorage.removeItem('stripe_payment_return'); $scope.listenForSubmissionStatus(); } } catch (e) { console.error('Erreur parsing returnData:', e); } } }; // Vérifier au chargement $scope.checkPaymentReturn(); ConfigService.getCurrencies().then(data => $scope.currencies = data); $scope.$watch('vehicle', saveState, true); $scope.$watch('parties', saveState, true); $scope.$watch('declaration', saveState, true); $scope.$watch('declarantType', saveState); $scope.$watch('step', saveState); $scope.$watch('method', saveState); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.validateVIN = function() { if (!$scope.vehicle.vin || $scope.vehicle.vin.length !== 17) { $scope.vinValid = false; return; } $http.post('/api/validate-vin', { vin: $scope.vehicle.vin }) .then(function(response) { $scope.vinValid = response.data.valid; if (response.data.valid && response.data.country && !$scope.declaration.dispatch_country) { $scope.declaration.dispatch_country = response.data.country; } }).catch(function() { $scope.vinValid = false; }); }; }); ---------------------------------------------------- public/js/controllers/paymentController.js angular.module('edecApp') .controller('PaymentController', function($scope, $location, $window) { // Ce controller n'est plus utilisé avec Stripe Checkout // Redirection automatique vers la page d'accueil $location.path('/'); }); /* .controller('PaymentController', function($scope, $http, $location, $window, $rootScope, $timeout, ConfigService) { // AJOUT de ConfigService // 🔴 CORRECTION IMPORTANTE : Remplacez par votre vraie clé Stripe const STRIPE_PUBLIC_KEY = 'pk_test_51SF3ZMDLAThCxOAyPFDmPowjqUJyyay34PQZu8SfEHDZaVNFSNCCPjE2dAJYpvHHtqQcR0I23gEstk9V24B83PAw00g4jIHw2r'; // Obtenez cette clé depuis votre dashboard Stripe $scope.loading = true; $scope.processing = false; // AJOUT : Nouvelles variables d'état pour le nouveau flux $scope.clientEmail = ''; // Nouvelle variable pour l'email en cas d'erreur plateforme $scope.declarationReady = !!sessionStorage.getItem('edec_pending_payload'); $scope.paymentError = null; $scope.stripe = null; $scope.elements = null; $scope.paymentElement = null; $scope.clientSecret = null; $scope.destinationType = sessionStorage.getItem('edec_intended_destination') || 'import'; $scope.paymentElementComplete = false; $scope.initializePayment = function() { if (window.Stripe) { $scope.createCheckoutSession(); return; } const script = document.createElement('script'); script.src = 'https://js.stripe.com/v3/'; script.onload = () => { $scope.$apply(() => { $scope.createCheckoutSession(); }); }; script.onerror = () => { $scope.$apply(() => { $scope.paymentError = $rootScope.t('payment_stripe_load_error'); }); }; $scope.createCheckoutSession = function() { // Rediriger directement vers votre lien Stripe Checkout window.location.href = 'https://buy.stripe.com/test_aFa8wP5s94W70v9acuafS00'; } $scope.initStripe = function() { try { $scope.stripe = Stripe(STRIPE_PUBLIC_KEY); } catch (e) { console.error('Erreur Stripe init:', e); $scope.paymentError = $rootScope.t('payment_stripe_init_error'); // Clé de traduction à ajouter $scope.loading = false; return; } // 1. Appeler le backend pour créer le Payment Intent $http.post('/api/create-payment-intent', { type: $scope.destinationType }) .then(function(response) { $scope.clientSecret = response.data.clientSecret; $scope.amount = response.data.amount; $scope.currency = response.data.currency; $scope.mountPaymentElement(); }) .catch(function(error) { console.error('Erreur création Payment Intent:', error); $scope.paymentError = error.data && error.data.message ? error.data.message : $rootScope.t('payment_unknown_error'); $scope.loading = false; }); }; $scope.mountPaymentElement = function() { if (!$scope.stripe || !$scope.clientSecret) return; $scope.elements = $scope.stripe.elements({ clientSecret: $scope.clientSecret }); // Configure le Payment Element const options = { layout: { type: 'tabs' } }; $scope.paymentElement = $scope.elements.create('payment', options); $scope.paymentElement.mount('#payment-element'); $scope.paymentElement.on('ready', () => { $scope.$apply(() => { $scope.loading = false; }); }); $scope.paymentElement.on('change', (event) => { $scope.$apply(() => { $scope.paymentElementComplete = event.complete; }); }); }; $scope.finalizePayment = function() { if (!$scope.stripe || !$scope.paymentElementComplete || $scope.processing) return; $scope.processing = true; $scope.paymentError = null; $scope.stripe.confirmPayment({ elements: $scope.elements, confirmParams: { // L'URL fournie ici est l'URL de retour après la confirmation (par exemple 3D Secure) return_url: $scope.getRedirectUrl(), } }).then(function({ error, paymentIntent }) { if (error) { console.error('Erreur de confirmation de paiement:', error); $scope.$apply(() => { $scope.paymentError = error.message || $rootScope.t('payment_unknown_error'); $scope.processing = false; $scope.$emit('paymentIframeError', $scope.paymentError); // Émettre l'erreur }); } else if (paymentIntent && paymentIntent.status === 'succeeded') { // Si la confirmation a réussi immédiatement (rare, mais possible) window.onPaymentSuccess({ sessionToken: paymentIntent.id, paymentMethod: paymentIntent.payment_method_types[0] }); } }).catch(function(confirmError) { console.error('Erreur durant la confirmation de paiement:', confirmError); $scope.$apply(() => { $scope.processing = false; }); }); }; $scope.createCheckoutSession = function() { $http.post('/api/create-checkout-session', { type: $scope.destinationType }) .then(function(response) { // Rediriger vers la page de checkout Stripe window.location.href = response.data.url; }) .catch(function(error) { $scope.paymentError = error.data?.error || $rootScope.t('payment_unknown_error'); }); }; // NOUVELLE FONCTION de soumission post-paiement (remplacement de l'ancienne) window.onPaymentSuccess = function(details) { $scope.processing = true; $scope.$apply(); const pendingPayload = sessionStorage.getItem('edec_pending_payload'); if (!pendingPayload) { // Pas de déclaration en attente, renvoyer l'utilisateur à la page d'accueil ou de paiement vide. $location.path('/payment'); $scope.$apply(); return; } const payload = JSON.parse(pendingPayload); // Étape 4 : Soumission réelle à la plateforme de la douane $http.post(ConfigService.getApiConfig().declaration_submit_url, { paymentDetails: details, declaration: payload.declaration, destinationType: $scope.destinationType, clientEmail: $scope.clientEmail || null, // Ajout de l'email }) .then(function(response) { if (response.data.success) { sessionStorage.removeItem('edec_pending_payload'); // Nettoyer le payload en attente // Stocker la preuve de paiement (pour éviter la re-soumission) localStorage.setItem('edec_payment_proof', JSON.stringify(response.data.paymentProof)); $location.path('/payment-success'); } else if (response.data.submissionPlatformError) { // Afficher l'erreur de plateforme avec demande d'email (si non déjà rempli) $scope.paymentError = $rootScope.t('submission_platform_error'); } else { // Erreur générique $scope.paymentError = response.data.message || $rootScope.t('payment_unknown_error'); } }) .catch(function(error) { $scope.paymentError = error.data && error.data.message ? error.data.message : $rootScope.t('payment_unknown_error'); }) .finally(function() { $scope.processing = false; $scope.$apply(); }); }; // AJOUT : Fonctions d'interaction utilisateur (Edit/Retry) $scope.retryPayment = function() { $scope.paymentError = null; $scope.processing = false; // Réinitialise l'élément pour une nouvelle tentative si nécessaire, ou se fie au bouton Stripe pour recommencer. }; $scope.editDeclaration = function() { sessionStorage.removeItem('edec_pending_payload'); // Nettoyer la déclaration en attente $location.path('/' + $scope.destinationType); // Rediriger vers Import ou Export }; $scope.getRedirectUrl = function() { return $window.location.origin + '/payment-success'; }; // AJOUT : Écouteur pour récupérer les erreurs de l'iframe de paiement (si non géré par onPaymentSuccess) $scope.$on('paymentIframeError', function(event, message) { $scope.paymentError = message || $rootScope.t('payment_unknown_error'); $scope.$apply(); // force l'update du scope pour afficher l'erreur }); $scope.verifyExistingPayment = function(paymentId) { $http.post('/api/verify-payment', { paymentIntentId: paymentId }) .then(function(response) { if (response.data.valid) { $rootScope.showNotification($rootScope.t('payment_already_valid'), 'success'); $timeout(() => { $location.path('/' + $scope.destinationType); }, 1500); } else { localStorage.removeItem('edec_payment_proof'); $scope.initializePayment(); } }) .catch(function(error) { console.error('Erreur vérification paiement:', error); localStorage.removeItem('edec_payment_proof'); $scope.initializePayment(); }); }; $scope.goBack = function() { sessionStorage.removeItem('edec_intended_destination'); $location.path('/'); }; // 🌟 CORRECTION : Initialiser le paiement après le rendu de la vue $scope.$on('$viewContentLoaded', function() { const existingPayment = localStorage.getItem('edec_payment_proof'); if (existingPayment && $scope.declarationReady) { // Si l'utilisateur arrive ici, c'est qu'il a déjà payé ET qu'il a une déclaration en attente (cas rare, mais possible) // On le redirige vers le succès pour relancer la soumission (gérée dans onPaymentSuccess) $location.path('/payment-success'); } else { // Si pas de paiement existant ou pas de déclaration prête, on lance l'initialisation du paiement standard $scope.initializePayment(); } }); }); */ ---------------------------------------------------- public/js/controllers/submissionErrorController.js angular.module('edecApp') .controller('SubmissionErrorController', function($scope, $http, $rootScope) { $scope.sessionToken = sessionStorage.getItem('error_session_token'); console.log('SessionToken :', $scope.sessionToken); $scope.data = {}; // <--- CREATE AN OBJECT $scope.data.email = ''; // <--- BIND THE EMAIL TO THE OBJECT $scope.submitted = false; $scope.errorMessage = ''; $scope.reportError = function() { if (!$scope.data.email || $scope.data.email.trim() === '') { // <--- CHECK THE OBJECT PROPERTY $scope.errorMessage = $rootScope.t('email_required') || 'Veuillez saisir une adresse email.'; return; } // Validation email simple mais correcte const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test($scope.data.email)) { $scope.errorMessage = $rootScope.t('email_invalid') || 'Format d\'email invalide.'; return; } if (!$scope.sessionToken) { $scope.errorMessage = 'Session expirée. Veuillez nous contacter au +41 76 360 73 63.'; return; } $scope.errorMessage = ''; $http.post('/api/submission-error/report', { sessionToken: $scope.sessionToken, email: $scope.data.email }).then(function(response) { if (response.data.success) { $scope.submitted = true; console.log('response :', response); } else { $scope.errorMessage = response.data.error || 'Une erreur est survenue.'; } }).catch(function(error) { $scope.errorMessage = error.data?.error || 'Une erreur est survenue.'; }); }; }); ---------------------------------------------------- public/js/controllers/exportController.js angular.module('edecApp') .controller('ExportController', function($scope, $http, $window, $rootScope, $timeout, $location, ConfigService, TranslationService) { function initScope() { $scope.step = 1; $scope.method = null; $scope.vehicle = {}; $scope.parties = { consignor: { name: '', firstname: '', address: '', zip: '', city: '', country: 'CH', ide: '' }, consignee: { name: '', firstname: '', address: '', zip: '', city: '', country: '', ide: '' }, declarant: { name: '', firstname: '', address: '', zip: '', city: '', country: '', ide: '' } }; $scope.declarantType = 'consignor'; $scope.declaration = { destination_country: '', transport_mode: '9', purchase_currency: 'CHF', purchase_price: 0, language: $rootScope.lang || 'fr' }; $scope.classification = null; $scope.ocrLoading = false; $scope.preparing = false; $scope.ocrAttempted = false; $scope.vinValid = null; $scope.classificationError = null; $scope.reporting = false; $scope.sessionToken = null; $scope.submissionProgress = 0; $scope.submissionStep = ''; $scope.submissionResult = null; $scope.showProgress = false; $scope.eventSource = null; $scope.origin = null; $scope.showStripeButton = false; $scope.tooltips = { transportMode: false, destinationCountry: false }; $scope.ocrCountdown = 0; $scope.ocrCountdownActive = false; } const SESSION_STORAGE_KEY = 'edec_export_form'; let saveTimeout = null; // --- DÉBUT CORRECTION STRIPE: Nouvelle fonction utilitaire --- function loadStripeBuyButtonScript() { $timeout(function() { if (!document.getElementById('stripe-buy-button-script')) { const script = document.createElement('script'); script.id = 'stripe-buy-button-script'; script.src = 'https://js.stripe.com/v3/buy-button.js'; script.async = true; document.head.appendChild(script); } }, 100); } // --- FIN CORRECTION STRIPE --- function saveState() { $timeout.cancel(saveTimeout); saveTimeout = $timeout(function() { const dataToSave = { step: Math.min($scope.step || 1, 3), method: $scope.method, vehicle: angular.copy($scope.vehicle), parties: angular.copy($scope.parties), declarantType: $scope.declarantType, declaration: angular.copy($scope.declaration), sessionToken: $scope.sessionToken }; sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(dataToSave)); }, 500); } // Fonction pour mettre en évidence TOUS les champs obligatoires manquants $scope.highlightInvalidFields = function(formName) { const form = $scope[formName]; if (!form) return false; let hasErrors = false; // Marquer tous les champs invalides comme "touched" et "dirty" angular.forEach(form, function(field, fieldName) { if (fieldName.indexOf('$') !== 0) { // Ignorer les propriétés internes d'AngularJS if (field && field.$invalid) { field.$setDirty(); field.$setTouched(); hasErrors = true; } } }); // Forcer un digest pour mettre à jour l'affichage if (!$scope.$$phase) { $scope.$apply(); } // Scroll vers le premier champ invalide après un court délai $timeout(function() { const firstInvalid = document.querySelector('.ng-invalid.ng-touched:not(form)'); if (firstInvalid) { firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Focus sur le champ si c'est un input/select if (firstInvalid.tagName === 'INPUT' || firstInvalid.tagName === 'SELECT') { firstInvalid.focus(); } } }, 200); return hasErrors; }; function loadSavedState() { const savedData = sessionStorage.getItem(SESSION_STORAGE_KEY); if (savedData) { try { const parsedData = JSON.parse(savedData); $scope.step = parsedData.step || 1; $scope.method = parsedData.method || null; angular.extend($scope.vehicle, parsedData.vehicle || {}); angular.extend($scope.parties, parsedData.parties || { consignor: { country: 'CH' }, consignee: {}, declarant: {} }); $scope.declarantType = parsedData.declarantType || 'consignor'; angular.extend($scope.declaration, parsedData.declaration || {}); $scope.sessionToken = parsedData.sessionToken || null; if ($scope.sessionToken && $scope.step === 3) { $scope.showStripeButton = true; // --- CORRECTION STRIPE APPLIQUÉE ICI --- loadStripeBuyButtonScript(); // ---------------------------------------- } $rootScope.showNotification($rootScope.t('data_restored'), 'info'); return true; } catch (e) { sessionStorage.removeItem(SESSION_STORAGE_KEY); return false; } } return false; } const urlParams = new URLSearchParams($window.location.search); if (urlParams.get('payment_cancelled') === 'true') { $rootScope.showNotification('Paiement annulé. Vous pouvez réessayer.', 'warning'); $location.search('payment_cancelled', null); } initScope(); loadSavedState(); ConfigService.getCurrencies().then(data => $scope.currencies = data); // Initialiser vide $scope.transportModes = []; // Attendre que les traductions soient chargées TranslationService.getPromise.then(function() { // Fonction pour générer les modes de transport traduits $scope.getTransportModes = function() { return [ { value: '9', label: $rootScope.t('transport_mode_9') }, { value: '3', label: $rootScope.t('transport_mode_3') }, { value: '2', label: $rootScope.t('transport_mode_2') }, { value: '4', label: $rootScope.t('transport_mode_4') }, { value: '8', label: $rootScope.t('transport_mode_8') }, { value: '7', label: $rootScope.t('transport_mode_7') } ]; }; // Initialiser les modes de transport $scope.transportModes = $scope.getTransportModes(); // Mettre à jour quand la langue change $scope.$watch(() => $rootScope.lang, function(newLang) { if (newLang) { $scope.transportModes = $scope.getTransportModes(); } }); }); $scope.$watch('parties.consignor.ide', function(newVal) { $scope.isCompany = !!newVal && newVal.trim() !== 'CHE222251936'; }); $scope.$watch('vehicle', saveState, true); $scope.$watch('parties', saveState, true); $scope.$watch('declaration', saveState, true); $scope.$watch('declarantType', saveState); $scope.$watch('step', saveState); $scope.$watch('method', saveState); // Gestion de l'exclusivité IDE vs Prénom pour tous les parties $scope.$watch('parties.consignor.ide', function(newVal) { $scope.consignorHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.consignor.firstname', function(newVal) { $scope.consignorHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.consignee.ide', function(newVal) { $scope.consigneeHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.consignee.firstname', function(newVal) { $scope.consigneeHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.declarant.ide', function(newVal) { $scope.declarantHasIDE = !!newVal && newVal.trim() !== ''; }); $scope.$watch('parties.declarant.firstname', function(newVal) { $scope.declarantHasFirstname = !!newVal && newVal.trim() !== ''; }); $scope.$watch('submissionResult', function(newVal) { if (newVal) { sessionStorage.removeItem(SESSION_STORAGE_KEY); } }); $scope.selectMethod = function(method) { $scope.method = method; $scope.step = 2; }; $scope.triggerFileInput = () => document.getElementById('file-upload').click(); $scope.handleFileSelect = function(event) { const files = event.target.files; if (!files || files.length === 0) return; $scope.$apply(() => { $scope.ocrLoading = true; $scope.ocrAttempted = true; }); const formData = new FormData(); formData.append('registration_card', files[0]); const context = $window.location.pathname.includes('/export') ? 'export' : 'import'; formData.append('context', context); $http.post('/api/ocr', formData, { transformRequest: angular.identity, headers: { 'Content-Type': undefined } }).then(function(response) { const ocrData = response.data; // Pour export: ignorer paper_origin, ne pas remplir destination_country // Pour import: utiliser paper_origin pour dispatch_country if (context === 'import' && ocrData.paper_origin && $rootScope.countries) { const ocrCountryName = ocrData.paper_origin.toLowerCase().trim(); const countryCode = Object.keys($rootScope.countries).find(code => { const fullCountryName = $rootScope.countries[code].toLowerCase().trim(); const mainCountryName = fullCountryName.split('(')[0].trim(); return mainCountryName === ocrCountryName; }); if (countryCode) { $scope.declaration.dispatch_country = countryCode; } } // Supprimer paper_origin sans l'utiliser delete ocrData.paper_origin; angular.extend($scope.vehicle, ocrData); if ($scope.vehicle.vin) $scope.validateVIN(); $scope.updateClassification(); $rootScope.showNotification($rootScope.t('ocr_success'), 'success'); $scope.startOcrCountdown(); }).catch(function(error) { const details = error.data?.details || error.data?.error || 'Erreur inconnue'; $rootScope.showNotification('Erreur OCR: ' + details, 'error'); }).finally(() => { $scope.ocrLoading = false; }); }; $scope.startOcrCountdown = function() { $scope.ocrCountdown = 30; $scope.ocrCountdownActive = true; const interval = setInterval(function() { $scope.$apply(function() { $scope.ocrCountdown--; if ($scope.ocrCountdown <= 0) { $scope.ocrCountdownActive = false; clearInterval(interval); } }); }, 1000); }; $scope.validateVIN = function() { if (!$scope.vehicle.vin || $scope.vehicle.vin.length !== 17) { $scope.vinValid = false; return; } $http.post('/api/validate-vin', { vin: $scope.vehicle.vin }) .then(function(response) { $scope.vinValid = response.data.valid; if (response.data.valid) { $scope.origin = response.data.country; } }).catch(function() { $scope.vinValid = false; }); }; $scope.updateClassification = function() { $scope.classification = null; $scope.classificationError = null; if (!$scope.vehicle.fuel_type || !$scope.vehicle.weight_empty) return; $http.post('/api/classify-vehicle', $scope.vehicle) .then(function(response) { if (response.data.error) { $scope.classificationError = response.data.message; $rootScope.showNotification(response.data.message, 'error'); return; } $scope.classification = response.data; $scope.vehicle.commodity_code = response.data.commodity_code; if (!response.data.needs_user_selection) { $scope.vehicle.statistical_key = response.data.statistical_key; } }) .catch(function(error) { const message = error.data?.message || 'Erreur réseau lors de la classification.'; $scope.classificationError = message; $rootScope.showNotification(message, 'error'); }); }; $scope.reportClassificationError = function() { if ($scope.reporting) return; $scope.reporting = true; const payload = { vehicle: $scope.vehicle, classification_error: $scope.classificationError || 'Classification non trouvée (signalement manuel)', consignor_ide: $scope.parties.consignor.ide || null, user_lang: $scope.declaration.language, declaration_type: 'export' }; $http.post('/api/report-classification-error', payload) .then(function(response) { if (response.data.success) { $rootScope.showNotification($rootScope.t('error_reported_success') + ' - Merci pour votre aide!', 'success'); } else { $rootScope.showNotification(response.data.message || $rootScope.t('error_reporting_failed'), 'error'); } }) .catch(function(error) { $rootScope.showNotification($rootScope.t('error_reporting_network') + ': ' + (error.data?.message || 'Erreur réseau'), 'error'); }) .finally(() => { $scope.reporting = false; }); }; $scope.nextStep = function() { if ($scope.classificationError) { $rootScope.showNotification($rootScope.t('classification_error_block'), 'error'); return; } if ($scope.step === 2) { const hasErrors = $scope.highlightInvalidFields('vehicleForm'); if (hasErrors || !$scope.vehicle.vin || !$scope.vehicle.brand || !$scope.vehicle.model || !$scope.vehicle.fuel_type || !$scope.vehicle.weight_empty || !$scope.vehicle.weight_total) { $rootScope.showNotification($rootScope.t('fill_required_vehicle'), 'error'); return; } } $scope.step++; $timeout(function() { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 100); }; $scope.prevStep = () => $scope.step--; $scope.showTooltip = function(tooltip, show) { $scope.tooltips[tooltip] = show; }; $scope.toggleTooltip = function(tooltip) { $scope.tooltips[tooltip] = !$scope.tooltips[tooltip]; }; $scope.copyLinkToClipboard = function(link) { const tempInput = document.createElement('input'); tempInput.style.position = 'absolute'; tempInput.style.left = '-9999px'; tempInput.value = 'myautoimport.ch' + link; document.body.appendChild(tempInput); tempInput.select(); document.execCommand('copy'); document.body.removeChild(tempInput); $rootScope.showNotification($rootScope.t('link_copied'), 'success'); }; $scope.prepareDeclaration = function() { if ($scope.preparing) return; const hasErrors = $scope.highlightInvalidFields('exportForm'); const consignor = $scope.parties.consignor; if (hasErrors || !consignor.name || !consignor.zip || !consignor.city || !$scope.declaration.destination_country) { $rootScope.showNotification($rootScope.t('fill_required_export'), 'error'); return; } if (!$scope.declaration.purchase_price || $scope.declaration.purchase_price <= 0) { $rootScope.showNotification($rootScope.t('fill_valid_price'), 'error'); return; } if ($scope.declaration.purchase_price < 2000 && !$scope.declaration.price_confirmation) { $rootScope.showNotification($rootScope.t('confirm_low_price'), 'error'); return; } $scope.preparing = true; const payload = { type: 'export', vehicle: $scope.vehicle, parties: $scope.parties, declaration: $scope.declaration, declarantType: $scope.declarantType }; $http.post('/api/prepare-declaration', payload) .then(function(response) { if (response.data.success) { $scope.sessionToken = response.data.sessionToken; $scope.showStripeButton = true; saveState(); loadStripeBuyButtonScript(); $rootScope.showNotification('Déclaration prête ! Procédez au paiement.', 'success'); } else { $rootScope.showNotification(response.data.message || 'Erreur de préparation', 'error'); } }) .catch(function(error) { $rootScope.showNotification('Erreur: ' + (error.data?.message || 'Erreur réseau'), 'error'); }) .finally(() => { $scope.preparing = false; }); }; $scope.listenForSubmissionStatus = function() { if (!$scope.sessionToken) return; if ($scope.eventSource) { $scope.eventSource.close(); } $scope.step = 4; $scope.showProgress = true; const source = new EventSource('/api/submission-status-sse/' + $scope.sessionToken); $scope.eventSource = source; source.onmessage = function(event) { $scope.$apply(() => { try { const status = JSON.parse(event.data); $scope.submissionProgress = status.progress; $scope.submissionStep = status.step; if (status.status === 'submitted') { $scope.submissionResult = status; $scope.showProgress = false; source.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); $rootScope.showNotification($rootScope.t('submission_success'), 'success'); } else if (status.status === 'submission_error') { $scope.showProgress = false; source.close(); $rootScope.setErrorContext($scope.sessionToken); $rootScope.showNotification($rootScope.t('submission_error_detected'), 'error'); } } catch (e) { console.error('[ExportController-SSE] Erreur:', e); } }); }; source.onerror = function(error) { console.error('[ExportController-SSE] Erreur:', error); $scope.$apply(() => { $scope.submissionStep = 'Erreur de connexion SSE...'; }); }; }; $scope.checkPaymentReturn = function() { const returnData = sessionStorage.getItem('stripe_payment_return'); if (returnData) { try { const data = JSON.parse(returnData); if (data.sessionToken === $scope.sessionToken) { sessionStorage.removeItem('stripe_payment_return'); $scope.listenForSubmissionStatus(); } } catch (e) { console.error('Erreur parsing returnData:', e); } } }; $scope.checkPaymentReturn(); $scope.downloadPDF = function(path) { if (!path) return; $window.open('/api/download-pdf?path=' + encodeURIComponent(path), '_blank'); }; $scope.resetForm = function() { if ($scope.eventSource) $scope.eventSource.close(); sessionStorage.removeItem(SESSION_STORAGE_KEY); initScope(); $rootScope.showNotification($rootScope.t('form_reset'), 'info'); }; }); ---------------------------------------------------- public/js/app.js angular.module('edecApp', ['ngRoute']) .config(['$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { $locationProvider.html5Mode({ enabled: true, requireBase: true }); $routeProvider .when('/', { templateUrl: 'views/home.html', controller: 'HomeController' }) .when('/import', { templateUrl: 'views/import.html', controller: 'ImportController' }) .when('/payment-success', { template: `

Paiement réussi !

Vérification du paiement en cours...

Lancement de la soumission...

{{ error }}

`, controller: ['$scope', '$location', '$timeout', '$http', '$rootScope', '$window', function($scope, $location, $timeout, $http, $rootScope, $window) { $scope.verifying = false; $scope.error = null; const urlParams = new URLSearchParams($window.location.search); const stripeSessionId = urlParams.get('session_id'); if (!stripeSessionId) { $scope.error = 'Session ID manquant. Retour à l\'accueil...'; $timeout(() => $location.path('/'), 3000); return; } const importData = sessionStorage.getItem('edec_import_form'); const exportData = sessionStorage.getItem('edec_export_form'); let sessionToken = null; let destinationType = 'import'; if (importData) { const parsed = JSON.parse(importData); sessionToken = parsed.sessionToken; destinationType = 'import'; } else if (exportData) { const parsed = JSON.parse(exportData); sessionToken = parsed.sessionToken; destinationType = 'export'; } if (!sessionToken) { $scope.error = 'Déclaration non trouvée. Retour à l\'accueil...'; $timeout(() => $location.path('/'), 3000); return; } $scope.verifying = true; $http.post('/api/verify-checkout-session', { sessionToken: sessionToken, stripeSessionId: stripeSessionId }) .then(function(response) { if (response.data.success) { sessionStorage.setItem('stripe_payment_return', JSON.stringify({ sessionToken: sessionToken, verified: true })); $timeout(() => { $location.path('/' + destinationType); }, 1500); } else { $scope.error = response.data.message || 'Erreur de vérification du paiement'; } }) .catch(function(error) { $scope.error = error.data?.message || 'Erreur réseau'; }); }] }) .when('/export', { templateUrl: 'views/export.html', controller: 'ExportController' }) .when('/payment', { templateUrl: '/views/payment.html', controller: 'PaymentController' }) .when('/submission-error', { templateUrl: 'views/submission-error.html', controller: 'SubmissionErrorController' }) .otherwise({ redirectTo: '/' }); }]) .controller('MainController', ['$scope', '$location', '$window', '$timeout', '$rootScope', 'ConfigService', function($scope, $location, $window, $timeout, $rootScope, ConfigService) { // Gestion de la langue $scope.currentLang = 'fr'; // Langue par défaut // Fonction d'initialisation de la langue $scope.initLanguage = function() { // 1. Vérifier le paramètre URL ?lang=xx var urlParams = new URLSearchParams($window.location.search); var langParam = urlParams.get('lang'); const SUPPORTED_LANGS = ['fr', 'de', 'it', 'en']; if (langParam && SUPPORTED_LANGS.includes(langParam)) { $scope.currentLang = langParam; localStorage.setItem('myAutoImportLang', langParam); } // 2. Sinon vérifier le localStorage else if (localStorage.getItem('myAutoImportLang')) { const storedLang = localStorage.getItem('myAutoImportLang'); if (SUPPORTED_LANGS.includes(storedLang)) { $scope.currentLang = storedLang; } } // 3. Sinon détecter la langue du navigateur else { var browserLang = navigator.language || navigator.userLanguage; if (browserLang.startsWith('de')) $scope.currentLang = 'de'; else if (browserLang.startsWith('it')) $scope.currentLang = 'it'; else if (browserLang.startsWith('en')) $scope.currentLang = 'en'; else $scope.currentLang = 'fr'; localStorage.setItem('myAutoImportLang', $scope.currentLang); } // Synchroniser avec $rootScope $rootScope.lang = $scope.currentLang; }; $scope.toggleMobileMenu = function() { var menu = document.querySelector('.nav-menu'); menu.classList.toggle('active'); }; // Fermer le menu si on clique ailleurs document.addEventListener('click', function(event) { var menu = document.querySelector('.nav-menu'); var burger = document.querySelector('.mobile-menu-toggle'); if (menu && burger && !menu.contains(event.target) && !burger.contains(event.target)) { menu.classList.remove('active'); } }); // Initialiser la langue au chargement $scope.initLanguage(); // 🌍 NOUVEAU : Charger les pays une seule fois ConfigService.getCountries().then(function(data) { $rootScope.allCountries = data; $rootScope.countries = data[$scope.currentLang] || data['fr'] || {}; console.log('🌍 [MainController] Pays chargés pour langue:', $scope.currentLang); }); // Fonction de traduction $scope.t = function(key) { // Utilise directement $rootScope.t si disponible if ($rootScope.t && typeof $rootScope.t === 'function') { return $rootScope.t(key); } // Sinon retourne la clé (fallback) return key; }; // CORRECTION: Fonction pour changer de langue SANS RECHARGER LA PAGE $scope.setLang = function(lang) { $scope.currentLang = lang; $rootScope.lang = lang; localStorage.setItem('myAutoImportLang', lang); // 🌍 NOUVEAU : Mettre à jour les pays selon la nouvelle langue if ($rootScope.allCountries && $rootScope.allCountries[lang]) { $rootScope.countries = $rootScope.allCountries[lang]; console.log('🔄 [MainController] Pays mis à jour pour langue:', lang); } // Forcer la mise à jour de la vue Angular $timeout(function() { $scope.$apply(); }); }; // Fonction pour vérifier la langue active $scope.isLang = function(lang) { return $scope.currentLang === lang; }; // Fonction pour obtenir le chemin actuel $scope.currentPath = function() { return $location.path(); }; $scope.$watch('currentLang', function(newLang) { var titles = { 'fr': 'Importer & Exporter Voiture en Suisse - Déclaration Douanière Simplifiée | MyAutoImport.ch', 'de': 'Auto Import & Export Schweiz - Vereinfachte Zollanmeldung | MyAutoImport.ch', 'it': 'Importare & Esportare Auto in Svizzera - Dichiarazione Semplificata | MyAutoImport.ch', 'en': 'Import & Export Car in Switzerland - Simplified Declaration | MyAutoImport.ch' }; $window.document.title = titles[newLang] || titles['fr']; }); }]) .factory('ConfigService', ['$http', '$q', function($http, $q) { const cache = {}; function getCached(key, apiUrl) { if (cache[key]) { return $q.resolve(cache[key]); } const dataFromSession = sessionStorage.getItem(key); if (dataFromSession) { cache[key] = JSON.parse(dataFromSession); return $q.resolve(cache[key]); } return $http.get(apiUrl).then(function(response) { cache[key] = response.data; sessionStorage.setItem(key, JSON.stringify(response.data)); return response.data; }); } return { getCountries: () => getCached('countries', '/api/countries'), getCurrencies: () => getCached('currencies', '/api/currencies'), getExchangeRates: () => getCached('exchange_rates', '/api/exchange-rates') }; }]) .factory('TranslationService', ['$http', '$q', function($http, $q) { let translations = {}; const promise = $http.get('/translations/strings.json').then(function(response) { translations = response.data; return translations; }); return { getPromise: promise, get: function(key, lang) { if (translations[key] && translations[key][lang]) { return translations[key][lang]; } return key; } }; }]) .run(['$rootScope', '$location', 'TranslationService', function($rootScope, $location, TranslationService) { // Supprime l'ancienne logique de gestion de la langue delete $rootScope.lang; delete $rootScope.setLang; delete $rootScope.isLang; // Assurer que $rootScope.t utilise la traduction fournie par TranslationService TranslationService.getPromise.then(function() { $rootScope.t = function(key) { // Récupère la langue depuis le scope parent (MainController) const lang = $rootScope.currentLang || localStorage.getItem('myAutoImportLang') || 'fr'; return TranslationService.get(key, lang); }; }); $rootScope.setErrorContext = function(token) { sessionStorage.setItem('error_session_token', token); $location.path('/submission-error'); }; $rootScope.notifications = []; $rootScope.showNotification = function(message, type, duration) { type = type || 'info'; duration = duration || 5000; const notification = { id: Date.now(), message: message, type: type, visible: true }; $rootScope.notifications.push(notification); if (duration > 0) { setTimeout(() => { $rootScope.$apply(() => { notification.visible = false; setTimeout(() => { $rootScope.$apply(() => { const index = $rootScope.notifications.indexOf(notification); if (index > -1) { $rootScope.notifications.splice(index, 1); } }); }, 300); }); }, duration); } return notification.id; }; $rootScope.hideNotification = function(id) { const notification = $rootScope.notifications.find(n => n.id === id); if (notification) { notification.visible = false; setTimeout(() => { $rootScope.$apply(() => { const index = $rootScope.notifications.indexOf(notification); if (index > -1) { $rootScope.notifications.splice(index, 1); } }); }, 300); } }; }]); ---------------------------------------------------- public/index.html Importer & Exporter Voiture en Suisse - Déclaration Douanière Simplifiée | MyAutoImport.ch
---------------------------------------------------- public/translations/script_convertion.sh #!/bin/bash INPUT_FILE="strings_old.json" OUTPUT_FILE="strings.json" TEMP_DIR=$(mktemp -d) echo "Transformation en cours..." # Étape 1: Extraire toutes les clés jq -r '[.fr, .en, .de, .it | keys] | flatten | unique | .[]' "$INPUT_FILE" > "$TEMP_DIR/keys.txt" # Étape 2: Créer un objet vide pour commencer echo '{}' > "$TEMP_DIR/result.json" # Étape 3: Pour chaque clé, construire l'objet de traduction while IFS= read -r key; do # Extraire les valeurs pour chaque langue fr_value=$(jq -r ".fr.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) en_value=$(jq -r ".en.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) de_value=$(jq -r ".de.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) it_value=$(jq -r ".it.\"$key\" // \"\"" "$INPUT_FILE" 2>/dev/null) # Construire l'objet JSON pour cette clé key_object=$(jq -n \ --arg key "$key" \ --arg fr "$fr_value" \ --arg en "$en_value" \ --arg de "$de_value" \ --arg it "$it_value" \ '{ ($key): { "fr": (if $fr == "" then null else $fr end), "en": (if $en == "" then null else $en end), "de": (if $de == "" then null else $de end), "it": (if $it == "" then null else $it end) } }') # Fusionner avec le résultat existant jq -s '.[0] * .[1]' "$TEMP_DIR/result.json" <(echo "$key_object") > "$TEMP_DIR/result_temp.json" mv "$TEMP_DIR/result_temp.json" "$TEMP_DIR/result.json" done < "$TEMP_DIR/keys.txt" # Étape 4: Trier et formater le résultat final jq -S '.' "$TEMP_DIR/result.json" > "$OUTPUT_FILE" # Nettoyer rm -rf "$TEMP_DIR" echo "✅ Transformation terminée! Fichier: $OUTPUT_FILE" # Vérification echo "" echo "Vérification des premières clés:" jq 'with_entries(select(.key | test("import_vehicle_nav|export_vehicle_nav|home_nav")))' "$OUTPUT_FILE" ---------------------------------------------------- public/translations/strings.json { "additional_costs":{ "de":"Zusätzliche Kosten (in derselben Währung)", "en":"Additional costs (in same currency)", "fr":"Coûts additionnels (dans la même devise)", "it":"Costi aggiuntivi (nella stessa valuta)" }, "address_street":{ "de":"Adresse (Strasse und Nummer)", "en":"Address (Street and number)", "fr":"Adresse (Rue et numéro)", "it":"Indirizzo (Via e numero)" }, "auto_extraction_ai":{ "de":"Automatische Datenextraktion durch KI (JPG, PNG, PDF)", "en":"Automatic data extraction by AI (JPG, PNG, PDF)", "fr":"Extraction automatique des données par IA (JPG, PNG, PDF)", "it":"Estrazione automatica dei dati tramite IA (JPG, PNG, PDF)" }, "back":{ "de":"← Zurück", "en":"← Back", "fr":"← Retour", "it":"← Indietro" }, "brand":{ "de":"Marke", "en":"Brand", "fr":"Marque", "it":"Marca" }, "choose_input_method":{ "de":"Wählen Sie Ihre Eingabemethode", "en":"Choose your input method", "fr":"Choisissez votre méthode de saisie", "it":"Scegli il tuo metodo di inserimento" }, "city":{ "de":"Ort", "en":"City", "fr":"Ville", "it":"Città" }, "companies_only":{ "de":"Nur für Unternehmen", "en":"For companies only", "fr":"Uniquement pour les entreprises", "it":"Solo per le aziende" }, "consignee":{ "de":"Empfänger", "en":"Consignee", "fr":"Destinataire", "it":"Destinatario" }, "consignee_definition":{ "de":"Empfänger ist die Person oder Firma im Ausland, der die Ware zugestellt wird.", "en":"The consignee is the person or company domiciled abroad to whom the goods are shipped.", "fr":"Est destinataire la personne ou l'entreprise domiciliée à l'étranger à qui la marchandise est expédiée", "it":"Persona o ditta domiciliata all'estero alla quale è consegnata la merce." }, "consignor":{ "de":"Versender", "en":"Consignor", "fr":"Expéditeur", "it":"Mittente" }, "consignor_definition":{ "de":"Versender ist die Person oder das Unternehmen mit Sitz in der Schweiz, die die Ware aus dem Gebiet versendet.", "en":"The consignor is the person or company domiciled in Switzerland who ships the goods out of the territory.", "fr":"Est expéditeur la personne ou l'entreprise domiciliée en Suisse qui expédie la marchandise hors du territoire.", "it":"È mittente la persona o l'azienda domiciliata in Svizzera che spedisce la merce fuori dal territorio." }, "continue":{ "de":"Weiter", "en":"Continue", "fr":"Continuer", "it":"Continua" }, "copy_access_link":{ "de":"Zugriffslink kopieren", "en":"Copy access link", "fr":"Copier le lien d'accès", "it":"Copiare il link di accesso" }, "copy_from_consignee":{ "de":"Von Empfänger kopieren", "en":"Copy from consignee", "fr":"Copier depuis destinataire", "it":"Copia da destinatario" }, "copy_from_consignor":{ "de":"Vom Versender kopieren", "en":"Copy from consignor", "fr":"Copier depuis expéditeur", "it":"Copia da mittente" }, "customs_regime":{ "de":"Zollverfahren", "en":"Customs Regime", "fr":"Régime Douanier", "it":"Regime Doganale" }, "cylinder_capacity":{ "de":"Hubraum", "en":"Engine displacement", "fr":"Cylindrée", "it":"Cilindrata" }, "data_restored":{ "de":"Vorherige Daten wiederhergestellt", "en":"Previous data restored", "fr":"Données précédentes restaurées", "it":"Dati precedenti ripristinati" }, "declarant":{ "de":"Anmelder", "en":"Declarant", "fr":"Déclarant", "it":"Dichiarante" }, "declarant_definition":{ "de":"Die Person, die diese Anmeldung durchführt.", "en":"The person making this declaration.", "fr":"La personne qui effectue la présente déclaration.", "it":"La persona che effettua la presente dichiarazione." }, "declarant_info":{ "de":"Informationen des Anmelders", "en":"Declarant information", "fr":"Informations du déclarant", "it":"Informazioni del dichiarante" }, "declaration":{ "de":"Anmeldung", "en":"Declaration", "fr":"Déclaration", "it":"Dichiarazione" }, "declaration_info":{ "de":"Informationen für die Anmeldung", "en":"Declaration information", "fr":"Informations pour la déclaration", "it":"Informazioni per la dichiarazione" }, "declaration_number":{ "de":"Anmeldungsnummer", "en":"Declaration number", "fr":"Numéro de déclaration", "it":"Numero di dichiarazione" }, "country":{ "de":"Land", "en":"Country", "fr":"Pays", "it":"Paese" }, "destination_country":{ "de":"Bestimmungsland", "en":"Destination country", "fr":"Pays de destination", "it":"Paese di destinazione" }, "diesel":{ "de":"Diesel", "en":"Diesel", "fr":"Diesel", "it":"Diesel" }, "dispatch_country":{ "de":"Versendungsland", "en":"Dispatch country", "fr":"Pays d'expédition", "it":"Paese di spedizione" }, "documents_expire_30_days":{ "de":"Die Dokumente und dieser Zugriffslink **verfallen in 30 Tagen**.", "en":"Documents and this access link **will expire in 30 days**.", "fr":"Les documents et ce lien d'accès **expireront dans 30 jours**.", "it":"I documenti e questo link di accesso **scadranno tra 30 giorni**." }, "download_delivery_bulletin":{ "de":"Abfertigungsschein herunterladen", "en":"Download Delivery Bulletin", "fr":"Télécharger le Bulletin de Délivrance", "it":"Scaricare il Bollettino di Consegna" }, "download_documents_text":{ "de":"Bitte laden Sie die folgenden Dokumente herunter. Sie werden beim Grenzübertritt benötigt.", "en":"Please download the documents below. They will be required at customs.", "fr":"Veuillez télécharger les documents ci-dessous. Ils vous seront demandés lors du passage en douane.", "it":"Si prega di scaricare i seguenti documenti. Ti saranno richiesti al passaggio in dogana." }, "download_export_list":{ "de":"Ausfuhranmeldung herunterladen", "en":"Download Export Declaration", "fr":"Télécharger la Déclaration d'Exportation", "it":"Scaricare la Dichiarazione di Esportazione" }, "download_import_list":{ "de":"Einfuhrliste herunterladen", "en":"Download Import List", "fr":"Télécharger la Liste d'Importation", "it":"Scaricare la Lista di Importazione" }, "electric":{ "de":"Elektrisch", "en":"Electric", "fr":"Électrique", "it":"Elettrico" }, "estimated_classification":{ "de":"Geschätzte Zollklassifizierung", "en":"Estimated customs classification", "fr":"Classification douanière estimée", "it":"Classificazione doganale stimata" }, "export_page_title":{ "de":"Ein Fahrzeug aus der Schweiz exportieren", "en":"Export a Vehicle from Switzerland", "fr":"Exporter un Véhicule depuis la Suisse", "it":"Esportare un Veicolo dalla Svizzera" }, "export_vehicle_nav":{ "de":"Exportieren", "en":"Export", "fr":"Exporter", "it":"Esportare" }, "extraction_in_progress":{ "de":"Extraktion läuft", "en":"Extraction in progress", "fr":"Extraction en cours", "it":"Estrazione in corso" }, "feature_ai_desc":{ "de":"Automatische Extraktion aus Ihrem Fahrzeugausweis", "en":"Automatic data extraction from your registration card", "fr":"Extraction automatique depuis votre carte grise", "it":"Estrazione automatica dalla tua carta di circolazione" }, "feature_ai_title":{ "de":"Integrierte KI", "en":"AI integrated", "fr":"IA intégrée", "it":"IA integrata" }, "feature_eco_desc":{ "de":"Transparente Preise, persönliches Tool", "en":"Transparent pricing, personal tool", "fr":"Tarifs transparents, outil personnel", "it":"Tariffe trasparenti, strumento personale" }, "feature_eco_title":{ "de":"Wirtschaftlich", "en":"Economical", "fr":"Économique", "it":"Economico" }, "feature_fast_desc":{ "de":"Erstellen Sie Ihre Anmeldung in weniger als 5 Minuten", "en":"Generate your declaration in under 5 minutes", "fr":"Générez votre déclaration en moins de 5 minutes", "it":"Genera la tua dichiarazione in meno di 5 minuti" }, "feature_fast_title":{ "de":"Schnell", "en":"Fast", "fr":"Rapide", "it":"Veloce" }, "feature_simple_desc":{ "de":"Intuitive Benutzeroberfläche, geführte Schritte", "en":"Intuitive interface, guided steps", "fr":"Interface intuitive, étapes guidées", "it":"Interfaccia intuitiva, passaggi guidati" }, "feature_simple_title":{ "de":"Einfach", "en":"Simple", "fr":"Simple", "it":"Semplice" }, "fill_form_step_by_step":{ "de":"Formular Schritt für Schritt ausfüllen", "en":"Fill out form step by step", "fr":"Remplir le formulaire pas à pas", "it":"Compilare il modulo passo dopo passo" }, "fill_required_export":{ "de":"Bitte geben Sie alle erforderlichen Informationen an.", "en":"Please provide all required information.", "fr":"Veuillez renseigner toutes les informations obligatoires.", "it":"Si prega di fornire tutte le informazioni obbligatorie." }, "fill_required_importer":{ "de":"Bitte geben Sie die Informationen des Importeurs und das Versendungsland an.", "en":"Please provide importer information and dispatch country.", "fr":"Veuillez renseigner les informations de l'importateur et le pays d'expédition.", "it":"Si prega di fornire le informazioni dell'importatore e il paese di spedizione." }, "fill_required_vehicle":{ "de":"Bitte füllen Sie die erforderlichen Fahrzeuginformationen aus.", "en":"Please fill in the required vehicle information.", "fr":"Veuillez remplir les informations obligatoires du véhicule.", "it":"Si prega di compilare le informazioni obbligatorie del veicolo." }, "fill_valid_price":{ "de":"Bitte geben Sie einen gültigen Kaufpreis an (größer als 0).", "en":"Please enter a valid purchase price (greater than 0).", "fr":"Veuillez renseigner un prix d'achat valide (supérieur à 0).", "it":"Si prega di inserire un prezzo d'acquisto valido (superiore a 0)." }, "financial_transactions":{ "de":"Finanzielle Transaktionen", "en":"Financial Transactions", "fr":"Transactions Financières", "it":"Transazioni Finanziarie" }, "firstname":{ "de":"Vorname", "en":"First name", "fr":"Prénom", "it":"Nome" }, "for_individuals":{ "de":"Für Privatpersonen", "en":"For individuals", "fr":"Pour les particuliers", "it":"Per i privati" }, "form_reset":{ "de":"Formular zurückgesetzt.", "en":"Form reset.", "fr":"Formulaire réinitialisé.", "it":"Modulo reimpostato." }, "fuel_type":{ "de":"Kraftstoffart", "en":"Fuel type", "fr":"Type de carburant", "it":"Tipo di carburante" }, "future_access_and_sharing":{ "de":"Zukünftiger Zugriff und Freigabe", "en":"Future access and sharing", "fr":"Accès futur et partage", "it":"Accesso futuro e condivisione" }, "how_it_works_title":{ "de":"Wie funktioniert es?", "en":"How It Works?", "fr":"Comment ça marche ?", "it":"Come Funziona?" }, "step_1_title":{ "de":"Wählen Sie Ihre Methode", "en":"Choose Your Method", "fr":"Choisissez votre méthode", "it":"Scegli il tuo metodo" }, "step_1_description":{ "de":"Scannen Sie den Fahrzeugausweis oder geben Sie die Daten manuell ein", "en":"Scan the registration certificate or manual entry", "fr":"Scanner la carte grise ou saisie manuelle", "it":"Scansiona la carta di circolazione o inserimento manuale" }, "step_2_title":{ "de":"Füllen Sie die Informationen aus", "en":"Fill in the Information", "fr":"Remplissez les informations", "it":"Compila le informazioni" }, "step_2_description":{ "de":"Fahrzeug, Finanzen und Kontaktdaten", "en":"Vehicle, finance, and contact details", "fr":"Véhicule, finances et coordonnées", "it":"Veicolo, dati finanziari e coordinate" }, "step_3_title":{ "de":"Generieren Sie die XML-Datei", "en":"Generate the XML File", "fr":"Générez le fichier XML", "it":"Genera il file XML" }, "step_3_description":{ "de":"Laden Sie Ihre sofort einsatzbereite e-dec-Deklaration herunter", "en":"Download your ready-to-use e-dec declaration", "fr":"Téléchargez votre déclaration e-dec prête à l'emploi", "it":"Scarica la tua dichiarazione e-dec pronta all'uso" }, "step_4_title":{ "de":"Reichen Sie beim Zoll ein", "en":"Submit to Customs", "fr":"Soumettez à la douane", "it":"Invia alla dogana" }, "step_4_description":{ "de":"Importieren Sie die Datei in das offizielle e-dec-Portal", "en":"Import the file on the official e-dec portal", "fr":"Importez le fichier sur le portail officiel e-dec", "it":"Importa il file sul portale ufficiale e-dec" }, "home_hero_cta_export":{ "de":"Fahrzeug exportieren", "en":"Export a vehicle", "fr":"Exporter un véhicule", "it":"Esportare un veicolo" }, "home_hero_cta_import":{ "de":"Fahrzeug importieren", "en":"Import a vehicle", "fr":"Importer un véhicule", "it":"Importare un veicolo" }, "home_nav":{ "de":"Startseite", "en":"Home", "fr":"Accueil", "it":"Home" }, "hybrid":{ "de":"Hybrid", "en":"Hybrid", "fr":"Hybride", "it":"Ibrido" }, "hybrid_plugin":{ "de":"Plugin-Hybrid", "en":"Plug-in hybrid", "fr":"Hybride rechargeable", "it":"Ibrido plug-in" }, "ide_number":{ "de":"IDE-Nummer", "en":"IDE number", "fr":"Numéro IDE", "it":"Numero IDE" }, "import_page_title":{ "de":"Ein Fahrzeug in die Schweiz importieren", "en":"Import a Vehicle to Switzerland", "fr":"Importer un Véhicule en Suisse", "it":"Importare un Veicolo in Svizzera" }, "import_vehicle_nav":{ "de":"Importieren", "en":"Import", "fr":"Importer", "it":"Importare" }, "importer":{ "de":"Importeur", "en":"Importer", "fr":"Importateur", "it":"Importatore" }, "importer_definition":{ "de":"Importeur ist, wer die Ware in das Schweizer Gebiet einführt oder für seine Rechnung einführen lässt. Importeur und Empfänger können dieselbe Person/Firma sein.", "en":"The importer is the person who imports the goods into Swiss territory or has them imported on their behalf. The importer and consignee can be the same person/company.", "fr":"Est importateur celui qui importe la marchandise dans le territoire Suisse ou la fait importer pour son compte. L'importateur et le destinataire peuvent être une seule et même personne / entreprise.", "it":"È importatore chi importa la merce nel territorio svizzero o la fa importare per proprio conto. L'importatore e il destinatario possono essere la stessa persona/azienda." }, "info_copied":{ "de":"Informationen kopiert!", "en":"Information copied!", "fr":"Informations copiées !", "it":"Informazioni copiate!" }, "invoice_currency":{ "de":"Rechnungswährung", "en":"Invoice currency", "fr":"Monnaie de la facture", "it":"Valuta della fattura" }, "involved_parties":{ "de":"Beteiligte Parteien", "en":"Involved Parties", "fr":"Parties Impliquées", "it":"Parti Coinvolte" }, "iva_exempt":{ "de":"Von der Automobilsteuer befreit (IVA / RG 660)", "en":"Exempt from automobile tax (IVA / RG 660)", "fr":"Exonéré de l'impôt sur les véhicules automobiles (IVA / RG 660)", "it":"Esente dall'imposta sugli autoveicoli (IVA / RG 660)" }, "legal_notice_text_1":{ "de":"Unser Service ist eine vereinfachte Schnittstelle zum offiziellen e-dec Portal der Schweizerischen Eidgenossenschaft, das kostenlos, aber komplex zu bedienen ist. Als Selbstanmelder bleiben Sie für die Richtigkeit der eingegebenen Daten verantwortlich.", "en":"Our service is a simplified interface for the official e-dec portal of the Swiss Confederation, which remains free but complex to use. As a self-declarant, you remain responsible for the accuracy of the data entered.", "fr":"Notre service est une interface simplifiée pour le portail e-dec officiel de la Confédération Suisse, qui reste gratuit mais complexe à utiliser. En tant qu'auto-déclarant, vous restez responsable de l'exactitude des données saisies.", "it":"Il nostro servizio è un'interfaccia semplificata per il portale e-dec ufficiale della Confederazione Svizzera, che rimane gratuito ma complesso da usare. Come autodichiarante, rimani responsabile dell'accuratezza dei dati inseriti." }, "legal_notice_text_2":{ "de":"Wenn Ihre Informationen korrekt sind, gibt es keine Probleme. Unser Team verfügt über 7 Jahre Erfahrung in Zollanmeldungen und garantiert einen zuverlässigen und konformen Prozess. Unsere Lösung ist bis zu 12-mal schneller als ein traditioneller Spediteur (5 Minuten gegenüber 1 Stunde) und viel wirtschaftlicher.", "en":"If your information is correct, there are no problems. Our team has 7 years of experience in customs declarations and we guarantee a reliable and compliant process. Our solution is up to 12 times faster than a traditional freight forwarder (5 minutes vs 1 hour) and much more economical.", "fr":"Si vos informations sont correctes, il n'y a aucun problème. Notre équipe possède 7 ans d'expérience dans les déclarations douanières et nous vous garantissons un processus fiable et conforme. Notre solution est jusqu'à 12 fois plus rapide qu'un transitaire traditionnel (5 minutes contre 1 heure) et bien plus économique.", "it":"Se le tue informazioni sono corrette, non ci sono problemi. Il nostro team ha 7 anni di esperienza nelle dichiarazioni doganali e garantiamo un processo affidabile e conforme. La nostra soluzione è fino a 12 volte più veloce di uno spedizioniere tradizionale (5 minuti contro 1 ora) e molto più economica." }, "legal_notice_text_3":{ "de":"Die Zahlung ist für eine einzige Anmeldung (Import oder Export) gültig. Bei nachgewiesenen technischen Problemen unsererseits kontaktieren wir Sie für Unterstützung.", "en":"Payment is valid for a single declaration (import or export). In case of proven technical issues on our part, we will contact you for assistance.", "fr":"Le paiement est valable pour une seule déclaration (import ou export). En cas de problème technique avéré de notre part, nous vous recontacterons pour assistance.", "it":"Il pagamento è valido per una sola dichiarazione (import o export). In caso di problemi tecnici comprovati da parte nostra, ti ricontatteremo per assistenza." }, "legal_notice_title":{ "de":"⚖️ Rechtliche Hinweise", "en":"⚖️ Legal Information", "fr":"⚖️ Informations Légales", "it":"⚖️ Informazioni Legali" }, "link_copied":{ "de":"Link in die Zwischenablage kopiert!", "en":"Link copied to clipboard!", "fr":"Lien copié dans le presse-papiers !", "it":"Link copiato negli appunti!" }, "loading_payment":{ "de":"Zahlungsmodul wird geladen", "en":"Loading payment module", "fr":"Chargement du module de paiement", "it":"Caricamento modulo di pagamento" }, "manual_input":{ "de":"Manuelle Eingabe", "en":"Manual input", "fr":"Saisie manuelle", "it":"Inserimento manuale" }, "matriculation_number":{ "de":"Kontrollschild-Nr.", "en":"License plate number", "fr":"N° de matricule (plaque)", "it":"N° di targa" }, "matriculation_number_hint":{ "de":"Format: 123.456.789", "en":"Format: 123.456.789", "fr":"Format: 123.456.789", "it":"Formato: 123.456.789" }, "method":{ "de":"Methode", "en":"Method", "fr":"Méthode", "it":"Metodo" }, "model":{ "de":"Typ", "en":"Model", "fr":"Modèle", "it":"Modello" }, "name_or_company":{ "de":"Name oder Firma", "en":"Name or company name", "fr":"Nom ou raison sociale", "it":"Nome o ragione sociale" }, "new_declaration":{ "de":"Neue Anmeldung durchführen", "en":"Make a new declaration", "fr":"Effectuer une nouvelle déclaration", "it":"Effettuare una nuova dichiarazione" }, "ocr_countdown_text":{ "de":"Bitte überprüfen Sie die von der KI extrahierten Daten sorgfältig. Fehler sind möglich. Die Schaltfläche \"Weiter\" wird aktiviert in:", "en":"Please carefully verify the data extracted by AI. Errors are possible. The \"Continue\" button will be enabled in:", "fr":"Veuillez vérifier attentivement les données extraites par l'IA. Des erreurs sont possibles. Le bouton \"Continuer\" sera activé dans :", "it":"Si prega di verificare attentamente i dati estratti dall'IA. Sono possibili errori. Il pulsante \"Continua\" sarà attivato tra:" }, "ocr_failed_retry":{ "de":"Analyse Ihres Fahrzeugausweises nicht möglich. Bitte Datei überprüfen und erneut versuchen oder zur manuellen Eingabe wechseln.", "en":"Registration card analysis failed. Please check file and retry, or switch to manual input.", "fr":"Analyse de votre carte grise impossible. Veuillez vérifier le fichier et réessayer, ou passer en saisie manuelle.", "it":"Analisi della carta di circolazione impossibile. Verificare il file e riprovare, o passare all'inserimento manuale." }, "ocr_success":{ "de":"Daten erfolgreich extrahiert!", "en":"Data extracted successfully!", "fr":"Données extraites avec succès !", "it":"Dati estratti con successo!" }, "optional":{ "de":"Optional", "en":"Optional", "fr":"Optionnel", "it":"Opzionale" }, "other_costs":{ "de":"Sonstige Kosten (z.B. Versicherung)", "en":"Other costs (e.g. insurance)", "fr":"Autres frais (ex: assurance)", "it":"Altri costi (es: assicurazione)" }, "other_person":{ "de":"Andere Person", "en":"Other person", "fr":"Autre personne", "it":"Altra persona" }, "pay_now":{ "de":"50 CHF bezahlen", "en":"Pay 50 CHF", "fr":"Payer 50 CHF", "it":"Pagare 50 CHF" }, "payment_init_error":{ "de":"Fehler bei der Zahlungsinitialisierung", "en":"Error initializing payment", "fr":"Erreur lors de l'initialisation du paiement", "it":"Errore durante l'inizializzazione del pagamento" }, "payment_invalid":{ "de":"Zahlung ungültig oder bereits verwendet", "en":"Payment invalid or already used", "fr":"Paiement invalide ou déjà utilisé", "it":"Pagamento non valido o già utilizzato" }, "payment_secure_info":{ "de":"Sichere Zahlung über Stripe. Ihre Bankdaten durchlaufen niemals unsere Server.", "en":"Secure payment via Stripe. Your banking data never passes through our servers.", "fr":"Paiement sécurisé par Stripe. Vos données bancaires ne transitent jamais par nos serveurs.", "it":"Pagamento sicuro tramite Stripe. I tuoi dati bancari non transitano mai sui nostri server." }, "payment_single_use":{ "de":"⚠️ Gültig für eine einzige Anmeldung", "en":"⚠️ Valid for one declaration only", "fr":"⚠️ Valable pour une seule déclaration", "it":"⚠️ Valido per una sola dichiarazione" }, "payment_subtitle":{ "de":"Eine Zahlung für eine Anmeldung", "en":"One payment for one declaration", "fr":"Un seul paiement pour une déclaration", "it":"Un solo pagamento per una dichiarazione" }, "payment_success":{ "de":"✅ Zahlung erfolgreich! Weiterleitung...", "en":"✅ Payment successful! Redirecting...", "fr":"✅ Paiement réussi ! Redirection...", "it":"✅ Pagamento riuscito! Reindirizzamento..." }, "payment_title":{ "de":"Sichere Zahlung", "en":"Secure Payment", "fr":"Paiement Sécurisé", "it":"Pagamento Sicuro" }, "petrol":{ "de":"Benzin", "en":"Petrol", "fr":"Essence", "it":"Benzina" }, "postal_code":{ "de":"PLZ", "en":"Postal code", "fr":"NPA", "it":"NAP" }, "postal_code_ch":{ "de":"PLZ (Schweiz)", "en":"Postal code (Switzerland)", "fr":"NPA (Suisse)", "it":"NAP (Svizzera)" }, "postal_code_ch_hint":{ "de":"Nur 4 Ziffern (Schweizer Adresse erforderlich)", "en":"4 digits only (Swiss address required)", "fr":"4 chiffres uniquement (adresse suisse obligatoire)", "it":"Solo 4 cifre (indirizzo svizzero obbligatorio)" }, "privacy":{ "de":"Datenschutz", "en":"Privacy", "fr":"Confidentialité", "it":"Privacy" }, "privacy_notice_ocr":{ "de":"Die Analyse Ihres Fahrzeugausweises erfolgt durch die KI Google Gemini über eine sichere API. Bei Datenschutzbedenken empfehlen wir die manuelle Eingabe. Keine Daten werden von Drittanbietern über die Anfrageverarbeitung hinaus gespeichert.", "en":"Your registration card analysis is performed by Google Gemini AI via secure API. If you have privacy concerns, we recommend manual input. No data is retained by third-party services beyond request processing.", "fr":"L'analyse de votre carte grise est effectuée par l'intelligence artificielle Google Gemini via API sécurisée. Si vous avez des préoccupations concernant la confidentialité de vos données, nous vous recommandons d'utiliser la saisie manuelle. Aucune donnée n'est conservée par les services tiers au-delà du traitement de votre demande.", "it":"L'analisi della tua carta di circolazione viene effettuata dall'intelligenza artificiale Google Gemini tramite API sicura. In caso di preoccupazioni sulla privacy dei dati, raccomandiamo l'inserimento manuale. Nessun dato viene conservato da servizi terzi oltre l'elaborazione della richiesta." }, "processing":{ "de":"Verarbeitung läuft", "en":"Processing", "fr":"Traitement en cours", "it":"Elaborazione in corso" }, "purchase_price":{ "de":"Kaufpreis des Fahrzeugs", "en":"Vehicle purchase price", "fr":"Prix d'achat du véhicule", "it":"Prezzo d'acquisto del veicolo" }, "relocation_import":{ "de":"Einfuhr wegen Wohnsitzverlegung (MwSt-Befreiung)", "en":"Import for relocation (VAT exemption)", "fr":"Importation pour déménagement (Exemption TVA)", "it":"Importazione per trasloco (Esenzione IVA)" }, "result":{ "de":"Ergebnis", "en":"Result", "fr":"Résultat", "it":"Risultato" }, "scan_registration_card":{ "de":"Fahrzeugausweis scannen", "en":"Scan registration card", "fr":"Scanner la carte grise", "it":"Scansiona la carta di circolazione" }, "select":{ "de":"Auswählen", "en":"Select", "fr":"Sélectionner", "it":"Selezionare" }, "select_statistical_key":{ "de":"Wählen Sie den statistischen Schlüssel", "en":"Select statistical key", "fr":"Sélectionnez la clé statistique", "it":"Seleziona la chiave statistica" }, "service_type":{ "de":"Art der Dienstleistung", "en":"Service type", "fr":"Type de service", "it":"Tipo di servizio" }, "statistical_key":{ "de":"Statistischer Schlüssel", "en":"Statistical key", "fr":"Clé statistique", "it":"Chiave statistica" }, "submission_completed":{ "de":"Einreichung erfolgreich abgeschlossen!", "en":"Submission completed successfully!", "fr":"Soumission terminée avec succès !", "it":"Invio completato con successo!" }, "submission_completed_text":{ "de":"Ihre Anmeldung wurde an das e-dec Portal übermittelt und die Dokumente wurden abgerufen.", "en":"Your declaration has been submitted to the e-dec portal and documents have been retrieved.", "fr":"Votre déclaration a été soumise au portail e-dec et les documents ont été récupérés.", "it":"La tua dichiarazione è stata inviata al portale e-dec e i documenti sono stati recuperati." }, "submission_error_detected":{ "de":"Einreichungsfehler erkannt. Konsultieren Sie die Fehlerseite für weitere Details.", "en":"Submission error detected. Check error page for details.", "fr":"Erreur de soumission détectée. Consultez la page d'erreur pour plus de détails.", "it":"Errore di invio rilevato. Consulta la pagina di errore per maggiori dettagli." }, "submission_in_progress":{ "de":"Einreichung läuft", "en":"Submission in progress", "fr":"Soumission en cours", "it":"Invio in corso" }, "submission_success":{ "de":"Einreichung erfolgreich! Dokumente zum Download bereit.", "en":"Submission successful! Documents ready for download.", "fr":"Soumission réussie ! Documents prêts à télécharger.", "it":"Invio riuscito! Documenti pronti per il download." }, "submit_declaration":{ "de":"Anmeldung einreichen", "en":"Submit declaration", "fr":"Soumettre la déclaration", "it":"Inviare la dichiarazione" }, "tares_code":{ "de":"TARES-Code", "en":"TARES code", "fr":"Code TARES", "it":"Codice TARES" }, "the_consignee":{ "de":"Der Empfänger", "en":"The consignee", "fr":"Le destinataire", "it":"Il destinatario" }, "the_consignor":{ "de":"Der Versender", "en":"The consignor", "fr":"L'expéditeur", "it":"Il mittente" }, "the_importer":{ "de":"Der Importeur", "en":"The importer", "fr":"L'importateur", "it":"L'importatore" }, "total_price":{ "de":"Gesamtpreis", "en":"Total price", "fr":"Prix total", "it":"Prezzo totale" }, "total_value_base":{ "de":"Gesamtwert (Berechnungsgrundlage MwSt/IVA)", "en":"Total value (VAT calculation base)", "fr":"Valeur totale (base de calcul TVA/IVA)", "it":"Valore totale (base di calcolo IVA)" }, "transport_costs":{ "de":"Transportkosten bis zur Grenze", "en":"Transport costs to border", "fr":"Frais de transport jusqu'à la frontière", "it":"Costi di trasporto fino al confine" }, "transport_mode":{ "de":"Transportart", "en":"Mode of transport", "fr":"Mode de transport", "it":"Modalità di trasporto" }, "unique_link_text":{ "de":"Verwenden Sie diesen eindeutigen Link, um später auf Ihre Dokumente zuzugreifen.", "en":"Use this unique link to access your documents later.", "fr":"Utilisez ce lien unique pour accéder à vos documents ultérieurement.", "it":"Usa questo link univoco per accedere ai tuoi documenti successivamente." }, "upload_photo_pdf":{ "de":"Foto aufnehmen oder Bild/PDF auswählen", "en":"Take photo or choose image/PDF", "fr":"Photographier ou choisir l'image/PDF", "it":"Fotografare o scegliere immagine/PDF" }, "vehicle":{ "de":"Fahrzeug", "en":"Vehicle", "fr":"Véhicule", "it":"Veicolo" }, "vehicle_info":{ "de":"Fahrzeuginformationen", "en":"Vehicle information", "fr":"Informations du véhicule", "it":"Informazioni sul veicolo" }, "verify_extracted_data":{ "de":"⚠️ Überprüfung Erforderlich", "en":"⚠️ Mandatory Verification", "fr":"⚠️ Vérification Obligatoire", "it":"⚠️ Verifica Obbligatoria" }, "vin_invalid":{ "de":"❌ FIN-Format ungültig (17 alphanumerische Zeichen, ohne I, O, Q)", "en":"❌ Invalid VIN format (17 alphanumeric characters, no I, O, Q)", "fr":"❌ Le format du VIN est invalide (17 caractères alphanumériques, sans I, O, Q)", "it":"❌ Formato VIN non valido (17 caratteri alfanumerici, senza I, O, Q)" }, "vin_label":{ "de":"FIN (Fahrgestellnummer / 17 Zeichen)", "en":"VIN (Vehicle Identification Number / 17 characters)", "fr":"VIN (Numéro de châssis / 17 caractères)", "it":"VIN (Numero di telaio / 17 caratteri)" }, "vin_valid":{ "de":"✅ FIN gültig. Herkunftsland erkannt", "en":"✅ Valid VIN. Country of origin detected", "fr":"✅ VIN valide. Pays d'origine détecté", "it":"✅ VIN valido. Paese di origine rilevato" }, "weight_empty":{ "de":"Leergewicht", "en":"Unladen weight", "fr":"Poids à vide", "it":"Massa a vuoto" }, "weight_total":{ "de":"Gesamtgewicht", "en":"Gross vehicle weight", "fr":"Poids total autorisé", "it":"Massa totale autorizzata" }, "who_is_declarant":{ "de":"Wer ist der Anmelder?", "en":"Who is the declarant?", "fr":"Qui est le déclarant ?", "it":"Chi è il dichiarante?" }, "why_choose_us":{ "de":"Warum unseren Service wählen?", "en":"Why choose our service?", "fr":"Pourquoi choisir notre service ?", "it":"Perché scegliere il nostro servizio?" }, "year_first_registration":{ "de":"Jahr der Erstzulassung", "en":"Year of first registration", "fr":"Année de 1ère mise en circulation", "it":"Anno di prima immatricolazione" }, "app_name":{ "fr":"Déclaration e-dec Simplifiée", "en":"Simplified e-dec Declaration", "de":"Vereinfachte e-dec-Anmeldung", "it":"Dichiarazione e-dec Semplificata" }, "home_title":{ "fr":"Bienvenue", "en":"Welcome", "de":"Willkommen", "it":"Benvenuto" }, "home_subtitle":{ "fr":"Sélectionnez votre type de déclaration", "en":"Select your declaration type", "de":"Wählen Sie Ihren Anmeldetyp", "it":"Seleziona il tipo di dichiarazione" }, "import_button":{ "fr":"Déclaration d'Importation (Entrée CH)", "en":"Import Declaration (Entry CH)", "de":"Einfuhranmeldung (Eintritt CH)", "it":"Dichiarazione d'Importazione (Entrata CH)" }, "export_button":{ "fr":"Déclaration d'Exportation (Sortie CH)", "en":"Export Declaration (Exit CH)", "de":"Ausfuhranmeldung (Ausgang CH)", "it":"Dichiarazione d'Esportazione (Uscita CH)" }, "sstep_1_title":{ "fr":"Étape 1: Saisie des informations", "en":"Step 1: Data Entry", "de":"Schritt 1: Dateneingabe", "it":"Passo 1: Inserimento dati" }, "sstep_2_title":{ "fr":"Étape 2: Confirmation et Validation", "en":"Step 2: Confirmation and Validation", "de":"Schritt 2: Bestätigung und Validierung", "it":"Passo 2: Conferma e Validazione" }, "sstep_3_title":{ "fr":"Étape 3: Génération du Fichier", "en":"Step 3: File Generation", "de":"Schritt 3: Dateigenerierung", "it":"Passo 3: Generazione del File" }, "continue_button":{ "fr":"Continuer", "en":"Continue", "de":"Weiter", "it":"Continua" }, "back_button":{ "fr":"Retour", "en":"Back", "de":"Zurück", "it":"Indietro" }, "download_xml":{ "fr":"Télécharger le Fichier XML", "en":"Download XML File", "de":"XML-Datei herunterladen", "it":"Scarica il File XML" }, "ocr_upload_label":{ "fr":"Télécharger un document (OCR)", "en":"Upload a document (OCR)", "de":"Dokument hochladen (OCR)", "it":"Carica un documento (OCR)" }, "manual_entry_label":{ "fr":"Saisie Manuelle", "en":"Manual Entry", "de":"Manuelle Eingabe", "it":"Inserimento manuale" }, "document_type_registration":{ "fr":"Carte Grise (CH/UE)", "en":"Registration Document (CH/EU)", "de":"Fahrzeugausweis (CH/EU)", "it":"Libretto di Circolazione (CH/UE)" }, "document_type_invoice":{ "fr":"Facture d'achat", "en":"Purchase Invoice", "de":"Kaufrechnung", "it":"Fattura d'acquisto" }, "importer_details":{ "fr":"Détails de l'Importateur (Destinataire)", "en":"Importer Details (Consignee)", "de":"Details des Importeurs (Empfänger)", "it":"Dettagli dell'Importatore (Destinatario)" }, "consignor_details":{ "fr":"Détails de l'Expéditeur (Consignor CH)", "en":"Consignor Details (Exporter CH)", "de":"Details des Absenders (Exporteur CH)", "it":"Dettagli dello Speditore (Consignante CH)" }, "consignee_details_export":{ "fr":"Détails du Destinataire (Consignee Étranger)", "en":"Consignee Details (Foreign Recipient)", "de":"Details des Empfängers (Ausländischer Empfänger)", "it":"Dettagli del Destinatario (Destinatario Estero)" }, "declarant_choice":{ "fr":"Qui est le Déclarant ?", "en":"Who is the Declarant?", "de":"Wer ist der Anmelder?", "it":"Chi è il Dichiarante?" }, "is_importer_declarant":{ "fr":"L'Importateur (Destinataire)", "en":"The Importer (Consignee)", "de":"Der Importeur (Empfänger)", "it":"L'Importatore (Destinatario)" }, "is_consignor_declarant":{ "fr":"L'Expéditeur (Consignor)", "en":"The Consignor (Exporter)", "de":"Der Absender (Exporteur)", "it":"Lo Speditore (Consignante)" }, "is_other_declarant":{ "fr":"Un Tiers (Transitaire ou Autre)", "en":"A Third Party (Forwarder or Other)", "de":"Ein Dritter (Spediteur oder Sonstiges)", "it":"Una Terza Parte (Spedizioniere o Altro)" }, "vehicle_details":{ "fr":"Détails du Véhicule", "en":"Vehicle Details", "de":"Fahrzeugdetails", "it":"Dettagli del Veicolo" }, "brand_code_label":{ "fr":"Code de Marque (e-dec)", "en":"Brand Code (e-dec)", "de":"Markencode (e-dec)", "it":"Codice Marca (e-dec)" }, "model_label":{ "fr":"Modèle", "en":"Model", "de":"Modell", "it":"Modello" }, "matriculation_number_label":{ "fr":"N° de Matricule (Point 18 Carte Grise CH, Optionnel)", "en":"Registration No. (Point 18 CH Registration, Optional)", "de":"Matrikelnummer (Feld 18 FA CH, Optional)", "it":"N° di Immatricolazione (Punto 18 LdC CH, Opzionale)" }, "country_of_origin":{ "fr":"Pays d'Origine", "en":"Country of Origin", "de":"Ursprungsland", "it":"Paese d'Origine" }, "dispatch_country_label":{ "fr":"Pays de provenance (Dispatch Country)", "en":"Dispatch Country", "de":"Versandland (Dispatch Country)", "it":"Paese di Provenienza (Dispatch Country)" }, "delivery_destination_label":{ "fr":"Pays de Destination", "en":"Delivery Destination Country", "de":"Bestimmungsland", "it":"Paese di Destinazione" }, "price_details":{ "fr":"Détails de la Transaction (Import)", "en":"Transaction Details (Import)", "de":"Transaktionsdetails (Import)", "it":"Dettagli della Transazione (Importazione)" }, "net_price_label":{ "fr":"Prix Net (€/CHF)", "en":"Net Price (€/CHF)", "de":"Nettopreis (€/CHF)", "it":"Prezzo Netto (€/CHF)" }, "vat_iva_label":{ "fr":"Montant TVA/IVA (€/CHF)", "en":"VAT/IVA Amount (€/CHF)", "de":"MwSt/IVA Betrag (€/CHF)", "it":"Importo IVA/IVA (€/CHF)" }, "currency_label":{ "fr":"Devise", "en":"Currency", "de":"Währung", "it":"Valuta" }, "tares_classification":{ "fr":"Classification TARES", "en":"TARES Classification", "de":"TARES-Klassifikation", "it":"Classificazione TARES" }, "loading_ocr":{ "fr":"Analyse en cours...", "en":"Analysis in progress...", "de":"Analyse läuft...", "it":"Analisi in corso..." }, "ocr_error":{ "fr":"Erreur lors de l'analyse du document. Veuillez réessayer ou utiliser la saisie manuelle.", "en":"Error analyzing document. Please try again or use manual entry.", "de":"Fehler bei der Dokumentenanalyse. Bitte versuchen Sie es erneut oder verwenden Sie die manuelle Eingabe.", "it":"Errore durante l'analisi del documento. Riprova o usa l'inserimento manuale." }, "error_general":{ "fr":"Une erreur inattendue est survenue.", "en":"An unexpected error occurred.", "de":"Ein unerwarteter Fehler ist aufgetreten.", "it":"Si è verificato un errore inatteso." }, "ocr_countdown_title":{ "fr":"Vérification des données extraites (obligatoire)", "en":"Verification of extracted data (mandatory)", "de":"Überprüfung der extrahierten Daten (obligatorisch)", "it":"Verifica dei dati estratti (obbligatoria)" }, "ocr_countdown_message":{ "fr":"Pour des raisons légales et de précision, vous devez relire les données extraites par l'IA et vérifier qu'aucune erreur ne s'est glissée dans les champs. Vous pourrez continuer dans ", "en":"For legal and accuracy reasons, you must re-read the data extracted by the AI and verify that no errors have slipped into the fields. You can continue in ", "de":"Aus rechtlichen und Genauigkeitsgründen müssen Sie die von der KI extrahierten Daten erneut überprüfen, um sicherzustellen, dass keine Fehler in den Feldern enthalten sind. Sie können fortfahren in ", "it":"Per motivi legali e di accuratezza, è necessario rileggere i dati estratti dall'IA e verificare che non ci siano errori nei campi. Potrai continuare tra " }, "payment_page_title":{ "fr":"Accès aux formulaires de Déclaration", "en":"Access to Declaration Forms", "de":"Zugang zu den Anmeldeformularen", "it":"Accesso ai Moduli di Dichiarazione" }, "payment_intro":{ "fr":"Veuillez procéder au paiement de 50 CHF pour débloquer l'accès au formulaire de déclaration que vous avez choisi. Le paiement est valable pour une seule déclaration.", "en":"Please proceed with the payment of 50 CHF to unlock access to the declaration form you have chosen. The payment is valid for a single declaration.", "de":"Bitte bezahlen Sie 50 CHF, um den Zugang zum von Ihnen gewählten Anmeldeformular freizuschalten. Die Zahlung ist nur für eine einzige Anmeldung gültig.", "it":"Si prega di procedere al pagamento di 50 CHF per sbloccare l'accesso al modulo di dichiarazione scelto. Il pagamento è valido per una sola dichiarazione." }, "payment_legal_text":{ "fr":"Notre solution est une surcouche simplifiée à l'application e-dec web officielle (qui est gratuite mais complexe). Vous restez l'auto-déclarant responsable des données saisies sur notre site. Forts de 7 ans d'expérience dans les déclarations douanières, nous vous garantissons que si les données que vous fournissez sont correctes, votre déclaration sera valide. Notre service simplifie énormément la procédure, vous fait gagner du temps (5 minutes contre 1 heure avec un transitaire) et est bien moins cher qu'un transitaire.", "en":"Our solution is a simplified layer over the official e-dec web application (which is free but complex). You remain the self-declarant responsible for the data entered on our site. With 7 years of experience in customs declarations, we guarantee that if the data you provide is correct, your declaration will be valid. Our service significantly simplifies the procedure, saves you time (5 minutes compared to 1 hour with a forwarder) and is much cheaper than a forwarder.", "de":"Unsere Lösung ist eine vereinfachte Schnittstelle zur offiziellen e-dec-Webanwendung (die kostenlos, aber komplex ist). Sie bleiben der Selbstanmelder und sind für die auf unserer Website eingegebenen Daten verantwortlich. Mit 7 Jahren Erfahrung in Zollanmeldungen garantieren wir Ihnen, dass Ihre Anmeldung gültig ist, sofern die von Ihnen bereitgestellten Daten korrekt sind. Unser Service vereinfacht das Verfahren erheblich, spart Ihnen Zeit (5 Minuten im Gegensatz zu 1 Stunde bei einem Spediteur) und ist viel günstiger als ein Spediteur.", "it":"La nostra soluzione è un livello semplificato sopra l'applicazione web ufficiale e-dec (che è gratuita ma complessa). Rimanete l'autodichiarante responsabile dei dati inseriti sul nostro sito. Con 7 anni di esperienza nelle dichiarazioni doganali, garantiamo che se i dati forniti sono corretti, la vostra dichiarazione sarà valida. Il nostro servizio semplifica enormemente la procedura, vi fa risparmiare tempo (5 minuti contro 1 ora con uno spedizioniere) ed è molto più economico di uno spedizioniere." }, "payment_processing":{ "fr":"Traitement du paiement en cours...", "en":"Processing payment...", "de":"Zahlung wird verarbeitet...", "it":"Elaborazione del pagamento in corso..." }, "payment_error":{ "fr":"Échec du paiement. Veuillez réessayer.", "en":"Payment failed. Please try again.", "de":"Zahlung fehlgeschlagen. Bitte versuchen Sie es erneut.", "it":"Pagamento fallito. Si prega di riprovare." }, "footer_copyright":{ "de":"© 2025 e-dec Véhicules. Vereinfachen Sie Ihre Zollanmeldungen.", "en":"© 2025 e-dec Véhicules. Simplify your customs declarations.", "fr":"© 2025 e-dec Véhicules. Simplifiez vos déclarations douanières.", "it":"© 2025 e-dec Véhicules. Semplifica le tue dichiarazioni doganali." }, "footer_disclaimer":{ "de":"⚠️ Dieses Tool hilft bei der Erstellung Ihrer Anmeldung. Die Verantwortung für die Informationen liegt beim Anmelder.", "en":"⚠️ This tool helps create your declaration. Responsibility for the information rests with the declarant.", "fr":"⚠️ Cet outil aide à créer votre déclaration. La responsabilité des informations incombe au déclarant.", "it":"⚠️ Questo strumento aiuta a creare la tua dichiarazione. La responsabilità delle informazioni è a carico del dichiarante." }, "payment_declaration_missing_title":{ "fr":"Déclaration non trouvée", "en":"Declaration not found", "de":"Erklärung nicht gefunden", "it":"Dichiarazione non trovata" }, "payment_declaration_missing_text":{ "fr":"Nous n'avons pas trouvé de déclaration en attente de paiement. Veuillez commencer une nouvelle déclaration ou vérifier si vous avez déjà soumis.", "en":"We couldn't find a declaration awaiting payment. Please start a new declaration or check if you have already submitted.", "de":"Wir konnten keine zur Zahlung ausstehende Erklärung finden. Bitte starten Sie eine neue Erklärung oder prüfen Sie, ob Sie bereits gesendet haben.", "it":"Non è stata trovata alcuna dichiarazione in attesa di pagamento. Si prega di iniziare una nuova dichiarazione o verificare se si è già inviato." }, "return_home":{ "fr":"Retourner à l'accueil", "en":"Return to home", "de":"Zurück zur Startseite", "it":"Torna alla home" }, "payment_error_title":{ "fr":"Erreur de paiement", "en":"Payment Error", "de":"Zahlungsfehler", "it":"Errore di pagamento" }, "retry_payment":{ "fr":"Réessayer le paiement", "en":"Retry Payment", "de":"Zahlung wiederholen", "it":"Riprova pagamento" }, "edit_declaration":{ "fr":"Modifier ma déclaration", "en":"Edit my declaration", "de":"Erklärung bearbeiten", "it":"Modifica la mia dichiarazione" }, "submission_error_title":{ "fr":"Erreur de soumission", "en":"Submission Error", "de":"Übermittlungsfehler", "it":"Errore di trasmissione" }, "submission_platform_error":{ "fr":"Nous rencontrons un problème sur la plateforme de la douane. Merci de laisser votre e-mail : nous vous enverrons vos documents dès résolution.", "en":"We are experiencing an issue on the customs platform. Please leave your email — we will send your documents once resolved.", "de":"Wir haben ein Problem mit der Zollplattform. Bitte hinterlassen Sie Ihre E-Mail; wir senden die Dokumente nach Behebung.", "it":"Stiamo riscontrando un problema sulla piattaforma doganale. Lascia la tua email: ti invieremo i documenti non appena risolto." }, "submit_email":{ "fr":"Soumettre mon email", "en":"Submit my email", "de":"Meine E-Mail senden", "it":"Invia la mia email" }, "validation_error_generic":{ "fr":"La validation a échoué. Veuillez vérifier les données saisies.", "en":"Validation failed. Please check the entered data.", "de":"Validierung fehlgeschlagen. Bitte überprüfen Sie die eingegebenen Daten.", "it":"Validazione fallita. Si prega di controllare i dati inseriti." }, "validation_error_network":{ "fr":"Erreur réseau lors de la validation. Veuillez réessayer.", "en":"Network error during validation. Please try again.", "de":"Netzwerkfehler während der Validierung. Bitte versuchen Sie es erneut.", "it":"Errore di rete durante la validazione. Si prega di riprovare." }, "submission_step_1_validation":{ "fr":"Étape 1/2 : Validation des données avec le serveur...", "en":"Step 1/2: Validating data with the server...", "de":"Schritt 1/2: Datenvalidierung mit dem Server...", "it":"Passo 1/2: Validazione dei dati con il server..." }, "submission_step_2_ready":{ "fr":"Étape 2/2 : Déclaration validée, prête pour le paiement.", "en":"Step 2/2: Declaration validated, ready for payment.", "de":"Schritt 2/2: Erklärung validiert, bereit zur Zahlung.", "it":"Passo 2/2: Dichiarazione validata, pronta per il pagamento." }, "validate_and_pay":{ "fr":"Valider et Payer", "en":"Validate and Pay", "de":"Validieren und Bezahlen", "it":"Valida e Paga" }, "payment_stripe_load_error":{ "fr":"Impossible de charger la librairie de paiement. Vérifiez votre connexion.", "en":"Could not load the payment library. Check your connection.", "de":"Zahlungsbibliothek konnte nicht geladen werden. Überprüfen Sie Ihre Verbindung.", "it":"Impossibile caricare la libreria di pagamento. Controlla la tua connessione." }, "payment_stripe_init_error":{ "fr":"Erreur d'initialisation du service de paiement.", "en":"Payment service initialization error.", "de":"Fehler bei der Initialisierung des Zahlungsdienstes.", "it":"Errore di inizializzazione del servizio di pagamento." }, "payment_already_valid":{ "fr":"Paiement déjà validé. Redirection vers la déclaration.", "en":"Payment already valid. Redirecting to declaration.", "de":"Zahlung bereits gültig. Weiterleitung zur Erklärung.", "it":"Pagamento già valido. Reindirizzamento alla dichiarazione." }, "home_hero_title":{ "de":"Fahrzeug-Zollanmeldung in der Schweiz: Einfach und Schnell", "en":"Vehicle Customs Declaration in Switzerland: Simple and Fast", "fr":"Déclaration Douanière de Véhicule en Suisse : Simple et Rapide", "it":"Dichiarazione Doganale Veicoli in Svizzera: Semplice e Veloce" }, "home_hero_subtitle":{ "de":"Importieren oder exportieren Sie Ihr Auto stressfrei. Erhalten Sie Ihr offizielles e-dec-Formular in 5 Minuten für nur 50 CHF.", "en":"Import or export your car without stress. Get your official e-dec form in 5 minutes for only 50 CHF.", "fr":"Importez ou exportez votre voiture sans stress. Obtenez votre formulaire e-dec officiel en 5 minutes pour seulement 50 CHF.", "it":"Importa o esporta la tua auto senza stress. Ottieni il tuo modulo e-dec ufficiale in 5 minuti a soli 50 CHF." }, "start_import":{ "de":"Import starten", "en":"Start an Import", "fr":"Commencer une importation", "it":"Inizia un'importazione" }, "start_export":{ "de":"Export starten", "en":"Start an Export", "fr":"Commencer une exportation", "it":"Inizia un'esportazione" }, "home_simplified_title":{ "de":"Fahrzeugverzollung, vereinfacht.", "en":"Car customs clearance, simplified.", "fr":"Le dédouanement de voiture, simplifié.", "it":"Lo sdoganamento auto, semplificato." }, "home_simplified_description":{ "de":"Das offizielle e-dec-Portal des Schweizer Zolls ist leistungsstark, aber für Laien oft komplex und einschüchternd. Spediteure sind teuer und langsam. MyAutoImport.ch ist die ideale Lösung: Wir führen Sie durch ein einfaches Formular und stellen nur die wesentlichen Fragen. Keine Verwirrung mehr, erhalten Sie Ihre Zollanmeldung in Rekordzeit.", "en":"The official e-dec portal of the Swiss customs is powerful, but often complex and intimidating for newcomers. Freight forwarders are expensive and slow. MyAutoImport.ch is the ideal solution: we guide you through a simple form, asking only the essential questions. No more confusion, get your customs declaration in record time.", "fr":"Le portail officiel e-dec de la douane suisse est puissant, mais souvent complexe et intimidant pour les non-initiés. Les transitaires, eux, sont chers et lents. MyAutoImport.ch est la solution idéale : nous vous guidons à travers un formulaire simple en ne posant que les questions essentielles. Fini la confusion, obtenez votre déclaration douanière en un temps record.", "it":"Il portale ufficiale e-dec della dogana svizzera è potente, ma spesso complesso e intimidatorio per i non addetti ai lavori. Gli spedizionieri, d'altra parte, sono costosi e lenti. MyAutoImport.ch è la soluzione ideale: ti guidiamo attraverso un modulo semplice ponendo solo le domande essenziali. Niente più confusione, ottieni la tua dichiarazione doganale in tempo record." }, "home_why_choose_us":{ "de":"Warum uns wählen?", "en":"Why choose us?", "fr":"Pourquoi nous choisir ?", "it":"Perché sceglierci?" }, "minutes":{ "de":"Minuten", "en":"minutes", "fr":"minutes", "it":"minuti" }, "feature_simple_guided":{ "de":"Einfach und geführt", "en":"Simple and guided", "fr":"Simple et guidé", "it":"Semplice e guidato" }, "feature_fast_online":{ "de":"Schnell, 100% online", "en":"Fast, 100% online", "fr":"Rapide, 100% en ligne", "it":"Veloce, 100% online" }, "feature_affordable":{ "de":"Kostengünstig", "en":"Cost-effective", "fr":"Économique", "it":"Economico" }, "traditional_forwarder":{ "de":"Traditioneller Spediteur", "en":"Traditional Forwarder", "fr":"Transitaire traditionnel", "it":"Spedizioniere tradizionale" }, "or_more":{ "de":"(oder länger)", "en":"(or more)", "fr":"(ou plus)", "it":"(o più)" }, "feature_waiting_times":{ "de":"Wartezeiten", "en":"Waiting times", "fr":"Délais d'attente", "it":"Tempi di attesa" }, "feature_more_expensive":{ "de":"Teurer", "en":"More expensive", "fr":"Plus cher", "it":"Più costoso" }, "feature_travel_required":{ "de":"Notwendige Fahrten", "en":"Travel required", "fr":"Déplacements nécessaires", "it":"Spostamenti necessari" }, "official_site":{ "de":"Offizielle Seite", "en":"Official Site", "fr":"Site officiel", "it":"Sito ufficiale" }, "free":{ "de":"Kostenlos", "en":"Free", "fr":"Gratuit", "it":"Gratuito" }, "very_variable":{ "de":"Sehr variabel", "en":"Very variable", "fr":"Très variable", "it":"Molto variabile" }, "feature_very_complex":{ "de":"Sehr komplex", "en":"Very complex", "fr":"Très complexe", "it":"Molto complesso" }, "feature_costly_mistakes":{ "de":"Risiko teurer Fehler", "en":"Risk of costly mistakes", "fr":"Risque d'erreurs coûteuses", "it":"Rischio di errori costosi" }, "feature_no_support":{ "de":"Kein Support", "en":"No support", "fr":"Pas de support", "it":"Nessun supporto" }, "transport_mode_9":{ "fr":"Autopropulsion", "de":"Eigenantrieb", "it":"Propulsione propria", "en":"Self-propulsion" }, "transport_mode_3":{ "fr":"Trafic routier", "de":"Strassenverkehr", "it":"Traffico stradale", "en":"Road transport" }, "transport_mode_2":{ "fr":"Trafic ferroviaire", "de":"Bahnverkehr", "it":"Traffico ferroviario", "en":"Rail transport" }, "transport_mode_4":{ "fr":"Trafic aérien", "de":"Luftverkehr", "it":"Traffico aereo", "en":"Air transport" }, "transport_mode_8":{ "fr":"Trafic par eau", "de":"Schiffverkehr", "it":"Traffico navale", "en":"Water transport" }, "transport_mode_7":{ "fr":"Pipeline, etc.", "de":"Pipeline, usw.", "it":"Pipeline, ecc.", "en":"Pipeline, etc." }, "dispatch_country_tooltip":{ "fr":"Le pays d'expédition correspond au dernier pays à partir duquel une marchandise a été expédiée directement en Suisse.", "en":"The dispatch country is the last country from which goods were sent directly to Switzerland.", "de":"Das Versandland ist das letzte Land, aus dem Waren direkt in die Schweiz versandt wurden.", "it":"Il paese di spedizione è l'ultimo paese da cui la merce è stata spedita direttamente in Svizzera." }, "transport_modes_title":{ "fr":"Modes de transport :", "en":"Modes of transport:", "de":"Transportarten:", "it":"Modalità di trasporto:" }, "transport_mode_9_desc":{ "fr":"Le véhicule roule par ses propres moyens jusqu'à la frontière.", "en":"The vehicle drives under its own power to the border.", "de":"Das Fahrzeug fährt aus eigener Kraft bis zur Grenze.", "it":"Il veicolo si muove con i propri mezzi fino al confine." }, "transport_mode_3_desc":{ "fr":"Le véhicule est transporté sur une remorque ou un camion.", "en":"The vehicle is transported on a trailer or truck.", "de":"Das Fahrzeug wird auf einem Anhänger oder LKW transportiert.", "it":"Il veicolo è trasportato su un rimorchio o camion." }, "transport_mode_2_desc":{ "fr":"Transport par train.", "en":"Transport by train.", "de":"Transport per Zug.", "it":"Trasporto per treno." }, "transport_mode_4_desc":{ "fr":"Transport par avion (rare pour véhicules).", "en":"Transport by plane (rare for vehicles).", "de":"Transport per Flugzeug (selten für Fahrzeuge).", "it":"Trasporto in aereo (raro per veicoli)." }, "transport_mode_8_desc":{ "fr":"Transport par bateau.", "en":"Transport by boat.", "de":"Transport per Schiff.", "it":"Trasporto via nave." }, "transport_mode_7_desc":{ "fr":"Autres modes spécifiques.", "en":"Other specific modes.", "de":"Andere spezifische Transportarten.", "it":"Altre modalità specifiche." }, "relocation_tooltip_title":{ "fr":"Exemption TVA pour déménagement :", "en":"VAT exemption for relocation:", "de":"Mehrwertsteuerbefreiung bei Umzug:", "it":"Esenzione IVA per trasloco:" }, "relocation_tooltip_text":{ "fr":"Cette exemption s'applique si vous transférez votre domicile en Suisse. La douane exigera des justificatifs.", "en":"This exemption applies if you move your residence to Switzerland. Customs will require supporting documents.", "de":"Diese Befreiung gilt, wenn Sie Ihren Wohnsitz in die Schweiz verlegen. Der Zoll verlangt Nachweise.", "it":"Questa esenzione si applica se trasferisci la residenza in Svizzera. La dogana richiederà documenti giustificativi." }, "iva_exempt_tooltip_title":{ "fr":"Exonération IVA (RG 660) :", "en":"IVA exemption (RG 660):", "de":"IVA-Befreiung (RG 660):", "it":"Esenzione IVA (RG 660):" }, "iva_exempt_tooltip_text":{ "fr":"L'exonération s'applique si le véhicule est utilisé depuis plus de 6 mois par son propriétaire.", "en":"The exemption applies if the vehicle has been used for more than 6 months by its owner.", "de":"Die Befreiung gilt, wenn das Fahrzeug seit mehr als 6 Monaten vom Eigentümer genutzt wird.", "it":"L'esenzione si applica se il veicolo è stato utilizzato per più di 6 mesi dal proprietario." }, "pay_and_validate":{ "fr":"💳 Payer et Valider", "en":"💳 Pay and Validate", "de":"💳 Bezahlen und Validieren", "it":"💳 Paga e Convalida" }, "preparing":{ "fr":"⏳ Préparation...", "en":"⏳ Preparing...", "de":"⏳ Vorbereitung...", "it":"⏳ Preparazione..." }, "declaration_ready":{ "fr":"✅ Déclaration prête !", "en":"✅ Declaration ready!", "de":"✅ Anmeldung bereit!", "it":"✅ Dichiarazione pronta!" }, "click_to_pay_securely":{ "fr":"Cliquez ci-dessous pour procéder au paiement sécurisé (50 CHF)", "en":"Click below to proceed with secure payment (50 CHF)", "de":"Klicken Sie unten, um die sichere Zahlung (50 CHF) durchzuführen", "it":"Clicca qui sotto per procedere al pagamento sicuro (50 CHF)" }, "secure_payment_stripe":{ "fr":"🔒 Paiement sécurisé par Stripe", "en":"🔒 Secure payment via Stripe", "de":"🔒 Sichere Zahlung über Stripe", "it":"🔒 Pagamento sicuro tramite Stripe" } } ---------------------------------------------------- public/fr/index.html Importer & Exporter Voiture en Suisse - Déclaration Douanière Simplifiée

Déclaration Douanière de Véhicule en Suisse : Simple et Rapide

Importez ou exportez votre voiture sans stress. Obtenez votre formulaire e-dec officiel en 5 minutes pour seulement 50 CHF.

Le dédouanement de voiture, simplifié.

Le portail officiel e-dec de la douane suisse est puissant, mais souvent complexe et intimidant pour les non-initiés. Les transitaires, eux, sont chers et lents. MyAutoImport.ch est la solution idéale : nous vous guidons à travers un formulaire simple en ne posant que les questions essentielles. Fini la confusion, obtenez votre déclaration douanière en un temps record.

Pourquoi nous choisir ?

MyAutoImport.ch

50 CHF

~ 5 minutes

  • ✅ Simple et guidé
  • ✅ Rapide et 100% en ligne
  • ✅ Économique

Transitaire traditionnel

~ 100 CHF

~ 45 minutes (ou plus)

  • ❌ Délais d'attente
  • ❌ Plus cher
  • ❌ Déplacements parfois nécessaires

e-dec (Site officiel)

Gratuit

Très variable

  • ❌ Très complexe
  • ❌ Risque d'erreurs coûteuses
  • ❌ Pas de support
---------------------------------------------------- public/css/stylev2.css /* ========================================================== 1. COLOR PALETTE & GLOBAL STYLES (Black, White, Gold) ========================================================== */ /* Buttons */ .btn { padding: 0.875rem 2.5rem; /* Plus large pour un look premium */ border: none; border-radius: 4px; /* Plus carré, plus élégant */ font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } /* Primary Button (Gold/Black) */ .btn-primary { background: #FFD700; /* Gold */ color: #1C1C1C; /* Deep Black for text on primary */ border: 1px solid #FFD700; } .btn-primary:hover { background: #e6c200; /* Darker Gold for hover */ transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } /* Secondary Button (White/Black, Bordered) */ .btn-secondary { background: #FFFFFF; /* White */ color: #1C1C1C; /* Deep Black */ border: 1px solid #1C1C1C; } .btn-secondary:hover { background: #1C1C1C; /* Inverser les couleurs au hover */ color: #FFD700; /* Gold text */ border-color: #FFD700; } /* Hero Section */ .hero { background: #1C1C1C; /* Deep Black background */ color: #FFFFFF; /* White text on hero */ padding: 6rem 0; /* Plus de padding pour un effet "luxe" */ text-align: center; } .hero h1 { font-size: 3.5rem; /* Plus grand */ margin-bottom: 1rem; font-weight: 300; /* Texte plus fin pour l'élégance */ letter-spacing: 2px; } .subtitle { font-size: 1.5rem; margin-bottom: 3rem; opacity: 0.8; } .cta-buttons { display: flex; gap: 2rem; /* Plus d'espace */ justify-content: center; flex-wrap: wrap; } /* Navigation */ .navbar { background: #FFFFFF; /* White Nav Bar */ padding: 1rem 0; box-shadow: 0 1px 10px rgba(0,0,0,0.05); border-bottom: 1px solid #F7F7F7; } .logo { font-size: 1.8rem; font-weight: 600; color: #1C1C1C; /* Deep Black Logo */ text-decoration: none; letter-spacing: 1px; } .nav-menu a { color: #1C1C1B; /* Dark Grey for navigation links #6E6E6E */ text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: #ffbf00; /* Gold hover */ } /* Language Switcher */ .btn-lang { padding: 0.5rem 0.8rem; border: 1px solid #E0E0E0; background: transparent; color: #1C1C1B; cursor: pointer; border-radius: 4px; transition: all 0.3s; } .btn-lang:hover, .btn-lang.active { background: #1C1C1C; border-color: #1C1C1C; color: #FFD700; /* Gold active/hover text */ } /* Form Styles */ legend { font-weight: bold; padding: 0 0.5rem; color: #FFD700; /* Gold for legend */ } input:focus, select:focus { outline: none; border-color: #FFD700; /* Gold focus border */ box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .upload-box:hover { border-color: #FFD700; /* Gold hover border */ background: #fcfcfc; } .classification-info { background: #fefefe; border-left: 4px solid #FFD700; /* Gold info border */ padding: 1.5rem; margin: 1.5rem 0; border-radius: 6px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } /* Progress Bar and Steps */ .step-number { width: 50px; /* Plus petit pour le détail */ height: 50px; background: #1C1C1C; /* Black step background */ color: #FFD700; /* Gold step number */ border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: bold; margin: 0 auto 1rem; box-shadow: 0 0 0 2px #FFD700; /* Double cerclage gold */ } .progress-step.active .step-num { background: #FFD700; /* Gold active step */ color: #1C1C1C; box-shadow: 0 0 0 4px #1C1C1C; /* Bordure noire pour le contraste */ } .progress-bar { height: 100%; /* background: #FFD700; */ /* Gold progress bar */ transition: width 0.5s ease; } /* Method Cards */ .method-card:hover { border-color: #FFD700; /* Gold border on hover */ transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } /* Footer */ .footer { background: #1C1C1C; /* Deep Black Footer */ color: #FFFFFF; padding: 2.5rem 0; text-align: center; margin-top: 5rem; border-top: 3px solid #FFD700; /* Petite touche de gold */ } /* Tooltip */ .tooltip-icon { color: #1C1C1C; /* Black icon text */ display: inline-block; width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: 50%; font-size: 0.85rem; cursor: pointer; margin-left: 0.5rem; vertical-align: middle; transition: background 0.3s; } /* Payment Section */ .payment-header { background: #1C1C1C; /* Deep Black header */ color: #FFFFFF; padding: 2rem; text-align: center; } .legal-notice { background: #FFFFFF; border-left: 4px solid #FFD700; /* Gold notice border */ padding: 1.5rem; margin: 2rem; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.05); } .pricing-box { background: #F7F7F7; /* Light Grey for pricing */ border: 2px solid #E0E0E0; border-radius: 8px; padding: 1.5rem; margin: 0 2rem 2rem 2rem; } .price-item.total { font-size: 1.4rem; font-weight: bold; color: #1C1C1C; /* Black total price */ padding-top: 1rem; border-top: 2px solid #FFD700; /* Gold total separator */ } .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #FFD700; /* Gold spinner */ border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; margin: 0 auto 1rem auto; } /* Stripe Elements Override */ .StripeElement--focus { border-color: #FFD700; /* Gold focus border */ box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } /* ========================================================== 2. SUCCESS, WARNING, AND ERROR STYLES (Standardized Colors) ========================================================== */ /* Success */ .success-icon { font-size: 5rem; margin-bottom: 1rem; color: #28a745; /* Success Green */ } .progress-step.completed .step-num { background: #FFD700; /* Success Green */ color: #000000; } .hint { display: block; font-size: 0.875rem; color: #28a745; /* Success Green */ margin-top: 0.25rem; } .total-box { background: #d4edda; border: 2px solid #28a745; padding: 1.5rem; border-radius: 8px; margin: 1.5rem 0; text-align: center; } .total-box h3 { color: #155724; margin: 0; } .security-info { background: #d4edda; border-left: 4px solid #28a745; padding: 1rem; margin: 2rem; border-radius: 4px; font-size: 0.9rem; } .notification-success { background-color: #d4edda; color: #155724; border-left: 5px solid #28a745; } .StripeElement--complete { border-color: #28a745; } /* Error & Warning */ .error { display: block; font-size: 0.875rem; color: #dc3545; /* Error Red */ margin-top: 0.25rem; } .notification-error { background-color: #f8d7da; color: #721c24; border-left: 5px solid #dc3545; } .StripeElement--invalid { border-color: #dc3545; } .ocr-countdown-notice, .notification-warning { background: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0; text-align: center; } .ocr-countdown-notice { animation: pulse-warning 2s ease-in-out infinite; } .ocr-countdown-notice p:first-child { font-weight: bold; font-size: 1.1rem; color: #856404; } .countdown-timer { font-size: 2.5rem; font-weight: bold; color: #856404; margin-top: 1rem; font-family: 'Courier New', monospace; background: rgba(255, 193, 7, 0.2); padding: 1rem 2rem; border-radius: 8px; display: inline-block; } .notification-warning { background-color: #fff3cd; color: #856404; border-left: 5px solid #ffc107; } .price-notice { margin: 1rem 0 0 0; padding: 0.75rem; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; font-size: 0.95rem; } /* Info */ .notification-info { background-color: #d1ecf1; color: #0c5460; border-left: 5px solid #17a2b8; } /* ========================================================== 3. BASE & STRUCTURE (Less likely to change) ========================================================== */ /* Reset & Base */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1C1C1C; /* Deep Black text */ background: #F7F7F7; /* Light grey background for luxury feel */ } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } /* Navigation */ .navbar .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } .nav-menu { display: flex; list-style: none; gap: 2rem; } /* Features */ .features { padding: 5rem 0; background: white; } .features h2 { text-align: center; margin-bottom: 3rem; font-size: 2.8rem; color: #1C1C1C; font-weight: 300; } .features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; } .feature-card { text-align: center; padding: 2.5rem; border-radius: 6px; background: #FFFFFF; transition: transform 0.3s, box-shadow 0.3s; border: 1px solid #EDEDED; } .feature-card:hover { transform: translateY(-5px); box-shadow: 0 10px 30px rgba(0,0,0,0.08); } .feature-icon { font-size: 3rem; margin-bottom: 1rem; color: #FFD700; } /* How it works */ .how-it-works { padding: 5rem 0; } .how-it-works h2 { text-align: center; margin-bottom: 4rem; font-size: 2.8rem; font-weight: 300; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; } .step-item { text-align: center; } /* Progress Bar (old) */ .progress-bar { display: flex; justify-content: space-between; margin: 2rem 0 3rem; position: relative; } .progress-bar::before { content: ''; position: absolute; top: 20px; left: 0; right: 0; height: 2px; background: #ddd; z-index: 0; } .progress-step { display: flex; flex-direction: column; align-items: center; position: relative; z-index: 1; } .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; } /* Progress Bar Container (new) */ .progress-bar-container { width: 100%; height: 10px; /* Plus fin */ background: #EAEAEA; border-radius: 5px; margin: 1rem 0; overflow: hidden; } .progress-section { text-align: center; padding: 2rem 0; } /* Method Cards */ .method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin: 2rem 0; } .method-card { text-align: center; padding: 3rem 2rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .method-icon { font-size: 4rem; margin-bottom: 1rem; color: #1C1C1C; } /* Form */ .form { max-width: 800px; margin: 2rem auto; background: white; padding: 3rem; /* Plus de padding */ border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); } fieldset { border: 1px solid #E0E0E0; padding: 1.5rem; margin-bottom: 2rem; border-radius: 8px; } .form-group { margin-bottom: 0.5rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #1C1C1C; } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0rem; /* Plus d'espace */ } /* Style des inputs: inclut text, email, number et select */ input[type="text"], input[type="email"], input[type="number"], select { width: 100%; padding: 0.9rem; /* Plus haut */ border: 1px solid #DDD; border-radius: 4px; font-size: 1rem; transition: border-color 0.3s; } /* Upload Box */ .upload-box { border: 2px dashed #DDD; border-radius: 8px; padding: 3rem; text-align: center; margin: 2rem 0; cursor: pointer; transition: all 0.3s; } .upload-label { font-size: 1.1rem; cursor: pointer; color: #1C1C1B; } /* Checkbox */ .checkbox-label { display: flex; align-items: center; cursor: pointer; margin: 1rem 0; } .checkbox-label input[type="checkbox"] { width: auto; margin-right: 0.75rem; height: 1.1em; width: 1.1em; } /* Form Actions */ .form-actions { display: flex; gap: 2rem; justify-content: space-between; margin-top: 3rem; } /* Success Content */ .success-content { text-align: center; padding: 3rem 0; } .next-steps-box { background: #FFFFFF; border: 1px solid #E0E0E0; border-radius: 12px; padding: 2.5rem; margin: 2rem auto; max-width: 600px; text-align: left; box-shadow: 0 4px 15px rgba(0,0,0,0.05); } .next-steps-box ol { padding-left: 1.5rem; } .next-steps-box li { margin-bottom: 0.7rem; } /* Disclaimer */ .footer .disclaimer { font-size: 0.875rem; opacity: 1; margin-top: 0.5rem; color: #ffffff; } /* Main Content */ .main-content { min-height: calc(100vh - 300px); padding: 2rem 0; } /* Tooltips */ .tooltip-bubble { background: #1C1C1C; color: #FFFFFF; padding: 1rem; border-radius: 8px; margin-top: 0.5rem; font-size: 0.9rem; line-height: 1.5; box-shadow: 0 4px 12px rgba(0,0,0,0.5); max-width: 30%; position: absolute; border: 1px solid #FFD700; } .tooltip-bubble::before { content: ''; position: absolute; top: -8px; left: 20px; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #1C1C1C; } /* Radio labels */ .radio-label { display: inline-flex; align-items: center; font-weight: normal; } .radio-label input[type="radio"] { width: auto; margin-right: 0.5rem; } /* ===== PAYMENT PAGE STYLES ===== */ .payment-container { max-width: 800px; margin: 3rem auto; } .payment-wrapper { background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); overflow: hidden; } .payment-header h1 { margin: 0 0 0.5rem 0; font-size: 2.2rem; } .payment-subtitle { margin: 0; opacity: 0.8; font-size: 1.2rem; } .legal-content p { margin-bottom: 1rem; line-height: 1.6; } .legal-content p:last-child { margin-bottom: 0; } .price-item { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 0; border-bottom: 1px solid #E0E0E0; } .price-item:last-of-type { border-bottom: none; } .price-label { font-weight: 500; } .price-value { font-weight: 600; } .payment-form-container { padding: 3rem; } #payment-element { margin-bottom: 1.5rem; } .payment-actions { display: flex; gap: 1rem; justify-content: space-between; } .payment-actions .btn { flex: 1; } .payment-loading { text-align: center; padding: 3rem 2rem; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* ===== OCR COUNTDOWN STYLES (Kept close to other warning styles) ===== */ @keyframes pulse-warning { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); } 50% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); } } .ocr-countdown-notice p { margin: 0.5rem 0; font-size: 1rem; } /* Désactiver le bouton pendant le countdown */ button[ng-disabled="ocrCountdownActive"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } /* ===== NOTIFICATION STYLES (Kept together) ===== */ .notifications-container { position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px; } .notification { margin-bottom: 10px; padding: 15px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; transform: translateX(0); opacity: 1; } .notification-hidden { transform: translateX(120%); opacity: 0; } .notification-content { display: flex; justify-content: space-between; align-items: center; } .notification-close { background: none; border: none; font-size: 18px; cursor: pointer; margin-left: 10px; opacity: 0.7; } .notification-close:hover { opacity: 1; } /* ===== STRIPE ELEMENTS OVERRIDE (Kept together) ===== */ .StripeElement { background-color: white; padding: 12px; border-radius: 4px; border: 1px solid #ddd; transition: border-color 0.3s; } /* ========================================================== MOBILE STYLES SPECIFIQUES POUR IMPORT-EXPORT ========================================================== */ @media (max-width: 768px) { .hero { padding: 4rem 1rem; } .hero h1 { font-size: 2rem; } .cta-buttons { flex-direction: column; align-items: center; width: 100%; gap: 1rem; padding: 0 1rem; } .cta-buttons .btn { width: 100%; max-width: 350px; /* Limite la largeur max */ } /* =========================== NAVBAR MOBILE - Logo gauche, Menu burger droite =========================== */ .navbar { padding: 0.8rem 1rem; position: relative; background: #FFFFFF; } .navbar .container.home { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 0; } .navbar .logo { flex: 1; text-align: left; font-size: 1.5rem; font-weight: 600; color: #1C1C1C; text-decoration: none; letter-spacing: 1px; } /* Masquer le lang-switcher sur mobile */ .navbar .btn-lang { display: none; } /* Menu hamburger */ .mobile-menu-toggle { display: block; background: none; border: none; font-size: 1.8rem; color: #1C1C1C; cursor: pointer; padding: 0.5rem; z-index: 100; } .nav-menu { display: none; flex-direction: column; background: #fff; position: absolute; top: 100%; right: 0; width: 200px; border: 1px solid #E0E0E0; border-radius: 6px; box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 99; list-style: none; } .nav-menu.active { display: flex; } .nav-menu a { padding: 1rem; text-align: left; border-bottom: 1px solid #f0f0f0; color: #1C1C1B; text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: #ffbf00; } .nav-menu a:last-child { border-bottom: none; } /* =========================== IMPORT-EXPORT PAGE MOBILE =========================== */ .import-export { padding: 1rem; min-height: calc(100vh - 300px); } /* 1️⃣ TITRE PRINCIPAL - Largeur écran, pas de retour ligne */ .import-export h1 { width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.6rem; text-align: center; margin-bottom: 1.5rem; color: #1C1C1C; font-weight: 300; letter-spacing: 1px; } /* 2️⃣ BARRE DE PROGRESSION - Row, centrée, 80% largeur */ .import-export .progress-bar { display: flex; flex-direction: row !important; justify-content: center; align-items: center; width: 80%; margin: 1rem auto 2rem; gap: 1rem; position: relative; } .import-export .progress-bar::before { display: none; } /* Masquer les labels des étapes */ .import-export .progress-bar .step-label { display: none !important; } .import-export .progress-step { flex-direction: column; align-items: center; justify-content: center; position: relative; z-index: 1; } .import-export .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; transition: all 0.3s ease; } .import-export .progress-step.active .step-num { background: #FFD700; color: #1C1C1C; box-shadow: 0 0 0 4px #1C1C1C; } .import-export .progress-step.completed .step-num { background: #FFD700; color: #000000; } /* 3️⃣ FORMULAIRE VALUATIONFORM - Espacements harmonisés */ .import-export form[name="valuationForm"] .form-row, .import-export form[name="valuationForm"] .form-group { margin-bottom: 1rem; } .import-export form[name="valuationForm"] .form-row { display: grid; grid-template-columns: 1fr; gap: 1rem; } .import-export .form-group label { font-size: 1rem; font-weight: 500; color: #1C1C1C; margin-bottom: 0.5rem; display: block; } /* 4️⃣ BOUTON COPIER - Largeur limitée */ .import-export .btn.copy { max-width: 120px; width: 100%; text-align: center; padding: 0.6rem 1rem; font-size: 0.9rem; background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; border-radius: 4px; font-weight: 600; cursor: pointer; transition: all 0.4s ease; } .import-export .btn.copy:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } /* 5️⃣ RADIO BUTTONS (Déclarant) - Empilés verticalement */ .import-export .radio-label { display: flex; align-items: center; margin-bottom: 0.5rem; width: 100%; font-weight: normal; cursor: pointer; } .import-export .radio-label input[type="radio"] { margin-right: 0.6rem; width: auto; } /* Forcer le conteneur des radios en colonne */ .import-export .form-group > div[style*="display: flex; gap: 2rem"] { flex-direction: column !important; gap: 0.8rem !important; } .import-export .form-group > div[style*="display: flex"] { flex-direction: column !important; gap: 0.8rem !important; } /* 6️⃣ AMÉLIORATIONS GÉNÉRALES DU FORMULAIRE */ .import-export .form { padding: 1.2rem; border-radius: 8px; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.05); margin: 1rem auto; } .import-export fieldset { margin-bottom: 1.2rem; padding: 1rem; border: 1px solid #E0E0E0; border-radius: 8px; } .import-export legend { font-size: 1.1rem; font-weight: bold; padding: 0 0.5rem; color: #FFD700; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { width: 100%; padding: 0.8rem; font-size: 1rem; border: 1px solid #DDD; border-radius: 4px; transition: border-color 0.3s; } .import-export input:focus, .import-export select:focus { outline: none; border-color: #FFD700; box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .import-export .btn { width: 100%; text-align: center; padding: 1rem; border: none; border-radius: 4px; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } .import-export .btn-primary { background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; } .import-export .btn-primary:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .import-export .btn-secondary { background: #FFFFFF; color: #1C1C1C; border: 1px solid #1C1C1C; } .import-export .btn-secondary:hover { background: #1C1C1C; color: #FFD700; border-color: #FFD700; } .import-export .form-actions { flex-direction: column-reverse; gap: 1rem; margin-top: 1.5rem; display: flex; } /* 7️⃣ ÉLÉMENTS SPÉCIFIQUES */ .import-export .classification-info, .import-export .total-box { margin: 1rem 0; padding: 1rem; border-radius: 6px; } .import-export .classification-info { background: #fefefe; border-left: 4px solid #FFD700; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } .import-export .total-box { background: #d4edda; border: 2px solid #28a745; text-align: center; } .import-export .classification-info h4 { font-size: 1.1rem; color: #1C1C1C; margin-bottom: 0.5rem; } .import-export .method-card { margin-bottom: 1rem; text-align: center; padding: 2rem 1rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .import-export .method-card:hover { border-color: #FFD700; transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } /* 8️⃣ NOTIFICATIONS MOBILE */ .notifications-container { left: 10px; right: 10px; top: 10px; max-width: none; position: fixed; z-index: 9999; } .notification { font-size: 0.95rem; padding: 1rem; margin-bottom: 10px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; } .notification-success { background-color: #d4edda; color: #155724; border-left: 5px solid #28a745; } .notification-error { background-color: #f8d7da; color: #721c24; border-left: 5px solid #dc3545; } .notification-warning { background-color: #fff3cd; color: #856404; border-left: 5px solid #ffc107; } .mobile-menu-toggle { -webkit-tap-highlight-color: transparent; user-select: none; } .nav-menu { -webkit-overflow-scrolling: touch; } .payment-header h1 { font-size: 1.5rem; } .payment-subtitle { font-size: 1rem; } .legal-notice, .pricing-box, .payment-form-container, .security-info { margin-left: 1rem; margin-right: 1rem; } .payment-actions { flex-direction: column-reverse; } .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; } .hero h1 { font-size: 2rem; } .navbar .container { flex-direction: column; gap: 1rem; } .nav-menu { flex-direction: column; gap: 0.5rem; text-align: center; width: 100%; } .form-row { grid-template-columns: 1fr; } .form-actions { flex-direction: column-reverse; /* Mieux: met le bouton principal en bas */ } .btn { width: 100%; } /* Empêcher le zoom sur les inputs iOS */ @media (max-width: 768px) { input[type="text"], input[type="email"], input[type="number"], select { font-size: 16px !important; /* Empêche le zoom iOS */ } } } /* =========================== MICRO-OPTIMISATIONS ULTRA SMALL (≤480px) =========================== */ @media (max-width: 480px) { .import-export h1 { font-size: 1.4rem; } .import-export .progress-bar { width: 90%; gap: 0.8rem; } .import-export .btn.copy { max-width: 100px; font-size: 0.8rem; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { font-size: 0.95rem; padding: 0.7rem; } .import-export legend { font-size: 1rem; } .import-export .step-num { width: 35px; height: 35px; font-size: 1.1rem; } .import-export .form { padding: 1rem; } .import-export fieldset { padding: 0.8rem; } .navbar { padding: 0.6rem 0.8rem; } .navbar .logo { font-size: 1.3rem; } .mobile-menu-toggle { font-size: 1.6rem; } .mobile-menu-toggle::before { font-size: 1.6rem; } } ---------------------------------------------------- public/css/style_home.css /* ========================================================== STYLE_HOME.CSS - Styles spécifiques pour les pages d'accueil Compatible avec toutes les versions (Angular et statiques) ========================================================== */ /* Hero Section Améliorée */ .hero { background: linear-gradient(135deg, #1C1C1C 0%, #2d2d2d 100%); color: #FFFFFF; padding: 5rem 0; text-align: center; position: relative; overflow: hidden; } .hero::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: radial-gradient(circle at 20% 50%, rgba(255, 215, 0, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(255, 215, 0, 0.08) 0%, transparent 50%); pointer-events: none; } .hero .container { position: relative; z-index: 1; } .hero h1 { font-size: 3rem; margin-bottom: 1.5rem; font-weight: 400; letter-spacing: 1px; line-height: 1.2; animation: fadeInUp 0.8s ease-out; } .hero .subtitle { font-size: 1.3rem; margin-bottom: 2.5rem; opacity: 0.9; max-width: 800px; margin-left: auto; margin-right: auto; line-height: 1.6; animation: fadeInUp 1s ease-out; } .hero .cta-buttons { display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap; animation: fadeInUp 1.2s ease-out; } /* Features Section */ .features { padding: 5rem 0; background: #FFFFFF; } .features h2 { text-align: center; margin-bottom: 1rem; font-size: 2.5rem; color: #1C1C1C; font-weight: 400; letter-spacing: 0.5px; } .features h3 { font-size: 2rem; color: #1C1C1C; font-weight: 400; } .features > .container > p { text-align: center; font-size: 1.1rem; line-height: 1.8; color: #444; } /* Comparison Grid */ .comparison-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem; margin-top: 2rem; } .comparison-card { background: #FFFFFF; border: 2px solid #E0E0E0; border-radius: 12px; padding: 2.5rem 2rem; text-align: center; transition: all 0.3s ease; position: relative; } .comparison-card:hover { transform: translateY(-8px); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.12); border-color: #FFD700; } .comparison-card.highlight { border-color: #FFD700; border-width: 3px; background: linear-gradient(135deg, #FFFEF7 0%, #FFFFFF 100%); box-shadow: 0 8px 25px rgba(255, 215, 0, 0.15); } .comparison-card.highlight::before { content: '⭐ RECOMMANDÉ'; position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #FFD700; color: #1C1C1C; padding: 0.3rem 1rem; border-radius: 20px; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px; } .comparison-card h4 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #1C1C1C; font-weight: 600; } .comparison-card .price { font-size: 2.5rem; font-weight: 700; color: #FFD700; margin-bottom: 0.5rem; font-family: 'Courier New', monospace; } .comparison-card.highlight .price { color: #1C1C1C; text-shadow: 0 2px 4px rgba(255, 215, 0, 0.3); } .comparison-card .time { font-size: 1rem; color: #666; margin-bottom: 2rem; font-style: italic; } .features-list { list-style: none; padding: 0; margin: 0; text-align: left; } .features-list li { padding: 0.75rem 0; border-bottom: 1px solid #f0f0f0; font-size: 1rem; line-height: 1.5; } .features-list li:last-child { border-bottom: none; } /* Animations */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } /* Section générique */ .section { padding: 4rem 0; } /* Responsive Design */ @media (max-width: 768px) { .hero h1 { font-size: 2rem; margin-bottom: 1rem; } .hero .subtitle { font-size: 1.1rem; margin-bottom: 2rem; } .hero .cta-buttons { flex-direction: column; gap: 1rem; } .hero .cta-buttons .btn { width: 100%; max-width: 300px; } .features h2 { font-size: 1.8rem; } .features h3 { font-size: 1.5rem; } .comparison-grid { grid-template-columns: 1fr; gap: 1.5rem; } .comparison-card { padding: 2rem 1.5rem; } .comparison-card .price { font-size: 2rem; } } @media (max-width: 480px) { .hero { padding: 3rem 0; } .hero h1 { font-size: 1.6rem; } .hero .subtitle { font-size: 1rem; } .features { padding: 3rem 0; } .comparison-card.highlight::before { font-size: 0.7rem; padding: 0.25rem 0.75rem; } } /* Amélioration des boutons dans le hero */ .hero .btn { min-width: 200px; font-size: 1.1rem; padding: 1rem 2.5rem; } .hero .btn-primary { background: #FFD700; color: #1C1C1C; border: 2px solid #FFD700; box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3); } .hero .btn-primary:hover { background: #FFC700; transform: translateY(-3px); box-shadow: 0 6px 20px rgba(255, 215, 0, 0.5); } .hero .btn-secondary { background: transparent; color: #FFFFFF; border: 2px solid #FFFFFF; } .hero .btn-secondary:hover { background: #FFFFFF; color: #1C1C1C; transform: translateY(-3px); } ---------------------------------------------------- public/css/style.css /* ========================================================== MYAUTOIMPORT.CH - UNIFIED STYLESHEET Fichier CSS unique fusionné et organisé ========================================================== */ /* ========================================================== 1. VARIABLES & RESET ========================================================== */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1C1C1C; background: #F7F7F7; } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } /* ========================================================== 2. BUTTONS (Global) ========================================================== */ .btn { padding: 0.875rem 2.5rem; border: none; border-radius: 4px; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } .btn-primary { background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; } .btn-primary:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .btn-secondary { background: #FFFFFF; color: #1C1C1C; border: 1px solid #1C1C1C; } .btn-secondary:hover { background: #1C1C1C; color: #FFD700; border-color: #FFD700; } /* ========================================================== 3. NAVIGATION (index.html) ========================================================== */ .navbar { background: #FFFFFF; padding: 1rem 0; box-shadow: 0 1px 10px rgba(0,0,0,0.05); border-bottom: 1px solid #F7F7F7; } .navbar .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } .logo { font-size: 1.8rem; font-weight: 600; color: #1C1C1C; text-decoration: none; letter-spacing: 1px; } .nav-menu { display: flex; list-style: none; gap: 0.5rem; } .nav-menu a { color: #1C1C1B; text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: #ffbf00; } .lang-switcher { display: flex; gap: 0.5rem; } .btn-lang { padding: 0.5rem 0.8rem; border: 1px solid #E0E0E0; background: transparent; color: #1C1C1B; cursor: pointer; border-radius: 4px; transition: all 0.3s; } .btn-lang:hover, .btn-lang.active { background: #1C1C1C; border-color: #1C1C1C; color: #FFD700; } .mobile-menu-toggle { display: none; } .mobile-lang-divider { display: none; } /* ========================================================== 4. HERO SECTION (home.html) ========================================================== */ .hero { background: linear-gradient(135deg, #1C1C1C 0%, #2d2d2d 100%); color: #FFFFFF; padding: 5rem 0; text-align: center; position: relative; overflow: hidden; } .hero::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: radial-gradient(circle at 20% 50%, rgba(255, 215, 0, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(255, 215, 0, 0.08) 0%, transparent 50%); pointer-events: none; } .hero .container { position: relative; z-index: 1; } .hero h1 { font-size: 3rem; margin-bottom: 1.5rem; font-weight: 400; letter-spacing: 1px; line-height: 1.2; animation: fadeInUp 0.8s ease-out; } .hero .subtitle { font-size: 1.3rem; margin-bottom: 2.5rem; opacity: 0.9; max-width: 800px; margin-left: auto; margin-right: auto; line-height: 1.6; animation: fadeInUp 1s ease-out; } .hero .cta-buttons { display: flex; gap: 1.5rem; justify-content: center; flex-wrap: wrap; animation: fadeInUp 1.2s ease-out; } .hero .btn { min-width: 200px; font-size: 1.1rem; padding: 1rem 2.5rem; } .hero .btn-primary { background: #FFD700; color: #1C1C1C; border: 2px solid #FFD700; box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3); } .hero .btn-primary:hover { background: #FFC700; transform: translateY(-3px); box-shadow: 0 6px 20px rgba(255, 215, 0, 0.5); } .hero .btn-secondary { background: transparent; color: #FFFFFF; border: 2px solid #FFFFFF; } .hero .btn-secondary:hover { background: #FFFFFF; color: #1C1C1C; transform: translateY(-3px); } /* ========================================================== 5. FEATURES & COMPARISON (home.html) ========================================================== */ .features { padding: 5rem 0; background: #FFFFFF; } .features h2 { text-align: center; margin-bottom: 1rem; font-size: 2.5rem; color: #1C1C1C; font-weight: 400; letter-spacing: 0.5px; } .features h3 { font-size: 2rem; color: #1C1C1C; font-weight: 400; text-align: center; margin: 4rem 0 2rem; } .features > .container > p { text-align: center; font-size: 1.1rem; line-height: 1.8; color: #444; } .comparison-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem; margin-top: 2rem; } .comparison-card { background: #FFFFFF; border: 2px solid #E0E0E0; border-radius: 12px; padding: 2.5rem 2rem; text-align: center; transition: all 0.3s ease; position: relative; } .comparison-card:hover { transform: translateY(-8px); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.12); border-color: #FFD700; } .comparison-card.highlight { border-color: #FFD700; border-width: 3px; background: linear-gradient(135deg, #FFFEF7 0%, #FFFFFF 100%); box-shadow: 0 8px 25px rgba(255, 215, 0, 0.15); } .comparison-card.highlight::before { content: 'BEST CHOICE'; position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #FFD700; color: #1C1C1C; padding: 0.3rem 1rem; border-radius: 20px; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px; } .comparison-card h4 { font-size: 1.5rem; margin-bottom: 1.5rem; color: #1C1C1C; font-weight: 600; } .comparison-card .price { font-size: 2.5rem; font-weight: 700; color: #FFD700; margin-bottom: 0.5rem; /* font-family: 'Courier New', monospace; */ } .comparison-card.highlight .price { color: #1C1C1C; text-shadow: 0 2px 4px rgba(255, 215, 0, 0.3); } .comparison-card .time { font-size: 1rem; color: #666; margin-bottom: 1rem; font-style: italic; } .features-list { list-style: none; padding: 0; margin: 0; text-align: left; } .features-list li { padding: 0.75rem 0; border-bottom: 1px solid #f0f0f0; font-size: 1rem; line-height: 1.5; } .features-list li:last-child { border-bottom: none; } /* ========================================================== 6. IMPORT/EXPORT PAGES (import.html, export.html) ========================================================== */ .main-content { min-height: calc(100vh - 300px); padding: 2rem 0; } /* Progress Bar */ .progress-bar { display: flex; justify-content: space-between; margin: 2rem 0 3rem; position: relative; } .progress-bar::before { content: ''; position: absolute; top: 20px; left: 0; right: 0; height: 2px; background: #ddd; z-index: 0; } .progress-step { display: flex; flex-direction: column; align-items: center; position: relative; z-index: 1; } .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; } .progress-step.active .step-num { background: #FFD700; color: #1C1C1C; box-shadow: 0 0 0 4px #1C1C1C; } .progress-step.completed .step-num { background: #FFD700; color: #000000; } .progress-bar-container { width: 100%; height: 10px; background: #EAEAEA; border-radius: 5px; margin: 1rem 0; overflow: hidden; } .progress-section { text-align: center; padding: 2rem 0; } /* Method Cards */ .method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin: 2rem 0; } .method-card { text-align: center; padding: 3rem 2rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .method-card:hover { border-color: #FFD700; transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } .method-icon { font-size: 4rem; margin-bottom: 1rem; color: #1C1C1C; } /* Forms */ .form { max-width: 800px; margin: 2rem auto; background: white; padding: 3rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); } fieldset { border: 1px solid #E0E0E0; padding: 1.5rem; margin-bottom: 2rem; border-radius: 8px; } legend { font-weight: bold; padding: 0 0.5rem; color: #FFD700; } .form-group { margin-bottom: 0.5rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #1C1C1C; } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } input[type="text"], input[type="email"], input[type="number"], select { width: 100%; padding: 0.9rem; border: 1px solid #DDD; border-radius: 4px; font-size: 1rem; transition: border-color 0.3s; } input:focus, select:focus { outline: none; border-color: #FFD700; box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .upload-box { border: 2px dashed #DDD; border-radius: 8px; padding: 3rem; text-align: center; margin: 2rem 0; cursor: pointer; transition: all 0.3s; } .upload-box:hover { border-color: #FFD700; background: #fcfcfc; } .upload-label { font-size: 1.1rem; cursor: pointer; color: #1C1C1B; } .checkbox-label { display: flex; align-items: center; cursor: pointer; margin: 1rem 0; } .checkbox-label input[type="checkbox"] { width: auto; margin-right: 0.75rem; height: 1.1em; width: 1.1em; } .radio-label { display: inline-flex; align-items: center; font-weight: normal; } .radio-label input[type="radio"] { width: auto; margin-right: 0.5rem; } .form-actions { display: flex; gap: 2rem; justify-content: space-between; margin-top: 3rem; } .classification-info { background: #fefefe; border-left: 4px solid #FFD700; padding: 1.5rem; margin: 1.5rem 0; border-radius: 6px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } .total-box { background: #d4edda; border: 2px solid #28a745; padding: 1.5rem; border-radius: 8px; margin: 1.5rem 0; text-align: center; } .total-box h3 { color: #155724; margin: 0; } /* Tooltips */ .tooltip-icon { color: #1C1C1C; display: inline-block; width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: 50%; font-size: 0.85rem; cursor: pointer; margin-left: 0.5rem; vertical-align: middle; transition: background 0.3s; } .tooltip-bubble { background: #1C1C1C; color: #FFFFFF; padding: 1rem; border-radius: 8px; margin-top: 0.5rem; font-size: 0.9rem; line-height: 1.5; box-shadow: 0 4px 12px rgba(0,0,0,0.5); max-width: 30%; position: absolute; border: 1px solid #FFD700; } .tooltip-bubble::before { content: ''; position: absolute; top: -8px; left: 20px; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #1C1C1C; } /* Success Content */ .success-content { text-align: center; padding: 3rem 0; } .success-icon { font-size: 5rem; margin-bottom: 1rem; color: #28a745; } .next-steps-box { background: #FFFFFF; border: 1px solid #E0E0E0; border-radius: 12px; padding: 2.5rem; margin: 2rem auto; max-width: 600px; text-align: left; box-shadow: 0 4px 15px rgba(0,0,0,0.05); } /* ========================================================== 7. NOTIFICATIONS ========================================================== */ .notifications-container { position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px; } .notification { margin-bottom: 10px; padding: 15px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; transform: translateX(0); opacity: 1; } .notification-hidden { transform: translateX(120%); opacity: 0; } .notification-content { display: flex; justify-content: space-between; align-items: center; } .notification-close { background: none; border: none; font-size: 18px; cursor: pointer; margin-left: 10px; opacity: 0.7; } .notification-close:hover { opacity: 1; } .notification-success { background-color: #d4edda; color: #155724; border-left: 5px solid #28a745; } .notification-error { background-color: #f8d7da; color: #721c24; border-left: 5px solid #dc3545; } .notification-warning { background-color: #fff3cd; color: #856404; border-left: 5px solid #ffc107; } .notification-info { background-color: #d1ecf1; color: #0c5460; border-left: 5px solid #17a2b8; } /* ========================================================== 8. MESSAGES & ALERTS ========================================================== */ .hint { display: block; font-size: 0.875rem; color: #28a745; margin-top: 0.25rem; } .error { display: block; font-size: 0.875rem; color: #dc3545; margin-top: 0.25rem; } .ocr-countdown-notice { background: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0; text-align: center; animation: pulse-warning 2s ease-in-out infinite; } .ocr-countdown-notice p { margin: 0.5rem 0; font-size: 1rem; } .ocr-countdown-notice p:first-child { font-weight: bold; font-size: 1.1rem; color: #856404; } .countdown-timer { font-size: 2.5rem; font-weight: bold; color: #856404; margin-top: 1rem; font-family: 'Courier New', monospace; background: rgba(255, 193, 7, 0.2); padding: 1rem 2rem; border-radius: 8px; display: inline-block; } button[ng-disabled="ocrCountdownActive"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } /* Validation error class for highlighting on button click */ .validation-error { border: 2px solid #dc3545 !important; background-color: #fff5f5 !important; animation: shake 0.3s; } .validation-error:focus { outline: 2px solid #dc3545; outline-offset: 2px; } @keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } } /* Style pour les champs désactivés */ input:disabled, select:disabled { background-color: #f0f0f0 !important; cursor: not-allowed !important; opacity: 0.6; } /* ========================================================== 9. FOOTER ========================================================== */ .footer { background: #1C1C1C; color: #FFFFFF; padding: 2.5rem 0; text-align: center; margin-top: 5rem; border-top: 3px solid #FFD700; } .footer .disclaimer { font-size: 0.875rem; opacity: 1; margin-top: 0.5rem; color: #ffffff; } /* ========================================================== 10. ANIMATIONS ========================================================== */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse-warning { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); } 50% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* ========================================================== 11. RESPONSIVE - MOBILE (≤768px) ========================================================== */ @media (max-width: 768px) { /* Hero & CTA */ .hero { padding: 4rem 1rem; } .hero h1 { font-size: 2rem; } .hero .subtitle { font-size: 1.1rem; margin-bottom: 2rem; } .cta-buttons { flex-direction: column; align-items: center; width: 100%; gap: 1rem; padding: 0 1rem; } .cta-buttons .btn { width: 100%; max-width: 350px; } /* Comparison Cards */ .comparison-grid { grid-template-columns: 1fr; gap: 1.5rem; padding: 0 1rem; } .comparison-card { padding: 2rem 1.5rem; } .comparison-card .price { font-size: 2rem; } .features h2 { font-size: 1.8rem; } .features h3 { font-size: 1.5rem; } /* Navbar */ .navbar { padding: 0.8rem 1rem; position: relative; background: #FFFFFF; } .navbar .container.home { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 0; } .navbar .logo { flex: 1; text-align: left; font-size: 1.5rem; font-weight: 600; color: #1C1C1C; text-decoration: none; letter-spacing: 1px; } .lang-switcher { display: none; } .mobile-menu-toggle { display: block; background: none; border: none; font-size: 1.8rem; color: #1C1C1C; cursor: pointer; padding: 0.5rem; z-index: 100; -webkit-tap-highlight-color: transparent; user-select: none; } .nav-menu { display: none; flex-direction: column; background: #fff; position: absolute; top: 100%; right: 0; width: 250px; border: 1px solid #E0E0E0; border-radius: 6px; box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 99; list-style: none; -webkit-overflow-scrolling: touch; } .nav-menu.active { display: flex; } .nav-menu a { padding: 1rem 1.5rem; border-bottom: 1px solid #f0f0f0; text-align: left; color: #1C1C1B; text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: #ffbf00; } .nav-menu a:last-child { border-bottom: none; } .mobile-lang-divider { display: flex; height: 50px; justify-content: space-evenly; flex-wrap: wrap; margin-top: -15px; border-block: none; vertical-align: middle; align-content: center; } .mobile-lang-divider a { padding: 0px; text-align: center; vertical-align: middle; } /* Import-Export Pages */ .import-export .btn.copy:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .import-export .radio-label { display: flex; align-items: center; margin-bottom: 0.5rem; width: 100%; font-weight: normal; cursor: pointer; } .import-export .radio-label input[type="radio"] { margin-right: 0.6rem; width: auto; } .import-export .form-group > div[style*="display: flex; gap: 2rem"] { flex-direction: column !important; gap: 0.8rem !important; } .import-export .form-group > div[style*="display: flex"] { flex-direction: column !important; gap: 0.8rem !important; } .import-export .form { padding: 1.2rem; border-radius: 8px; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.05); margin: 1rem auto; } .import-export fieldset { margin-bottom: 1.2rem; padding: 1rem; border: 1px solid #E0E0E0; border-radius: 8px; } .import-export legend { font-size: 1.1rem; font-weight: bold; padding: 0 0.5rem; color: #FFD700; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { width: 100%; padding: 0.8rem; font-size: 1rem; border: 1px solid #DDD; border-radius: 4px; transition: border-color 0.3s; } .import-export input:focus, .import-export select:focus { outline: none; border-color: #FFD700; box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .import-export .btn { width: 100%; text-align: center; padding: 1rem; border: none; border-radius: 4px; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } .import-export .btn-primary { background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; } .import-export .btn-primary:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .import-export .btn-secondary { background: #FFFFFF; color: #1C1C1C; border: 1px solid #1C1C1C; } .import-export .btn-secondary:hover { background: #1C1C1C; color: #FFD700; border-color: #FFD700; } .import-export .form-actions { flex-direction: column-reverse; gap: 1rem; margin-top: 1.5rem; display: flex; } .import-export .classification-info, .import-export .total-box { margin: 1rem 0; padding: 1rem; border-radius: 6px; } .import-export .classification-info { background: #fefefe; border-left: 4px solid #FFD700; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } .import-export .total-box { background: #d4edda; border: 2px solid #28a745; text-align: center; } .import-export .classification-info h4 { font-size: 1.1rem; color: #1C1C1C; margin-bottom: 0.5rem; } .import-export .method-card { margin-bottom: 1rem; text-align: center; padding: 2rem 1rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .import-export .method-card:hover { border-color: #FFD700; transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } /* Notifications */ .notifications-container { left: 10px; right: 10px; top: 10px; max-width: none; position: fixed; z-index: 9999; } .notification { font-size: 0.95rem; padding: 1rem; margin-bottom: 10px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; } /* Forms généraux */ .form-row { grid-template-columns: 1fr; } .form-actions { flex-direction: column-reverse; } .btn { width: 100%; } .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; } /* Empêcher le zoom sur les inputs iOS */ input[type="text"], input[type="email"], input[type="number"], select { font-size: 16px !important; } } /* ========================================================== 12. RESPONSIVE - ULTRA SMALL (≤480px) ========================================================== */ @media (max-width: 480px) { .hero { padding: 3rem 1rem; } .hero h1 { font-size: 1.6rem; } .hero .subtitle { font-size: 1rem; } .features { padding: 3rem 0; } .comparison-card.highlight::before { font-size: 0.7rem; padding: 0.25rem 0.75rem; } .import-export h1 { font-size: 1.4rem; } .import-export .progress-bar { width: 90%; gap: 0.8rem; } .import-export .btn.copy { max-width: 100px; font-size: 0.8rem; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { font-size: 0.95rem; padding: 0.7rem; } .import-export legend { font-size: 1rem; } .import-export .step-num { width: 35px; height: 35px; font-size: 1.1rem; } .import-export .form { padding: 1rem; } .import-export fieldset { padding: 0.8rem; } .navbar { padding: 0.6rem 0.8rem; } .navbar .logo { font-size: 1.3rem; } .mobile-menu-toggle { font-size: 1.6rem; } .import-export { padding: 1rem; min-height: calc(100vh - 300px); } .import-export h1 { width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.6rem; text-align: center; margin-bottom: 1.5rem; color: #1C1C1C; font-weight: 300; letter-spacing: 1px; } .import-export .progress-bar { display: flex; flex-direction: row !important; justify-content: center; align-items: center; width: 80%; margin: 1rem auto 2rem; gap: 1rem; position: relative; } .import-export .progress-bar::before { display: none; } .import-export .progress-bar .step-label { display: none !important; } .import-export .progress-step { flex-direction: column; align-items: center; justify-content: center; position: relative; z-index: 1; } .import-export .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; transition: all 0.3s ease; } .import-export .progress-step.active .step-num { background: #FFD700; color: #1C1C1C; box-shadow: 0 0 0 4px #1C1C1C; } .import-export .progress-step.completed .step-num { background: #FFD700; color: #000000; } .import-export form[name="valuationForm"] .form-row, .import-export form[name="valuationForm"] .form-group { margin-bottom: 1rem; } .import-export form[name="valuationForm"] .form-row { display: grid; grid-template-columns: 1fr; gap: 1rem; } .import-export .form-group label { font-size: 1rem; font-weight: 500; color: #1C1C1C; margin-bottom: 0.5rem; display: block; } .import-export .btn.copy { max-width: 120px; width: 100%; text-align: center; padding: 0.6rem 1rem; font-size: 0.9rem; background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; border-radius: 4px; font-weight: 600; cursor: pointer; transition: all 0.4s ease; } .import-export .btn.copy:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .import-export .radio-label { display: flex; align-items: center; margin-bottom: 0.5rem; width: 100%; font-weight: normal; cursor: pointer; } .import-export .radio-label input[type="radio"] { margin-right: 0.6rem; width: auto; } .import-export .form-group > div[style*="display: flex; gap: 2rem"] { flex-direction: column !important; gap: 0.8rem !important; } .import-export .form-group > div[style*="display: flex"] { flex-direction: column !important; gap: 0.8rem !important; } .import-export .form { padding: 1.2rem; border-radius: 8px; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.05); margin: 1rem auto; } .import-export fieldset { margin-bottom: 1.2rem; padding: 1rem; border: 1px solid #E0E0E0; border-radius: 8px; } .import-export legend { font-size: 1.1rem; font-weight: bold; padding: 0 0.5rem; color: #FFD700; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { width: 100%; padding: 0.8rem; font-size: 1rem; border: 1px solid #DDD; border-radius: 4px; transition: border-color 0.3s; } .import-export input:focus, .import-export select:focus { outline: none; border-color: #FFD700; box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .import-export .btn { width: 100%; text-align: center; padding: 1rem; border: none; border-radius: 4px; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } .import-export .btn-primary { background: #FFD700; color: #1C1C1C; border: 1px solid #FFD700; } .import-export .btn-primary:hover { background: #e6c200; transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } .import-export .btn-secondary { background: #FFFFFF; color: #1C1C1C; border: 1px solid #1C1C1C; } .import-export .btn-secondary:hover { background: #1C1C1C; color: #FFD700; border-color: #FFD700; } .import-export .form-actions { flex-direction: column-reverse; gap: 1rem; margin-top: 1.5rem; display: flex; } .import-export .classification-info, .import-export .total-box { margin: 1rem 0; padding: 1rem; border-radius: 6px; } .import-export .classification-info { background: #fefefe; border-left: 4px solid #FFD700; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } .import-export .total-box { background: #d4edda; border: 2px solid #28a745; text-align: center; } .import-export .classification-info h4 { font-size: 1.1rem; color: #1C1C1C; margin-bottom: 0.5rem; } .import-export .method-card { margin-bottom: 1rem; text-align: center; padding: 2rem 1rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .import-export .method-card:hover { border-color: #FFD700; transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } /* Notifications */ .notifications-container { left: 10px; right: 10px; top: 10px; max-width: none; position: fixed; z-index: 9999; } .notification { font-size: 0.95rem; padding: 1rem; margin-bottom: 10px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; } /* Forms généraux */ .form-row { grid-template-columns: 1fr; } .form-actions { flex-direction: column-reverse; } .btn { /* width: 100%; */ } .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; } /* Empêcher le zoom sur les inputs iOS */ input[type="text"], input[type="email"], input[type="number"], select { font-size: 16px !important; } } /* ========================================================== 12. RESPONSIVE - ULTRA SMALL (≤480px) ========================================================== */ @media (max-width: 480px) { .hero { padding: 3rem 1rem; } .hero h1 { font-size: 1.6rem; } .hero .subtitle { font-size: 1rem; } .features { padding: 3rem 0; } .comparison-card.highlight::before { font-size: 0.7rem; padding: 0.25rem 0.75rem; } .import-export h1 { font-size: 1.4rem; } .import-export .progress-bar { width: 90%; gap: 0.8rem; } .import-export .btn.copy { max-width: 100px; font-size: 0.8rem; } .import-export input[type="text"], .import-export input[type="email"], .import-export input[type="number"], .import-export select { font-size: 0.95rem; padding: 0.7rem; } .import-export legend { font-size: 1rem; } .import-export .step-num { width: 35px; height: 35px; font-size: 1.1rem; } .import-export .form { padding: 1rem; } .import-export fieldset { padding: 0.8rem; } .navbar { padding: 0.6rem 0.8rem; } .navbar .logo { font-size: 1.3rem; } .mobile-menu-toggle { font-size: 1.6rem; } } ---------------------------------------------------- public/css/stylev1.css /* ========================================================== 1. COLOR PALETTE & GLOBAL STYLES (Black, White, Gold) ========================================================== */ /* Buttons */ .btn { padding: 0.875rem 2.5rem; /* Plus large pour un look premium */ border: none; border-radius: 4px; /* Plus carré, plus élégant */ font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; transition: all 0.4s ease; display: inline-block; letter-spacing: 0.5px; } /* Primary Button (Gold/Black) */ .btn-primary { background: #FFD700; /* Gold */ color: #1C1C1C; /* Deep Black for text on primary */ border: 1px solid #FFD700; } .btn-primary:hover { background: #e6c200; /* Darker Gold for hover */ transform: translateY(-2px); box-shadow: 0 6px 15px rgba(255, 215, 0, 0.4); } /* Secondary Button (White/Black, Bordered) */ .btn-secondary { background: #FFFFFF; /* White */ color: #1C1C1C; /* Deep Black */ border: 1px solid #1C1C1C; } .btn-secondary:hover { background: #1C1C1C; /* Inverser les couleurs au hover */ color: #FFD700; /* Gold text */ border-color: #FFD700; } /* Hero Section */ .hero { background: #1C1C1C; /* Deep Black background */ color: #FFFFFF; /* White text on hero */ padding: 6rem 0; /* Plus de padding pour un effet "luxe" */ text-align: center; } .hero h1 { font-size: 3.5rem; /* Plus grand */ margin-bottom: 1rem; font-weight: 300; /* Texte plus fin pour l'élégance */ letter-spacing: 2px; } .subtitle { font-size: 1.5rem; margin-bottom: 3rem; opacity: 0.8; } .cta-buttons { display: flex; gap: 2rem; /* Plus d'espace */ justify-content: center; flex-wrap: wrap; } /* Navigation */ .navbar { background: #FFFFFF; /* White Nav Bar */ padding: 1rem 0; box-shadow: 0 1px 10px rgba(0,0,0,0.05); border-bottom: 1px solid #F7F7F7; } .logo { font-size: 1.8rem; font-weight: 600; color: #1C1C1C; /* Deep Black Logo */ text-decoration: none; letter-spacing: 1px; } .nav-menu a { color: #1C1C1B; /* Dark Grey for navigation links #6E6E6E */ text-decoration: none; font-weight: 500; transition: color 0.3s; } .nav-menu a:hover, .nav-menu a.active { color: #ffbf00; /* Gold hover */ } /* Language Switcher */ .btn-lang { padding: 0.5rem 0.8rem; border: 1px solid #E0E0E0; background: transparent; color: #1C1C1B; cursor: pointer; border-radius: 4px; transition: all 0.3s; } .btn-lang:hover, .btn-lang.active { background: #1C1C1C; border-color: #1C1C1C; color: #FFD700; /* Gold active/hover text */ } /* Form Styles */ legend { font-weight: bold; padding: 0 0.5rem; color: #FFD700; /* Gold for legend */ } input:focus, select:focus { outline: none; border-color: #FFD700; /* Gold focus border */ box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } .upload-box:hover { border-color: #FFD700; /* Gold hover border */ background: #fcfcfc; } .classification-info { background: #fefefe; border-left: 4px solid #FFD700; /* Gold info border */ padding: 1.5rem; margin: 1.5rem 0; border-radius: 6px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03); } /* Progress Bar and Steps */ .step-number { width: 50px; /* Plus petit pour le détail */ height: 50px; background: #1C1C1C; /* Black step background */ color: #FFD700; /* Gold step number */ border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: bold; margin: 0 auto 1rem; box-shadow: 0 0 0 2px #FFD700; /* Double cerclage gold */ } .progress-step.active .step-num { background: #FFD700; /* Gold active step */ color: #1C1C1C; box-shadow: 0 0 0 4px #1C1C1C; /* Bordure noire pour le contraste */ } .progress-bar { height: 100%; /* background: #FFD700; */ /* Gold progress bar */ transition: width 0.5s ease; } /* Method Cards */ .method-card:hover { border-color: #FFD700; /* Gold border on hover */ transform: translateY(-5px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } /* Footer */ .footer { background: #1C1C1C; /* Deep Black Footer */ color: #FFFFFF; padding: 2.5rem 0; text-align: center; margin-top: 5rem; border-top: 3px solid #FFD700; /* Petite touche de gold */ } /* Tooltip */ .tooltip-icon { color: #1C1C1C; /* Black icon text */ display: inline-block; width: 20px; height: 20px; line-height: 20px; text-align: center; border-radius: 50%; font-size: 0.85rem; cursor: pointer; margin-left: 0.5rem; vertical-align: middle; transition: background 0.3s; } /* Payment Section */ .payment-header { background: #1C1C1C; /* Deep Black header */ color: #FFFFFF; padding: 2rem; text-align: center; } .legal-notice { background: #FFFFFF; border-left: 4px solid #FFD700; /* Gold notice border */ padding: 1.5rem; margin: 2rem; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.05); } .pricing-box { background: #F7F7F7; /* Light Grey for pricing */ border: 2px solid #E0E0E0; border-radius: 8px; padding: 1.5rem; margin: 0 2rem 2rem 2rem; } .price-item.total { font-size: 1.4rem; font-weight: bold; color: #1C1C1C; /* Black total price */ padding-top: 1rem; border-top: 2px solid #FFD700; /* Gold total separator */ } .spinner { border: 4px solid #f3f3f3; border-top: 4px solid #FFD700; /* Gold spinner */ border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; margin: 0 auto 1rem auto; } /* Stripe Elements Override */ .StripeElement--focus { border-color: #FFD700; /* Gold focus border */ box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1); } /* ========================================================== 2. SUCCESS, WARNING, AND ERROR STYLES (Standardized Colors) ========================================================== */ /* Success */ .success-icon { font-size: 5rem; margin-bottom: 1rem; color: #28a745; /* Success Green */ } .progress-step.completed .step-num { background: #FFD700; /* Success Green */ color: #000000; } .hint { display: block; font-size: 0.875rem; color: #28a745; /* Success Green */ margin-top: 0.25rem; } .total-box { background: #d4edda; border: 2px solid #28a745; padding: 1.5rem; border-radius: 8px; margin: 1.5rem 0; text-align: center; } .total-box h3 { color: #155724; margin: 0; } .security-info { background: #d4edda; border-left: 4px solid #28a745; padding: 1rem; margin: 2rem; border-radius: 4px; font-size: 0.9rem; } .notification-success { background-color: #d4edda; color: #155724; border-left: 5px solid #28a745; } .StripeElement--complete { border-color: #28a745; } /* Error & Warning */ .error { display: block; font-size: 0.875rem; color: #dc3545; /* Error Red */ margin-top: 0.25rem; } .notification-error { background-color: #f8d7da; color: #721c24; border-left: 5px solid #dc3545; } .StripeElement--invalid { border-color: #dc3545; } .ocr-countdown-notice, .notification-warning { background: #fff3cd; border: 2px solid #ffc107; border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0; text-align: center; } .ocr-countdown-notice { animation: pulse-warning 2s ease-in-out infinite; } .ocr-countdown-notice p:first-child { font-weight: bold; font-size: 1.1rem; color: #856404; } .countdown-timer { font-size: 2.5rem; font-weight: bold; color: #856404; margin-top: 1rem; font-family: 'Courier New', monospace; background: rgba(255, 193, 7, 0.2); padding: 1rem 2rem; border-radius: 8px; display: inline-block; } .notification-warning { background-color: #fff3cd; color: #856404; border-left: 5px solid #ffc107; } .price-notice { margin: 1rem 0 0 0; padding: 0.75rem; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; font-size: 0.95rem; } /* Info */ .notification-info { background-color: #d1ecf1; color: #0c5460; border-left: 5px solid #17a2b8; } /* ========================================================== 3. BASE & STRUCTURE (Less likely to change) ========================================================== */ /* Reset & Base */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1C1C1C; /* Deep Black text */ background: #F7F7F7; /* Light grey background for luxury feel */ } .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; } /* Navigation */ .navbar .container { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } .nav-menu { display: flex; list-style: none; gap: 2rem; } /* Features */ .features { padding: 5rem 0; background: white; } .features h2 { text-align: center; margin-bottom: 3rem; font-size: 2.8rem; color: #1C1C1C; font-weight: 300; } .features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem; } .feature-card { text-align: center; padding: 2.5rem; border-radius: 6px; background: #FFFFFF; transition: transform 0.3s, box-shadow 0.3s; border: 1px solid #EDEDED; } .feature-card:hover { transform: translateY(-5px); box-shadow: 0 10px 30px rgba(0,0,0,0.08); } .feature-icon { font-size: 3rem; margin-bottom: 1rem; color: #FFD700; } /* How it works */ .how-it-works { padding: 5rem 0; } .how-it-works h2 { text-align: center; margin-bottom: 4rem; font-size: 2.8rem; font-weight: 300; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; } .step-item { text-align: center; } /* Progress Bar (old) */ .progress-bar { display: flex; justify-content: space-between; margin: 2rem 0 3rem; position: relative; } .progress-bar::before { content: ''; position: absolute; top: 20px; left: 0; right: 0; height: 2px; background: #ddd; z-index: 0; } .progress-step { display: flex; flex-direction: column; align-items: center; position: relative; z-index: 1; } .step-num { width: 40px; height: 40px; background: #ddd; color: #666; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-bottom: 0.5rem; } /* Progress Bar Container (new) */ .progress-bar-container { width: 100%; height: 10px; /* Plus fin */ background: #EAEAEA; border-radius: 5px; margin: 1rem 0; overflow: hidden; } .progress-section { text-align: center; padding: 2rem 0; } /* Method Cards */ .method-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin: 2rem 0; } .method-card { text-align: center; padding: 3rem 2rem; border: 2px solid #E0E0E0; border-radius: 8px; cursor: pointer; transition: all 0.3s; background: #FFFFFF; } .method-icon { font-size: 4rem; margin-bottom: 1rem; color: #1C1C1C; } /* Form */ .form { max-width: 800px; margin: 2rem auto; background: white; padding: 3rem; /* Plus de padding */ border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); } fieldset { border: 1px solid #E0E0E0; padding: 1.5rem; margin-bottom: 2rem; border-radius: 8px; } .form-group { margin-bottom: 1.5rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #1C1C1C; } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; /* Plus d'espace */ } /* Style des inputs: inclut text, email, number et select */ input[type="text"], input[type="email"], input[type="number"], select { width: 100%; padding: 0.9rem; /* Plus haut */ border: 1px solid #DDD; border-radius: 4px; font-size: 1rem; transition: border-color 0.3s; } /* Upload Box */ .upload-box { border: 2px dashed #DDD; border-radius: 8px; padding: 3rem; text-align: center; margin: 2rem 0; cursor: pointer; transition: all 0.3s; } .upload-label { font-size: 1.1rem; cursor: pointer; color: #1C1C1B; } /* Checkbox */ .checkbox-label { display: flex; align-items: center; cursor: pointer; margin: 1rem 0; } .checkbox-label input[type="checkbox"] { width: auto; margin-right: 0.75rem; height: 1.1em; width: 1.1em; } /* Form Actions */ .form-actions { display: flex; gap: 2rem; justify-content: space-between; margin-top: 3rem; } /* Success Content */ .success-content { text-align: center; padding: 3rem 0; } .next-steps-box { background: #FFFFFF; border: 1px solid #E0E0E0; border-radius: 12px; padding: 2.5rem; margin: 2rem auto; max-width: 600px; text-align: left; box-shadow: 0 4px 15px rgba(0,0,0,0.05); } .next-steps-box ol { padding-left: 1.5rem; } .next-steps-box li { margin-bottom: 0.7rem; } /* Disclaimer */ .footer .disclaimer { font-size: 0.875rem; opacity: 1; margin-top: 0.5rem; color: #ffffff; } /* Main Content */ .main-content { min-height: calc(100vh - 300px); padding: 2rem 0; } /* Tooltips */ .tooltip-bubble { background: #1C1C1C; color: #FFFFFF; padding: 1rem; border-radius: 8px; margin-top: 0.5rem; font-size: 0.9rem; line-height: 1.5; box-shadow: 0 4px 12px rgba(0,0,0,0.5); max-width: 30%; position: absolute; border: 1px solid #FFD700; } .tooltip-bubble::before { content: ''; position: absolute; top: -8px; left: 20px; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-bottom: 8px solid #1C1C1C; } /* Radio labels */ .radio-label { display: inline-flex; align-items: center; font-weight: normal; } .radio-label input[type="radio"] { width: auto; margin-right: 0.5rem; } /* ===== PAYMENT PAGE STYLES ===== */ .payment-container { max-width: 800px; margin: 3rem auto; } .payment-wrapper { background: white; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.1); overflow: hidden; } .payment-header h1 { margin: 0 0 0.5rem 0; font-size: 2.2rem; } .payment-subtitle { margin: 0; opacity: 0.8; font-size: 1.2rem; } .legal-content p { margin-bottom: 1rem; line-height: 1.6; } .legal-content p:last-child { margin-bottom: 0; } .price-item { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 0; border-bottom: 1px solid #E0E0E0; } .price-item:last-of-type { border-bottom: none; } .price-label { font-weight: 500; } .price-value { font-weight: 600; } .payment-form-container { padding: 3rem; } #payment-element { margin-bottom: 1.5rem; } .payment-actions { display: flex; gap: 1rem; justify-content: space-between; } .payment-actions .btn { flex: 1; } .payment-loading { text-align: center; padding: 3rem 2rem; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* ===== OCR COUNTDOWN STYLES (Kept close to other warning styles) ===== */ @keyframes pulse-warning { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.4); } 50% { box-shadow: 0 0 0 10px rgba(255, 193, 7, 0); } } .ocr-countdown-notice p { margin: 0.5rem 0; font-size: 1rem; } /* Désactiver le bouton pendant le countdown */ button[ng-disabled="ocrCountdownActive"] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } /* ===== NOTIFICATION STYLES (Kept together) ===== */ .notifications-container { position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 400px; } .notification { margin-bottom: 10px; padding: 15px; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; transform: translateX(0); opacity: 1; } .notification-hidden { transform: translateX(120%); opacity: 0; } .notification-content { display: flex; justify-content: space-between; align-items: center; } .notification-close { background: none; border: none; font-size: 18px; cursor: pointer; margin-left: 10px; opacity: 0.7; } .notification-close:hover { opacity: 1; } /* ===== STRIPE ELEMENTS OVERRIDE (Kept together) ===== */ .StripeElement { background-color: white; padding: 12px; border-radius: 4px; border: 1px solid #ddd; transition: border-color 0.3s; } /* ===== RESPONSIVE ADJUSTMENTS (Kept last) ===== */ @media (max-width: 768px) { .payment-header h1 { font-size: 1.5rem; } .payment-subtitle { font-size: 1rem; } .legal-notice, .pricing-box, .payment-form-container, .security-info { margin-left: 1rem; margin-right: 1rem; } .payment-actions { flex-direction: column-reverse; } .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; } .hero h1 { font-size: 2rem; } .navbar .container { flex-direction: column; gap: 1rem; } .nav-menu { flex-direction: column; gap: 0.5rem; text-align: center; width: 100%; } .form-row { grid-template-columns: 1fr; } .form-actions { flex-direction: column-reverse; /* Mieux: met le bouton principal en bas */ } .btn { width: 100%; } } /* ========================================================== RESPONSIVE MOBILE STYLES ========================================================== */ @media (max-width: 768px) { /* Global adjustments */ .container { padding: 0 15px; } text /* Hero Section */ .hero { padding: 3rem 0; } .hero h1 { font-size: 2rem; letter-spacing: 1px; margin-bottom: 0.5rem; } .subtitle { font-size: 1.1rem; margin-bottom: 2rem; padding: 0 10px; } .cta-buttons { flex-direction: column; gap: 1rem; padding: 0 20px; } .cta-buttons .btn { width: 100%; text-align: center; } /* Navigation */ .navbar { padding: 0.8rem 0; position: relative; } .navbar .container { flex-direction: column; gap: 1rem; } .logo { font-size: 1.5rem; } .nav-menu { flex-direction: column; gap: 0.5rem; text-align: center; width: 100%; display: none; /* Hidden by default on mobile */ } .nav-menu.active { display: flex; } .nav-menu a { padding: 0.8rem; display: block; border-bottom: 1px solid #f0f0f0; } /* Mobile menu toggle */ .mobile-menu-toggle { display: block; position: absolute; right: 15px; top: 50%; transform: translateY(-50%); background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #1C1C1C; } /* Features Section */ .features { padding: 3rem 0; } .features h2 { font-size: 2rem; margin-bottom: 2rem; } .features-grid { grid-template-columns: 1fr; gap: 1.5rem; } .feature-card { padding: 1.5rem; margin: 0 10px; } /* How It Works */ .how-it-works { padding: 3rem 0; } .how-it-works h2 { font-size: 2rem; margin-bottom: 2rem; } .steps { grid-template-columns: 1fr; gap: 2rem; } /* Progress Bar */ .progress-bar { flex-direction: column; gap: 2rem; margin: 1rem 0; } .progress-bar::before { display: none; } .progress-step { flex-direction: row; text-align: left; gap: 1rem; } .step-num { margin-bottom: 0; flex-shrink: 0; } /* Form Styles */ .form { padding: 1.5rem; margin: 1rem auto; border-radius: 8px; } .form-row { grid-template-columns: 1fr; gap: 1rem; } fieldset { padding: 1rem; margin-bottom: 1.5rem; } .form-group { margin-bottom: 1rem; } /* Form Actions */ .form-actions { flex-direction: column-reverse; gap: 1rem; margin-top: 2rem; } .form-actions .btn { width: 100%; margin: 0; } /* Buttons */ .btn { padding: 1rem 1.5rem; width: 100%; text-align: center; } .import-export .btn.copy { max-width: 120px; } /* Method Cards */ .method-cards { grid-template-columns: 1fr; gap: 1rem; } .method-card { padding: 2rem 1rem; margin: 0 10px; } .method-icon { font-size: 3rem; } /* Upload Box */ .upload-box { padding: 2rem 1rem; margin: 1.5rem 0; } /* Success Content */ .success-content { padding: 2rem 0; } .next-steps-box { padding: 1.5rem; margin: 1.5rem 10px; } /* Tooltips */ .tooltip-bubble { max-width: 90%; position: relative; margin: 1rem auto; } .tooltip-bubble::before { display: none; } /* Payment Page */ .payment-container { margin: 1rem auto; } .payment-header { padding: 1.5rem 1rem; } .payment-header h1 { font-size: 1.8rem; } .payment-subtitle { font-size: 1rem; } .legal-notice { margin: 1rem; padding: 1rem; } .pricing-box { margin: 0 1rem 1rem 1rem; padding: 1rem; } .payment-form-container { padding: 1.5rem; } .payment-actions { flex-direction: column-reverse; gap: 1rem; } /* Notifications */ .notifications-container { top: 10px; right: 10px; left: 10px; max-width: none; } .notification { margin-bottom: 0.5rem; padding: 1rem; } /* Countdown Timer */ .countdown-timer { font-size: 2rem; padding: 0.75rem 1.5rem; } .ocr-countdown-notice { padding: 1rem; margin: 1rem 10px; } /* Classification Info */ .classification-info { padding: 1rem; margin: 1rem 0; } /* Footer */ .footer { padding: 2rem 0; margin-top: 3rem; } /* Progress Section */ .progress-section { padding: 1.5rem 0; } /* Security Info */ .security-info { margin: 1rem; padding: 1rem; } /* Total Box */ .total-box { padding: 1rem; margin: 1rem 0; } /* Language Switcher */ .btn-lang { padding: 0.4rem 0.6rem; font-size: 0.9rem; } /* Step Number */ .step-number { width: 40px; height: 40px; font-size: 1.2rem; } } /* Extra small devices (phones, 480px and down) */ @media (max-width: 480px) { .hero h1 { font-size: 1.8rem; } text .subtitle { font-size: 1rem; } .features h2, .how-it-works h2 { font-size: 1.7rem; } .feature-icon, .method-icon { font-size: 2.5rem; } .payment-header h1 { font-size: 1.5rem; } .countdown-timer { font-size: 1.8rem; padding: 0.5rem 1rem; } .success-icon { font-size: 4rem; } .step-number { width: 35px; height: 35px; font-size: 1.1rem; } } /* Mobile landscape orientation adjustments */ @media (max-width: 768px) and (orientation: landscape) { .hero { padding: 2rem 0; } /* =========================== AMÉLIORATIONS MOBILE IMPORT/EXPORT CHATGPT =========================== */ /* NAVBAR — version mobile */ @media (max-width: 768px) { .navbar { display: flex; align-items: center; justify-content: space-between; padding: 0.8rem 1rem; position: relative; } .navbar .logo { flex: 1; text-align: left; } .navbar .btn-lang { display: none; /* Masqué sur mobile */ } /* Menu hamburger */ .mobile-menu-toggle { display: block; background: none; border: none; font-size: 1.8rem; color: #1C1C1C; cursor: pointer; position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); } .nav-menu { display: none; flex-direction: column; background: #fff; position: absolute; top: 100%; right: 0; width: 200px; border: 1px solid #E0E0E0; border-radius: 6px; box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 99; } .nav-menu.active { display: flex; } .nav-menu a { padding: 1rem; text-align: left; border-bottom: 1px solid #f0f0f0; } .nav-menu a:last-child { border-bottom: none; } } /* =========================== IMPORT-EXPORT PAGE MOBILE =========================== */ @media (max-width: 768px) { .import-export { padding: 1rem; } /* 1️⃣ TITRE PRINCIPAL */ .import-export h1 { width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.6rem; text-align: center; margin-bottom: 1.5rem; } /* 2️⃣ BARRE DE PROGRESSION */ .import-export .progress-bar { display: flex; flex-direction: row !important; justify-content: center; align-items: center; width: 80%; margin: 1rem auto 2rem; gap: 1rem; } .import-export .progress-bar::before { display: none; } .import-export .progress-bar .step-label { display: none !important; } .import-export .progress-step { flex-direction: column; align-items: center; justify-content: center; } /* 3️⃣ FORMULAIRE VALUATIONFORM — harmonisation des espacements */ .import-export form[name="valuationForm"] .form-row, .import-export form[name="valuationForm"] .form-group { margin-bottom: 1rem; } .import-export form[name="valuationForm"] .form-row { display: grid; grid-template-columns: 1fr; gap: 1rem; } .import-export .form-group label { font-size: 1rem; } /* 4️⃣ BOUTON COPIER */ .import-export .btn.copy { max-width: 120px; width: 100%; text-align: center; padding: 0.6rem 1rem; font-size: 0.9rem; } /* 5️⃣ RADIO BUTTONS (Déclarant) — empilés verticalement */ .import-export .radio-label { display: flex; align-items: center; margin-bottom: 0.5rem; width: 100%; } .import-export .radio-label input[type="radio"] { margin-right: 0.6rem; } .import-export .form-group > div[style*="display: flex; gap: 2rem"] { flex-direction: column !important; gap: 0.8rem !important; } /* 6️⃣ DIVERS AMÉLIORATIONS GÉNÉRALES */ .import-export .form { padding: 1.2rem; border-radius: 8px; } .import-export fieldset { margin-bottom: 1.2rem; padding: 1rem; } .import-export legend { font-size: 1.1rem; } .import-export input, .import-export select { padding: 0.8rem; font-size: 1rem; } .import-export .btn { width: 100%; text-align: center; padding: 1rem; } .import-export .form-actions { flex-direction: column-reverse; gap: 1rem; margin-top: 1.5rem; } /* 7️⃣ Petits ajustements visuels */ .import-export .classification-info, .import-export .total-box { margin: 1rem 0; padding: 1rem; } .import-export .classification-info h4 { font-size: 1.1rem; } .import-export .method-card { margin-bottom: 1rem; } /* 8️⃣ Meilleure lisibilité notifications */ .notifications-container { left: 10px; right: 10px; top: 10px; max-width: none; } .notification { font-size: 0.95rem; padding: 1rem; } } /* =========================== MICRO-OPTIMISATIONS ULTRA SMALL (≤480px) =========================== */ @media (max-width: 480px) { .import-export h1 { font-size: 1.4rem; } .import-export .progress-bar { width: 90%; gap: 0.8rem; } .import-export .btn.copy { max-width: 100px; font-size: 0.8rem; } .import-export input, .import-export select { font-size: 0.95rem; } .import-export legend { font-size: 1rem; } .import-export .step-num { width: 35px; height: 35px; font-size: 1.1rem; } } ---------------------------------------------------- public/it/index.html Importare & Esportare Auto in Svizzera - Dichiarazione Doganale Semplificata

Dichiarazione Doganale Veicoli in Svizzera: Semplice e Veloce

Importa o esporta la tua auto senza stress. Ottieni il tuo modulo e-dec ufficiale in 5 minuti a soli 50 CHF.

Lo sdoganamento auto, semplificato.

Il portale ufficiale e-dec della dogana svizzera è potente, ma spesso complesso e intimidatorio per i non addetti ai lavori. Gli spedizionieri, d'altra parte, sono costosi e lenti. MyAutoImport.ch è la soluzione ideale: ti guidiamo attraverso un modulo semplice ponendo solo le domande essenziali. Niente più confusione, ottieni la tua dichiarazione doganale in tempo record.

Perché sceglierci?

MyAutoImport.ch

50 CHF

~ 5 minuti

  • ✅ Semplice e guidato
  • ✅ Veloce e 100% online
  • ✅ Economico

Spedizioniere tradizionale

~ 100 CHF

~ 45 minuti (o più)

  • ❌ Tempi di attesa
  • ❌ Più costoso
  • ❌ A volte sono necessari spostamenti

e-dec (Sito ufficiale)

Gratuito

Molto variabile

  • ❌ Molto complesso
  • ❌ Rischio di errori costosi
  • ❌ Nessun supporto
---------------------------------------------------- public/sitemap.xml https://www.myautoimport.ch/ 2025-01-15 weekly 1.0 https://www.myautoimport.ch/fr/ 2025-01-15 weekly 1.0 https://www.myautoimport.ch/de/ 2025-01-15 weekly 1.0 https://www.myautoimport.ch/it/ 2025-01-15 weekly 1.0 https://www.myautoimport.ch/en/ 2025-01-15 weekly 1.0 https://www.myautoimport.ch/import 2025-01-15 monthly 0.8 https://www.myautoimport.ch/export 2025-01-15 monthly 0.8 ---------------------------------------------------- public/views/submission-error.html
⚠️

Erreur Technique de Soumission

Nous avons rencontré une erreur inattendue lors de la communication avec le portail de la douane. Votre déclaration a bien été générée mais n'a pas pu être soumise automatiquement.

Que faire ?

Notre équipe technique a été informée. Pour un suivi personnalisé, veuillez laisser votre adresse e-mail ci-dessous. Nous analyserons le problème et vous recontactons dans les plus brefs délais avec une solution.

Veuillez saisir votre adresse e-mail. Format d'e-mail invalide (doit contenir @ et .).

{{ errorMessage }}

Merci !

Nous avons bien reçu votre adresse e-mail. Notre équipe est sur le coup et reviendra vers vous très prochainement.

---------------------------------------------------- public/views/payment.html

{{ t('payment_title') }}

{{ t('payment_subtitle') }}

{{ t('payment_declaration_missing_title') }}

{{ t('payment_declaration_missing_text') }}

{{ t('payment_error_title') }}

{{ t('submission_error_title') }}

{{ paymentError }}

{{ t('service_type') }}: {{ t(destinationType === 'import' ? 'import_vehicle_nav' : 'export_vehicle_nav') }}
{{ t('total_price') }}: {{ amount | currency: (currency + ' ') }}

{{ t('payment_single_use') }}

{{ t('processing_payment') }}

{{ t('loading_payment') }}

{{ t('loading_data') }}
---------------------------------------------------- public/views/home.html

{{ t('home_hero_title') }}

{{ t('home_hero_subtitle') }}

{{ t('home_simplified_title') }}

{{ t('home_simplified_description') }}

{{ t('home_why_choose_us') }}

MyAutoImport.ch

50 CHF

~ 5 {{ t('minutes') }}

  • ✅ {{ t('feature_simple_guided') }}
  • ✅ {{ t('feature_fast_online') }}
  • ✅ {{ t('feature_affordable') }}

{{ t('traditional_forwarder') }}

Min. 100 CHF

~ 45 {{ t('minutes') }} {{ t('or_more') }}

  • ❌ {{ t('feature_waiting_times') }}
  • ❌ {{ t('feature_more_expensive') }}
  • ❌ {{ t('feature_travel_required') }}

e-dec Web ({{ t('official_site') }})

{{ t('free') }}

{{ t('very_variable') }}

  • ❌ {{ t('feature_very_complex') }}
  • ❌ {{ t('feature_costly_mistakes') }}
  • ❌ {{ t('feature_no_support') }}
---------------------------------------------------- public/views/export.html
{{notification.message}}

{{ t('export_page_title') }}

1 {{ t('method') }}
2 {{ t('vehicle') }}
3 {{ t('declaration') }}
4 {{ t('result') }}

{{ t('choose_input_method') }}

📸

{{ t('scan_registration_card') }}

{{ t('auto_extraction_ai') }}

⌨️

{{ t('manual_input') }}

{{ t('fill_form_step_by_step') }}

{{ t('vehicle_info') }}

ℹ️ {{ t('privacy') }}: {{ t('privacy_notice_ocr') }}

⚠️ {{ t('verify_extracted_data') }}

{{ t('ocr_countdown_text') }}

{{ ocrCountdown }}s
✅ {{ t('vin_valid') }}: {{origin}} ❌ {{ t('vin_invalid') }}

❌ {{ t('classification_error_title') }}

{{ classificationError }}

{{ t('classification_not_found_action') }}

{{ t('ocr_failed_retry') }}

{{ t('declaration_info') }}

{{ t('involved_parties') }}

📍 {{ t('consignor') }}

{{ t('consignor_definition') }}

{{ t('postal_code_ch_hint') }}

📦 {{ t('consignee') }}

{{ t('consignee_definition') }}

✏️ {{ t('declarant') }}

{{ t('declarant_definition') }}

{{ t('declarant_info') }}
{{ t('customs_regime') }}
{{ t('destination_country') }} :
{{ t('destination_country_tooltip') }}
{{ t('transport_modes_title') }}
{{ t('transport_mode_9') }} : {{ t('transport_mode_9_desc') }}
{{ t('transport_mode_3') }} : {{ t('transport_mode_3_desc') }}
{{ t('transport_mode_2') }} : {{ t('transport_mode_2_desc') }}
{{ t('transport_mode_4') }} : {{ t('transport_mode_4_desc') }}
{{ t('transport_mode_8') }} : {{ t('transport_mode_8_desc') }}
{{ t('transport_mode_7') }} : {{ t('transport_mode_7_desc') }}
{{ t('financial_transactions') }}

{{ t('declaration_ready') }}

{{ t('click_to_pay_securely') }}

{{ t('secure_payment_stripe') }}

{{submissionStep}} ({{submissionProgress}}%)

{{ t('submission_completed') }}

{{ t('submission_completed_text') }}

{{ t('declaration_number') }}: {{ submissionResult.declarationNumber }}

{{ t('download_documents_text') }}


{{ t('future_access_and_sharing') }}

{{ t('unique_link_text') }}

{{ t('documents_expire_30_days') }}

---------------------------------------------------- public/views/import.html
{{notification.message}}

{{ t('import_page_title') }}

1 {{ t('method') }}
2 {{ t('vehicle') }}
3 {{ t('declaration') }}
4 {{ t('result') }}

{{ t('choose_input_method') }}

📸

{{ t('scan_registration_card') }}

{{ t('auto_extraction_ai') }}

⌨️

{{ t('manual_input') }}

{{ t('fill_form_step_by_step') }}

{{ t('vehicle_info') }}

ℹ️ {{ t('privacy') }}: {{ t('privacy_notice_ocr') }}

⚠️ {{ t('verify_extracted_data') }}

{{ t('ocr_countdown_text') }}

{{ ocrCountdown }}s
{{ t('vin_valid') }}: {{declaration.dispatch_country}} {{ t('vin_invalid') }}

❌ {{ t('classification_error_title') }}

{{ classificationError }}

{{ t('classification_not_found_action') }}

{{ t('ocr_failed_retry') }}

{{ t('declaration_info') }}

{{ t('involved_parties') }}

📍 {{ t('consignee') }}

{{ t('consignee_definition') }}

{{ t('postal_code_ch_hint') }}

📦 {{ t('importer') }}

{{ t('importer_definition') }}

✏️ {{ t('declarant') }}

{{ t('declarant_definition') }}

{{ t('declarant_info') }}
{{ t('customs_regime') }}
{{ t('dispatch_country') }} :
{{ t('dispatch_country_tooltip') }}
{{ t('transport_modes_title') }}
{{ t('transport_mode_9') }} : {{ t('transport_mode_9_desc') }}
{{ t('transport_mode_3') }} : {{ t('transport_mode_3_desc') }}
{{ t('transport_mode_2') }} : {{ t('transport_mode_2_desc') }}
{{ t('transport_mode_4') }} : {{ t('transport_mode_4_desc') }}
{{ t('transport_mode_8') }} : {{ t('transport_mode_8_desc') }}
{{ t('transport_mode_7') }} : {{ t('transport_mode_7_desc') }}
{{ t('relocation_tooltip_title') }}
{{ t('relocation_tooltip_text') }}
{{ t('iva_exempt_tooltip_title') }}
{{ t('iva_exempt_tooltip_text') }}
{{ t('financial_transactions') }}

{{ t('additional_costs') }}

{{ t('total_value_base') }}: ~ {{ vatValueCHF | number:0 }} CHF

{{ t('declaration_ready') }}

{{ t('click_to_pay_securely') }}

{{ t('secure_payment_stripe') }}

{{submissionStep}} ({{submissionProgress}}%)

{{ t('submission_completed') }}

{{ t('submission_completed_text') }}

{{ t('declaration_number') }}: {{ submissionResult.declarationNumber }}

{{ t('download_documents_text') }}


{{ t('future_access_and_sharing') }}

{{ t('unique_link_text') }}

{{ t('documents_expire_30_days') }}

---------------------------------------------------- public/robots.txt # robots.txt pour MyAutoImport.ch # Permettre à tous les robots d'indexer le site User-agent: * Allow: / # Bloquer l'accès aux fichiers sensibles Disallow: /js/ Disallow: /css/ Disallow: /api/ Disallow: /download/ # Permettre l'accès aux pages importantes Allow: /fr/ Allow: /de/ Allow: /it/ Allow: /en/ Allow: /import Allow: /export # Sitemap Sitemap: https://www.myautoimport.ch/sitemap.xml # Optimisations spécifiques pour Google User-agent: Googlebot Allow: / Crawl-delay: 0 # Optimisations pour Bing User-agent: Bingbot Allow: / Crawl-delay: 0 ---------------------------------------------------- public/de/index.html Auto Import & Export Schweiz - Vereinfachte Zollanmeldung

Fahrzeug-Zollanmeldung in der Schweiz: Einfach und Schnell

Importieren oder exportieren Sie Ihr Auto stressfrei. Erhalten Sie Ihr offizielles e-dec-Formular in 5 Minuten für nur 50 CHF.

Fahrzeugverzollung, vereinfacht.

Das offizielle e-dec-Portal des Schweizer Zolls ist leistungsstark, aber für Laien oft komplex und einschüchternd. Spediteure sind teuer und langsam. MyAutoImport.ch ist die ideale Lösung: Wir führen Sie durch ein einfaches Formular und stellen nur die wesentlichen Fragen. Keine Verwirrung mehr, erhalten Sie Ihre Zollanmeldung in Rekordzeit.

Warum uns wählen?

MyAutoImport.ch

50 CHF

~ 5 Minuten

  • ✅ Einfach und geführt
  • ✅ Schnell und 100% online
  • ✅ Kostengünstig

Traditioneller Spediteur

~ 100 CHF

~ 45 Minuten (oder länger)

  • ❌ Wartezeiten
  • ❌ Teurer
  • ❌ Manchmal Anreise erforderlich

e-dec (Offizielle Seite)

Kostenlos

Sehr variabel

  • ❌ Sehr komplex
  • ❌ Risiko teurer Fehler
  • ❌ Kein Support
---------------------------------------------------- package.json { "name": "edec-vehicles", "version": "1.3.0", "description": "Application e-dec Véhicules avec AngularJS et ExpressJS", "main": "server/server.js", "scripts": { "start": "node server/server.js", "dev": "nodemon server/server.js" }, "dependencies": { "axios": "^1.6.2", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.4.0", "express-validator": "^7.1.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "mysql2": "^3.6.5", "node-cron": "^3.0.3", "pdf-parse": "^1.1.1", "playwright": "^1.40.1", "sharp": "^0.33.4", "stripe": "^14.25.0", "uuid": "^9.0.1", "winston": "^3.11.0" }, "devDependencies": { "nodemon": "^3.0.2" } }