diff --git a/data/README.md b/data/README.md new file mode 100644 index 00000000..92bf1f09 --- /dev/null +++ b/data/README.md @@ -0,0 +1,8 @@ +Build-time data lives here. No runtime requests. + +- integrations.json: Directory of integrations for filterable UI. +- caseStudies.json: Cards feed for homepage; preferably generated from blog. +- contributors.json: Generated under github/ by tasks; consumed directly. + +These files are read by tasks/render_html.js to render static HTML. + diff --git a/data/caseStudies.json b/data/caseStudies.json new file mode 100644 index 00000000..d9fd383d --- /dev/null +++ b/data/caseStudies.json @@ -0,0 +1,10 @@ +[ + { + "id": "namecheap", + "title": "Namecheap", + "excerpt": "Namecheap surpasses $73M in BTC revenue with 1.1m transactions through BTCPay", + "hero": "/img/case-studies/namecheap-featured.png", + "url": "https://blog.btcpayserver.org/case-study-namecheap/", + "pdf": "/case-studies/namecheap.pdf" + } +] \ No newline at end of file diff --git a/data/integrations.json b/data/integrations.json new file mode 100644 index 00000000..a3d75186 --- /dev/null +++ b/data/integrations.json @@ -0,0 +1,38 @@ +[ + { + "id": "shopify", + "name": "Shopify", + "logo": "/img/shopify.svg", + "link": "https://docs.btcpayserver.org/ShopifyV2/", + "type": "plugin", + "tags": ["Hosted", "Ecommerce"], + "descriptionKey": "shopify-desc" + }, + { + "id": "woocommerce", + "name": "WooCommerce", + "logo": "/img/woo.svg", + "link": "https://wordpress.org/plugins/btcpay-greenfield-for-woocommerce/", + "type": "plugin", + "tags": ["Self-hosted", "Ecommerce"], + "descriptionKey": "woocommerce-desc" + }, + { + "id": "drupal", + "name": "Drupal", + "logo": "/img/drupal.svg", + "link": "https://docs.btcpayserver.org/Drupal/", + "type": "plugin", + "tags": ["Self-hosted", "Ecommerce"], + "descriptionKey": "drupal-desc" + }, + { + "id": "zapier", + "name": "Zapier", + "logo": "/img/zapier.svg", + "link": "https://zapier.com/apps/btcpay-server/integrations", + "type": "integration", + "tags": ["Automation"], + "descriptionKey": "zapier-desc" + } +] diff --git a/package.json b/package.json index 5eae4d14..12cefdc5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "init": "npm-run-all clean -p d:*", "d:translations": "node -r dotenv/config tasks/download_translations.js && cp transifex/resources/video.json transifex/download/video/en_GB.json && cp transifex/resources/website.json transifex/download/website/en_GB.json", "d:contributors": "node -r dotenv/config tasks/download_contributors.js", + "d:case-studies": "node tasks/fetch_case_studies.js", "start": "npm-run-all clean -p start:*", "start:static": "onchange -i -k 'src/static/**/*' -- npm run build:static", "start:scripts": "onchange -i -k 'src/js/**/*' -- npm run build:scripts", @@ -20,7 +21,7 @@ "start:vtt": "onchange -i -k 'src/vtt/**/*' 'tasks/{render_vtt,util}.js' -- npm run build:vtt", "start:serve": "browser-sync start --no-open --watch --server dist", "build": "npm-run-all clean -p build:*", - "build:static": "cp -rT src/static dist", + "build:static": "node tasks/copy_static.js", "build:scripts": "cp -r src/js dist", "build:styles": "cp -r src/css dist", "build:html": "node tasks/render_html.js", @@ -28,6 +29,7 @@ "optimize": "npm-run-all -p optimize:*", "optimize:styles": "csso dist/css/styles.css --output dist/css/styles.css", "optimize:page-styles": "csso dist/css/page-styles.css --output dist/css/page-styles.css", + "optimize:images": "node tasks/optimize_images.js", "prod": "NODE_ENV=production npm-run-all build optimize", "netlify": "npm run init && npm run prod" }, diff --git a/src/css/design-tokens.css b/src/css/design-tokens.css new file mode 100644 index 00000000..bd3d3393 --- /dev/null +++ b/src/css/design-tokens.css @@ -0,0 +1,478 @@ +/* Design tokens and system-first theming */ +:root { + /* Color palette */ + --color-bg: #ffffff; + --color-fg: #0f3b21; + --color-muted: #516a5e; + --color-accent: #1e7a44; + --color-accent-strong: #51b13e; + --color-surface: #f6f8f7; + --color-border: #e1e7e4; + + /* Typography */ + --font-sans: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + --font-size-0: 12px; + --font-size-1: 14px; + --font-size-2: 16px; + --font-size-3: 18px; + --font-size-4: 22px; + --font-size-5: 28px; + --font-size-6: 36px; + + /* Spacing (8pt scale) */ + --space-0: 4px; + --space-1: 8px; + --space-2: 12px; + --space-3: 16px; + --space-4: 24px; + --space-5: 32px; + --space-6: 48px; + --space-7: 64px; + + /* Radius & elevation */ + --radius-1: 6px; + --radius-2: 10px; + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Focus ring */ + --focus-ring: 0 0 0 3px rgba(30, 122, 68, 0.35); +} + +/* Respect user preference by default; explicit toggle remains in scripts.js */ +@media (prefers-color-scheme: dark) { + :root { + --color-bg: #0b1a12; + --color-fg: #d9efe3; + --color-muted: #9ab7aa; + --color-accent: #51b13e; + --color-accent-strong: #cedc21; + --color-surface: #0f241a; + --color-border: #213b2d; + } +} + +/* Baseline a11y */ +html, body { + color: var(--color-fg); + background: var(--color-bg); + font-family: var(--font-sans); +} + +:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-radius: var(--radius-1); +} + +/* Skip link */ +.skip-link { + position: absolute; + left: -9999px; + top: -9999px; +} + +.skip-link:focus { + left: 16px; + top: 16px; + z-index: 1000; + background: var(--color-accent); + color: #fff; + padding: 8px 12px; + border-radius: var(--radius-1); +} + +/* Screen-reader only */ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* Language modal (minimal styles) */ +[hidden] { + display: none !important; +} + +.lang-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); +} + +.lang-modal__dialog { + background: var(--color-bg); + color: var(--color-fg); + width: min(680px, 92vw); + border-radius: var(--radius-2); + box-shadow: var(--shadow-2); +} + +.lang-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.lang-modal__body { + padding: var(--space-3) var(--space-4); +} + +.lang-modal__close { + background: transparent; + border: 0; + color: var(--color-fg); + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +#langSearch { + width: 100%; + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-1); + margin-bottom: var(--space-3); + background: var(--color-surface); + color: var(--color-fg); +} + +.lang-list { + max-height: 320px; + overflow: auto; + list-style: none; + padding-left: 0; + margin: 0; +} + +.lang-list li a { + display: block; + padding: 8px 10px; + color: inherit; + text-decoration: none; + border-radius: var(--radius-1); +} + +.lang-list li a:hover, .lang-list li a:focus { + background: var(--color-surface); +} + +/* Language trigger + dropdown */ +.lnNomPar { + position: relative; +} + +.lnNomPar .compact-langs { + position: absolute; + right: 0; + top: calc(100% + 6px); + min-width: 200px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-1); + padding: 6px 0; + box-shadow: var(--shadow-2); + display: none; + max-height: 60vh; + overflow: auto; + z-index: 20; +} + +.lnNomPar.open .compact-langs { + display: block; +} + +.compact-langs li { + list-style: none; +} + +.compact-langs li a { + display: block; + padding: 8px 12px; + color: inherit; + text-decoration: none; +} + +.compact-langs li a:hover, .compact-langs li a:focus { + background: var(--color-surface); +} + +/* Integrations page */ +.int-filters { + display: flex; + gap: var(--space-3); + align-items: center; + flex-wrap: wrap; + margin: var(--space-3) 0; +} + +.int-quick { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.int-chip { + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-fg); + padding: 6px 10px; + border-radius: 999px; + cursor: pointer; +} + +.int-chip.-active { + background: var(--color-accent); + color: white; + border-color: var(--color-accent); +} + +.int-grid { + list-style: none; + padding-left: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-3); +} + +.int-card { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-2); + padding: var(--space-3); + box-shadow: var(--shadow-1); +} + +.int-card img { + max-width: 100%; + height: 48px; + object-fit: contain; + display: block; + margin-bottom: var(--space-2); +} + +.int-card h3 { + font-size: var(--font-size-3); + margin: 0; +} + +/* Video carousel */ +.video-carousel { + position: relative; + padding: var(--space-4) 0; + overflow: visible; +} + +.video-carousel__container { + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-4); + overflow: visible; +} + +.video-carousel .vc-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: var(--color-bg); + color: var(--color-fg); + border: 1px solid var(--color-border); + width: 42px; + height: 42px; + border-radius: 999px; + box-shadow: var(--shadow-1); + cursor: pointer; +} + +.video-carousel .vc-nav.-prev { + left: 6px; +} + +.video-carousel .vc-nav.-next { + right: 6px; +} + +.vc-track { + list-style: none; + padding: 0 0 var(--space-3) 0; + margin: 0; + display: flex; + align-items: stretch; + gap: 0; + transition: transform .5s ease; + will-change: transform; + overflow-x: auto; + overflow-y: visible; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.vc-track::-webkit-scrollbar { + display: none; +} + +/* When JS is active, disable native scrolling and snap; use programmatic centering */ +.js-vc .vc-track { overflow: visible; scroll-snap-type: none; } + +.vc-card { + flex: 0 0 var(--vc-card-width, 88%); + margin-left: calc(var(--vc-card-overlap, 20%) * -1); + position: relative; + z-index: 1; + transition: transform .4s ease, box-shadow .3s ease, filter .3s ease; + scroll-snap-align: center; +} + +.vc-card:first-child { + margin-left: 0; +} + +.vc-card .vc-inner { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 18px; + box-shadow: var(--shadow-1); + overflow: hidden; + transform-origin: center; + will-change: transform, filter; + transition: transform .4s ease; +} + +.vc-card .vc-title { + font-size: var(--font-size-3); + margin: var(--space-3) var(--space-4) 0; +} + +.vc-card figure { + margin: 0; +} + +.vc-card iframe { + width: 100%; + aspect-ratio: 16/9; + border: 0; + display: block; +} + +.vc-card.is-active { + transform: translateY(-4px); + z-index: 2; +} + +.vc-card.is-active .vc-inner { + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28), 0 8px 16px rgba(0, 0, 0, 0.18); + filter: none; + transform: perspective(1200px) rotateY(0deg) scale(1.0); +} + +.vc-card:not(.is-active) .vc-inner { + transform: perspective(1200px) rotateY(-15deg) scale(0.8); + filter: saturate(0.5) blur(1px); + opacity: 0.7; +} + +.vc-card:not(.is-active):nth-child(odd) .vc-inner { + transform: perspective(1200px) rotateY(15deg) scale(0.8); +} + +.vc-player { + position: relative; +} + +.vc-thumb { + width: 100%; + aspect-ratio: 16/9; + object-fit: cover; + display: block; +} + +.vc-play { + position: absolute; + inset: 0; + margin: auto; + width: 64px; + height: 64px; + border-radius: 999px; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 20px; + cursor: pointer; + display: grid; + place-items: center; + transition: transform .2s ease, box-shadow .2s ease, background .2s ease; +} + +.vc-play:hover, .vc-play:focus-visible { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + background: rgba(0, 0, 0, 0.7); +} + +.vc-dots { + display: flex; + gap: 8px; + justify-content: center; + margin-top: var(--space-3); +} + +.vc-dots button { + width: 8px; + height: 8px; + border-radius: 50%; + border: 0; + background: var(--color-border); + cursor: pointer; + display: block; + aspect-ratio: 1/1; +} + +.vc-dots button[aria-selected="true"] { + background: var(--color-accent); +} + +/* Responsive tuning for overlap/width */ +@media (min-width: 900px) { + .video-carousel__container { + max-width: 1200px; + } + + .video-carousel { + --vc-card-width: 72%; + --vc-card-overlap: 24%; + } +} + +@media (max-width: 600px) { + .video-carousel { --vc-card-width: 100%; --vc-card-overlap: 0%; } + .video-carousel .vc-nav { top: auto; bottom: -6px; transform: none; } +} + +/* Fullscreen styles */ +.vc-inner:fullscreen, .vc-player:fullscreen { width: 100vw; height: 100vh; border-radius: 0; margin: 0; } +.vc-inner:-webkit-full-screen, .vc-player:-webkit-full-screen { width: 100vw; height: 100vh; border-radius: 0; margin: 0; } +.vc-inner:fullscreen iframe, .vc-player:fullscreen iframe { width: 100%; height: 100%; } +.vc-inner:-webkit-full-screen iframe, .vc-player:-webkit-full-screen iframe { width: 100%; height: 100%; } + +@media (prefers-reduced-motion: reduce) { + .vc-track { + transition: none; + } + + .vc-card { + transition: none; + } +} diff --git a/src/css/page-styles.css b/src/css/page-styles.css index 43d4ec7f..c1fe5856 100644 --- a/src/css/page-styles.css +++ b/src/css/page-styles.css @@ -61,6 +61,35 @@ contents a { min-width: 300px; border-color: var(--lighter-grey); } + +/* Contributors wave-in animation (progressive enhancement) */ +.individual-grid.-wave-ready .ind-icon { + opacity: 0; + transform: translateY(10px) scale(0.96); +} +.individual-grid.is-inview .ind-icon { + /* Subtle pop + rise */ + animation: popWave 420ms cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc(var(--i, 0) * 60ms); +} +@keyframes popWave { + from { + opacity: 0; + transform: translateY(10px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@media (prefers-reduced-motion: reduce) { + .individual-grid.-wave-ready .ind-icon, + .individual-grid.is-inview .ind-icon { + opacity: 1; + transform: none; + animation: none !important; + } +} .ind-icon .in-img { background-color: var(--lime-green); height: 80px; diff --git a/src/css/styles.css b/src/css/styles.css index aa53135c..926ccd0a 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -486,7 +486,7 @@ a { display: none; } #contributors, -#caseStudies, +.case-studies-container, .video-holder-watch-video { text-align: center; background: linear-gradient( @@ -503,21 +503,45 @@ a { .video-holder-watch-video + .video-holder-watch-video { margin-top: 0 !important; } -#caseStudies h2 { +.case-studies-container{ + padding-left:0; +} +.case-studies-wrapper{ + width: 100vw; + padding-left: 5vw; + /* Horizontal-only scroll */ + overflow-x: auto; + overflow-y: hidden; + /* Smooth & snap */ + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scroll-padding-left: 5vw; + /* iOS momentum */ + -webkit-overflow-scrolling: touch; + /* Prevent chain scrolling into the page */ + overscroll-behavior-x: contain; + /* Edge fade to hint more content */ + --fade: 40px; + -webkit-mask-image: linear-gradient(90deg, transparent 0, var(--light-grey-greenish) 10%, var(--light-grey-greenish) 90%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0, var(--light-grey-greenish) 10%, var(--light-grey-greenish) 90%, transparent 100%); +} +.case-studies h2 { margin: 0 0 3rem; } -#caseStudies ul { +.case-studies ul { list-style: none; display: flex; - flex-wrap: wrap; - max-width: 990px; + flex-direction: row; gap: 2rem; - justify-content: center; - margin: 0 auto 3rem; + margin: 0 0 3rem; padding: 0; + width: max-content; /* allow natural overflow */ } -#caseStudies ul li { - flex: 0 1 410px; +.case-studies ul li { + flex: 0 0 auto; + width: 380px; /* stable card width */ + max-width: 80vw; /* mobile safety */ + min-width: 320px; /* optional lower bound */ display: flex; flex-direction: column; gap: .5rem; @@ -525,20 +549,23 @@ a { border: 1px solid var(--shadow-20); overflow: hidden; background: var(--light-grey); + /* snap each card neatly to the left edge */ + scroll-snap-align: start; + scroll-snap-stop: always; } -#caseStudies ul li img { +.case-studies ul li img { width: 100%; height: 210px; object-fit: cover; object-position: top; } -#caseStudies ul li h3 { +.case-studies ul li h3 { margin: 0; } -#caseStudies ul li p { +.case-studies ul li p { margin: 0 1.5rem auto; } -#caseStudies ul li .buttons { +.case-studies ul li .buttons { display: flex; align-items: center; flex-wrap: wrap; @@ -546,7 +573,7 @@ a { justify-content: space-between; margin: 1rem 1.5rem 1.5rem; } -#caseStudies ul li .buttons a { +.case-studies ul li .buttons a { margin: 0; flex: 1 0 9rem; text-align: center; @@ -558,10 +585,25 @@ a { padding: .75rem 1rem; transition: 0.2s ease; } -#caseStudies ul li .buttons a:hover { +.case-studies ul li .buttons a:hover { color: var(--sunkist-lime); border-color: var(--sunkist-lime); } + +/* Horizontal scrollbar polish */ +.case-studies-wrapper::-webkit-scrollbar { height: 10px; } +.case-studies-wrapper::-webkit-scrollbar-track { background: transparent; } +.case-studies-wrapper::-webkit-scrollbar-thumb { background: var(--shadow-20); border-radius: 6px; } + +/* Optional: controls container (only applies if buttons are added in HTML) */ +.carousel-controls { display: flex; gap: .5rem; justify-content: flex-end; padding: 0 5vw; margin-top: -1rem; } +.carousel-controls button { appearance: none; border: 1px solid var(--shadow-20); background: var(--white); border-radius: 999px; padding: .5rem .9rem; cursor: pointer; transition: box-shadow .2s ease; } +.carousel-controls button:hover { box-shadow: 0 6px 18px var(--shadow-10); } +.carousel-controls button:disabled { opacity: .5; cursor: default; box-shadow: none; } + +@media (prefers-reduced-motion: reduce) { + .case-studies-wrapper { scroll-behavior: auto; } +} .check-list { margin-left: 2rem; text-align: left; @@ -601,25 +643,25 @@ a { transform: scaleX(-1); } .hero-artwork { - max-height: 600px; - height: 60vh; + max-height: 640px; + height: 58vh; min-height: calc(280px*1.856 + 20px); position: relative; - max-width: 860px; + max-width: 880px; display: grid; - grid-template-columns: 280px 1fr 3fr; + grid-template-columns: repeat(5, 1fr); grid-template-rows: auto; align-items: center; justify-items: center; - width: 50vw; + width: 100%; } .i-a { position: absolute; - width: 90%; - max-width: 550px; + width: 100%; + max-width: 700px; border-radius: 3px; box-shadow: 0 10px 30px var(--persistent-shadow); - grid-column: 2/4; + grid-column: 2/6; grid-row: 1/2; z-index: 1; } @@ -629,11 +671,17 @@ a { box-shadow: 0 10px 30px var(--persistent-shadow); position: absolute; max-width: 280px; - grid-column: 1/3; + grid-column: 1/5; grid-row: 1/2; z-index: 2; height: auto; max-height: calc(280px * 1.856); + animation: heroFloat 6s ease-in-out infinite; +} +@keyframes heroFloat { + 0% { transform: translateY(0) } + 50% { transform: translateY(-6px) } + 100% { transform: translateY(0) } } .no-js-toggle-parent { display: grid; @@ -997,10 +1045,14 @@ img.n_imagery { } .prpmpLinkOrg { display: inline-flex; - flex-wrap: wrap; + flex-wrap: nowrap; flex-direction: row; - margin: auto; + align-items: center; + gap: 12px 15px; + margin: 1.25rem 0 0 0; } +.prpmpLinkOrg .button { margin: 0; +text-align: left;padding: .75rem 1rem;} #lnNom { position: relative; } @@ -1360,6 +1412,8 @@ a.nonJavascriptMessage { display: grid; display: var(--grid, grid); margin: auto; + overflow: hidden; + max-width: 100vw; } .pageContainer > div { margin-top: 50px; @@ -1369,6 +1423,15 @@ a.nonJavascriptMessage { display: grid; display: var(--grid, grid); background: linear-gradient(var(--light-grey), var(--white)); + position: relative; +} +.pageContainerTopBackground::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(1200px 600px at 80% 40%, color-mix(in oklab, var(--secondary-green) 14%, transparent), transparent 70%); + opacity: 0.4; } .flu_bgH { display: block; @@ -1385,22 +1448,23 @@ g#triangularis { display: none; } .promoArea { - display: flex; - flex-flow: row; - width: 90vw; - max-width: 1300px; - margin: 0 auto 0 auto; - transition: 0.1s ease; + display: grid; + grid-template-columns: minmax(300px, 1.05fr) minmax(340px, 0.95fr); align-items: center; + gap: clamp(24px, 5vw, 80px); + width: min(1200px, 92vw); + margin: 0 auto; + transition: 0.1s ease; + padding: clamp(20px, 5vw, 60px) 0 0; } .promoDetails { background: 0 0; border-radius: 20px; - padding: 60px 0 0 0; - margin: auto; + padding: 0; + margin: 0; text-align: left; - max-width: 600px; - width: calc(50% - 120px); + max-width: 640px; + width: auto; } .pageItem a.modernLink, .promoButton, @@ -1427,20 +1491,19 @@ g#triangularis { margin-left: 10px; } .promoDetails p { - font-size: 16.5px; + font-size: clamp(16px, 1.9vw, 18px); + line-height: 1.6; font-family: "Open Sans", Verdana; font-weight: 300; - margin-top: 0; - grid-row: 2/3; - grid-column: 1/3; + margin: 0.75rem 0 0; + max-width: 60ch; } .promoDetails h1 { color: var(--primary-green); - margin-top: 0; - font-size: 2.2em; - grid-row: 1/2; - grid-column: 1/3; - font-weight: 600; + margin: 0 0 0.5rem 0; + font-size: clamp(2rem, 4.2vw, 3rem); + font-weight: 700; + letter-spacing: -0.01em; } .promoDetails .lnHo { margin-top: 20px; @@ -1535,6 +1598,15 @@ svg { width: 90vw; margin: auto; } +#watchVideo{ + background: linear-gradient(var(--light-grey-greenish),var(--light-grey-greenish)); + margin: -50px 0 !important; + width: 100%; + padding: 50px 20px; +} +#watchVideo h2{ + text-align: center; +} .page-item-free { display: grid; align-items: center; @@ -2063,7 +2135,7 @@ figure { z-index: 1; margin: 0; } -@media (max-width: 900px) { +/* @media (max-width: 900px) { .i-a { display: none; } @@ -2071,6 +2143,10 @@ figure { grid-column: 1/6; max-width: 250px; } + .promoArea { + grid-template-columns: 1fr; + gap: 24px; + } .features-section > ul { width: 100vw; } @@ -2078,8 +2154,11 @@ figure { border: 18px solid var(--white); box-shadow: 0 12px 18px var(--persistent-shadow-light); } -} +} */ @media (max-width: 700px) { +.prpmpLinkOrg { + gap: 10px; +} .integrations-grid { display: flex; flex-direction: row; @@ -2118,7 +2197,9 @@ figure { max-width: 50vw; } .promoArea { - flex-flow: column; + grid-template-columns: 1fr; + gap: 20px; + justify-items: center; } .promoDetails, .promoDetails.__fal-left { diff --git a/src/html/donate/tmpl.html b/src/html/donate/tmpl.html index 834a978d..706b64aa 100644 --- a/src/html/donate/tmpl.html +++ b/src/html/donate/tmpl.html @@ -1,5 +1,5 @@ - +
@@ -9,16 +9,24 @@ + + + + + + {{_hreflangLinks}}{{btcpay-server-tag-line}}
+{{btcpay-server-tag-line}}
-
+
-
- Namecheap surpasses $73M in BTC revenue with 1.1m transactions through BTCPay
- -
-
- €115,100 from 8,750 Transactions in 3 Days, Showcasing Bitcoin's Role as a Payment Method.
- -
-
- Bitcoin People built a mobile app on top of BTCPay's API to scale bitcoin to 270 merchants.
- -
-
- Bitcoin Jungle enables 200+ stores in Costa Rica to embrace Bitcoin.
- -
+
+
+
+ {{need-help-using}}
- {{read-docs}} + {{read-docs}}{{having-a-technical}}
@@ -379,7 +372,8 @@
+
+ `;
+ }
+
+ function makeIframe(card, autoplay, requestFs) {
+ const player = card.querySelector('.vc-player');
+ if (!player || !player.dataset.yt) return;
+ const id = player.dataset.yt;
+ const auto = autoplay ? '1' : '0';
+ player.innerHTML = ``;
+ if (requestFs && isSmallScreen()) {
+ const inner = card.querySelector('.vc-inner') || player;
+ reqFullscreen(inner);
+ }
+ }
+
+ function stopNonActive() {
+ cards.forEach((c, idx) => { if (idx !== i) makeThumb(c); });
+ }
+
+ function updateDots() {
+ if (!dotsWrap) return;
+ dotsWrap.innerHTML = '';
+ cards.forEach((c, idx) => {
+ const dot = document.createElement('button');
+ dot.type = 'button';
+ const titleEl = c.querySelector('.vc-title');
+ const label = (titleEl && titleEl.textContent || '').trim() || `Slide ${idx+1}`;
+ dot.setAttribute('aria-label', label);
+ dot.setAttribute('role', 'tab');
+ dot.setAttribute('aria-selected', idx === i ? 'true' : 'false');
+ dot.addEventListener('click', ()=>{ i = idx; update(); });
+ dotsWrap.appendChild(dot);
+ })
+ }
+
+ function update() {
+ const px = centerActivePx();
+ cards.forEach((c, idx) => c.classList.toggle('is-active', idx === i));
+ track.style.transform = `translateX(-${px}px)`;
+ updateDots();
+ stopNonActive();
+ // announce to SR
+ if (ariaLive) {
+ const titleEl = cards[i].querySelector('.vc-title');
+ const label = (titleEl && titleEl.textContent || '').trim() || `Slide ${i+1}`;
+ ariaLive.textContent = `${i+1} of ${cards.length}: ${label}`;
+ }
+ // autoplay if requested and current not yet iframe
+ if (pendingAutoplay) {
+ const player = cards[i].querySelector('.vc-player');
+ if (player && !player.querySelector('iframe')) makeIframe(cards[i], true, true);
+ pendingAutoplay = false;
+ }
+ }
+
+ function go(dir, opts) {
+ i = (i + dir + cards.length) % cards.length;
+ pendingAutoplay = !!(opts && opts.autoplay);
+ update();
+ }
+
+ if (prev) prev.addEventListener('click', () => go(-1, {autoplay:false}));
+ if (next) next.addEventListener('click', () => go(1, {autoplay:false}));
+ root.addEventListener('keydown', (e)=>{
+ if (e.key === 'ArrowLeft') { go(-1, {autoplay:false}); }
+ if (e.key === 'ArrowRight') { go(1, {autoplay:false}); }
+ });
+
+ root.addEventListener('click', (e)=>{
+ const btn = e.target.closest('.vc-play');
+ if (!btn) return;
+ const card = e.target.closest('.vc-card');
+ const idx = cards.indexOf(card);
+ if (idx === i) makeIframe(card, true, true);
+ });
+
+ // touch swipe
+ let tsX = 0, tsY = 0, isTouching = false;
+ root.addEventListener('touchstart', (e)=>{
+ const t = e.changedTouches[0];
+ tsX = t.clientX; tsY = t.clientY; isTouching = true;
+ }, {passive: true});
+
+ // wheel/trackpad horizontal paging
+ let wheelLock = false;
+ root.addEventListener('wheel', (e)=>{
+ // only handle if horizontal intent (or strong vertical like 2-finger scroll)
+ const ax = Math.abs(e.deltaX);
+ const ay = Math.abs(e.deltaY);
+ if (wheelLock) return;
+ if (ax > 10 || ay > 30) {
+ const dir = (e.deltaX > 0 || e.deltaY > 0) ? 1 : -1;
+ wheelLock = true;
+ e.preventDefault();
+ go(dir, {autoplay:false});
+ setTimeout(()=> wheelLock = false, 400);
+ }
+ }, {passive: false});
+ root.addEventListener('touchend', (e)=>{
+ if (!isTouching) return;
+ const t = e.changedTouches[0];
+ const dx = t.clientX - tsX; const dy = t.clientY - tsY;
+ isTouching = false;
+ if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) {
+ if (dx < 0) go(1, {autoplay:true}); else go(-1, {autoplay:true});
+ }
+ }, {passive: true});
+
+ cards.forEach(makeThumb);
+ update();
+ window.addEventListener('resize', ()=> update());
+ window.addEventListener('orientationchange', ()=>{
+ const active = cards[i];
+ if (!active) return;
+ const hasIframe = !!active.querySelector('.vc-player iframe');
+ if (window.innerWidth <= 700) {
+ if (window.innerWidth > window.innerHeight && hasIframe && !document.fullscreenElement) {
+ const inner = active.querySelector('.vc-inner') || active.querySelector('.vc-player');
+ if (inner && inner.requestFullscreen) try { inner.requestFullscreen(); } catch(_) {}
+ } else if (window.innerWidth <= window.innerHeight && document.fullscreenElement) {
+ if (document.exitFullscreen) try { document.exitFullscreen(); } catch(_) {}
+ }
+ }
+ });
+})();
+
+// Language modal + search
+(function(){
+ const modal = document.getElementById('langModal');
+ if (!modal) return;
+ const openers = document.querySelectorAll('.open-lang-modal');
+ const closer = modal.querySelector('[data-lang-modal-close]');
+ const search = document.getElementById('langSearch');
+ const list = document.getElementById('langAllList');
+ const links = list ? Array.from(list.querySelectorAll('li')) : [];
+ function open() { modal.removeAttribute('hidden'); if (search) setTimeout(()=>search.focus(), 10); }
+ function close() { modal.setAttribute('hidden', ''); }
+ openers.forEach(el => el.addEventListener('click', (e)=>{ e.preventDefault(); open(); }));
+ if (closer) closer.addEventListener('click', (e)=>{ e.preventDefault(); close(); });
+ modal.addEventListener('click', (e)=>{ if (e.target === modal) close(); });
+ document.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && !modal.hasAttribute('hidden')) close(); });
+ if (search && links.length) {
+ search.addEventListener('input', ()=>{
+ const q = search.value.trim().toLowerCase();
+ links.forEach(li => {
+ const text = (li.textContent || '').toLowerCase();
+ li.style.display = !q || text.indexOf(q) >= 0 ? '' : 'none';
+ })
+ })
+ }
+})();
+
+// Language dropdown (compact lists)
+(function(){
+ const triggers = Array.from(document.querySelectorAll('#lnNom'));
+ if (!triggers.length) return;
+ triggers.forEach(trigger => {
+ const parent = trigger.closest('.lnNomPar');
+ if (!parent) return;
+ trigger.addEventListener('click', function(e){
+ parent.classList.toggle('open');
+ });
+ document.addEventListener('click', function(e){
+ if (!parent.contains(e.target)) parent.classList.remove('open');
+ });
+ });
+})();
+
+// Integrations grid subtle parallax
+(function(){
+ const root = document.querySelector('.integrations-grid');
+ if (!root) return;
+ const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (prefersReduced) return;
+
+ const items = Array.from(root.querySelectorAll('.svg-link'));
+ if (!items.length) return;
+
+ // Stable depth pattern; increase overall depth and avoid zeros for more motion
+ const pattern = [0.85, 0.55, 0.2, -0.45, -0.8, 0.35, -0.25, -0.6, 0.5, -0.3, 0.7, 0.15, -0.5, 0.4];
+ items.forEach((el, idx) => {
+ const d = parseFloat(el.getAttribute('data-depth'));
+ const depth = Number.isFinite(d) ? Math.max(-1, Math.min(1, d)) : pattern[idx % pattern.length];
+ // @ts-ignore - stash for quick access
+ el._depth = depth;
+ // small perf hint
+ el.style.willChange = 'transform';
+ });
+
+ const MAX_SHIFT_DESKTOP = 36; // px
+ const MAX_SHIFT_MOBILE = 22; // px
+
+ let ticking = false;
+ function onScroll() {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(update);
+ }
+ }
+
+ function update() {
+ ticking = false;
+ const rect = root.getBoundingClientRect();
+ const vh = window.innerHeight || document.documentElement.clientHeight || 800;
+ const maxShift = (vh <= 700) ? MAX_SHIFT_MOBILE : MAX_SHIFT_DESKTOP;
+
+ // Visibility progress of the grid through the viewport: 0..1
+ const total = rect.height + vh || 1;
+ const seen = Math.min(total, Math.max(0, vh - rect.top));
+ const phase = seen / total; // 0 at first touch, 1 when fully passed
+ const base = (phase - 0.5) * 2 * maxShift; // -max..max around the midpoint
+
+ items.forEach((el) => {
+ // @ts-ignore
+ const depth = el._depth || 0;
+ const shift = Math.round((base * depth) * 100) / 100;
+ el.style.transform = `translateY(${shift}px)`;
+ });
+ }
+
+ // Only run reactions while in view to save work
+ if ('IntersectionObserver' in window) {
+ const io = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ window.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onScroll);
+ onScroll();
+ } else {
+ window.removeEventListener('scroll', onScroll);
+ window.removeEventListener('resize', onScroll);
+ }
+ });
+ }, { root: null, threshold: 0 });
+ io.observe(root);
+ } else {
+ window.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onScroll);
+ onScroll();
+ }
+})();
+
+// Contributors: staggered pop-in (Mexican wave) when in view
+(function(){
+ const grid = document.getElementById('donGr');
+ if (!grid) return;
+ const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (prefersReduced) return;
+
+ const items = Array.from(grid.querySelectorAll('.ind-icon'));
+ if (!items.length) return;
+
+ // Prepare initial state only when JS is active
+ grid.classList.add('-wave-ready');
+ items.forEach((el, i) => el.style.setProperty('--i', String(i)));
+
+ function activate() {
+ grid.classList.add('is-inview');
+ }
+
+ if ('IntersectionObserver' in window) {
+ const section = document.getElementById('contributors') || grid;
+ const io = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ activate();
+ io.disconnect(); // fire once
+ }
+ });
+ }, { root: null, threshold: 0.15 });
+ io.observe(section);
+ } else {
+ // Fallback without IO: trigger shortly after load
+ setTimeout(activate, 200);
+ }
+})();
diff --git a/tasks/copy_static.js b/tasks/copy_static.js
new file mode 100644
index 00000000..235f0f71
--- /dev/null
+++ b/tasks/copy_static.js
@@ -0,0 +1,28 @@
+// Cross-platform copy: copies contents of src/static into dist
+const { readdirSync, statSync, mkdirSync, copyFileSync } = require('fs')
+const { resolve, join, relative, dirname } = require('path')
+
+const SRC = resolve(__dirname, '../src/static')
+const DEST = resolve(__dirname, '../dist')
+
+function walk(dir) {
+ return readdirSync(dir).flatMap(name => {
+ const p = join(dir, name)
+ const s = statSync(p)
+ return s.isDirectory() ? walk(p) : [p]
+ })
+}
+
+function main() {
+ const files = walk(SRC)
+ files.forEach(file => {
+ const rel = relative(SRC, file)
+ const out = join(DEST, rel)
+ mkdirSync(dirname(out), { recursive: true })
+ copyFileSync(file, out)
+ })
+ console.log(`✅ Copied ${files.length} static file(s) to dist/`)
+}
+
+try { main() } catch (e) { console.error('🚨 copy_static failed:', e.message); process.exit(1) }
+
diff --git a/tasks/fetch_case_studies.js b/tasks/fetch_case_studies.js
new file mode 100644
index 00000000..facbba40
--- /dev/null
+++ b/tasks/fetch_case_studies.js
@@ -0,0 +1,76 @@
+// Build-time fetcher for blog case studies
+// Writes data/caseStudies.json for tasks/render_html.js to consume
+const { writeFileSync, mkdirSync } = require('fs')
+const { resolve, dirname } = require('path')
+const request = require('sync-request')
+
+function tryFetch(url) {
+ try {
+ const res = request('GET', url, { headers: { 'User-Agent': 'btcpayserver.org-build' } })
+ if (res.statusCode >= 200 && res.statusCode < 300) return res.getBody('utf8')
+ } catch {}
+ return null
+}
+
+function saveJSON(file, data) {
+ const filePath = resolve(__dirname, `../data/${file}`)
+ mkdirSync(dirname(filePath), { recursive: true })
+ writeFileSync(filePath, JSON.stringify(data, null, 2))
+}
+
+function run() {
+ // Prefer JSON Feed if available
+ const jsonFeed = tryFetch('https://blog.btcpayserver.org/feed.json')
+ if (jsonFeed) {
+ try {
+ const feed = JSON.parse(jsonFeed)
+ let items = (feed.items || []).filter(it => (it.tags || []).some(t => /case[- ]?stud/i.test(t))).map(it => ({
+ id: it.id || it.url,
+ title: it.title,
+ excerpt: (it.summary || '').replace(/\s+/g, ' ').trim(),
+ hero: (it.image || ''),
+ url: it.url,
+ pdf: null
+ }))
+ // take top 5
+ if (items.length >= 5) {
+ return saveJSON('caseStudies.json', items.slice(0,5))
+ }
+ // pad to at least 5 with known fallbacks
+ const fallback = [
+ { id: 'namecheap', title: 'Namecheap', excerpt: 'Namecheap surpasses $73M in BTC revenue with 1.1m transactions through BTCPay', hero: '/img/case-studies/namecheap-featured.png', url: 'https://blog.btcpayserver.org/case-study-namecheap/', pdf: '/case-studies/namecheap.pdf' },
+ { id: 'bitcoin-atlantis', title: 'Bitcoin Atlantis', excerpt: '€115,100 from 8,750 transactions in 3 days.', hero: '/img/case-studies/bitcoin-atlantis-featured.png', url: 'https://blog.btcpayserver.org/case-study-bitcoin-atlantis/', pdf: '/case-studies/BitcoinAtlantis.pdf' },
+ { id: 'bitcoin-people', title: 'Bitcoin People', excerpt: "Built a mobile app atop BTCPay's API to scale to 270 merchants.", hero: '/img/case-studies/bitcoin-people.jpg', url: 'https://blog.btcpayserver.org/case-study-bitcoin-people/', pdf: '/case-studies/BitcoinPeople2024.pdf' },
+ { id: 'bitcoin-jungle', title: 'Bitcoin Jungle', excerpt: 'Enables 200+ stores in Costa Rica to embrace Bitcoin.', hero: '/img/case-studies/bitcoin-jungle.jpg', url: 'https://blog.btcpayserver.org/case-study-bitcoin-jungle-cr/', pdf: '/case-studies/BitcoinJungleCR2023.pdf' },
+ { id: 'hodlhodl', title: 'HodlHodl', excerpt: 'A Bitcoin business case using BTCPay Server.', hero: '/img/case-studies/hodlhodl.jpg', url: 'https://blog.btcpayserver.org/category/case-studies/', pdf: null }
+ ]
+ const seen = new Set(items.map(it => it.id))
+ for (const f of fallback) { if (items.length >= 5) break; if (!seen.has(f.id)) items.push(f) }
+ return saveJSON('caseStudies.json', items.slice(0,5))
+ } catch {}
+ }
+
+ // Fallback: default to existing static examples (no network parsing of RSS here)
+ const fallback = [
+ {
+ id: 'namecheap',
+ title: 'Namecheap',
+ excerpt: 'Namecheap surpasses $73M in BTC revenue with 1.1m transactions through BTCPay',
+ hero: '/img/case-studies/namecheap-featured.png',
+ url: 'https://blog.btcpayserver.org/case-study-namecheap/',
+ pdf: '/case-studies/namecheap.pdf'
+ },
+ { id: 'bitcoin-atlantis', title: 'Bitcoin Atlantis', excerpt: '€115,100 from 8,750 transactions in 3 days.', hero: '/img/case-studies/bitcoin-atlantis-featured.png', url: 'https://blog.btcpayserver.org/case-study-bitcoin-atlantis/', pdf: '/case-studies/BitcoinAtlantis.pdf' },
+ { id: 'bitcoin-people', title: 'Bitcoin People', excerpt: "Built a mobile app atop BTCPay's API to scale to 270 merchants.", hero: '/img/case-studies/bitcoin-people.jpg', url: 'https://blog.btcpayserver.org/case-study-bitcoin-people/', pdf: '/case-studies/BitcoinPeople2024.pdf' },
+ { id: 'bitcoin-jungle', title: 'Bitcoin Jungle', excerpt: 'Enables 200+ stores in Costa Rica to embrace Bitcoin.', hero: '/img/case-studies/bitcoin-jungle.jpg', url: 'https://blog.btcpayserver.org/case-study-bitcoin-jungle-cr/', pdf: '/case-studies/BitcoinJungleCR2023.pdf' },
+ { id: 'hodlhodl', title: 'HodlHodl', excerpt: 'A Bitcoin business case using BTCPay Server.', hero: '/img/case-studies/hodlhodl.jpg', url: 'https://blog.btcpayserver.org/category/case-studies/', pdf: null }
+ ]
+ saveJSON('caseStudies.json', fallback.slice(0,5))
+}
+
+try {
+ run()
+ console.log('✅ Case studies data prepared')
+} catch (e) {
+ console.error('🚨 Could not generate case studies:', e.message)
+}
diff --git a/tasks/optimize_images.js b/tasks/optimize_images.js
new file mode 100644
index 00000000..f93309d3
--- /dev/null
+++ b/tasks/optimize_images.js
@@ -0,0 +1,35 @@
+// Optional image optimizer: generates WebP/AVIF alongside originals
+// Safe no-op if sharp is not installed.
+const { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } = require('fs')
+const { join, dirname, extname, basename } = require('path')
+
+let sharp
+try { sharp = require('sharp') } catch { console.log('ℹ️ Image optimizer: sharp not installed; skipping.') ; process.exit(0) }
+
+const SRC = join(__dirname, '../src/static/img')
+const OUT = join(__dirname, '../dist/img')
+
+function walk(dir) {
+ return readdirSync(dir).flatMap(name => {
+ const p = join(dir, name)
+ const s = statSync(p)
+ if (s.isDirectory()) return walk(p)
+ return [p]
+ })
+}
+
+async function convert(file) {
+ const buf = readFileSync(file)
+ const rel = file.replace(SRC, '')
+ const base = join(OUT, dirname(rel), basename(rel, extname(rel)))
+ mkdirSync(dirname(base), { recursive: true })
+ await sharp(buf).webp({ quality: 82 }).toFile(base + '.webp')
+ await sharp(buf).avif({ quality: 55 }).toFile(base + '.avif')
+}
+
+(async function(){
+ const files = walk(SRC).filter(f => /\.(png|jpg|jpeg)$/i.test(f))
+ await Promise.all(files.map(convert))
+ console.log(`✅ Image optimization complete: ${files.length} files processed.`)
+})().catch(e => { console.error('🚨 Image optimize failed:', e.message); process.exit(1) })
+
diff --git a/tasks/render_html.js b/tasks/render_html.js
index 86c3ff05..d4204e0e 100644
--- a/tasks/render_html.js
+++ b/tasks/render_html.js
@@ -15,6 +15,7 @@ const indexTmpl = getTemplate('html/tmpl.html')
const donateTmpl = getTemplate('html/donate/tmpl.html')
const menuTmpl = getTemplate('html/menu-tmpl.html')
const footerTmpl = getTemplate('html/footer-tmpl.html')
+const integrationsTmpl = getTemplate('html/integrations/tmpl.html')
const contributors = getContributorJSON()
const _contributorsBlock = contributors.map((item) => `
@@ -22,7 +23,174 @@ const _contributorsBlock = contributors.map((item) => `
${item.login}
`).join('')
-function getLanguageOptions (langs, lang, pagePath = '') {
+const { readFileSync, existsSync } = require('fs')
+
+function buildHreflangLinks(langs, pagePath = '') {
+ const base = 'https://btcpayserver.org'
+ const links = langs.map(code => {
+ const [main, rgn] = code.split('_')
+ const isEn = main === 'en'
+ const prefix = isEn ? '' : `/${code}`
+ const href = `${base}${prefix}/${pagePath}`
+ const hreflang = rgn ? `${main}-${rgn.toLowerCase()}` : main
+ return ``
+ }).join('\n\t')
+ const xdefault = ``
+ return `${links}\n\t${xdefault}`
+}
+
+function loadJSON(pathRel, fallback = null) {
+ try {
+ const file = require('path').resolve(__dirname, pathRel)
+ if (!existsSync(file)) return fallback
+ return JSON.parse(readFileSync(file, 'utf8'))
+ } catch { return fallback }
+}
+
+function buildIntegrations(json) {
+ if (!Array.isArray(json) || !json.length) return { grid: '', filters: '' }
+ const items = json.map(it => {
+ const tags = (it.tags || []).join(',')
+ const type = it.type || ''
+ const logo = it.logo || ''
+ return `
+ ${excerpt}
+ +
+
+ Namecheap surpasses $73M in BTC revenue with 1.1m transactions through BTCPay
+ +
+
+ €115,100 from 8,750 Transactions in 3 Days, Showcasing Bitcoin's Role as a Payment Method.
+ +
+
+ Bitcoin People built a mobile app on top of BTCPay's API to scale bitcoin to 270 merchants.
+ +
+
+ Bitcoin Jungle enables 200+ stores in Costa Rica to embrace Bitcoin.
+ ++ ${labels.viewAll || 'View all case studies'} + +
` +} + +function getLanguageOptions(langs, lang, pagePath = '') { return langs.reduce((res, code) => { if (code === lang) return res const [main, rgn] = code.split('_') @@ -35,13 +203,27 @@ function getLanguageOptions (langs, lang, pagePath = '') { }, []).join('') } +function getLanguageOptionsSubset(langs, lang, subset = [], pagePath = '') { + const set = subset.length ? langs.filter(c => subset.includes(c)) : langs + return set.reduce((res, code) => { + if (code === lang) return res + const [main, rgn] = code.split('_') + const isEn = main === 'en' + const name = getLanguageName(code) || code + const region = rgn && !isEn ? ` (${rgn.toLowerCase()})` : '' + const prefix = isEn ? '' : `/${code}` + const url = `${prefix}/${pagePath}` + return res.concat(`