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}} {{donate}} | BTCPay Server + +
@@ -97,5 +105,19 @@

{{individual-donations}}

} } + diff --git a/src/html/integrations/tmpl.html b/src/html/integrations/tmpl.html new file mode 100644 index 00000000..9534a6cc --- /dev/null +++ b/src/html/integrations/tmpl.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + {{_hreflangLinks}} + + + + {{integrations-and-plugins}} | BTCPay Server + + + + + + + +
+
+
{{_menTemp}}
+
+
+
+

{{integrations-and-plugins}}

+
+ +
{{_integrationsFilters}}
+
+
    {{_integrationsGrid}}
+
+ {{_ftblk}} +
+
+ + + + diff --git a/src/html/menu-tmpl.html b/src/html/menu-tmpl.html index 75c1af5e..5b362a18 100644 --- a/src/html/menu-tmpl.html +++ b/src/html/menu-tmpl.html @@ -77,19 +77,16 @@
- {{live-demo}}    + {{getting-started}}    + {{live-demo}}    + +
diff --git a/src/html/tmpl.html b/src/html/tmpl.html index e02aaa0c..339e8121 100644 --- a/src/html/tmpl.html +++ b/src/html/tmpl.html @@ -10,11 +10,18 @@ + + + + + + {{_hreflangLinks}} BTCPay Server + @@ -52,10 +59,25 @@ "priceCurrency": "BTC" } } - + + +
@@ -65,23 +87,22 @@

{{start-accepting-bitcoin}}

-

{{btcpay-server-tag-line}}

+

{{btcpay-server-tag-line}}

- Server - Invoice + Server + Invoice
@@ -193,62 +214,10 @@

{{automation-via-api}}

- -
-

{{case-studies}}

- -

- {{view-all-case-studies}}   - -

-
+
+

{{case-studies}}

+ {{_caseStudiesBlock}} +
-
-

{{what-is-btcpay}}

-
- -
-
- -
@@ -412,12 +406,12 @@

{{join-the-community}}

href="https://www.youtube.com/channel/UCpG9WL6TJuoNfFVkaDMp9ug" target="blank_"> YouTube
-
-
- -
-
-
+
+
+
+
+
+

Contributors

@@ -434,6 +428,52 @@

Contributors

