From ac714d3b38bc3b6ad1398eb2cc9b720c3161590d Mon Sep 17 00:00:00 2001 From: Natanael Rodriguez Ramos Date: Thu, 25 Jun 2026 09:57:42 +0100 Subject: [PATCH 1/5] feat: add Vue SSR pre-rendering for pricing and servers pages --- .env.example | 8 + .gitignore | 1 + Dockerfile | 13 +- Dockerfile.bak | 57 ++++++ build.sh | 2 + docker-compose.yml | 49 +++++ src/data/ssr/light.json | 1 + src/data/ssr/pricing.json | 1 + src/data/ssr/pricing_es.json | 1 + src/data/ssr/servers.json | 1 + src/themes/ivpn-v3/assets/js/app.js | 14 +- .../ivpn-v3/assets/js/components/PriceBox.vue | 2 +- src/themes/ivpn-v3/assets/js/servers.js | 2 +- .../ivpn-v3/assets/js/ssr/entry.light.js | 68 +++++++ .../ivpn-v3/assets/js/ssr/entry.pricing.js | 182 ++++++++++++++++++ .../ivpn-v3/assets/js/ssr/entry.servers.js | 48 +++++ .../assets/js/ssr/shims/estree-walker-shim.js | 7 + .../ssr/shims/vue-compat-server-renderer.js | 4 + .../ivpn-v3/layouts/pages/application.html | 35 +++- src/themes/ivpn-v3/layouts/pages/servers.html | 10 +- src/themes/ivpn-v3/package-lock.json | 19 +- src/themes/ivpn-v3/package.json | 9 +- src/themes/ivpn-v3/scripts/ssr-render.mjs | 79 ++++++++ src/themes/ivpn-v3/vite.config.ssr.js | 143 ++++++++++++++ 24 files changed, 720 insertions(+), 36 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile.bak create mode 100644 build.sh create mode 100644 docker-compose.yml create mode 100644 src/data/ssr/light.json create mode 100644 src/data/ssr/pricing.json create mode 100644 src/data/ssr/pricing_es.json create mode 100644 src/data/ssr/servers.json create mode 100644 src/themes/ivpn-v3/assets/js/ssr/entry.light.js create mode 100644 src/themes/ivpn-v3/assets/js/ssr/entry.pricing.js create mode 100644 src/themes/ivpn-v3/assets/js/ssr/entry.servers.js create mode 100644 src/themes/ivpn-v3/assets/js/ssr/shims/estree-walker-shim.js create mode 100644 src/themes/ivpn-v3/assets/js/ssr/shims/vue-compat-server-renderer.js create mode 100644 src/themes/ivpn-v3/scripts/ssr-render.mjs create mode 100644 src/themes/ivpn-v3/vite.config.ssr.js diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..aea5715fc --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# ivpn.net frontend environment variables — Altcha integration + +# Public site key presented to the Altcha widget (not secret). +# Set this to the value generated for your Altcha Sentinel instance. +VITE_APP_ALTCHA_SITE_KEY=your-altcha-site-key + +# Backend API URL (already exists — shown here for completeness) +VITE_APP_WEBAPI_URL=http://localhost:8001 diff --git a/.gitignore b/.gitignore index c44a4ae43..e37171918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ src/themes/*/node_modules +src/themes/*/ssr-dist src/public node_modules public diff --git a/Dockerfile b/Dockerfile index 84bea97af..3c18750aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,17 @@ -FROM node:22.12-slim AS builder +FROM node:22-slim AS builder ARG ENV=production -ARG BASE_URL=http://localhost:8010 -ARG API_URL=https://api.ivpn.net +ARG BASE_URL=https://www.ivpn.net +ARG API_URL=http://api.ivpn.net ARG PAYPAL_CLIENT_ID=Ae94bviH2d45LcrM1nnnUcIDuJNb2NZgR_-3GKoJS1krkkNiInSl9V-SHMLvpPd1V8P9-yDRNoWBWK44 WORKDIR /opt/build -# Install Hugo and cleanup -RUN apt-get update \ +# Install Hugo and cleanup (detect arch for arm64/amd64 compatibility) +RUN ARCH=$(dpkg --print-architecture) \ + && apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -L https://github.com/gohugoio/hugo/releases/download/v0.125.3/hugo_0.125.3_linux-amd64.deb -o /tmp/hugo.deb \ + && curl -L https://github.com/gohugoio/hugo/releases/download/v0.125.3/hugo_0.125.3_linux-${ARCH}.deb -o /tmp/hugo.deb \ && dpkg -i /tmp/hugo.deb \ && rm /tmp/hugo.deb \ && apt-get clean \ diff --git a/Dockerfile.bak b/Dockerfile.bak new file mode 100644 index 000000000..d5448aed6 --- /dev/null +++ b/Dockerfile.bak @@ -0,0 +1,57 @@ +FROM node:18-slim AS builder + +ARG ENV=production +ARG BASE_URL=http://localhost:8001 +ARG API_URL=http://localhost:8001 +ARG PAYPAL_CLIENT_ID=Ae94bviH2d45LcrM1nnnUcIDuJNb2NZgR_-3GKoJS1krkkNiInSl9V-SHMLvpPd1V8P9-yDRNoWBWK44 + +WORKDIR /opt/build + +# Install Hugo and cleanup +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && curl -L https://github.com/gohugoio/hugo/releases/download/v0.125.3/hugo_0.125.3_linux-amd64.deb -o /tmp/hugo.deb \ + && dpkg -i /tmp/hugo.deb \ + && rm /tmp/hugo.deb \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install dependencies (cached layer for faster rebuilds) +COPY ./src/themes/ivpn-v3/package.json ./src/themes/ivpn-v3/package-lock.json ./src/themes/ivpn-v3/ +RUN cd ./src/themes/ivpn-v3/ && npm install + +# Copy source and build assets +COPY ./src ./src +RUN echo "VITE_APP_WEBAPI_URL=${BASE_URL}\nVITE_APP_API_URL=${API_URL}\nVITE_APP_PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}\n" > ./src/themes/ivpn-v3/.env.${ENV} \ + && echo "Environment: $ENV\nBase URL: ${BASE_URL}\nAPI URL: ${API_URL}\nPayPal Client ID: ${PAYPAL_CLIENT_ID}\n" \ + && cd ./src/themes/ivpn-v3/ && npm run $ENV + +# Generate mobile app pages +RUN sed -E \ + -e 's/^layout:(.*)$/layout: mobile-app/' \ + -e 's/^url:(.*)$/url: \/en\/privacy-mobile-app\//' \ + -e 's/^canonical:(.*)$/canonical: \/en\/privacy\//' \ + ./src/content/en/pages/privacy-policy.md > ./src/content/en/pages/privacy-policy-mobile.md \ + && sed -E \ + -e 's/^layout:(.*)$/layout: mobile-app/' \ + -e 's/^url:(.*)$/url: \/en\/tos-mobile-app\//' \ + -e 's/^canonical:(.*)$/canonical: \/en\/tos\//' \ + ./src/content/en/pages/terms-of-service.md > ./src/content/en/pages/terms-of-service-mobile.md \ + && sed -E \ + -e 's/^layout:(.*)$/layout: mobile-app/' \ + -e 's/^url:(.*)$/url: \/es\/privacy-mobile-app\//' \ + -e 's/^canonical:(.*)$/canonical: \/es\/privacy\//' \ + ./src/content/es/pages/privacy-policy.md > ./src/content/es/pages/privacy-policy-mobile.md \ + && sed -E \ + -e 's/^layout:(.*)$/layout: mobile-app/' \ + -e 's/^url:(.*)$/url: \/es\/tos-mobile-app\//' \ + -e 's/^canonical:(.*)$/canonical: \/es\/tos\//' \ + ./src/content/es/pages/terms-of-service.md > ./src/content/es/pages/terms-of-service-mobile.md + +# Build Hugo site +RUN hugo -s ./src -b ${BASE_URL} --environment $ENV -d /opt/build/public + +FROM nginx:1.27-alpine + +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /opt/build/public /var/www/static diff --git a/build.sh b/build.sh new file mode 100644 index 000000000..f4c73d4e7 --- /dev/null +++ b/build.sh @@ -0,0 +1,2 @@ +docker buildx build --no-cache --platform linux/amd64 -t website:latest --build-arg ENV=staging . +docker run -it --publish 8010:80 website diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..738b1af0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: '3.8' +services: + website: + build: + context: . + dockerfile: Dockerfile + args: + ENV: staging + no_cache: true + image: website:latest + platform: linux/amd64 + ports: + - "8010:80" + stdin_open: true + tty: true + + website-dev: + build: + context: . + dockerfile: Dockerfile + target: builder + args: + ENV: development + no_cache: true + image: website-dev:latest + platform: linux/amd64 + command: > + /bin/sh -c " + npm --prefix ./src/themes/ivpn-v3 install && + ./src/themes/ivpn-v3/node_modules/.bin/esbuild \ + $(node -e \"require('glob').sync('/opt/build/src/themes/ivpn-v3/**/*.{js,ts,css}').forEach(f=>console.log(f))\") \ + --bundle \ + --outdir=/opt/build/src/static \ + --watch=forever & + hugo server -s /opt/build/src --bind 0.0.0.0 --baseURL http://localhost:8010 --disableFastRender -D + " + ports: + - "8010:1313" # Hugo live reload + volumes: + - ./src:/opt/build/src:delegated + - node_modules:/opt/build/src/themes/ivpn-v3/node_modules + environment: + - ENV=development + - NODE_ENV=development + stdin_open: true + tty: true + +volumes: + node_modules: diff --git a/src/data/ssr/light.json b/src/data/ssr/light.json new file mode 100644 index 000000000..37b26178a --- /dev/null +++ b/src/data/ssr/light.json @@ -0,0 +1 @@ +{"html":""} diff --git a/src/data/ssr/pricing.json b/src/data/ssr/pricing.json new file mode 100644 index 000000000..172d84a34 --- /dev/null +++ b/src/data/ssr/pricing.json @@ -0,0 +1 @@ +{"html":"

Select your plan

Three tiers. No upselling. Pick based on your privacy needs.

IVPN Standard
Core VPN service
IVPN / 5 Devices [i]
AntiTracker [i]
$2
$6
$60
IVPN Plus
Added DNS & email privacy
IVPN / 5 Devices [i]
modDNS [i]
Mailx [i]
$3
$8
$80
IVPN Pro Suite
Complete network stack
IVPN / 10 Devices [i]
modDNS [i]
Mailx [i]
Portmaster Pro (Linux, Windows) [i]
$4
$10
$100

Pricing in USD. Team licensing for VPN-only plans available here.

No email required for signup
30-day refund window
Cash, Monero, Bitcoin accepted

The privacy stack

Four standalone privacy services operated by IVPN
All services are open-source
Additional services are activated separately after purchase
Additional services authorise access without receiving your IVPN account ID (Unlinked Access)
REVIEW SERVICE FEATURES
\"IVPN\"IVPN

IVPN features

Open-source clients (Android, iOS, macOS, Windows, Linux/CLI)
Private DNS resolvers (no third-party logging)
DNS-based AntiTracker with configurable blocklists
Kill switch with network firewall integration
Obfuscation protocols (v2Ray, obfsproxy)
SOCKS5 proxy service
Router and NAS deployment support

Payment Methods

Credit Card PayPal Bitcoin BTC Lightning Monero Cash

Audited & transparent

