// https://nuxt.com/docs/api/configuration/nuxt-config // Sentry release name = the build's git hash (injected into GITHASH at deploy time). // Skip the literal '' placeholder used in local/un-deployed envs. const sentryRelease = process.env.GITHASH && !process.env.GITHASH.includes('<') ? process.env.GITHASH : undefined // Second build target: a static, mobile-only SPA bundled into the Capacitor Android app. // Activated with BUILD_TARGET=capacitor. Every branch below is gated on this flag, so when // it is unset the web/SSR build is byte-identical to before. const isCapacitor = process.env.BUILD_TARGET === 'capacitor' // The bundled app talks to ONE backend host for everything — /api, /media-api and /files-api are all // served from this origin via nginx. Strapi base/token follow it so the app never points at the local // .env's dev Strapi host (that mismatch broke product images + media uploads). Override per channel // with CAP_API_BASE / CAP_STRAPI_BASE / CAP_STRAPI_TOKEN. const capApiBase = process.env.CAP_API_BASE || 'https://app.logship.de' // Non-/mobile routes the bundled app needs: the login flow + the staff chat page (/chat). const capAuthPaths = ['/signin', '/select-role', '/login', '/register', '/verify-email', '/forgot-password', '/reset-password', '/chat'] export default defineNuxtConfig({ devtools: { enabled: false }, telemetry: false, // App target = client-only SPA; web target keeps SSR (the Nuxt default → identical output). ssr: isCapacitor ? false : true, modules: ['@nuxt/ui', 'nuxt-lazy-load', '@sentry/nuxt/module'], imports: { dirs: [ 'composables', 'composables/menu/admin', 'composables/menu/fulfillmentCustomer' ] }, /*i18n: { vueI18n: './i18n.config.ts' // if you are using custom path, default },*/ plugins: isCapacitor ? [] : [ '~/plugins/amcharts.client.js' ], // App build only: (1) bundle just the mobile routes (+ auth) into the SPA; (2) drop // server-coupled / desktop-only client plugins. Hook object is undefined for the web build. hooks: { ...(isCapacitor ? { 'pages:extend'(pages: any[]) { const allow = (p: any) => typeof p?.path === 'string' && (p.path.startsWith('/mobile') || capAuthPaths.includes(p.path)) const walk = (arr: any[]) => { for (let i = arr.length - 1; i >= 0; i--) { const r = arr[i] if (r.children?.length) { walk(r.children) if (!r.children.length && !allow(r)) arr.splice(i, 1) } else if (!allow(r)) { arr.splice(i, 1) } } } walk(pages) }, } : {}), // App build: drop server-coupled / desktop-only plugins. Web build: drop the app-only API // plugin so it never loads (and @capacitor/preferences never enters) the web bundle. 'app:resolve'(app: any) { if (isCapacitor) { // Note: 'chat-socket' is intentionally KEPT in the app build (chat works in the app). // 'push-notifications' drops the WEB VAPID plugin; the native FCM plugin is named // 'native-push' so it is NOT matched here. const drop = ['amcharts', 'service-worker', 'push-notifications', 'notifications-stream', 'pwa-session-resume'] app.plugins = app.plugins.filter((p: any) => !drop.some(d => String(p.src).includes(d))) } else { // Web build: drop the app-only plugins so they (and their Capacitor deps) never load. app.plugins = app.plugins.filter((p: any) => !['capacitor-api', 'native-push'].some(d => String(p.src).includes(d))) } }, }, sourcemap: { server: false, // Generate client source maps (without the //# sourceMappingURL comment) so Sentry // can symbolicate browser stack traces. The maps are uploaded then deleted from the // build output (see `sentry.sourcemaps.filesToDeleteAfterUpload`), never served. client: 'hidden', }, // Sentry error + performance monitoring. Build-time options (source-map upload + // server auto-injection). Runtime DSN lives in runtimeConfig.public.sentry below. sentry: { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, // build-time only; missing token just skips upload // sentryUrl: process.env.SENTRY_URL, // uncomment + set when moving to self-hosted Sentry release: sentryRelease ? { name: sentryRelease } : undefined, // Inject Sentry into the Nitro server entry at build time so the server SDK loads // WITHOUT a `node --import` flag — important because PM2 runs in cluster mode. autoInjectServerSentry: 'top-level-import', sourcemaps: { // Remove generated .map files from .output after upload so they are never served publicly. filesToDeleteAfterUpload: ['./.output/**/*.map'], }, }, experimental: { payloadExtraction: false, }, vite: { // Build-time flag so app/router.options.ts can tree-shake the desktop-only routes out of // the app bundle. Only injected for the app build; web build never sees __CAP_BUILD__. ...(isCapacitor ? { define: { __CAP_BUILD__: 'true' } } : {}), build: { target: "es2022", minify: 'esbuild', rollupOptions: { maxParallelFileOps: 1, }, }, esbuild: { target: "es2022" }, optimizeDeps: { include: [ "pdfjs-dist", // amCharts 5 + its entry points (app/plugins/amcharts.client.js) "@amcharts/amcharts5", "@amcharts/amcharts5/xy", "@amcharts/amcharts5/percent", "@amcharts/amcharts5/themes/Animated", // CJS deps Vite would otherwise discover lazily on first use (causes a dev page reload) "pdfmake/build/pdfmake.js", "pdfmake/build/vfs_fonts.js", "alga-js", "bulma-toast", "tedir-calendar", ], esbuildOptions: { target: "es2022", supported: { "top-level-await": true }, }, }, server: { watch: { ignored: [ '**/node_modules/**', '**/.git/**', '**/.nuxt/**', '**/.output/**', '**/dist/**', '**/android/**', '**/ios/**', '**/public/**', '**/coverage/**', '**/reference/**' ] } }, }, nitro: { // Enable native WebSocket support (crossws) for the staff live-chat module. // Harmless when chat is disabled — nothing connects to /chat/ws. experimental: { websocket: true, }, rollupConfig: { maxParallelFileOps: 1, }, minify: false, // Keep tesseract.js external so its node worker_threads script resolves to real node_modules. // canvas is a native addon (Tier-2 OCR rasterize fallback); pdfjs-dist (legacy build, used // server-side for PDF text extraction + rasterize) is large — both resolve from node_modules. externals: { external: ['tesseract.js', 'tesseract.js-core', 'canvas', 'pdfjs-dist', 'sharp'] }, hooks: { // Nitro externalizes the deps above but only traces their JS entry — it // does NOT copy tesseract.js-core's .wasm or pdfjs-dist's worker into // .output, so they 404/ENOENT at runtime. Copy them in after build. async compiled(nitro: any) { const fs = await import('node:fs') const path = await import('node:path') const serverDir = nitro?.options?.output?.serverDir if (!serverDir) return const root = process.cwd() const copyDir = (rel: string) => { const src = path.join(root, 'node_modules', rel) const dest = path.join(serverDir, 'node_modules', rel) if (!fs.existsSync(src)) { console.warn('[nitro] runtime asset missing in node_modules:', rel); return } fs.mkdirSync(dest, { recursive: true }) fs.cpSync(src, dest, { recursive: true }) } const copyFiles = (relDir: string, files: string[]) => { const srcDir = path.join(root, 'node_modules', relDir) const destDir = path.join(serverDir, 'node_modules', relDir) if (!fs.existsSync(srcDir)) { console.warn('[nitro] runtime asset dir missing:', relDir); return } fs.mkdirSync(destDir, { recursive: true }) for (const f of files) { const s = path.join(srcDir, f) if (fs.existsSync(s)) fs.copyFileSync(s, path.join(destDir, f)) } } try { copyDir('tesseract.js-core') // *.wasm + js wrappers copyFiles(path.join('pdfjs-dist', 'legacy', 'build'), // Node "fake worker" ['pdf.worker.mjs', 'pdf.worker.min.mjs']) console.log('[nitro] ✅ copied OCR/PDF runtime assets into .output/server/node_modules') } catch (e: any) { console.warn('[nitro] runtime-asset copy failed:', e?.message || e) } } } }, ui: { icons: ['mdi'] }, fonts: { // Fonts are self-hosted via assets/css/fonts.css — use the local provider // so @nuxt/fonts (bundled with @nuxt/ui v4) does not fetch any remote fonts. // ('none' is no longer a valid provider in the fontless-based engine.) provider: 'local', families: [ { name: 'Inter', provider: 'local' }, { name: 'Inconsolata', provider: 'local' }, ], }, colorMode: { preference: 'light', fallback: 'light', classSuffix: '', dataValue: 'mode', storageKey: 'logship_theme' }, lazyLoad: { // These are the default values images: true, videos: true, audios: true, iframes: true, native: false, directiveOnly: false, // Default image must be in the public folder defaultImage: '/assets/skeleton-loader-placeholder.jpg', // To remove class set value to false loadingClass: 'isLoading', loadedClass: 'isLoaded', appendClass: 'lazyLoad', observerConfig: { // See IntersectionObserver documentation } }, css: [ '~/assets/css/fonts.css', '~/assets/sass/main.scss', '~/assets/css/style.css', // '~/assets/css/helper.css', // '~/node_modules/bulma/css/bulma.css', '~/assets/css/main.css', // tedir-calendar DatePicker styles (data-v scoped to the component — safe globally; // without it the popup/grid is unstyled and navigation/selection appear broken). 'tedir-calendar/css', 'bulma/css/versions/bulma-no-dark-mode.min.css', 'bulma-extensions/dist/css/bulma-extensions.min.css', '@creativebulma/bulma-tooltip/src/docs/static/css/bulma-tooltip.css', 'bulma-responsive-tables/css/main.min.css', '~/assets/css/custom.css', // Mobile design-token system (light + dark). Loaded last so it wins. '~/assets/css/mobile-theme.css', ], postcss: { plugins: { 'alga-css': { important: false, //extract: [ //'./layouts/**/*.vue', //'./pages/**/*.vue', //'./components/**/*.vue' //], src: './app/assets/alga/*.alga', plugins: ['tedir-table', 'tedir-dropzone', 'tedir-select', 'tedir-calendar'] } } }, app: { head: { title: 'ERP Solutions', link: [ { rel: "stylesheet", href: "/assets/vendor/fonts/tabler-icons.css" }, { rel: "stylesheet", href: "/css/styles.css" }, { rel: "manifest", href: "/manifest.json" } ], meta: [ // theme-color + apple status-bar style are set reactively in app.vue // (useHead driven by useTheme().isDark) so they follow light/dark mode. { name: "apple-mobile-web-app-capable", content: "yes" }, { name: "apple-mobile-web-app-title", content: "LogShip ERP" } ], htmlAttrs: { 'data-mode': 'light', 'lang': 'en-US' }, script: [ { innerHTML: ` let defaultLanguage = 'en-US' let defaultTheme = 'light' if(localStorage.getItem('logship_lang')) { defaultLanguage = localStorage.getItem('logship_lang') } if(localStorage.getItem('logship_theme_set') === '1') { // The user made an explicit choice via the theme toggle — honour it everywhere. defaultTheme = localStorage.getItem('logship_theme') || 'light' } else { // No explicit choice yet → default by context, recomputed every load so the // two contexts never bleed into each other: mobile = DARK, desktop = light. defaultTheme = location.pathname.indexOf('/mobile') === 0 ? 'dark' : 'light' // Keep the colorMode storage key in sync with the DOM so colorMode // doesn't flip it back on hydration. try { localStorage.setItem('logship_theme', defaultTheme) } catch(e) {} } document.documentElement.setAttribute('lang', defaultLanguage) document.documentElement.setAttribute('data-mode', defaultTheme) document.documentElement.classList.add(defaultTheme) // Lock screen orientation to portrait on mobile phones only (allow tablets to use landscape). // Math.min of screen dimensions = the smaller side, invariant under rotation. // Tablets typically have min dimension >= 600px, phones are smaller. // Note: lock() requires standalone PWA / fullscreen context. On installed PWAs the // dynamic manifest at /manifest.json (orientation: portrait for phones) provides // the OS-level lock; this JS is a belt-and-braces retry for runtime cases where the // first call rejects (e.g. page loaded while device was already in landscape). var minScreenDimension = Math.min(screen.width, screen.height) var isPhone = minScreenDimension < 600 if (isPhone && screen.orientation && screen.orientation.lock) { var tryLock = function() { screen.orientation.lock('portrait').catch(function() { // Silently swallow — not in a context that allows locking. }) } tryLock() window.addEventListener('orientationchange', tryLock) document.addEventListener('visibilitychange', function() { if (!document.hidden) tryLock() }) } ` } ] } }, runtimeConfig: { // Staff live-chat kill-switch (server side). Set CHAT_ENABLED=true to turn on. chatEnabled: process.env.CHAT_ENABLED === 'true', pgHost: process.env.PG_HOST, pgPort: process.env.PG_PORT, pgDatabase: process.env.PG_DATABASE, pgUser: process.env.PG_USER, pgPassword: process.env.PG_PASSWORD, webauthn: { rpId: process.env.WEBAUTHN_RP_ID, rpName: process.env.WEBAUTHN_RP_NAME ?? 'LogShip ERP', origin: process.env.WEBAUTHN_ORIGIN, }, credentialEncryptionKey: process.env.CREDENTIAL_ENCRYPTION_KEY, api: { url: process.env.URLV1, strapi: process.env.STRAPIV1, strapiupload: process.env.STRAPIUPLOAD, strapitoken: process.env.STRAPITOKEN, idempieretoken: process.env.IDEMPIERETOKEN, postgrest: process.env.POSTGREST, laravel: process.env.LARAVEL, sendcloud: process.env.SENDCLOUD, sendcloudpublickey: process.env.SENDCLOUDPUBLICKEY, sendcloudprivatekey: process.env.SENDCLOUDPRIVATEKEY, dhlurl: process.env.DHLURL, dhlkey: process.env.DHLKEY, dhlsecret: process.env.DHLSECRET, dhluser: process.env.DHLUSER, dhlpass: process.env.DHLPASS, dpdurl: process.env.DPDURL, dpddelisid: process.env.DPDDELISID, dpdpassword: process.env.DPDPASSWORD, dpdcustomernumber: process.env.DPDCUSTOMERNUMBER, dpddepot: process.env.DPDDEPOT, elastic: process.env.ELASTIC, elastickey: process.env.ELASTICKEY, elasticusername: process.env.ELASTIC_USER, elasticpass: process.env.ELASTIC_PASS, // Marketplace Bestellabgleich: C_Charge_ID of the difference-adjustment charge line. amazonAdjustmentChargeId: process.env.AMAZON_ADJUSTMENT_CHARGE_ID, // (legacy/unused) previous product-based adjustment line id. amazonAdjustmentProductId: process.env.AMAZON_ADJUSTMENT_PRODUCT_ID }, synology: { url: process.env.SYNOLOGY_URL, user: process.env.SYNOLOGY_USER, password: process.env.SYNOLOGY_PASSWORD, cameraIds: process.env.SYNOLOGY_CAMERA_IDS, preSeconds: process.env.SYNOLOGY_PRE_SECONDS, postSeconds: process.env.SYNOLOGY_POST_SECONDS, }, // Incoming-invoice (Eingangsrechnung) OCR engine. Default = self-hosted // (no LLM, nothing leaves the server). Optional LLM plug-in: set // AP_OCR_LLM_ENABLED=true + provider/baseUrl/apiKey/model. The key is // server-only; non-secret knobs can be overridden per-org in settings. ap: { llm: { enabled: process.env.AP_OCR_LLM_ENABLED === 'true', provider: process.env.AP_OCR_LLM_PROVIDER || 'compatible', baseUrl: process.env.AP_OCR_LLM_BASE_URL || '', apiKey: process.env.AP_OCR_LLM_API_KEY || '', model: process.env.AP_OCR_LLM_MODEL || '', mode: process.env.AP_OCR_LLM_MODE || 'assisted', threshold: Number(process.env.AP_OCR_LLM_THRESHOLD || 0.6), }, }, public: { // App build only: absolute API base the bundled SPA calls. Absent on the web build, // which keeps using same-origin /api. The app-only plugin keys all its behavior off this. ...(isCapacitor ? { apiBase: capApiBase } : {}), // In the app build, Strapi media/files (/media-api, /files-api) are served from the SAME // host as the API, so the base follows apiBase — NOT the local .env STRAPIPUBLIC (which may // be a dev host, which broke images/uploads). Token can be overridden with CAP_STRAPI_TOKEN. strapi: isCapacitor ? (process.env.CAP_STRAPI_BASE || capApiBase) : process.env.STRAPIPUBLIC, strapitoken: isCapacitor ? (process.env.CAP_STRAPI_TOKEN || process.env.STRAPITOKEN) : process.env.STRAPITOKEN, githash: process.env.GITHASH, buildtime: process.env.BUILDTIME, solutionname: process.env.SOLUTIONNAME, doctypeid: process.env.TARGET_DOCUMENT_TYPE_ID, doctypevalue: process.env.TARGET_DOCUMENT_TYPE_VALUE, partnergroupid: process.env.BUSINESS_PARTNER_GROUP_ID, partnergroupvalue: process.env.BUSINESS_PARTNER_GROUP_VALUE, iscustomer: process.env.IS_CUSTOMER, pricelistid: process.env.PRICE_LIST_ID, pricelistvalue: process.env.PRICE_LIST_VALUE, purchasepricelistversionid: process.env.PURCHASE_PRICE_LIST_VERSION_ID, countryid: process.env.COUNTRY_ID, countryvalue: process.env.COUNTRY_VALUE, elastic: process.env.ELASTIC, aggrid: process.env.AGGRID, vapidPublicKey: process.env.VAPID_PUBLIC_KEY, // Staff live-chat kill-switch (client side). Mirrors the private flag. chatEnabled: process.env.CHAT_ENABLED === 'true', // Incoming-invoice OCR: expose only whether the LLM plug-in is enabled // (UI visibility); the API key never reaches the client. apOcrLlmEnabled: process.env.AP_OCR_LLM_ENABLED === 'true', // Sentry runtime config (client). Empty dsn = Sentry disabled (dev/kill-switch). sentry: { dsn: process.env.SENTRY_DSN, environment: process.env.SENTRY_ENVIRONMENT || 'production', }, } }, compatibilityDate: '2026-06-01', })