- + + + + diff --git a/src/js/scripts.js b/src/js/scripts.js index feb9b933..03525e3f 100644 --- a/src/js/scripts.js +++ b/src/js/scripts.js @@ -136,3 +136,361 @@ if (document.getElementById("backgroundBubbles")) { } s.setAttribute("xlink:href", t); } + +// Integrations page filtering +(function(){ + const grid = document.getElementById('integrationsGrid'); + if (!grid) return; + const items = Array.from(grid.querySelectorAll('.int-card')); + const search = document.getElementById('intSearch'); + const quick = document.getElementById('intQuickFilters'); + function apply() { + const q = (search && search.value || '').trim().toLowerCase(); + const active = quick ? (quick.querySelector('.int-chip.-active')?.dataset.chip || '') : ''; + items.forEach(li => { + const name = li.getAttribute('data-name') || ''; + const tags = (li.getAttribute('data-tags') || '').toLowerCase(); + const hitName = !q || name.indexOf(q) >= 0; + const hitChip = !active || tags.indexOf(active) >= 0 || name.indexOf(active) >= 0; + li.style.display = hitName && hitChip ? '' : 'none'; + }); + } + if (search) search.addEventListener('input', apply); + if (quick) quick.addEventListener('click', (e)=>{ + const btn = e.target.closest('.int-chip'); + if (!btn) return; + const cur = quick.querySelector('.int-chip.-active'); + if (cur === btn) btn.classList.remove('-active'); else { if (cur) cur.classList.remove('-active'); btn.classList.add('-active'); } + apply(); + }); +})(); + +// Video carousel (rolodex-style overlap, swipe, dots, lazy embed) +(function(){ + const root = document.querySelector('[data-vc]'); + if (!root) return; + const track = root.querySelector('.vc-track'); + const cards = Array.from(root.querySelectorAll('.vc-card')); + const prev = root.querySelector('[data-vc-prev]'); + const next = root.querySelector('[data-vc-next]'); + const dotsWrap = root.querySelector('.vc-dots'); + const ariaLive = root.querySelector('.vc-aria'); + let i = 0; + let pendingAutoplay = false; + // mark JS-enhanced + root.classList.add('js-vc'); + + function isSmallScreen() { return window.innerWidth <= 700; } + function isLandscape() { return window.innerWidth > window.innerHeight; } + + + function isSmallScreen() { return window.innerWidth <= 700; } + function isLandscape() { return window.innerWidth > window.innerHeight; } + function reqFullscreen(el) { + try { + if (el.requestFullscreen) return el.requestFullscreen(); + if (el.webkitRequestFullscreen) return el.webkitRequestFullscreen(); + if (el.msRequestFullscreen) return el.msRequestFullscreen(); + } catch(_) {} + } + function exitFullscreen() { + const d = document; + try { + if (d.exitFullscreen) return d.exitFullscreen(); + if (d.webkitExitFullscreen) return d.webkitExitFullscreen(); + if (d.msExitFullscreen) return d.msExitFullscreen(); + } catch(_) {} + } + + function centerActivePx() { + const active = cards[i]; + if (!active) return 0; + const cardCenter = active.offsetLeft + active.offsetWidth / 2; + const containerW = root.clientWidth; + const target = Math.max(0, cardCenter - containerW / 2); + return target; + } + + function makeThumb(card) { + const player = card.querySelector('.vc-player'); + if (!player || !player.dataset.yt) return; + if (player.querySelector('iframe')) return; + const id = player.dataset.yt; + player.innerHTML = ` + Video thumbnail + `; + } + + 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 ` +
  • + + ${logo ? `${it.name}` : ''} +

    ${it.name}

    +
    +
  • ` + }).join('') + const popular = ['Shopify', 'WooCommerce', 'Drupal', 'Zapier', 'API'] + const chips = popular.map(p => ``).join('') + return { grid: items, filters: chips } +} + +function ensureMinCaseStudies(list) { + 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((list || []).map(it => it.id || it.title)) + const padded = [...(list || [])] + for (const f of fallback) { if (padded.length >= 5) break; if (!seen.has(f.id)) padded.push(f) } + return padded.slice(0, 5) +} + +function buildCaseStudiesBlock(labels = { viewCaseStudy: 'View', downloadPdf: 'Download PDF', viewAll: 'View all', rl: 'right' }) { + try { + const file = require('path').resolve(__dirname, '../data/caseStudies.json') + if (existsSync(file)) { + const json = JSON.parse(readFileSync(file, 'utf8')) || [] + const list = ensureMinCaseStudies(json) + if (Array.isArray(list) && list.length) { + const items = list.map(item => { + const img = item.hero || '/img/case-studies/placeholder.png' + const title = item.title || '' + const excerpt = item.excerpt || '' + const url = item.url || '#' + const pdf = item.pdf + return ` +
  • + + ${title} + +

    ${title}

    +

    ${excerpt}

    + +
  • ` + }).join('') + return ` +
    +
    +
      ${items}
    +
    +
    + +

    + ${labels.viewAll}   + +

    ` + } + } + } catch (e) { + console.warn('⚠️ Case studies data not available:', e.message) + } + // Fallback to previous static block + return `
    +
    + +
    +
    + +

    + ${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(`
  • ${name}${region}
  • `) + }, []).join('') +} + console.log(`ℹ️ HTML: Rendering ${langs.length} translations …`) langs.forEach(lang => { const [lng] = lang.split('_') const isRtl = ['ar', 'fa', 'he'].includes(lng) const directory = lng === 'en' ? '' : lng === 'en_GB' ? '' : `${lang}/` - const translations = getTransifexJSON(`website/${lang}`) + const translations = getTransifexJSON(`website/${lang}`) const lngName = getLanguageName(lang) if (!lngName) { console.warn(`🛑 Missing language name for "${lang}" – please add it to the LANGUAGE_NAMES in tasks/util.js`) @@ -49,17 +231,38 @@ langs.forEach(lang => { const _sub = lngName || lang const _lngOpts = getLanguageOptions(langs, lang) + const TOP_LOCALES = ['en_GB', 'fr_FR', 'es_ES', 'de_DE', 'pt_BR', 'ru_RU', 'ja_JP', 'zh-Hans'] + const topAvailable = langs.filter(c => TOP_LOCALES.includes(c)) + const _topLngOpts = getLanguageOptionsSubset(langs, lang, topAvailable) + const _allLngOpts = getLanguageOptions(langs, lang) const _lngst = directory === 'en' ? '' : '/' + directory; + const _canonical = `https://btcpayserver.org/${directory}` + const _hreflangLinks = buildHreflangLinks(langs) + const _caseStudiesBlock = buildCaseStudiesBlock({ + viewCaseStudy: (translations['view-case-study'] || master['view-case-study'] || 'View case study'), + downloadPdf: (translations['download-pdf'] || master['download-pdf'] || 'Download PDF'), + viewAll: (translations['view-all-case-studies'] || master['view-all-case-studies'] || 'View all case studies'), + rl: isRtl ? 'left' : 'right' + }) + const integrations = loadJSON('../data/integrations.json', []) + const builtInt = buildIntegrations(integrations) const tmplVars = Object.assign({}, master, translations, { _contributorsBlock, _lngOpts, + _topLngOpts, + _allLngOpts, _to: isRtl ? 'rtl' : 'ltr', _rl: isRtl ? 'left' : 'right', _align: isRtl ? 'stickRight' : 'stickLeft', _lnstr: lang, _lngst, _sub, - _exp0: lng + _exp0: lng, + _canonical, + _hreflangLinks, + _caseStudiesBlock, + _integrationsGrid: builtInt.grid, + _integrationsFilters: builtInt.filters }) // render footer and add the result to the vars @@ -69,8 +272,20 @@ langs.forEach(lang => { tmplVars._menTemp = replaceTemplateVars(menuTmpl, tmplVars) // files + // index saveFile(`${directory}index.html`, replaceTemplateVars(indexTmpl, tmplVars)) - saveFile(`${directory}/donate/index.html`, replaceTemplateVars(donateTmpl, tmplVars)) + // donate page has its own path, update canonical/hreflang for donate + const donateVars = Object.assign({}, tmplVars, { + _canonical: `https://btcpayserver.org/${directory}donate/`, + _hreflangLinks: buildHreflangLinks(langs, 'donate/') + }) + saveFile(`${directory}/donate/index.html`, replaceTemplateVars(donateTmpl, donateVars)) + // integrations page + const integrationsVars = Object.assign({}, tmplVars, { + _canonical: `https://btcpayserver.org/${directory}integrations/`, + _hreflangLinks: buildHreflangLinks(langs, 'integrations/') + }) + saveFile(`${directory}/integrations/index.html`, replaceTemplateVars(integrationsTmpl, integrationsVars)) }) console.log('✅ HTML: Rendering done …') diff --git a/tasks/util.js b/tasks/util.js index 01a49ebb..ffad8e4b 100644 --- a/tasks/util.js +++ b/tasks/util.js @@ -74,6 +74,7 @@ function getContributorJSON(resource) { return JSON.parse(content) } catch (err) { console.error('🚨 Could not read file', file, ':', err.message) + return [] } }