No connection logs or session data retained
No customer information stored post-purchase
Annual third-party security audits (#7 completed May 2025)
Public transparency reports and warrant canary
Disclosed ownership and team with 15-year operating history
No affiliate program, no tracking scripts, no surveillance advertising

Limitations we acknowledge

We operate servers in 58 locations / 41 countries.
We don't offer port forwarding.
No streaming compatibility guarantees.
\"Pixel

If you've read this far, you know what we're about.

Choose the plan that matches your threat model.

"} \ No newline at end of file diff --git a/src/data/ssr/pricing_es.json b/src/data/ssr/pricing_es.json new file mode 100644 index 000000000..20c019125 --- /dev/null +++ b/src/data/ssr/pricing_es.json @@ -0,0 +1 @@ +{"html":"

Selecciona tu plan

Tres niveles. Sin ventas adicionales. Elige según tus necesidades de privacidad.

IVPN Standard
Servicio VPN básico
IVPN / 5 dispositivos [i]
AntiTracker [i]
$2
$6
$60
IVPN Plus
Added DNS & email privacy
IVPN / 5 dispositivos [i]
modDNS [i]
Mailx [i]
$3
$8
$80
IVPN Pro Suite
Control total de red
IVPN / 10 Dispositivos [i]
modDNS [i]
Mailx [i]
Portmaster Pro (Linux, Windows) [i]
$4
$10
$100

Todos los precios están en dólares estadounidenses. Consulta los precios solo VPN para empresas aquí.

No se requiere correo electrónico
Reembolso de 30 días
Se aceptan efectivo, Monero, Bitcoin

La estructura de privacidad

Cuatro servicios de privacidad independientes operados por IVPN
Todos los servicios son de código abierto
Los servicios adicionales se activan por separado después de la compra
Los servicios adicionales autorizan el acceso sin recibir tu ID de cuenta IVPN (Acceso no vinculado)
REVISAR CARACTERÍSTICAS DEL SERVICIO
\"IVPN\"IVPN

Características de IVPN

Clientes de código abierto (Android, iOS, macOS, Windows, Linux/CLI)
Resolutores DNS privados (sin registro de terceros)
AntiTracker basado en DNS con listas de bloqueo configurables
Interruptor de corte con integración de cortafuegos de red
Protocolos de ofuscación (v2Ray, obfsproxy)
Servicio proxy SOCKS5
Soporte para despliegue en routers y NAS

Métodos de pago

T. Crédito PayPal Bitcoin BTC Lightning Monero Efectivo

Auditado y transparente

Sin registros de conexión ni datos de sesión retenidos
Sin información del cliente almacenada después de la compra
Auditorías de seguridad anuales realizadas por terceros (#7 completadas en mayo de 2025)
Informes públicos de transparencia y canario de orden judicial
Propiedad y equipo transparentes, con 15 años de experiencia operativa.
Sin programa de afiliados, sin scripts de seguimiento, sin publicidad de vigilancia

Limitaciones que reconocemos

Operamos servidores en 58 ubicaciones / 41 países.
No ofrecemos reenvío de puertos.
No garantizamos compatibilidad con streaming.
\"Pixel

Si has leído hasta aquí, sabes de qué se trata.

Elige el plan que se adapte a tu modelo de amenaza.

"} \ No newline at end of file diff --git a/src/data/ssr/servers.json b/src/data/ssr/servers.json new file mode 100644 index 000000000..f1cbcdc5b --- /dev/null +++ b/src/data/ssr/servers.json @@ -0,0 +1 @@ +{"html":"

IVPN Servers

 
 
"} \ No newline at end of file diff --git a/src/themes/ivpn-v3/assets/js/app.js b/src/themes/ivpn-v3/assets/js/app.js index ca6fea241..9bbc2bff3 100644 --- a/src/themes/ivpn-v3/assets/js/app.js +++ b/src/themes/ivpn-v3/assets/js/app.js @@ -1,4 +1,4 @@ -import { createApp, h } from 'vue' +import { createSSRApp as createApp, h } from 'vue' import App from './App.vue' import router from './router/index.js' @@ -79,7 +79,9 @@ let products = { } store.commit('product/setAll', { products }) -store.dispatch('auth/init') +// auth/init is dispatched AFTER mount so Vue hydrates over SSR HTML with the +// same initial state (isAuthenticated: false). Auth state then updates +// reactively without triggering a hydration mismatch. const i18n = createI18n({ legacy: false, @@ -148,4 +150,10 @@ app.config.productionTip = false app.use(store) app.use(router) app.use(i18n) -app.mount('#application') + +// Await router resolution before mounting so $route is correct during SSR hydration. +router.isReady().then(() => { + app.mount('#application') + // Initialise auth state after hydration so it never mismatches the SSR output. + store.dispatch('auth/init') +}) diff --git a/src/themes/ivpn-v3/assets/js/components/PriceBox.vue b/src/themes/ivpn-v3/assets/js/components/PriceBox.vue index 5b8f3f712..9ca206332 100644 --- a/src/themes/ivpn-v3/assets/js/components/PriceBox.vue +++ b/src/themes/ivpn-v3/assets/js/components/PriceBox.vue @@ -16,7 +16,7 @@ - +
{{ $t('account.currentPlanBadge') }} diff --git a/src/themes/ivpn-v3/assets/js/servers.js b/src/themes/ivpn-v3/assets/js/servers.js index e1dd6b0c3..884df0382 100644 --- a/src/themes/ivpn-v3/assets/js/servers.js +++ b/src/themes/ivpn-v3/assets/js/servers.js @@ -1,4 +1,4 @@ -import { createApp } from 'vue' +import { createSSRApp as createApp } from 'vue' import ServerList from './components/ServerList.vue' import { createI18n } from 'vue-i18n' import en from '../../locales/en.json' diff --git a/src/themes/ivpn-v3/assets/js/ssr/entry.light.js b/src/themes/ivpn-v3/assets/js/ssr/entry.light.js new file mode 100644 index 000000000..6a00f5816 --- /dev/null +++ b/src/themes/ivpn-v3/assets/js/ssr/entry.light.js @@ -0,0 +1,68 @@ +/** + * SSR entry – IVPN Light page (OnePageCheckout) + * Pre-renders the static HTML shell of the checkout form. + */ + +if (typeof globalThis.window === 'undefined') { + globalThis.window = { + location: { href: 'http://localhost/light/' }, + updateLoginMenu: () => {}, + getLanguage: () => 'en', + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + crypto: { + getRandomValues: (arr) => { arr.fill(0); return arr }, + subtle: {}, + }, + } + globalThis.document = { + cookie: '', + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + } +} + + +import { createSSRApp } from 'vue' +import { createI18n } from 'vue-i18n' +import { createStore } from 'vuex' +import { createRouter, createMemoryHistory } from 'vue-router' +import { renderToString } from '@vue/server-renderer' +import OnePageCheckout from '@/views/OnePageCheckout.vue' +import en from '@/../../locales/en.json' +import es from '@/../../locales/es.json' + +const lightModule = { + namespaced: true, + state: () => ({}), + mutations: {}, + actions: {}, + getters: {}, +} + +export async function render(lang = 'en', baseURL = 'http://localhost') { + globalThis.window.location.href = `${baseURL}/light/` + const store = createStore({ modules: { light: lightModule } }) + + const i18n = createI18n({ + legacy: false, + locale: lang, + fallbackLocale: 'en', + messages: { en, es }, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/light', component: OnePageCheckout }], + }) + + const app = createSSRApp(OnePageCheckout) + app.use(store) + app.use(i18n) + app.use(router) + + await router.push('/light') + await router.isReady() + + return renderToString(app) +} diff --git a/src/themes/ivpn-v3/assets/js/ssr/entry.pricing.js b/src/themes/ivpn-v3/assets/js/ssr/entry.pricing.js new file mode 100644 index 000000000..6d319524c --- /dev/null +++ b/src/themes/ivpn-v3/assets/js/ssr/entry.pricing.js @@ -0,0 +1,182 @@ +/** + * SSR entry – Pricing page + * Pre-renders Prices.vue (unauthenticated state) at build time. + * The exported `render(lang)` is called by scripts/ssr-render.mjs. + */ + +// Polyfill browser globals before any module that touches them is imported. +// window.location.href is updated per-render call using the baseURL argument. +if (typeof globalThis.window === 'undefined') { + globalThis.window = { + location: { href: 'http://localhost/en/pricing/' }, + updateLoginMenu: () => {}, + getLanguage: () => 'en', + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + } + globalThis.document = { + cookie: '', + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + } +} + +import { createSSRApp, h } from 'vue' +import { createI18n } from 'vue-i18n' +import { createStore } from 'vuex' +import { createRouter, createMemoryHistory } from 'vue-router' +import { renderToString } from '@vue/server-renderer' +import App from '@/App.vue' +import Prices from '@/views/Prices.vue' +import en from '@/../../locales/en.json' +import es from '@/../../locales/es.json' + +// ── Product data (mirrors app.js) ──────────────────────────────────────────── +const products = { + tier1: { + name: 'Tier 1', + capabilities: { hasWireGuard: true, hasPortForwarding: false }, + prices: [ + { id: 'standard-1week', name: en.pricing.oneWeek, price: 2 }, + { id: 'standard-1month', name: en.pricing.oneMonth, price: 6 }, + { id: 'standard-1year', name: en.pricing.oneYear, price: 60 }, + { id: 'standard-2year', name: en.pricing.twoYears, price: 100 }, + { id: 'standard-3year', name: en.pricing.threeYears, price: 140 }, + ], + pricesEs: [ + { id: 'standard-1week', name: es.pricing.oneWeek, price: 2 }, + { id: 'standard-1month', name: es.pricing.oneMonth, price: 6 }, + { id: 'standard-1year', name: es.pricing.oneYear, price: 60 }, + { id: 'standard-2year', name: es.pricing.twoYears, price: 100 }, + { id: 'standard-3year', name: es.pricing.threeYears, price: 140 }, + ], + }, + tier2: { + name: 'Tier 2', + capabilities: { hasWireGuard: true, hasPortForwarding: false }, + prices: [ + { id: 'plus-1week', name: en.pricing.oneWeek, price: 3 }, + { id: 'plus-1month', name: en.pricing.oneMonth, price: 8 }, + { id: 'plus-1year', name: en.pricing.oneYear, price: 80 }, + { id: 'plus-2year', name: en.pricing.twoYears, price: 140 }, + { id: 'plus-3year', name: en.pricing.threeYears, price: 180 }, + ], + pricesEs: [ + { id: 'plus-1week', name: es.pricing.oneWeek, price: 3 }, + { id: 'plus-1month', name: es.pricing.oneMonth, price: 8 }, + { id: 'plus-1year', name: es.pricing.oneYear, price: 80 }, + { id: 'plus-2year', name: es.pricing.twoYears, price: 140 }, + { id: 'plus-3year', name: es.pricing.threeYears, price: 180 }, + ], + }, + tier3: { + name: 'Tier 3', + capabilities: { hasWireGuard: true, hasPortForwarding: false }, + prices: [ + { id: 'pro-1week', name: en.pricing.oneWeek, price: 4 }, + { id: 'pro-1month', name: en.pricing.oneMonth, price: 10 }, + { id: 'pro-1year', name: en.pricing.oneYear, price: 100 }, + { id: 'pro-2year', name: en.pricing.twoYears, price: 160 }, + { id: 'pro-3year', name: en.pricing.threeYears, price: 220 }, + ], + pricesEs: [ + { id: 'pro-1week', name: es.pricing.oneWeek, price: 4 }, + { id: 'pro-1month', name: es.pricing.oneMonth, price: 10 }, + { id: 'pro-1year', name: es.pricing.oneYear, price: 100 }, + { id: 'pro-2year', name: es.pricing.twoYears, price: 160 }, + { id: 'pro-3year', name: es.pricing.threeYears, price: 220 }, + ], + }, +} + +// ── Minimal Vuex modules for SSR (no browser APIs, unauthenticated state) ──── +const authModule = { + namespaced: true, + state: () => ({ + isAuthenticated: false, + isLegacy: false, + isLoaded: false, + inProgress: false, + account: null, + error: null, + }), + mutations: { + setState() {}, + setAccount() {}, + updateAccount() {}, + started() {}, + failed() {}, + done() {}, + }, + actions: { + init() {}, + load() {}, + createAccount() {}, + }, + getters: {}, +} + +const productModule = { + namespaced: true, + state: () => ({ inProgress: false, error: null, all: [] }), + mutations: { + started(state) { state.inProgress = true; state.error = null }, + failed(state, { error }) { state.inProgress = false; state.error = error }, + done(state) { state.inProgress = false }, + setAll(state, { products }) { state.all = products }, + }, + actions: {}, +} + +const popupModule = { + namespaced: true, + state: () => ({}), + mutations: { show() {} }, + actions: {}, +} + +// ── render() – creates a fresh app instance per call ───────────────────────── +export async function render(lang = 'en', baseURL = 'http://localhost') { + globalThis.window.location.href = `${baseURL}/${lang}/pricing/` + + const store = createStore({ + modules: { auth: authModule, product: productModule, popup: popupModule }, + }) + store.commit('product/setAll', { products }) + + const i18n = createI18n({ + legacy: false, + locale: lang, + fallbackLocale: 'en', + messages: { en, es }, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/pricing', component: Prices }, + { path: '/en/pricing', component: Prices }, + { path: '/es/pricing', component: Prices }, + ], + }) + + // Render App.vue (same root component as the client) so that the SSR HTML + // has the exact same DOM structure — including Vue fragment markers — that + // createSSRApp(App) expects to find during hydration. + const app = createSSRApp({ render: () => h(App) }) + app.use(store) + app.use(i18n) + app.use(router) + app.mixin({ + methods: { + emptyObject(obj) { + return obj == null || obj === undefined || Object.keys(obj).length === 0 + }, + }, + }) + + await router.push(`/${lang}/pricing`) + await router.isReady() + + return renderToString(app) +} diff --git a/src/themes/ivpn-v3/assets/js/ssr/entry.servers.js b/src/themes/ivpn-v3/assets/js/ssr/entry.servers.js new file mode 100644 index 000000000..f96e0a40d --- /dev/null +++ b/src/themes/ivpn-v3/assets/js/ssr/entry.servers.js @@ -0,0 +1,48 @@ +/** + * SSR entry – Server Status page + * Pre-renders ServerList.vue structural shell (empty server list). + * Server data is fetched client-side from the API after hydration. + */ + +if (typeof globalThis.window === 'undefined') { + globalThis.window = { + location: { href: 'http://localhost/en/status/' }, + getLanguage: () => 'en', + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + } + globalThis.document = { + cookie: '', + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + } + globalThis.document = { + cookie: '', + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + } +} + +import { createSSRApp } from 'vue' +import { createI18n } from 'vue-i18n' +import { renderToString } from '@vue/server-renderer' +import ServerList from '@/components/ServerList.vue' +import en from '@/../../locales/en.json' +import es from '@/../../locales/es.json' + +export async function render(lang = 'en', baseURL = 'http://localhost') { + globalThis.window.location.href = `${baseURL}/${lang}/status/` + + const i18n = createI18n({ + legacy: false, + locale: lang, + fallbackLocale: 'en', + messages: { en, es }, + }) + + const app = createSSRApp(ServerList) + app.use(i18n) + + return renderToString(app) +} diff --git a/src/themes/ivpn-v3/assets/js/ssr/shims/estree-walker-shim.js b/src/themes/ivpn-v3/assets/js/ssr/shims/estree-walker-shim.js new file mode 100644 index 000000000..386239eb5 --- /dev/null +++ b/src/themes/ivpn-v3/assets/js/ssr/shims/estree-walker-shim.js @@ -0,0 +1,7 @@ +// estree-walker v3 is pure ESM with only named exports (walk, asyncWalk). +// @vue/compiler-core (bundled inside @vue/compat) was compiled from CJS and +// does `import walker from 'estree-walker'` (default import), which fails. +// This shim provides the default export that CJS-compiled code expects. +import { walk, asyncWalk } from 'estree-walker' +export { walk, asyncWalk } +export default { walk, asyncWalk } diff --git a/src/themes/ivpn-v3/assets/js/ssr/shims/vue-compat-server-renderer.js b/src/themes/ivpn-v3/assets/js/ssr/shims/vue-compat-server-renderer.js new file mode 100644 index 000000000..2dff2370d --- /dev/null +++ b/src/themes/ivpn-v3/assets/js/ssr/shims/vue-compat-server-renderer.js @@ -0,0 +1,4 @@ +// Re-export everything from @vue/server-renderer. +// This file is used as a virtual module by vite.config.ssr.js to intercept +// the alias-rewritten import "vue/server-renderer" → "@vue/compat/server-renderer". +export * from '@vue/server-renderer' diff --git a/src/themes/ivpn-v3/layouts/pages/application.html b/src/themes/ivpn-v3/layouts/pages/application.html index 2914d5320..24091b2f3 100644 --- a/src/themes/ivpn-v3/layouts/pages/application.html +++ b/src/themes/ivpn-v3/layouts/pages/application.html @@ -1,5 +1,38 @@ {{ define "main" }} -
+ {{- /* Embed SSR pre-rendered HTML when available for pricing/light pages. + Vue's createSSRApp will hydrate over it; other pages get an empty div. */ -}} + {{- $ssrHtml := "" -}} + {{- if site.Data.ssr -}} + {{- if in .RelPermalink "pricing" -}} + {{- if eq .Page.Language.Lang "es" -}} + {{- with index site.Data.ssr "pricing_es" -}}{{- $ssrHtml = .html -}}{{- end -}} + {{- else -}} + {{- with index site.Data.ssr "pricing" -}}{{- $ssrHtml = .html -}}{{- end -}} + {{- end -}} + {{- else if in .RelPermalink "light" -}} + {{- with index site.Data.ssr "light" -}}{{- $ssrHtml = .html -}}{{- end -}} + {{- end -}} + {{- end -}} + {{- /* + Wrap SSR HTML in the same structure App.vue renders for unauthenticated users: + div.app + ← PopUp component (v-if=false, no popup active) + ← AccountMenu (v-if="account && !account.is_new", null → false) + div.bottom-spacing.page-centered-pricing + div#app.app-content + [Prices.vue SSR HTML] + This matches exactly what createSSRApp(App) expects to find in #application, + preventing hydration mismatches that corrupt the CSS layout. + For non-SSR pages ($ssrHtml empty), the wrapper is still correct for hydration. + */ -}} +
+ {{- if $ssrHtml -}} + {{- /* SSR output from renderToString(App) already includes the full + div.app > div.bottom-spacing > div#app.app-content > [page] structure + with correct Vue comment anchors, so inject it directly. */ -}} + {{ $ssrHtml | safeHTML }} + {{- end -}} +
{{ end }} {{ define "main-style" }} diff --git a/src/themes/ivpn-v3/layouts/pages/servers.html b/src/themes/ivpn-v3/layouts/pages/servers.html index 3ee08dd10..11c3df7f7 100644 --- a/src/themes/ivpn-v3/layouts/pages/servers.html +++ b/src/themes/ivpn-v3/layouts/pages/servers.html @@ -1,8 +1,12 @@ {{ define "main" }}
-
- -
+ {{- /* Embed SSR pre-rendered HTML for the server list structural shell. + ServerList.vue fetches live data from the API after hydration. */ -}} + {{- $ssrHtml := "" -}} + {{- if site.Data.ssr -}} + {{- with index site.Data.ssr "servers" -}}{{- $ssrHtml = .html -}}{{- end -}} + {{- end -}} +
{{ $ssrHtml | safeHTML }}
{{ end }} diff --git a/src/themes/ivpn-v3/package-lock.json b/src/themes/ivpn-v3/package-lock.json index 0d08b62ee..dbdccc737 100644 --- a/src/themes/ivpn-v3/package-lock.json +++ b/src/themes/ivpn-v3/package-lock.json @@ -18,6 +18,7 @@ "@vitejs/plugin-vue": "^6.0.7", "@vue/compat": "^3.2.47", "@vue/compiler-sfc": "^3.2.47", + "@vue/server-renderer": "^3.2.47", "braintree-web": "^3.101.0", "date-fns": "^2.29.3", "file-saver": "^2.0.5", @@ -1167,9 +1168,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1187,9 +1185,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1207,9 +1202,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1227,9 +1219,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1247,9 +1236,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1267,9 +1253,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/themes/ivpn-v3/package.json b/src/themes/ivpn-v3/package.json index 7f003a1eb..5e1099027 100644 --- a/src/themes/ivpn-v3/package.json +++ b/src/themes/ivpn-v3/package.json @@ -1,6 +1,7 @@ { "name": "ivpn-v3", "version": "1.0.0", + "type": "module", "main": "index.js", "license": "MIT", "private": true, @@ -8,14 +9,16 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "development": "vite build --mode development", - "staging": "vite build --mode staging", - "production": "vite build --mode production" + "ssr": "vite build --config vite.config.ssr.js && node scripts/ssr-render.mjs", + "development": "npm run ssr && vite build --mode development", + "staging": "npm run ssr && vite build --mode staging", + "production": "npm run ssr && vite build --mode production" }, "devDependencies": { "@vitejs/plugin-vue": "^6.0.7", "@vue/compat": "^3.2.47", "@vue/compiler-sfc": "^3.2.47", + "@vue/server-renderer": "^3.2.47", "braintree-web": "^3.101.0", "date-fns": "^2.29.3", "file-saver": "^2.0.5", diff --git a/src/themes/ivpn-v3/scripts/ssr-render.mjs b/src/themes/ivpn-v3/scripts/ssr-render.mjs new file mode 100644 index 000000000..31ed80736 --- /dev/null +++ b/src/themes/ivpn-v3/scripts/ssr-render.mjs @@ -0,0 +1,79 @@ +/** + * scripts/ssr-render.mjs + * + * Build-time SSR pre-render script. + * Run AFTER `vite build --config vite.config.ssr.js` has produced ssr-dist/. + * + * Imports each SSR bundle, calls render(lang, baseURL), and writes the resulting + * HTML to ../../data/ssr/.json so Hugo can include it in static pages. + * + * baseURL is read from VITE_APP_WEBAPI_URL in the env file that was written by + * the Dockerfile before this script runs. Falls back to http://localhost. + */ + +import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const themeDir = resolve(__dirname, '..') +const dataDir = resolve(themeDir, '../../data/ssr') + +// ── Read baseURL from the env file written by the Dockerfile ───────────────── +function readEnvFile(filePath) { + if (!existsSync(filePath)) return {} + return Object.fromEntries( + readFileSync(filePath, 'utf8') + .split('\n') + .filter(line => line.includes('=') && !line.startsWith('#')) + .map(line => { + const idx = line.indexOf('=') + return [line.slice(0, idx).trim(), line.slice(idx + 1).trim()] + }) + ) +} + +// NODE_ENV tells us which .env file was written (staging / production / development) +const env = process.env.NODE_ENV || 'development' +const envVars = readEnvFile(resolve(themeDir, `.env.${env}`)) +const baseURL = ( + process.env.VITE_APP_WEBAPI_URL || + envVars.VITE_APP_WEBAPI_URL || + 'http://localhost' +).replace(/\/$/, '') // strip trailing slash + +console.log(`SSR pre-rendering with baseURL: ${baseURL}`) + +mkdirSync(dataDir, { recursive: true }) + +const entries = [ + { name: 'pricing', bundle: 'entry.pricing.js', lang: 'en' }, + { name: 'pricing_es', bundle: 'entry.pricing.js', lang: 'es' }, + { name: 'servers', bundle: 'entry.servers.js', lang: 'en' }, + // light (OnePageCheckout) is skipped: fully interactive payment/crypto page; + // SSR adds no SEO value and the component requires browser crypto at module load. +] + +for (const { name, bundle, lang } of entries) { + const bundlePath = pathToFileURL(resolve(themeDir, `ssr-dist/${bundle}`)).href + + let html = '' + try { + // Dynamic import re-uses the cached module for same-bundle entries (e.g. + // pricing + pricing_es both import entry.pricing.js once). + const mod = await import(bundlePath) + html = await mod.render(lang, baseURL) + console.log(` ✓ ssr: rendered ${name} (${lang})`) + } catch (err) { + // Print the full error so it's visible in Docker build logs. + console.error(`\n ✗ ssr: render failed for ${name} (${lang})`) + console.error(err.stack || err.message) + // Exit with non-zero so the build fails loudly instead of silently + // writing empty HTML that makes pages look broken without any warning. + process.exit(1) + } + + writeFileSync(resolve(dataDir, `${name}.json`), JSON.stringify({ html })) +} + +console.log('SSR pre-rendering complete.') diff --git a/src/themes/ivpn-v3/vite.config.ssr.js b/src/themes/ivpn-v3/vite.config.ssr.js new file mode 100644 index 000000000..a1f1a92f9 --- /dev/null +++ b/src/themes/ivpn-v3/vite.config.ssr.js @@ -0,0 +1,143 @@ +/** + * vite.config.ssr.js + * Vite configuration for build-time SSR pre-rendering. + * Produces Node.js-compatible ESM bundles in ssr-dist/. + * Run via: vite build --config vite.config.ssr.js + */ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' + +/** + * Resolve `vue` and `@vue/compat` to the runtime-only compat build. + * + * Goals: + * 1. Exact imports (`import from 'vue'`) → vue.runtime.esm-bundler.js + * Excludes @vue/compiler-dom/-core and their deps (estree-walker, entities) + * which cause CJS/ESM default-import failures in Node.js. + * 2. Sub-path import `vue/server-renderer` / `@vue/compat/server-renderer` + * → virtual module re-exporting @vue/server-renderer. + * (The alias-based approach would produce `/path/to/file.js/server-renderer` + * which breaks; the plugin intercepts before that happens.) + * 3. Runtime-only vue-i18n: we pass pre-compiled JSON messages, no compiler. + * + * Using a plugin (not resolve.alias) for (1) avoids breaking subpath imports. + */ +function vueSSRResolutionPlugin() { + const VIRTUAL_SERVER_RENDERER = '\0vue-ssr-server-renderer' + const VUE_RUNTIME = resolve(__dirname, 'node_modules/@vue/compat/dist/vue.runtime.esm-bundler.js') + const VUE_I18N_RUNTIME = resolve(__dirname, 'node_modules/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js') + + return { + name: 'vue-ssr-resolution', + enforce: 'pre', + resolveId(id) { + // Sub-path: server-renderer virtual shim + if (id === 'vue/server-renderer' || id === '@vue/compat/server-renderer') { + return VIRTUAL_SERVER_RENDERER + } + // Exact: route to runtime builds (no compiler → no estree-walker/entities) + if (id === 'vue' || id === '@vue/compat') { + return VUE_RUNTIME + } + if (id === 'vue-i18n') { + return VUE_I18N_RUNTIME + } + }, + load(id) { + if (id === VIRTUAL_SERVER_RENDERER) { + // Re-export from the real @vue/server-renderer package. + return `export * from '@vue/server-renderer'` + } + }, + } +} + +export default defineConfig({ + plugins: [ + vueSSRResolutionPlugin(), // must be first — intercepts before all other plugins + vue({ + template: { + compilerOptions: { + compatConfig: { MODE: 2 }, + }, + // Don't transform absolute URLs to module imports – they are static + // assets served by Hugo/nginx and must remain as plain HTML src strings. + transformAssetUrls: { + includeAbsolute: false, + }, + }, + }), + VueI18nPlugin({}), + ], + + resolve: { + alias: { + '@': resolve(__dirname, 'assets/js'), + 'scss': resolve(__dirname, 'assets/scss'), + }, + }, + + // Bundle Vue and related packages so Rollup handles CJS→ESM interop. + // Node.js v22 cannot reliably detect named exports from @vue/compat's CJS + // build dynamically, but Rollup's static bundling produces correct ESM. + ssr: { + noExternal: [ + '@vue/compat', + '@vue/reactivity', + '@vue/runtime-core', + '@vue/runtime-dom', + '@vue/shared', + '@vue/server-renderer', + 'vue-i18n', + '@intlify/core-base', + '@intlify/shared', + 'vuex', + 'vue-router', + 'js-cookie', + 'date-fns', + ], + }, + + css: { + preprocessorOptions: { + scss: { + quietDeps: true, + silenceDeprecations: ['global-builtin', 'slash-div', 'color-functions'], + }, + }, + }, + + build: { + ssr: true, + outDir: 'ssr-dist', + emptyOutDir: true, + sourcemap: false, + rollupOptions: { + input: { + 'entry.pricing': resolve(__dirname, 'assets/js/ssr/entry.pricing.js'), + 'entry.servers': resolve(__dirname, 'assets/js/ssr/entry.servers.js'), + // entry.light is excluded: OnePageCheckout is a fully interactive + // payment/crypto page that requires browser APIs at module load time. + }, + external: [ + // Absolute static-asset paths are served by Hugo/nginx, not bundled. + /^\/images\//, + /^\/images-static\//, + /^\/img\//, + /^\/resources\//, + /^\/images$/, + ], + output: { + format: 'esm', + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js', + paths: (id) => { + if (id.startsWith('/')) return id + }, + }, + }, + }, +}) + From 0a7e04a28d64f4fe8c37636b370529525d27c79e Mon Sep 17 00:00:00 2001 From: Natanael Rodriguez Ramos Date: Sun, 19 Jul 2026 22:29:39 +0100 Subject: [PATCH 2/5] task: LLM optimizations 2 --- Dockerfile.bak | 57 -------------------------------------------------- build.sh | 2 -- 2 files changed, 59 deletions(-) delete mode 100644 Dockerfile.bak delete mode 100644 build.sh diff --git a/Dockerfile.bak b/Dockerfile.bak deleted file mode 100644 index d5448aed6..000000000 --- a/Dockerfile.bak +++ /dev/null @@ -1,57 +0,0 @@ -FROM node:18-slim AS builder - -ARG ENV=production -ARG BASE_URL=http://localhost:8001 -ARG API_URL=http://localhost:8001 -ARG PAYPAL_CLIENT_ID=Ae94bviH2d45LcrM1nnnUcIDuJNb2NZgR_-3GKoJS1krkkNiInSl9V-SHMLvpPd1V8P9-yDRNoWBWK44 - -WORKDIR /opt/build - -# Install Hugo and cleanup -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ - && curl -L https://github.com/gohugoio/hugo/releases/download/v0.125.3/hugo_0.125.3_linux-amd64.deb -o /tmp/hugo.deb \ - && dpkg -i /tmp/hugo.deb \ - && rm /tmp/hugo.deb \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Install dependencies (cached layer for faster rebuilds) -COPY ./src/themes/ivpn-v3/package.json ./src/themes/ivpn-v3/package-lock.json ./src/themes/ivpn-v3/ -RUN cd ./src/themes/ivpn-v3/ && npm install - -# Copy source and build assets -COPY ./src ./src -RUN echo "VITE_APP_WEBAPI_URL=${BASE_URL}\nVITE_APP_API_URL=${API_URL}\nVITE_APP_PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}\n" > ./src/themes/ivpn-v3/.env.${ENV} \ - && echo "Environment: $ENV\nBase URL: ${BASE_URL}\nAPI URL: ${API_URL}\nPayPal Client ID: ${PAYPAL_CLIENT_ID}\n" \ - && cd ./src/themes/ivpn-v3/ && npm run $ENV - -# Generate mobile app pages -RUN sed -E \ - -e 's/^layout:(.*)$/layout: mobile-app/' \ - -e 's/^url:(.*)$/url: \/en\/privacy-mobile-app\//' \ - -e 's/^canonical:(.*)$/canonical: \/en\/privacy\//' \ - ./src/content/en/pages/privacy-policy.md > ./src/content/en/pages/privacy-policy-mobile.md \ - && sed -E \ - -e 's/^layout:(.*)$/layout: mobile-app/' \ - -e 's/^url:(.*)$/url: \/en\/tos-mobile-app\//' \ - -e 's/^canonical:(.*)$/canonical: \/en\/tos\//' \ - ./src/content/en/pages/terms-of-service.md > ./src/content/en/pages/terms-of-service-mobile.md \ - && sed -E \ - -e 's/^layout:(.*)$/layout: mobile-app/' \ - -e 's/^url:(.*)$/url: \/es\/privacy-mobile-app\//' \ - -e 's/^canonical:(.*)$/canonical: \/es\/privacy\//' \ - ./src/content/es/pages/privacy-policy.md > ./src/content/es/pages/privacy-policy-mobile.md \ - && sed -E \ - -e 's/^layout:(.*)$/layout: mobile-app/' \ - -e 's/^url:(.*)$/url: \/es\/tos-mobile-app\//' \ - -e 's/^canonical:(.*)$/canonical: \/es\/tos\//' \ - ./src/content/es/pages/terms-of-service.md > ./src/content/es/pages/terms-of-service-mobile.md - -# Build Hugo site -RUN hugo -s ./src -b ${BASE_URL} --environment $ENV -d /opt/build/public - -FROM nginx:1.27-alpine - -COPY ./nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /opt/build/public /var/www/static diff --git a/build.sh b/build.sh deleted file mode 100644 index f4c73d4e7..000000000 --- a/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -docker buildx build --no-cache --platform linux/amd64 -t website:latest --build-arg ENV=staging . -docker run -it --publish 8010:80 website From ce2ce323cdf1b06d2df5d48ad0c86109e92f226c Mon Sep 17 00:00:00 2001 From: Natanael Rodriguez Ramos Date: Sun, 19 Jul 2026 22:52:07 +0100 Subject: [PATCH 3/5] task: LLM optimizations 2 --- Dockerfile | 2 +- docker-compose.yml | 49 ---------------------------------------------- 2 files changed, 1 insertion(+), 50 deletions(-) delete mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 3c18750aa..ca40b292b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-slim AS builder ARG ENV=production ARG BASE_URL=https://www.ivpn.net -ARG API_URL=http://api.ivpn.net +ARG API_URL=https://api.ivpn.net ARG PAYPAL_CLIENT_ID=Ae94bviH2d45LcrM1nnnUcIDuJNb2NZgR_-3GKoJS1krkkNiInSl9V-SHMLvpPd1V8P9-yDRNoWBWK44 WORKDIR /opt/build diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 738b1af0b..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,49 +0,0 @@ -version: '3.8' -services: - website: - build: - context: . - dockerfile: Dockerfile - args: - ENV: staging - no_cache: true - image: website:latest - platform: linux/amd64 - ports: - - "8010:80" - stdin_open: true - tty: true - - website-dev: - build: - context: . - dockerfile: Dockerfile - target: builder - args: - ENV: development - no_cache: true - image: website-dev:latest - platform: linux/amd64 - command: > - /bin/sh -c " - npm --prefix ./src/themes/ivpn-v3 install && - ./src/themes/ivpn-v3/node_modules/.bin/esbuild \ - $(node -e \"require('glob').sync('/opt/build/src/themes/ivpn-v3/**/*.{js,ts,css}').forEach(f=>console.log(f))\") \ - --bundle \ - --outdir=/opt/build/src/static \ - --watch=forever & - hugo server -s /opt/build/src --bind 0.0.0.0 --baseURL http://localhost:8010 --disableFastRender -D - " - ports: - - "8010:1313" # Hugo live reload - volumes: - - ./src:/opt/build/src:delegated - - node_modules:/opt/build/src/themes/ivpn-v3/node_modules - environment: - - ENV=development - - NODE_ENV=development - stdin_open: true - tty: true - -volumes: - node_modules: From 37da266a1db1b4f276e8b62dd5f70de478b16480 Mon Sep 17 00:00:00 2001 From: Natanael Rodriguez Ramos Date: Mon, 20 Jul 2026 16:14:16 +0100 Subject: [PATCH 4/5] task: LLM optimizations 2 --- src/themes/ivpn-v3/assets/js/app.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/themes/ivpn-v3/assets/js/app.js b/src/themes/ivpn-v3/assets/js/app.js index 9bbc2bff3..bada7dd64 100644 --- a/src/themes/ivpn-v3/assets/js/app.js +++ b/src/themes/ivpn-v3/assets/js/app.js @@ -79,9 +79,6 @@ let products = { } store.commit('product/setAll', { products }) -// auth/init is dispatched AFTER mount so Vue hydrates over SSR HTML with the -// same initial state (isAuthenticated: false). Auth state then updates -// reactively without triggering a hydration mismatch. const i18n = createI18n({ legacy: false, @@ -151,9 +148,12 @@ app.use(store) app.use(router) app.use(i18n) +// auth/init reads the loggedIn cookie synchronously. It must run before +// router.isReady() so that router.beforeEach sees the correct isAuthenticated +// state during the initial navigation (e.g. a page refresh on /account). +store.dispatch('auth/init') + // Await router resolution before mounting so $route is correct during SSR hydration. router.isReady().then(() => { app.mount('#application') - // Initialise auth state after hydration so it never mismatches the SSR output. - store.dispatch('auth/init') }) From 874406cce351e557934fea1cdccaeb99140445af Mon Sep 17 00:00:00 2001 From: Natanael Rodriguez Ramos Date: Tue, 21 Jul 2026 11:40:39 +0100 Subject: [PATCH 5/5] task: LLM optimizations 2 --- src/themes/ivpn-v3/assets/js/views/Prices.vue | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/themes/ivpn-v3/assets/js/views/Prices.vue b/src/themes/ivpn-v3/assets/js/views/Prices.vue index 425c3a6fd..604d2e7a5 100644 --- a/src/themes/ivpn-v3/assets/js/views/Prices.vue +++ b/src/themes/ivpn-v3/assets/js/views/Prices.vue @@ -34,7 +34,7 @@ {{ $t('pricing.tier1.feature1') }} [i]
-
{{ $t('pricing.tier1.feature1Title') }}
+
{{ $t('pricing.tier1.feature1Title') }}
-
{{ $t('pricing.tier1.feature3Title') }}
+
{{ $t('pricing.tier1.feature3Title') }}
@@ -72,7 +72,7 @@ {{ $t('pricing.tier2.feature1') }} [i] -
{{ $t('pricing.tier2.feature1Title') }}
+
{{ $t('pricing.tier2.feature1Title') }}
-
{{ $t('pricing.tier2.feature3Title') }}
+
{{ $t('pricing.tier2.feature3Title') }}
-
{{ $t('pricing.tier2.feature4Title') }}
+
{{ $t('pricing.tier2.feature4Title') }}
@@ -118,7 +118,7 @@ {{ $t('pricing.tier3.feature1') }} [i] -
{{ $t('pricing.tier3.feature1Title') }}
+
{{ $t('pricing.tier3.feature1Title') }}
-
{{ $t('pricing.tier3.feature3Title') }}
+
{{ $t('pricing.tier3.feature3Title') }}
-
{{ $t('pricing.tier3.feature4Title') }}
+
{{ $t('pricing.tier3.feature4Title') }}
-
{{ $t('pricing.tier3.feature5Title') }}
+
{{ $t('pricing.tier3.feature5Title') }}