Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
src/themes/*/node_modules
src/themes/*/ssr-dist
src/public
node_modules
public
Expand Down
11 changes: 6 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 BASE_URL=https://www.ivpn.net
ARG API_URL=https://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 \
Expand Down
1 change: 1 addition & 0 deletions src/data/ssr/light.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"html":""}
1 change: 1 addition & 0 deletions src/data/ssr/pricing.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/data/ssr/pricing_es.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/data/ssr/servers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"html":"<div class=\"servers\" data-v-758c5c11><div class=\"servers__heading\" data-v-758c5c11><h1 data-v-758c5c11>IVPN Servers</h1></div><div class=\"servers__list\" data-v-758c5c11><div class=\"row row__filter\" data-v-758c5c11><div class=\"col server\" data-v-758c5c11><form class=\"search\" autocomplete=\"off\" data-v-758c5c11><input name=\"search\" type=\"text\" placeholder=\"Server\" data-v-758c5c11><input type=\"submit\" value=\" \" data-v-758c5c11></form></div><div class=\"col country active\" data-v-758c5c11><form class=\"select\" data-v-758c5c11><select name=\"country\" data-filter=\"country\" data-v-758c5c11><option value=\"\" data-v-758c5c11>Country: Any</option><!--[--><!--]--></select><i data-v-758c5c11></i></form></div><div class=\"col city\" data-v-758c5c11><form class=\"select\" data-v-758c5c11><select name=\"city\" data-filter=\"city\" data-v-758c5c11><option value=\"\" data-v-758c5c11>City: Any</option><!--[--><!--]--></select><i data-v-758c5c11></i></form></div><div class=\"col provider\" data-v-758c5c11><form class=\"select\" data-v-758c5c11><select name=\"provider\" data-filter=\"isp\" data-v-758c5c11><option value=\"\" data-v-758c5c11>Provider: Any</option><!--[--><!--]--></select><i data-v-758c5c11></i></form></div><div class=\"col load\" data-v-758c5c11> </div><div class=\"col action\" data-v-758c5c11><a data-v-758c5c11>Reset</a></div></div><header class=\"row row__header\" data-v-758c5c11><div class=\"col server\" data-v-758c5c11><a data-sort=\"gateway\" data-v-758c5c11>SERVER<i data-v-758c5c11></i></a></div><div class=\"col country active\" data-v-758c5c11><a data-sort=\"country\" data-v-758c5c11>COUNTRY<i data-v-758c5c11></i></a></div><div class=\"col city\" data-v-758c5c11><a data-sort=\"city\" data-v-758c5c11>CITY<i data-v-758c5c11></i></a></div><div class=\"col provider\" data-v-758c5c11><a data-sort=\"isp\" data-v-758c5c11>PROVIDER<i data-v-758c5c11></i></a></div><div class=\"col load\" data-v-758c5c11><a data-sort=\"load\" data-v-758c5c11>LOAD<i data-v-758c5c11></i></a></div><div class=\"col action\" data-v-758c5c11> </div></header><main data-v-758c5c11><!--[--><!--]--></main></div></div>"}
14 changes: 11 additions & 3 deletions src/themes/ivpn-v3/assets/js/app.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -79,7 +79,6 @@ let products = {
}

store.commit('product/setAll', { products })
store.dispatch('auth/init')

const i18n = createI18n({
legacy: false,
Expand Down Expand Up @@ -148,4 +147,13 @@ app.config.productionTip = false
app.use(store)
app.use(router)
app.use(i18n)
app.mount('#application')

// 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')
})
2 changes: 1 addition & 1 deletion src/themes/ivpn-v3/assets/js/components/PriceBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
</div>
</div>
</div>
<slot name="footer"></slot>
<template v-if="$slots.footer"><slot name="footer"></slot></template>
<div class="price-button">
<div v-if="current && hideButton" class="btn btn-big btn-outline btn-current-plan" style="margin-top: 2em;">
{{ $t('account.currentPlanBadge') }}
Expand Down
2 changes: 1 addition & 1 deletion src/themes/ivpn-v3/assets/js/servers.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
68 changes: 68 additions & 0 deletions src/themes/ivpn-v3/assets/js/ssr/entry.light.js
Original file line number Diff line number Diff line change
@@ -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)
}
182 changes: 182 additions & 0 deletions src/themes/ivpn-v3/assets/js/ssr/entry.pricing.js
Original file line number Diff line number Diff line change
@@ -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)
}
48 changes: 48 additions & 0 deletions src/themes/ivpn-v3/assets/js/ssr/entry.servers.js
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions src/themes/ivpn-v3/assets/js/ssr/shims/estree-walker-shim.js
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading