feat: Hugo engine for iGaming affiliate template (layouts, assets, i18n, verified data, CI build script)

This commit is contained in:
pipeline
2026-07-16 06:07:22 +03:00
parent b9385a1f72
commit 1816ff2093
58 changed files with 5631 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# =============================================================================
# .env.example — copy to .env and fill in per-deployment secrets.
# Hugo does NOT read .env automatically; these are consumed by CI/host build
# settings or exported into the shell before building. Keep .env out of git.
# =============================================================================
# Public base URL for the deployed site (overrides hugo.toml baseURL in CI).
# Example: https://www.brandname.com/
HUGO_BASEURL="https://example.com/"
# Hugo build environment ("production" enables production optimisations).
HUGO_ENV="production"
# Optional analytics IDs. These are loaded by the cookie-consent gated loader
# only AFTER the visitor accepts the relevant category.
ANALYTICS_GA4_ID=""
ANALYTICS_PLAUSIBLE_DOMAIN=""
# Optional: default affiliate sub-id override for campaign-level tracking.
# Per-page sub-ids are still generated automatically by cta-button.html.
AFFILIATE_CAMPAIGN_ID=""
+20
View File
@@ -0,0 +1,20 @@
# Hugo build output & caches
/public/
/resources/
.hugo_build.lock
# Standalone CSS build artifact (npm run build:css)
/dist/
# Dependencies
/node_modules/
# Env & secrets
.env
.env.*
!.env.example
# OS / editor
.DS_Store
Thumbs.db
*.log
+228
View File
@@ -0,0 +1,228 @@
/* =========================================================================
main.css — Hugo Pipes entry (processed with resources.ExecuteAsTemplate |
css.PostCSS | fingerprint). The :root block below is injected from
data/brand.yaml so every brand colour flows through Tailwind utilities
(bg-primary, text-primary, ...) with zero template edits.
========================================================================= */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* --- Brand tokens injected from data/brand.yaml --------------------------
Both a hex value (for gradients/SVG) and an "R G B" channel triplet (for
Tailwind alpha modifiers) are emitted for each brand colour. Colours in
data/brand.yaml MUST be full 6-digit hex (e.g. #0057FF). */
{{- $brand := .Site.Data.brand -}}
{{- $hexmap := dict "0" 0 "1" 1 "2" 2 "3" 3 "4" 4 "5" 5 "6" 6 "7" 7 "8" 8 "9" 9 "a" 10 "b" 11 "c" 12 "d" 13 "e" 14 "f" 15 -}}
{{- $colors := dict "primary" $brand.primaryColor "secondary" $brand.secondaryColor "primary-contrast" ($brand.primaryContrast | default "#FFFFFF") -}}
:root {
{{- range $name, $hex := $colors }}
{{- /* Normalise: strip '#', lowercase, expand 3-digit shorthand (#05f -> #0055ff). */ -}}
{{- $h := lower (strings.TrimPrefix "#" $hex) }}
{{- if eq (len $h) 3 }}
{{- $h = printf "%s%s%s%s%s%s" (substr $h 0 1) (substr $h 0 1) (substr $h 1 1) (substr $h 1 1) (substr $h 2 1) (substr $h 2 1) }}
{{- end }}
{{- /* Validate: exactly 6 hex characters, else fail the build naming the colour. */ -}}
{{- $ok := eq (len $h) 6 }}
{{- range $ch := (split $h "") }}{{- if not (isset $hexmap $ch) }}{{- $ok = false }}{{- end }}{{- end }}
{{- if not $ok }}{{- errorf "brand.yaml: %s colour %q is not a valid 3- or 6-digit hex value (e.g. #0057FF)" $name $hex }}{{- end }}
--brand-{{ $name }}: #{{ $h }};
{{- if $ok }}
--brand-{{ $name }}-rgb: {{ add (mul (index $hexmap (substr $h 0 1)) 16) (index $hexmap (substr $h 1 1)) }} {{ add (mul (index $hexmap (substr $h 2 1)) 16) (index $hexmap (substr $h 3 1)) }} {{ add (mul (index $hexmap (substr $h 4 1)) 16) (index $hexmap (substr $h 5 1)) }};
{{- else }}
--brand-{{ $name }}-rgb: 0 0 0;
{{- end }}
{{- end }}
}
/* --- Base layer --------------------------------------------------------- */
@layer base {
/* Alpine.js: hide elements until initialised to avoid flashes. */
[x-cloak] {
display: none !important;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-secondary antialiased;
}
h1, h2, h3, h4, h5 {
@apply font-bold tracking-tight text-secondary;
}
a {
@apply text-primary underline-offset-2;
}
a:hover {
@apply underline;
}
/* Visible keyboard focus for accessibility */
:focus-visible {
@apply outline outline-2 outline-offset-2 outline-primary;
}
}
/* --- Components --------------------------------------------------------- */
@layer components {
.site-container {
@apply mx-auto w-full max-w-6xl px-4 sm:px-6;
}
/* Buttons ------------------------------------------------------------- */
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 text-base font-semibold no-underline transition-transform duration-150 focus-visible:outline-2;
}
.btn:hover {
@apply -translate-y-0.5 no-underline;
}
.btn-primary {
@apply btn bg-primary text-primary-contrast shadow-sm hover:shadow-md;
}
.btn-secondary {
@apply btn border-2 border-primary bg-transparent text-primary hover:bg-primary hover:text-primary-contrast;
}
.btn-ghost {
@apply btn bg-secondary/5 text-secondary hover:bg-secondary/10;
}
.btn-lg {
@apply px-7 py-4 text-lg;
}
/* Cards --------------------------------------------------------------- */
.card {
@apply rounded-xl border border-secondary/10 bg-white p-6 shadow-sm;
}
.card-hover {
@apply transition-shadow duration-150 hover:shadow-lg;
}
/* Badges -------------------------------------------------------------- */
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
.badge-primary {
@apply badge bg-primary/10 text-primary;
}
.badge-muted {
@apply badge bg-secondary/10 text-secondary;
}
/* Section helpers ----------------------------------------------------- */
.section {
@apply py-12 sm:py-16;
}
.eyebrow {
@apply text-sm font-semibold uppercase tracking-wider text-primary;
}
/* Rendered markdown body --------------------------------------------- */
.prose-content {
@apply max-w-none text-[1.05rem] leading-relaxed text-secondary/90;
}
.prose-content h2 {
@apply mb-4 mt-10 text-2xl font-bold text-secondary;
}
.prose-content h3 {
@apply mb-3 mt-8 text-xl font-semibold text-secondary;
}
.prose-content p {
@apply mb-4;
}
.prose-content ul {
@apply mb-4 list-disc space-y-1 pl-6;
}
.prose-content ol {
@apply mb-4 list-decimal space-y-1 pl-6;
}
/* Exclude affiliate CTA buttons so their button styling is preserved. */
.prose-content a:not([data-affiliate]) {
@apply font-medium text-primary underline;
}
.prose-content a[data-affiliate] {
@apply my-2 no-underline;
}
.prose-content blockquote {
@apply my-6 border-l-4 border-primary/40 bg-secondary/5 py-2 pl-4 italic;
}
.prose-content table {
@apply my-6 w-full border-collapse text-left text-sm;
}
.prose-content th {
@apply border-b-2 border-secondary/20 bg-secondary/5 px-3 py-2 font-semibold;
}
.prose-content td {
@apply border-b border-secondary/10 px-3 py-2 align-top;
}
.prose-content code {
@apply rounded bg-secondary/10 px-1.5 py-0.5 text-sm;
}
/* Data tables (payment table, etc.) ---------------------------------- */
.data-table {
@apply w-full border-collapse overflow-hidden text-left text-sm;
}
.data-table th {
@apply border-b-2 border-secondary/20 bg-secondary/5 px-3 py-2 font-semibold;
}
.data-table td {
@apply border-b border-secondary/10 px-3 py-2 align-middle;
}
.data-table tr:hover td {
@apply bg-primary/5;
}
.data-table tr.is-active-locale td {
@apply bg-primary/10;
}
/* Nav ---------------------------------------------------------------- */
.nav-link {
@apply rounded-md px-3 py-2 text-sm font-medium text-secondary/80 no-underline hover:bg-secondary/5 hover:text-secondary;
}
.nav-link-active {
@apply bg-primary/10 text-primary;
}
/* Desktop nav can scroll horizontally when there are many sections; hide the
scrollbar chrome so it stays clean (still scrollable via trackpad/shift-wheel). */
.nav-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.nav-scroll::-webkit-scrollbar {
display: none;
}
}
/* --- Overlays: age gate + cookie consent -------------------------------- */
@layer components {
.age-gate {
@apply fixed inset-0 z-[100] hidden items-center justify-center bg-secondary/95 p-4 text-center;
}
.age-gate-active .age-gate {
@apply flex;
}
.age-gate-card {
@apply w-full max-w-md rounded-2xl bg-white p-8 shadow-2xl;
}
.cookie-banner {
@apply fixed inset-x-0 bottom-0 z-[90] border-t-2 border-primary/30 bg-white p-4 shadow-2xl sm:p-6;
}
.cookie-toggle {
@apply flex items-start gap-3 rounded-lg border border-secondary/10 p-3;
}
}
/* --- Utilities layer ---------------------------------------------------- */
@layer utilities {
/* Lock page scroll while the age gate is open. */
.age-gate-active {
overflow: hidden;
}
.hero-gradient {
background-image: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
}
/* Placeholder used when an image resource is missing (guarded pipeline). */
.img-placeholder {
background-image: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
}
}
+10
View File
@@ -0,0 +1,10 @@
/* =========================================================================
tailwind.src.css — template-free entry used ONLY by `npm run build:css`
to emit a standalone, verifiable Tailwind stylesheet (dist/).
The site itself is compiled from main.css via Hugo Pipes (css.PostCSS),
which additionally injects the data/brand.yaml :root tokens.
========================================================================= */
@tailwind base;
@tailwind components;
@tailwind utilities;
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+175
View File
@@ -0,0 +1,175 @@
/* =========================================================================
age-gate.js — non-dismissible age verification interstitial.
The <html> class "age-gate-active" is set as early as possible by an inline
script in baseof.html to avoid a flash for returning (confirmed) visitors.
This module wires the confirm/exit buttons, syncs the displayed age with the
active locale (set by the locale switcher) and — because the dialog is
role="dialog" aria-modal="true" — manages focus: it moves focus into the
dialog, traps Tab/Shift+Tab, marks the rest of the page inert while active,
and restores focus on confirm.
Deliberately dependency-free (no Alpine, no eval/new Function): the gate is
driven purely by the CSS "age-gate-active" class, so it still fails safe
(gate shows, confirm works) even if Alpine never loads.
========================================================================= */
(function () {
"use strict";
var STORAGE_KEY = "ageConfirmed";
var INERT_SELECTORS = ["header", "main", "footer"];
var FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
var gate = null;
var lastFocused = null;
var trapActive = false;
function hasConfirmed() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (e) {
return false;
}
}
function focusableIn(el) {
return Array.prototype.filter.call(
el.querySelectorAll(FOCUSABLE),
function (node) {
return (
node.offsetWidth > 0 ||
node.offsetHeight > 0 ||
node === document.activeElement
);
}
);
}
function onKeydown(e) {
if (!trapActive || e.key !== "Tab" || !gate) return;
var f = focusableIn(gate);
if (!f.length) {
e.preventDefault();
return;
}
var first = f[0];
var last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function onFocusIn(e) {
if (trapActive && gate && !gate.contains(e.target)) {
var f = focusableIn(gate);
if (f.length) f[0].focus();
}
}
function setBackgroundInert(on) {
INERT_SELECTORS.forEach(function (sel) {
var el = document.querySelector(sel);
if (!el) return;
if (on) {
el.setAttribute("inert", "");
el.setAttribute("aria-hidden", "true");
} else {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
}
});
}
function activateTrap() {
if (trapActive || !gate) return;
trapActive = true;
lastFocused = document.activeElement;
setBackgroundInert(true);
document.addEventListener("keydown", onKeydown, true);
document.addEventListener("focusin", onFocusIn);
var confirmBtn = gate.querySelector("[data-age-confirm]");
if (confirmBtn) {
// Defer to ensure the element is focusable after the first paint.
window.setTimeout(function () {
confirmBtn.focus();
}, 0);
}
}
function releaseTrap() {
trapActive = false;
setBackgroundInert(false);
document.removeEventListener("keydown", onKeydown, true);
document.removeEventListener("focusin", onFocusIn);
}
function restoreFocus() {
var target =
(lastFocused &&
document.contains(lastFocused) &&
lastFocused !== document.body &&
lastFocused) ||
document.querySelector("header a, header button") ||
document.getElementById("main");
if (!target) return;
if (target.id === "main" && !target.hasAttribute("tabindex")) {
target.setAttribute("tabindex", "-1");
}
try {
target.focus();
} catch (e) {
/* focus can fail on detached nodes — ignore */
}
}
function confirmAge() {
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch (e) {
/* storage unavailable — still allow through for this session */
}
document.documentElement.classList.remove("age-gate-active");
releaseTrap();
restoreFocus();
}
function exitSite() {
window.location.href = "https://www.google.com/";
}
function init() {
gate = document.getElementById("age-gate");
if (!gate) {
document.documentElement.classList.remove("age-gate-active");
return;
}
var confirmBtn = gate.querySelector("[data-age-confirm]");
if (confirmBtn) {
confirmBtn.addEventListener("click", confirmAge);
}
var exitBtn = gate.querySelector("[data-age-exit]");
if (exitBtn) {
exitBtn.addEventListener("click", exitSite);
}
if (hasConfirmed()) {
document.documentElement.classList.remove("age-gate-active");
setBackgroundInert(false);
} else {
document.documentElement.classList.add("age-gate-active");
activateTrap();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+154
View File
@@ -0,0 +1,154 @@
/* =========================================================================
cookie-consent.js — GDPR-style consent manager.
Non-essential scripts must be added to the page as:
<script type="text/plain" data-cookiecategory="analytics"> ... </script>
They stay inert until the visitor grants that category, at which point this
module reactivates them. Consent is stored in localStorage.
========================================================================= */
(function () {
"use strict";
var STORAGE_KEY = "cookieConsent";
var CATEGORIES = ["necessary", "analytics", "marketing"];
function readConsent() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
} catch (e) {
return null;
}
}
function writeConsent(consent) {
consent.necessary = true; // always on
consent.ts = Date.now();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(consent));
} catch (e) {
/* ignore */
}
activateScripts(consent);
}
function activateScripts(consent) {
var nodes = document.querySelectorAll(
'script[type="text/plain"][data-cookiecategory]'
);
Array.prototype.forEach.call(nodes, function (node) {
if (node.getAttribute("data-cookie-activated") === "1") return;
var cat = node.getAttribute("data-cookiecategory");
if (!consent[cat]) return;
var s = document.createElement("script");
for (var i = 0; i < node.attributes.length; i++) {
var attr = node.attributes[i];
if (attr.name === "type" || attr.name === "data-cookiecategory") continue;
s.setAttribute(attr.name, attr.value);
}
s.type = "text/javascript";
if (!node.src) {
s.text = node.textContent;
}
node.parentNode.insertBefore(s, node.nextSibling);
node.setAttribute("data-cookie-activated", "1");
});
}
function banner() {
return document.getElementById("cookie-consent");
}
function showBanner() {
var b = banner();
if (b) b.hidden = false;
}
function hideBanner() {
var b = banner();
if (b) b.hidden = true;
}
function acceptAll() {
var consent = { necessary: true };
CATEGORIES.forEach(function (c) {
consent[c] = true;
});
writeConsent(consent);
hideBanner();
}
function rejectNonEssential() {
var consent = { necessary: true, analytics: false, marketing: false };
writeConsent(consent);
hideBanner();
}
function savePreferences() {
var b = banner();
var consent = { necessary: true };
CATEGORIES.forEach(function (c) {
if (c === "necessary") return;
var box = b
? b.querySelector('[data-cc-category="' + c + '"]')
: null;
consent[c] = !!(box && box.checked);
});
writeConsent(consent);
hideBanner();
}
function toggleManage() {
var b = banner();
if (!b) return;
var panel = b.querySelector("[data-cc-panel]");
if (panel) panel.hidden = !panel.hidden;
}
function init() {
var b = banner();
var existing = readConsent();
if (existing) {
activateScripts(existing);
if (b) b.hidden = true;
} else if (b) {
b.hidden = false;
}
if (!b) return;
var accept = b.querySelector("[data-cc-accept-all]");
if (accept) accept.addEventListener("click", acceptAll);
var reject = b.querySelector("[data-cc-reject]");
if (reject) reject.addEventListener("click", rejectNonEssential);
var save = b.querySelector("[data-cc-save]");
if (save) save.addEventListener("click", savePreferences);
var manage = b.querySelector("[data-cc-manage]");
if (manage) manage.addEventListener("click", toggleManage);
}
// Public: reopen the consent banner (used by footer / cookie-policy links).
window.openCookieSettings = function () {
var existing = readConsent();
var b = banner();
if (b && existing) {
CATEGORIES.forEach(function (c) {
if (c === "necessary") return;
var box = b.querySelector('[data-cc-category="' + c + '"]');
if (box) box.checked = !!existing[c];
});
var panel = b.querySelector("[data-cc-panel]");
if (panel) panel.hidden = false;
}
showBanner();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+47
View File
@@ -0,0 +1,47 @@
/* =========================================================================
games-filter.js — Alpine.js component powering the slots library filter.
Cards are rendered server-side by Hugo (so thumbnails go through the image
pipeline); this component toggles their visibility via data-attributes.
Registered on `alpine:init`, so this file must load before the Alpine CDN.
========================================================================= */
document.addEventListener("alpine:init", function () {
window.Alpine.data("gamesFilter", function () {
return {
provider: "all",
category: "all",
search: "",
visibleCount: 0,
get normalizedSearch() {
return this.search.trim().toLowerCase();
},
cardVisible: function (el) {
var okProvider =
this.provider === "all" || el.dataset.provider === this.provider;
var okCategory =
this.category === "all" || el.dataset.category === this.category;
var okSearch =
this.normalizedSearch === "" ||
(el.dataset.title || "").indexOf(this.normalizedSearch) !== -1;
return okProvider && okCategory && okSearch;
},
recount: function () {
var self = this;
var cards = this.$root.querySelectorAll("[data-game]");
var n = 0;
Array.prototype.forEach.call(cards, function (el) {
if (self.cardVisible(el)) n++;
});
this.visibleCount = n;
},
reset: function () {
this.provider = "all";
this.category = "all";
this.search = "";
},
};
});
});
+5
View File
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
# =============================================================================
# locales.yaml — per-market legal/compliance data layer.
# One entry per brand.supportedLocales. Consumed by the client-side locale
# switcher, the age gate and the responsible-gambling block.
#
# NOTE: Responsible-gambling org names/URLs below are the best-known public
# support services per market. Verify them against current regulation before
# go-live; entries marked "(placeholder)" MUST be replaced.
# =============================================================================
- locale: "en-GB"
label: "United Kingdom"
ageRestriction: 18
responsibleGamblingOrgName: "BeGambleAware / GamCare"
responsibleGamblingUrl: "https://www.begambleaware.org"
currencySymbol: "£"
requiredKycDocs:
- "Passport, driving licence or national ID card"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "de-DE"
label: "Germany"
ageRestriction: 18
responsibleGamblingOrgName: "Check dein Spiel (BZgA)"
responsibleGamblingUrl: "https://www.check-dein-spiel.de"
currencySymbol: "€"
requiredKycDocs:
- "Personalausweis or passport"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "de-AT"
label: "Austria"
ageRestriction: 18
responsibleGamblingOrgName: "Spielsuchthilfe (Spielerschutz)"
responsibleGamblingUrl: "https://www.spielsuchthilfe.at"
currencySymbol: "€"
requiredKycDocs:
- "Reisepass or national ID card"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "it-IT"
label: "Italy"
ageRestriction: 18
responsibleGamblingOrgName: "Gioca Responsabile"
responsibleGamblingUrl: "https://www.giocaresponsabile.it"
currencySymbol: "€"
requiredKycDocs:
- "Carta d'identità or passport"
- "Codice fiscale"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "es-ES"
label: "Spain"
ageRestriction: 18
responsibleGamblingOrgName: "Jugar Bien"
responsibleGamblingUrl: "https://www.jugarbien.es"
currencySymbol: "€"
requiredKycDocs:
- "DNI, NIE or passport"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "pl-PL"
label: "Poland"
ageRestriction: 18
responsibleGamblingOrgName: "Anonimowi Hazardzisci"
responsibleGamblingUrl: "https://anonimowihazardzisci.org"
currencySymbol: "zl"
requiredKycDocs:
- "Dowod osobisty (national ID) or passport"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "nl-NL"
label: "Netherlands"
ageRestriction: 18
responsibleGamblingOrgName: "Loket Kansspel"
responsibleGamblingUrl: "https://www.loketkansspel.nl"
currencySymbol: "€"
requiredKycDocs:
- "Paspoort, ID card or driving licence"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "fi-FI"
label: "Finland"
ageRestriction: 18
responsibleGamblingOrgName: "Peluuri"
responsibleGamblingUrl: "https://www.peluuri.fi"
currencySymbol: "€"
requiredKycDocs:
- "Passport or national ID card"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "sv-SE"
label: "Sweden"
ageRestriction: 18
responsibleGamblingOrgName: "Stodlinjen"
responsibleGamblingUrl: "https://www.stodlinjen.se"
currencySymbol: "kr"
requiredKycDocs:
- "BankID verification"
- "Passport or national ID card"
- locale: "no-NO"
label: "Norway"
ageRestriction: 18
responsibleGamblingOrgName: "Hjelpelinjen"
responsibleGamblingUrl: "https://hjelpelinjen.no"
currencySymbol: "kr"
requiredKycDocs:
- "Passport or national ID card"
- "Proof of address (utility bill or bank statement, < 3 months)"
- locale: "da-DK"
label: "Denmark"
ageRestriction: 18
responsibleGamblingOrgName: "StopSpillet"
responsibleGamblingUrl: "https://www.stopspillet.dk"
currencySymbol: "kr"
requiredKycDocs:
- "MitID verification"
- "Passport or national ID card"
+77
View File
@@ -0,0 +1,77 @@
# =============================================================================
# payment_methods.yaml — rendered by the {{< payment-table >}} shortcut and the
# partials/payment-table.html partial. `availableLocales` powers per-market
# filtering. Amounts are in the market's major currency unit.
# =============================================================================
- name: "Visa"
type: "card"
minDeposit: 10
maxDeposit: 5000
processingTime: "Instant"
fees: "None"
logoPath: "/images/payments/visa.svg"
availableLocales: ["en-GB", "de-DE", "de-AT", "it-IT", "es-ES", "pl-PL", "nl-NL", "fi-FI", "sv-SE", "no-NO", "da-DK"]
- name: "Mastercard"
type: "card"
minDeposit: 10
maxDeposit: 5000
processingTime: "Instant"
fees: "None"
logoPath: "/images/payments/mastercard.svg"
availableLocales: ["en-GB", "de-DE", "de-AT", "it-IT", "es-ES", "pl-PL", "nl-NL", "fi-FI", "sv-SE", "no-NO", "da-DK"]
- name: "PayPal"
type: "e-wallet"
minDeposit: 10
maxDeposit: 8000
processingTime: "Instant"
fees: "None"
logoPath: "/images/payments/paypal.svg"
availableLocales: ["en-GB", "de-DE", "de-AT", "it-IT", "es-ES"]
- name: "Skrill"
type: "e-wallet"
minDeposit: 10
maxDeposit: 10000
processingTime: "Instant"
fees: "None"
logoPath: "/images/payments/skrill.svg"
availableLocales: ["en-GB", "de-DE", "de-AT", "it-IT", "es-ES", "pl-PL", "nl-NL", "fi-FI"]
- name: "Neteller"
type: "e-wallet"
minDeposit: 10
maxDeposit: 10000
processingTime: "Instant"
fees: "None"
logoPath: "/images/payments/neteller.svg"
availableLocales: ["en-GB", "de-DE", "it-IT", "es-ES", "pl-PL", "fi-FI", "sv-SE"]
- name: "Trustly"
type: "bank-transfer"
minDeposit: 20
maxDeposit: 20000
processingTime: "Instant (deposit) / 1-2 days (withdrawal)"
fees: "None"
logoPath: "/images/payments/trustly.svg"
availableLocales: ["de-DE", "de-AT", "nl-NL", "fi-FI", "sv-SE", "no-NO", "da-DK"]
- name: "Paysafecard"
type: "prepaid"
minDeposit: 10
maxDeposit: 1000
processingTime: "Instant (deposit only)"
fees: "None"
logoPath: "/images/payments/paysafecard.svg"
availableLocales: ["en-GB", "de-DE", "de-AT", "it-IT", "es-ES", "pl-PL", "nl-NL"]
- name: "Bitcoin"
type: "crypto"
minDeposit: 20
maxDeposit: 50000
processingTime: "10-30 minutes (network dependent)"
fees: "Network fee only"
logoPath: "/images/payments/bitcoin.svg"
availableLocales: ["de-DE", "de-AT", "fi-FI", "no-NO"]
+53
View File
@@ -0,0 +1,53 @@
# =============================================================================
# i18n/en.yaml — UI string catalogue (English).
# The site currently runs single-language via the data layer, but externalising
# UI strings here means Hugo multilingual can be enabled later by adding
# [languages] to hugo.toml and translating this file (e.g. i18n/de.yaml).
# =============================================================================
# Global / chrome
skipToContent: "Skip to content"
playNow: "Play Now"
visitCasino: "Visit Casino"
readMore: "Read more"
selectRegion: "Select your region"
toggleMenu: "Toggle navigation menu"
# Footer
footerExplore: "Explore"
footerLegal: "Legal & Safety"
footerResponsible: "Play Responsibly"
cookieSettings: "Cookie settings"
affiliateDisclosure: "Affiliate disclosure"
allRightsReserved: "All rights reserved."
# Age gate
ageGateTitle: "Please confirm your age"
ageGateConfirm: "I confirm I am of legal age"
ageGateExit: "I am under age — leave"
# Cookie consent
cookieTitle: "We value your privacy"
cookieAcceptAll: "Accept all"
cookieReject: "Reject non-essential"
cookieManage: "Manage"
cookieSave: "Save preferences"
cookieNecessary: "Necessary"
cookieAnalytics: "Analytics"
cookieMarketing: "Marketing"
# Games filter
filterSearch: "Search"
filterProvider: "Provider"
filterCategory: "Category"
filterAllProviders: "All providers"
filterAllCategories: "All categories"
filterReset: "Reset filters"
# Responsible gambling
rgNeedHelp: "Need help?"
rgFindSupport: "Find local support"
# Common
relatedPages: "Related pages"
lastUpdated: "Last updated"
+23
View File
@@ -0,0 +1,23 @@
{{ define "main" }}
<section class="site-container section text-center">
<p class="eyebrow">Error 404</p>
<h1 class="mt-2 text-4xl font-extrabold tracking-tight sm:text-5xl">Page not found</h1>
<p class="mx-auto mt-4 max-w-md text-secondary/70">
The page you&rsquo;re looking for doesn&rsquo;t exist or may have moved. Let&rsquo;s get you back on track.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-3">
<a href="{{ "/" | relURL }}" class="btn-primary">Back to home</a>
{{ with site.GetPage "faq" }}<a href="{{ .RelPermalink }}" class="btn-secondary">Visit the FAQ</a>{{ end }}
</div>
<div class="mx-auto mt-12 grid max-w-2xl gap-3 sm:grid-cols-3">
{{- range slice "slots" "bonuses" "responsible-gambling" -}}
{{ with site.GetPage . }}
<a href="{{ .RelPermalink }}" class="card card-hover block no-underline text-left">
<span class="font-semibold text-primary">{{ .LinkTitle }}</span>
</a>
{{ end }}
{{- end -}}
</div>
</section>
{{ end }}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode | default "en" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="{{ .Site.Data.brand.primaryColor }}">
{{ partial "head-seo.html" . }}
{{/* Brand + locale data exposed to client-side scripts (age gate, locale switcher).
safeJS prevents html/template from escaping the JSON into a quoted string. */}}
<script>
window.__BRAND__ = {{ dict "name" .Site.Data.brand.name "defaultLocale" .Site.Data.brand.defaultLocale "ageRestrictionDefault" .Site.Data.brand.ageRestrictionDefault | jsonify | safeJS }};
window.__LOCALES__ = {{ .Site.Data.locales | jsonify | safeJS }};
</script>
{{/* Add the age-gate class as early as possible to avoid a flash for confirmed visitors. */}}
<script>
(function () {
try {
if (localStorage.getItem("ageConfirmed") !== "1") {
document.documentElement.classList.add("age-gate-active");
}
} catch (e) {
document.documentElement.classList.add("age-gate-active");
}
})();
</script>
{{/* CSS pipeline: inject brand vars (ExecuteAsTemplate) -> Tailwind/PostCSS ->
minify (production only) -> fingerprint. Hugo's [minify] does not minify
Pipes resources, so we minify explicitly for production builds. */}}
{{ $css := resources.Get "css/main.css" | resources.ExecuteAsTemplate "css/main.css" . | css.PostCSS }}
{{ if hugo.IsProduction }}{{ $css = $css | minify }}{{ end }}
{{ $css = $css | fingerprint "sha256" }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous">
<link rel="icon" href="{{ "favicon.svg" | relURL }}" type="image/svg+xml">
</head>
<body class="flex min-h-screen flex-col bg-white">
<a href="#main" class="sr-only rounded bg-primary px-4 py-2 text-primary-contrast focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[110]">{{ i18n "skipToContent" }}</a>
{{ partial "age-gate-banner.html" . }}
{{ partial "header.html" . }}
<main id="main" class="flex-1">
{{ block "main" . }}{{ end }}
</main>
{{ partial "footer.html" . }}
{{ partial "cookie-consent.html" . }}
{{/* App bundle (age gate, cookie consent, games filter). Loads BEFORE Alpine so the
gamesFilter component is registered on the alpine:init event. */}}
{{ $appJs := slice (resources.Get "js/age-gate.js") (resources.Get "js/cookie-consent.js") (resources.Get "js/games-filter.js") | resources.Concat "js/app.js" | minify | fingerprint "sha256" }}
<script defer src="{{ $appJs.RelPermalink }}" integrity="{{ $appJs.Data.Integrity }}" crossorigin="anonymous"></script>
{{/* Alpine.js — vendored first-party (assets/js/vendor/alpine.js, pinned 3.14.8,
refreshed from node_modules by `npm run build:js`) and fingerprinted so it
ships with Subresource Integrity like the other assets. Deferred and placed
AFTER the app bundle so components register on alpine:init before core runs. */}}
{{ $alpine := resources.Get "js/vendor/alpine.js" | fingerprint "sha256" }}
<script defer src="{{ $alpine.RelPermalink }}" integrity="{{ $alpine.Data.Integrity }}" crossorigin="anonymous"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{{ define "main" }}
{{ partial "breadcrumbs.html" . }}
<div class="site-container section">
<header class="mb-8 max-w-3xl">
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">{{ .Params.h1 | default .Title }}</h1>
{{ with .Params.description }}<p class="mt-3 text-lg text-secondary/70">{{ . }}</p>{{ end }}
</header>
{{ with .Content }}<div class="prose-content max-w-3xl">{{ . }}</div>{{ end }}
{{ with .Pages }}
<div class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{- range . -}}
<a href="{{ .RelPermalink }}" class="card card-hover block no-underline">
<h2 class="text-lg font-bold">{{ .LinkTitle }}</h2>
<p class="mt-2 text-sm text-secondary/70">{{ .Params.description | default (.Summary | plainify) | truncate 120 }}</p>
</a>
{{- end -}}
</div>
{{ end }}
</div>
{{ partial "related-pages.html" . }}
{{ end }}
+70
View File
@@ -0,0 +1,70 @@
{{ define "main" }}
{{ partial "breadcrumbs.html" . }}
<article class="site-container section">
<header class="mb-8 max-w-3xl">
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">{{ .Params.h1 | default .Title }}</h1>
{{ with .Params.description }}<p class="mt-3 text-lg text-secondary/70">{{ . }}</p>{{ end }}
{{ if not .Lastmod.IsZero }}<p class="mt-2 text-xs text-secondary/70">Last updated {{ .Lastmod.Format "2 January 2006" }}</p>{{ end }}
</header>
{{ with .Content }}<div class="prose-content max-w-3xl">{{ . }}</div>{{ end }}
{{ if or .Params.pros .Params.cons }}
<section class="mt-12 max-w-3xl">
<h2 class="mb-4 text-2xl font-bold">Pros &amp; cons at a glance</h2>
{{ partial "pros-cons-table.html" . }}
</section>
{{ end }}
{{ if .Params.showRatingWidget }}
<section class="mt-12 max-w-3xl">
<h2 class="mb-4 text-2xl font-bold">Our verdict</h2>
{{ partial "rating-widget.html" . }}
</section>
{{ end }}
{{ if .Params.showKycByLocale }}
<section class="mt-12 max-w-4xl">
<h2 class="mb-3 text-2xl font-bold">Verification documents by region</h2>
<p class="mb-4 text-secondary/70">KYC requirements vary by market. Choose your region in the header to highlight your row; the full list is below.</p>
<div class="overflow-x-auto">
<table class="data-table">
<thead><tr><th scope="col">Region</th><th scope="col">Required documents (KYC)</th></tr></thead>
<tbody>
{{- range .Site.Data.locales -}}
<tr data-kyc-row data-locale="{{ .locale }}">
<th scope="row" class="font-medium">{{ .label | default .locale }}</th>
<td><ul class="list-disc space-y-1 pl-4">{{ range .requiredKycDocs }}<li>{{ . }}</li>{{ end }}</ul></td>
</tr>
{{- end -}}
</tbody>
</table>
</div>
</section>
{{ end }}
{{ if .Params.showFaqAccordion }}
<section class="mt-12">
<h2 class="mb-6 text-2xl font-bold">Frequently asked questions</h2>
{{ partial "faq-accordion.html" . }}
</section>
{{ end }}
{{ if .Params.responsibleGamblingSection }}
<section class="mt-12">
{{ partial "responsible-gambling-block.html" (dict "page" . "variant" "full") }}
</section>
{{ end }}
{{ if not .Params.hideCta }}
<div class="mt-12 max-w-3xl rounded-2xl border border-primary/20 bg-primary/5 p-6">
<h2 class="text-xl font-bold">Ready to get started?</h2>
<p class="mt-1 text-secondary/70"><span data-age-value>{{ .Site.Data.brand.ageRestrictionDefault }}</span>+ only. T&amp;Cs apply. Please gamble responsibly.</p>
<div class="mt-4">{{ partial "cta-button.html" (dict "page" . "text" "Visit Casino" "size" "lg") }}</div>
</div>
{{ end }}
</article>
{{ partial "related-pages.html" . }}
{{ end }}
+102
View File
@@ -0,0 +1,102 @@
{{ define "main" }}
{{ $brand := .Site.Data.brand }}
{{/* Hero */}}
<section class="hero-gradient text-white">
<div class="site-container grid items-center gap-10 py-16 lg:grid-cols-2 lg:py-24">
<div>
<p class="eyebrow text-white/80">{{ $brand.name }}</p>
<h1 class="mt-2 text-4xl font-extrabold leading-tight tracking-tight sm:text-5xl">{{ .Params.h1 | default .Title }}</h1>
<p class="mt-4 max-w-xl text-lg text-white/90">{{ .Params.description | default $brand.tagline }}</p>
<div class="mt-8 flex flex-wrap gap-3">
{{ partial "cta-button.html" (dict "page" . "text" "Play Now" "size" "lg") }}
{{ with site.GetPage "bonuses" }}<a href="{{ .RelPermalink }}" class="btn border-2 border-white/80 text-white hover:bg-white hover:text-secondary">See bonuses</a>{{ end }}
</div>
<p class="mt-5 text-sm text-white/70"><span data-age-value>{{ $brand.ageRestrictionDefault }}</span>+ only &bull; T&amp;Cs apply &bull; Please gamble responsibly</p>
</div>
<div class="relative">
{{ $hero := resources.Get "images/hero.png" }}
<div class="overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/20">
{{ partial "responsive-image.html" (dict
"resource" $hero
"alt" (printf "%s highlights" $brand.name)
"loading" "eager"
"class" "w-full"
"widths" (slice 600 900 1200)
"sizes" "(min-width:1024px) 50vw, 100vw") }}
</div>
</div>
</div>
</section>
{{/* USP blocks */}}
{{ with .Params.usps }}
<section class="site-container section">
<div class="grid gap-6 md:grid-cols-3">
{{- range . -}}
<div class="card card-hover">
<div class="mb-3 grid h-11 w-11 place-items-center rounded-lg bg-primary/10 text-primary">
{{- if .icon -}}
<span class="text-xl" aria-hidden="true">{{ .icon }}</span>
{{- else -}}
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{- end -}}
</div>
<h2 class="text-lg font-bold">{{ .title }}</h2>
<p class="mt-2 text-secondary/70">{{ .text }}</p>
</div>
{{- end -}}
</div>
</section>
{{ end }}
{{/* Editorial intro (from _index.md body) */}}
{{ with .Content }}
<section class="site-container section pt-0">
<div class="prose-content max-w-3xl">{{ . }}</div>
</section>
{{ end }}
{{/* Quick shortcuts */}}
<section class="site-container section pt-0">
<h2 class="mb-6 text-2xl font-bold">Start here</h2>
<div class="grid gap-4 sm:grid-cols-3">
{{- range slice "registration" "slots" "bonuses" -}}
{{ with site.GetPage . }}
<a href="{{ .RelPermalink }}" class="card card-hover block no-underline">
<span class="badge-primary">{{ .LinkTitle }}</span>
<p class="mt-2 text-secondary/70">{{ .Params.description | truncate 90 }}</p>
<span class="mt-3 inline-block font-semibold text-primary">Explore &rarr;</span>
</a>
{{ end }}
{{- end -}}
</div>
</section>
{{/* Featured games */}}
{{ $featured := where (where .Site.RegularPages "Section" "slots") "Params.featured" true }}
{{ with $featured }}
<section class="site-container section pt-0">
<div class="mb-6 flex items-end justify-between">
<h2 class="text-2xl font-bold">Featured games</h2>
{{ with site.GetPage "slots" }}<a href="{{ .RelPermalink }}" class="font-semibold text-primary">All games &rarr;</a>{{ end }}
</div>
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{{- range first 4 . -}}
{{ $img := .Resources.GetMatch "thumb.*" }}
<article class="card card-hover overflow-hidden p-0">
<div class="aspect-[3/2] overflow-hidden bg-secondary/10">
{{ partial "responsive-image.html" (dict "resource" $img "alt" .Title "class" "h-full w-full object-cover" "widths" (slice 300 600)) }}
</div>
<div class="p-4">
<span class="badge-primary">{{ .Params.provider }}</span>
<h3 class="mt-2 font-bold"><a href="{{ .RelPermalink }}" class="no-underline hover:underline">{{ .Title }}</a></h3>
</div>
</article>
{{- end -}}
</div>
</section>
{{ end }}
{{ partial "related-pages.html" . }}
{{ end }}
+24
View File
@@ -0,0 +1,24 @@
{{- /* age-gate-banner.html — full-screen, non-dismissible age interstitial shown
on first visit. Visibility is driven by the .age-gate-active class on
<html> (set early in baseof.html and cleared by age-gate.js on confirm).
The age value is data-driven and updated per locale by the switcher. */ -}}
{{- $brand := .Site.Data.brand -}}
<div id="age-gate" class="age-gate" role="dialog" aria-modal="true" aria-labelledby="age-gate-title" aria-describedby="age-gate-desc">
<div class="age-gate-card">
<div class="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-full bg-primary/10 text-xl font-extrabold text-primary">
<span><span data-age-value>{{ $brand.ageRestrictionDefault }}</span>+</span>
</div>
<h2 id="age-gate-title" class="text-2xl font-bold text-secondary">{{ i18n "ageGateTitle" }}</h2>
<p id="age-gate-desc" class="mt-3 text-secondary/80">
This website is intended for users aged <span data-age-value>{{ $brand.ageRestrictionDefault }}</span>+ only.
By entering, you confirm you meet the legal gambling age in your jurisdiction. Gamble responsibly.
</p>
<div class="mt-6 flex flex-col gap-3">
<button type="button" data-age-confirm class="btn-primary btn-lg">{{ i18n "ageGateConfirm" }}</button>
<button type="button" data-age-exit class="btn-ghost">{{ i18n "ageGateExit" }}</button>
</div>
<p class="mt-4 text-xs text-secondary/60">
Underage gambling is an offence. This is an independent affiliate resource, not the operator.
</p>
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
{{- /* breadcrumbs.html — visible breadcrumb trail + BreadcrumbList JSON-LD on all
non-home pages. Built from .Ancestors (reversed) + the current page. */ -}}
{{- if not .IsHome -}}
{{- $crumbs := slice -}}
{{- range .Ancestors.Reverse -}}
{{- $crumbs = $crumbs | append . -}}
{{- end -}}
{{- $crumbs = $crumbs | append . -}}
{{- $last := sub (len $crumbs) 1 -}}
<nav aria-label="Breadcrumb" class="site-container pt-6">
<ol class="flex flex-wrap items-center gap-1 text-sm text-secondary/60">
{{- range $i, $c := $crumbs -}}
{{- if $i }}<li aria-hidden="true" class="px-1 text-secondary/30">/</li>{{ end -}}
<li>
{{- if eq $i $last -}}
<span aria-current="page" class="font-medium text-secondary/80">{{ $c.LinkTitle }}</span>
{{- else -}}
<a href="{{ $c.RelPermalink }}" class="no-underline hover:text-primary hover:underline">{{ $c.LinkTitle }}</a>
{{- end -}}
</li>
{{- end -}}
</ol>
</nav>
{{- $items := slice -}}
{{- range $i, $c := $crumbs -}}
{{- $items = $items | append (dict "@type" "ListItem" "position" (add $i 1) "name" $c.LinkTitle "item" $c.Permalink) -}}
{{- end -}}
{{- $bc := dict "@context" "https://schema.org" "@type" "BreadcrumbList" "itemListElement" $items -}}
<script type="application/ld+json">{{ $bc | jsonify | safeJS }}</script>
{{- end -}}
+48
View File
@@ -0,0 +1,48 @@
{{- /* cookie-consent.html — GDPR consent banner (accept / reject / manage).
Non-essential scripts stay inert (type="text/plain" data-cookiecategory)
until the visitor consents; cookie-consent.js then activates them. */ -}}
<div id="cookie-consent" class="cookie-banner" role="region" aria-label="Cookie consent" hidden>
<div class="site-container">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-2xl text-sm text-secondary/80">
<p class="font-semibold text-secondary">{{ i18n "cookieTitle" }}</p>
<p class="mt-1">
We use strictly necessary cookies to run this site and, with your consent, analytics and
marketing cookies to improve it. You can change your choice at any time.
{{ with site.GetPage "cookie-policy" }}<a href="{{ .RelPermalink }}" class="font-medium text-primary underline">Read our Cookie Policy</a>.{{ end }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" data-cc-accept-all class="btn-primary">{{ i18n "cookieAcceptAll" }}</button>
<button type="button" data-cc-reject class="btn-secondary">{{ i18n "cookieReject" }}</button>
<button type="button" data-cc-manage class="btn-ghost">{{ i18n "cookieManage" }}</button>
</div>
</div>
<div data-cc-panel hidden class="mt-4">
<div class="grid gap-3 sm:grid-cols-3">
<div class="cookie-toggle">
<input type="checkbox" checked disabled class="mt-1" aria-label="Necessary cookies (always on)">
<span><span class="block font-semibold text-secondary">{{ i18n "cookieNecessary" }}</span><span class="text-xs text-secondary/70">Always active. Required for core functionality.</span></span>
</div>
<label class="cookie-toggle">
<input type="checkbox" data-cc-category="analytics" class="mt-1">
<span><span class="block font-semibold text-secondary">{{ i18n "cookieAnalytics" }}</span><span class="text-xs text-secondary/70">Anonymous usage statistics.</span></span>
</label>
<label class="cookie-toggle">
<input type="checkbox" data-cc-category="marketing" class="mt-1">
<span><span class="block font-semibold text-secondary">{{ i18n "cookieMarketing" }}</span><span class="text-xs text-secondary/70">Personalised offers and campaign measurement.</span></span>
</label>
</div>
<div class="mt-3">
<button type="button" data-cc-save class="btn-primary">{{ i18n "cookieSave" }}</button>
</div>
</div>
</div>
</div>
{{/* Example gated script — stays inert until the visitor accepts "analytics".
Replace the body with your real analytics snippet (e.g. GA4/Plausible). */}}
<script type="text/plain" data-cookiecategory="analytics">
// console.log("Analytics enabled after consent");
</script>
+39
View File
@@ -0,0 +1,39 @@
{{- /* cta-button.html — the ONLY place an affiliate link is constructed.
Link = brand.affiliateBaseUrl ? brand.affiliateSubIdParam = <per-page sub-id>.
Never hardcode a raw affiliate URL anywhere else.
Call with a dict:
{{ partial "cta-button.html" (dict "page" . "text" "Play Now") }}
Optional keys: subid, style ("primary"|"secondary"), size ("lg"), class, block(bool).
Sub-id resolution (robust when .File is nil for generated pages):
explicit subid -> .File.ContentBaseName -> .Type -> "home"
ContentBaseName equals the classic BaseFileName for standard pages and,
unlike BaseFileName, yields the real slug for page bundles (e.g. games). */ -}}
{{- $page := .page -}}
{{- $brand := $page.Site.Data.brand -}}
{{- $text := .text | default "Visit Casino" -}}
{{- $style := .style | default "primary" -}}
{{- $size := .size | default "" -}}
{{- $subid := .subid | default "" -}}
{{- if not $subid -}}
{{- if $page.IsHome -}}
{{- $subid = "home" -}}
{{- else -}}
{{- with $page.File -}}{{- $subid = .ContentBaseName -}}{{- end -}}
{{- end -}}
{{- end -}}
{{- if not $subid -}}{{- $subid = $page.Type -}}{{- end -}}
{{- if or (eq $subid "_index") (eq $subid "") -}}{{- $subid = "home" -}}{{- end -}}
{{- $href := printf "%s?%s=%s" $brand.affiliateBaseUrl $brand.affiliateSubIdParam $subid -}}
{{- $classes := "btn-primary" -}}
{{- if eq $style "secondary" -}}{{- $classes = "btn-secondary" -}}{{- end -}}
{{- if eq $style "ghost" -}}{{- $classes = "btn-ghost" -}}{{- end -}}
{{- if $size -}}{{- $classes = printf "%s btn-%s" $classes $size -}}{{- end -}}
{{- if .block -}}{{- $classes = printf "%s w-full" $classes -}}{{- end -}}
{{- with .class -}}{{- $classes = printf "%s %s" $classes . -}}{{- end -}}
<a href="{{ $href }}" class="{{ $classes }}" rel="sponsored nofollow noopener" target="_blank" data-affiliate data-subid="{{ $subid }}">{{ $text }}</a>
+27
View File
@@ -0,0 +1,27 @@
{{- /* faq-accordion.html — accordion over data/faq.yaml (sorted by weight).
Progressive enhancement: each item is a native <details>/<summary>, so
every answer is fully readable and keyboard-operable with NO JavaScript.
Alpine (when present) layers single-open accordion behaviour on top via
x-bind:open + the native `toggle` event — never hiding content with
x-cloak. The FAQPage JSON-LD is emitted separately by schema-jsonld.html. */ -}}
{{- $faqs := sort .Site.Data.faq "weight" -}}
<div class="mx-auto max-w-3xl divide-y divide-secondary/10 rounded-xl border border-secondary/10" x-data="{ open: null }">
{{- range $i, $f := $faqs -}}
<details class="group"
x-bind:open="open === {{ $i }}"
@toggle="open = $event.target.open ? {{ $i }} : (open === {{ $i }} ? null : open)">
<summary id="faq-btn-{{ $i }}" aria-controls="faq-panel-{{ $i }}"
class="flex w-full cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 text-left text-base font-semibold text-secondary [&::-webkit-details-marker]:hidden">
<span>{{ $f.question }}</span>
<svg class="h-5 w-5 shrink-0 text-primary transition-transform duration-200 group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
</svg>
</summary>
<div id="faq-panel-{{ $i }}" role="region" aria-labelledby="faq-btn-{{ $i }}"
class="px-5 pb-5 text-secondary/80">
<p>{{ $f.answer }}</p>
{{- with $f.category }}<p class="mt-2 text-xs uppercase tracking-wide text-secondary/70">{{ . }}</p>{{ end -}}
</div>
</details>
{{- end -}}
</div>
+86
View File
@@ -0,0 +1,86 @@
{{- /* footer.html — appears on every page. Includes (per spec): affiliate
disclosure link, licence/authority text (from data/brand.yaml),
responsible-gambling links, cookie policy link, an "18+ Gamble
Responsibly" badge and a dynamic copyright year. */ -}}
{{- $brand := .Site.Data.brand -}}
{{- $footerPages := where .Site.Pages "Params.showInFooter" true -}}
{{- $primary := sort (where $footerPages "Params.footerGroup" "primary") "Weight" -}}
{{- $legal := sort (where $footerPages "Params.footerGroup" "legal") "Weight" -}}
<footer class="mt-16 bg-secondary text-white">
<div class="site-container grid gap-10 py-12 md:grid-cols-2 lg:grid-cols-4">
{{/* Brand + social */}}
<div>
<div class="flex items-center gap-2">
{{ partial "responsive-image.html" (dict "src" $brand.logoPath "alt" (printf "%s logo" $brand.name) "class" "h-9 w-9") }}
<span class="text-lg font-extrabold">{{ $brand.name }}</span>
</div>
<p class="mt-3 text-sm text-white/70">{{ $brand.tagline }}</p>
<div class="mt-4 flex items-center gap-3">
{{- range $brand.socialLinks -}}
<a href="{{ .url }}" target="_blank" rel="noopener nofollow" class="text-white/70 hover:text-white" aria-label="{{ .platform | title }}">
{{- if eq .platform "twitter" -}}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.9 2H22l-7.5 8.6L23 22h-6.8l-5.3-7-6.1 7H1.7l8-9.2L1 2h7l4.8 6.4L18.9 2Zm-2.4 18h1.9L7.6 4H5.6l10.9 16Z"/></svg>
{{- else if eq .platform "facebook" -}}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22 12a10 10 0 1 0-11.6 9.9v-7H7.9V12h2.5V9.8c0-2.5 1.5-3.9 3.8-3.9 1.1 0 2.2.2 2.2.2v2.5h-1.2c-1.2 0-1.6.8-1.6 1.6V12h2.7l-.4 2.9h-2.3v7A10 10 0 0 0 22 12Z"/></svg>
{{- else if eq .platform "instagram" -}}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.2c3.2 0 3.6 0 4.9.1 1.2.1 1.8.3 2.2.4.6.2 1 .5 1.4.9.4.4.7.8.9 1.4.1.4.3 1 .4 2.2.1 1.3.1 1.7.1 4.9s0 3.6-.1 4.9c-.1 1.2-.3 1.8-.4 2.2-.2.6-.5 1-.9 1.4-.4.4-.8.7-1.4.9-.4.1-1 .3-2.2.4-1.3.1-1.7.1-4.9.1s-3.6 0-4.9-.1c-1.2-.1-1.8-.3-2.2-.4-.6-.2-1-.5-1.4-.9-.4-.4-.7-.8-.9-1.4-.1-.4-.3-1-.4-2.2C2.2 15.6 2.2 15.2 2.2 12s0-3.6.1-4.9c.1-1.2.3-1.8.4-2.2.2-.6.5-1 .9-1.4.4-.4.8-.7 1.4-.9.4-.1 1-.3 2.2-.4C8.4 2.2 8.8 2.2 12 2.2Zm0 3.2A6.6 6.6 0 1 0 18.6 12 6.6 6.6 0 0 0 12 5.4Zm0 10.9A4.3 4.3 0 1 1 16.3 12 4.3 4.3 0 0 1 12 16.3Zm6.9-11.1a1.5 1.5 0 1 1-1.5-1.5 1.5 1.5 0 0 1 1.5 1.5Z"/></svg>
{{- else if eq .platform "youtube" -}}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 12s0-3.2-.4-4.7a2.5 2.5 0 0 0-1.8-1.8C19.3 5 12 5 12 5s-7.3 0-8.8.5A2.5 2.5 0 0 0 1.4 7.3C1 8.8 1 12 1 12s0 3.2.4 4.7a2.5 2.5 0 0 0 1.8 1.8C4.7 19 12 19 12 19s7.3 0 8.8-.5a2.5 2.5 0 0 0 1.8-1.8C23 15.2 23 12 23 12Zm-13 3V9l5.2 3-5.2 3Z"/></svg>
{{- else -}}
<span class="text-sm">{{ .platform | title }}</span>
{{- end -}}
</a>
{{- end -}}
</div>
</div>
{{/* Explore */}}
<nav aria-label="Footer">
<h2 class="text-sm font-semibold uppercase tracking-wider text-white/60">{{ i18n "footerExplore" }}</h2>
<ul class="mt-3 space-y-2 text-sm">
{{- range $primary -}}
<li><a href="{{ .RelPermalink }}" class="text-white/80 no-underline hover:text-white hover:underline">{{ .LinkTitle }}</a></li>
{{- end -}}
</ul>
</nav>
{{/* Legal & safety */}}
<nav aria-label="Legal and safety">
<h2 class="text-sm font-semibold uppercase tracking-wider text-white/60">{{ i18n "footerLegal" }}</h2>
<ul class="mt-3 space-y-2 text-sm">
{{- range $legal -}}
<li><a href="{{ .RelPermalink }}" class="text-white/80 no-underline hover:text-white hover:underline">{{ .LinkTitle }}</a></li>
{{- end -}}
<li>
<button type="button" onclick="window.openCookieSettings && window.openCookieSettings()" class="text-white/80 underline-offset-2 hover:text-white hover:underline">{{ i18n "cookieSettings" }}</button>
</li>
</ul>
</nav>
{{/* Responsible gambling summary */}}
<div>
<h2 class="text-sm font-semibold uppercase tracking-wider text-white/60">{{ i18n "footerResponsible" }}</h2>
<div class="mt-3 text-sm text-white/80">
{{ partial "responsible-gambling-block.html" (dict "page" . "variant" "compact") }}
</div>
</div>
</div>
{{ partial "trust-badges.html" . }}
{{/* Licence + copyright bar */}}
<div class="border-t border-white/10">
<div class="site-container flex flex-col gap-3 py-6 text-xs text-white/60 md:flex-row md:items-center md:justify-between">
<p>
<span class="mr-2 inline-flex items-center rounded bg-white/10 px-2 py-1 font-bold text-white"><span data-age-value>{{ $brand.ageRestrictionDefault }}</span>+ Gamble Responsibly</span>
{{ $brand.legalOperatorName }} is licensed and regulated by the {{ $brand.licenseAuthority }} (Licence No. {{ $brand.licenseNumber }}).
</p>
<p>
&copy; {{ now.Year }} {{ $brand.name }}. {{ i18n "allRightsReserved" }}
{{ with site.GetPage "affiliate-disclosure" }}<a href="{{ .RelPermalink }}" class="ml-2 text-white/80 underline hover:text-white">{{ i18n "affiliateDisclosure" }}</a>{{ end }}
</p>
</div>
</div>
</footer>
+77
View File
@@ -0,0 +1,77 @@
{{- /* games-filter.html — Alpine.js-powered filter for the slots library.
Call with (dict "page" . "games" <slice of game pages>).
Cards are rendered server-side (so thumbnails use the image pipeline) and
shown/hidden by the gamesFilter Alpine component (assets/js/games-filter.js). */ -}}
{{- $games := .games -}}
{{- $providers := slice -}}
{{- range $games -}}{{ with .Params.provider }}{{ $providers = $providers | append . }}{{ end }}{{- end -}}
{{- $providers = $providers | uniq | sort -}}
{{- $categories := slice -}}
{{- range $games -}}{{ with .Params.category }}{{ $categories = $categories | append . }}{{ end }}{{- end -}}
{{- $categories = $categories | uniq | sort -}}
<div x-data="gamesFilter()" x-init="recount()" x-effect="recount()">
{{/* Controls */}}
<div class="grid gap-3 rounded-xl border border-secondary/10 bg-secondary/5 p-4 sm:grid-cols-3">
<div>
<label for="game-search" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-secondary/60">{{ i18n "filterSearch" }}</label>
<input id="game-search" type="search" x-model="search" placeholder="Game name&hellip;"
class="w-full rounded-md border border-secondary/20 px-3 py-2 text-sm focus-visible:outline-primary">
</div>
<div>
<label for="game-provider" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-secondary/60">{{ i18n "filterProvider" }}</label>
<select id="game-provider" x-model="provider" class="w-full rounded-md border border-secondary/20 px-3 py-2 text-sm focus-visible:outline-primary">
<option value="all">{{ i18n "filterAllProviders" }}</option>
{{- range $providers }}<option value="{{ . }}">{{ . }}</option>{{ end -}}
</select>
</div>
<div>
<label for="game-category" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-secondary/60">{{ i18n "filterCategory" }}</label>
<select id="game-category" x-model="category" class="w-full rounded-md border border-secondary/20 px-3 py-2 text-sm focus-visible:outline-primary">
<option value="all">{{ i18n "filterAllCategories" }}</option>
{{- range $categories }}<option value="{{ . }}">{{ . | title }}</option>{{ end -}}
</select>
</div>
</div>
<div class="mt-3 flex items-center justify-between text-sm text-secondary/60">
<p><span x-text="visibleCount"></span> game(s) shown</p>
<button type="button" @click="reset()" class="font-semibold text-primary hover:underline">{{ i18n "filterReset" }}</button>
</div>
{{/* Cards */}}
<div class="mt-5 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{{- range $games -}}
{{- $img := .Resources.GetMatch "thumb.*" -}}
<article data-game
data-provider="{{ .Params.provider }}"
data-category="{{ .Params.category }}"
data-title="{{ .Title | lower }}"
x-show="cardVisible($el)" x-transition.opacity
class="card card-hover overflow-hidden p-0">
<div class="aspect-[3/2] w-full overflow-hidden bg-secondary/10">
{{ partial "responsive-image.html" (dict
"resource" $img
"alt" (printf "%s by %s" .Title (.Params.provider | default "provider"))
"class" "h-full w-full object-cover"
"widths" (slice 300 600)
"sizes" "(min-width:1024px) 300px, (min-width:640px) 50vw, 100vw") }}
</div>
<div class="p-4">
<div class="flex items-center justify-between gap-2">
<span class="badge-primary">{{ .Params.provider }}</span>
<span class="badge-muted capitalize">{{ .Params.category }}</span>
</div>
<h3 class="mt-2 text-lg font-bold"><a href="{{ .RelPermalink }}" class="no-underline hover:underline">{{ .Title }}</a></h3>
{{ with .Params.rtp }}<p class="mt-1 text-sm text-secondary/60">RTP {{ . }}%</p>{{ end }}
</div>
</article>
{{- end -}}
</div>
<p x-show="visibleCount === 0" x-cloak class="mt-6 rounded-lg bg-secondary/5 p-6 text-center text-secondary/70">
No games match your filters. <button type="button" @click="reset()" class="font-semibold text-primary underline">Reset</button>
</p>
</div>
+72
View File
@@ -0,0 +1,72 @@
{{- /* head-seo.html — <title>, meta description, canonical, Open Graph, Twitter,
RSS alternate, and JSON-LD (delegated to schema-jsonld.html). All values
fall back sensibly to brand data. */ -}}
{{- $brand := .Site.Data.brand -}}
{{- $title := "" -}}
{{- if .IsHome -}}
{{- $title = printf "%s | %s" $brand.name $brand.tagline -}}
{{- else -}}
{{- $title = printf "%s | %s" (.Params.title | default .Title) $brand.name -}}
{{- end -}}
{{- $desc := "" -}}
{{- with .Params.description -}}
{{- $desc = . -}}
{{- else -}}
{{- $desc = .Summary | default .Site.Params.defaultDescription -}}
{{- end -}}
{{- $desc = $desc | plainify | chomp | truncate 160 -}}
{{- /* Per-page OG image override (.Params.image) with fallback to the site default. */ -}}
{{- $ogImage := (.Params.image | default .Site.Params.ogImage) | absURL -}}
<title>{{ $title }}</title>
<meta name="description" content="{{ $desc }}">
<link rel="canonical" href="{{ .Permalink }}">
<meta name="robots" content="{{ if .Params.noindex }}noindex,nofollow{{ else }}index,follow{{ end }}">
<meta name="author" content="{{ $brand.name }}">
{{- /* Open Graph */ -}}
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:title" content="{{ $title }}">
<meta property="og:description" content="{{ $desc }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:site_name" content="{{ $brand.name }}">
<meta property="og:locale" content="{{ replace $brand.defaultLocale "-" "_" }}">
<meta property="og:image" content="{{ $ogImage }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{{- /* Twitter */ -}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $title }}">
<meta name="twitter:description" content="{{ $desc }}">
<meta name="twitter:image" content="{{ $ogImage }}">
{{- /* hreflang alternates for the multilingual build. No-op on single-language
sites (.IsTranslated is false), so it is safe to keep permanently. URL
scheme: defaultContentLanguageInSubdir = false (English at root). */ -}}
{{- if .IsTranslated -}}
{{- range .AllTranslations -}}
<link rel="alternate" hreflang="{{ .Language.LanguageCode | default .Language.Lang }}" href="{{ .Permalink }}">
{{- end -}}
{{- /* x-default must point at the site's DEFAULT content language (English at
root), independent of the page currently being rendered. site.Languages
is ordered by weight, so index 0 is the default language. */ -}}
{{- $defaultLang := (index site.Languages 0).Lang -}}
{{- range .AllTranslations -}}
{{- if eq .Language.Lang $defaultLang -}}
<link rel="alternate" hreflang="x-default" href="{{ .Permalink }}">
{{- end -}}
{{- end -}}
{{- range .Translations -}}
<meta property="og:locale:alternate" content="{{ replace (.Language.LanguageCode | default .Language.Lang) "-" "_" }}">
{{- end -}}
{{- end }}
{{ with .OutputFormats.Get "RSS" -}}
<link rel="alternate" type="application/rss+xml" href="{{ .RelPermalink }}" title="{{ $brand.name }} RSS">
{{- end }}
{{ partial "schema-jsonld.html" . }}
+28
View File
@@ -0,0 +1,28 @@
{{- /* header.html — sticky site header: logo (brand name from data), primary nav,
language switcher and a desktop affiliate CTA.
The region/compliance data layer (age/RG/currency/payment filtering) is
applied headlessly by locale-switcher.html — it no longer renders a visible
selector, but its script must still run on every page. */ -}}
<header class="sticky top-0 z-40 border-b border-secondary/10 bg-white/95 backdrop-blur">
<div class="site-container flex h-16 items-center gap-3 md:gap-4">
<a href="{{ "/" | relURL }}" class="flex shrink-0 items-center gap-2 no-underline" aria-label="{{ .Site.Data.brand.name }} home">
{{ partial "responsive-image.html" (dict
"src" .Site.Data.brand.logoPath
"alt" (printf "%s logo" .Site.Data.brand.name)
"class" "h-9 w-9"
"loading" "eager") }}
<span class="text-lg font-extrabold tracking-tight text-secondary">{{ .Site.Data.brand.name }}</span>
</a>
{{ partial "nav.html" . }}
<div class="ml-auto flex shrink-0 items-center gap-2">
{{ if gt (len site.Languages) 1 }}{{ partial "language-switcher.html" . }}{{ end }}
<span class="hidden sm:inline-flex">
{{ partial "cta-button.html" (dict "page" . "text" (i18n "playNow")) }}
</span>
</div>
</div>
{{ partial "locale-switcher.html" . }}
</header>
+32
View File
@@ -0,0 +1,32 @@
{{- /* language-switcher.html — content LANGUAGE switcher (distinct from the region
switcher). The region switcher (locale-switcher.html) changes compliance
chrome (age/RG/currency) client-side; THIS switcher navigates to the
translated page of the SAME content (linked via front-matter translationKey).
Renders nothing on single-language builds. */ -}}
{{- if gt (len site.Languages) 1 -}}
<div class="relative">
<label for="language-switcher" class="sr-only">{{ i18n "selectLanguage" | default "Language" }}</label>
<select id="language-switcher" data-language-switcher
onchange="if(this.value){window.location.href=this.value;}"
class="rounded-md border border-secondary/20 bg-white py-1.5 pl-2 pr-7 text-sm text-secondary focus-visible:outline-primary">
{{- $cur := . -}}
{{- range site.Languages -}}
{{- $lang := . -}}
{{- $isCurrent := eq $lang.Lang $cur.Language.Lang -}}
{{- $href := "" -}}
{{- /* Prefer the direct translation of the current page… */ -}}
{{- range $cur.AllTranslations -}}
{{- if eq .Language.Lang $lang.Lang -}}{{- $href = .RelPermalink -}}{{- end -}}
{{- end -}}
{{- /* …otherwise fall back to that language's home page. */ -}}
{{- if not $href -}}
{{- with site.Home.AllTranslations -}}
{{- range . -}}{{- if eq .Language.Lang $lang.Lang -}}{{- $href = .RelPermalink -}}{{- end -}}{{- end -}}
{{- end -}}
{{- end -}}
{{- if not $href -}}{{- $href = "/" -}}{{- end -}}
<option value="{{ $href }}"{{ if $isCurrent }} selected{{ end }}>{{ $lang.LanguageName | default (upper $lang.Lang) }}</option>
{{- end -}}
</select>
</div>
{{- end -}}
+80
View File
@@ -0,0 +1,80 @@
{{- /* locale-switcher.html — HEADLESS region/compliance data layer.
This partial no longer renders a visible region selector (the header now
exposes only the content language switcher). It still applies the stored
or default locale on load so all locale-dependent COMPLIANCE bindings
render correctly across the site:
[data-age-value] age restriction
[data-rg-name] responsible-gambling org name
[data-rg-link] responsible-gambling URL
[data-locale-currency]currency symbol (payment table)
[data-payment-row] show only methods available in the locale
[data-kyc-row] highlight the matching KYC row
Data comes from data/locales.yaml (exposed as window.__LOCALES__).
window.applyLocale(loc) and the "localechange" event remain the public
API, so a region selector can be reintroduced later without touching the
compliance logic. */ -}}
<script>
(function () {
function apply(loc) {
var locales = window.__LOCALES__ || [];
var entry = null;
for (var i = 0; i < locales.length; i++) {
if (locales[i].locale === loc) { entry = locales[i]; break; }
}
if (!entry) {
if (!locales.length) return;
entry = locales[0];
loc = entry.locale;
}
try { localStorage.setItem("siteLocale", loc); } catch (e) {}
document.querySelectorAll("[data-age-value]").forEach(function (el) {
el.textContent = entry.ageRestriction;
});
document.querySelectorAll("[data-rg-name]").forEach(function (el) {
el.textContent = entry.responsibleGamblingOrgName;
});
document.querySelectorAll("[data-rg-link]").forEach(function (el) {
el.setAttribute("href", entry.responsibleGamblingUrl);
});
document.querySelectorAll("[data-locale-currency]").forEach(function (el) {
el.textContent = entry.currencySymbol;
});
// Show only payment methods available in the selected locale.
document.querySelectorAll("[data-payment-row]").forEach(function (row) {
var locs = (row.getAttribute("data-locales") || "").split(",");
row.hidden = locs.indexOf(loc) === -1;
});
// Highlight the KYC row matching the selected locale.
document.querySelectorAll("[data-kyc-row]").forEach(function (row) {
row.classList.toggle("is-active-locale", row.getAttribute("data-locale") === loc);
});
// Kept for backwards compatibility if a region selector is reintroduced.
document.querySelectorAll("[data-locale-switcher]").forEach(function (sel) {
sel.value = loc;
});
document.dispatchEvent(new CustomEvent("localechange", { detail: { locale: loc, entry: entry } }));
}
window.applyLocale = apply;
function init() {
var brand = window.__BRAND__ || {};
var stored = null;
try { stored = localStorage.getItem("siteLocale"); } catch (e) {}
apply(stored || brand.defaultLocale);
// If a region selector is present (reintroduced later), wire it up.
document.querySelectorAll("[data-locale-switcher]").forEach(function (sel) {
sel.addEventListener("change", function () { apply(this.value); });
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
+47
View File
@@ -0,0 +1,47 @@
{{- /* nav.html — primary navigation built from pages with showInNav: true,
ordered by weight. Desktop = inline list; mobile = Alpine.js toggle. */ -}}
{{- $current := . -}}
{{- $navPages := sort (where .Site.Pages "Params.showInNav" true) "Weight" -}}
{{/* Desktop — min-w-0 lets the nav shrink inside the flex row; overflow-x-auto
keeps a long section list on one line and scrollable instead of pushing the
header past the viewport. */}}
<nav aria-label="Primary" class="hidden min-w-0 flex-1 lg:block">
<ul class="nav-scroll flex items-center gap-1 overflow-x-auto whitespace-nowrap">
{{- range $navPages -}}
{{- $active := eq $current.RelPermalink .RelPermalink -}}
<li class="shrink-0">
<a href="{{ .RelPermalink }}" class="nav-link{{ if $active }} nav-link-active{{ end }}"{{ if $active }} aria-current="page"{{ end }}>{{ .LinkTitle }}</a>
</li>
{{- end -}}
</ul>
</nav>
{{/* Mobile — native <details> so the menu works with NO JavaScript; Alpine adds
escape-to-close and click-outside-to-close when available. */}}
<details class="group relative lg:hidden"
x-data="{ open: false }"
x-bind:open="open"
@toggle="open = $event.target.open"
@keydown.escape.window="open = false"
@click.outside="open = false">
<summary class="inline-flex cursor-pointer list-none items-center justify-center rounded-md p-2 text-secondary hover:bg-secondary/5 [&::-webkit-details-marker]:hidden"
aria-controls="mobile-menu" aria-label="{{ i18n "toggleMenu" }}">
<svg class="h-6 w-6 group-open:hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg class="hidden h-6 w-6 group-open:block" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</summary>
<div id="mobile-menu"
class="absolute inset-x-0 top-16 z-40 max-h-[calc(100vh-4rem)] overflow-y-auto border-b border-secondary/10 bg-white p-4 shadow-lg">
<ul class="flex flex-col gap-1">
{{- range $navPages -}}
<li><a href="{{ .RelPermalink }}" class="nav-link block">{{ .LinkTitle }}</a></li>
{{- end -}}
<li class="pt-3">{{ partial "cta-button.html" (dict "page" $current "text" (i18n "playNow") "block" true) }}</li>
</ul>
</div>
</details>
+50
View File
@@ -0,0 +1,50 @@
{{- /* payment-table.html — renders data/payment_methods.yaml.
Call with (dict "page" . "locale" "<optional locale>").
- If "locale" is given, methods are filtered server-side to that market.
- Otherwise ALL methods render, and each row carries data-locales so the
locale switcher can show/hide rows for the visitor's selected region. */ -}}
{{- $page := .page -}}
{{- $brand := $page.Site.Data.brand -}}
{{- $methods := $page.Site.Data.payment_methods -}}
{{- $locale := .locale -}}
{{- if $locale -}}
{{- $f := slice -}}
{{- range $methods -}}{{ if in .availableLocales $locale }}{{ $f = $f | append . }}{{ end }}{{- end -}}
{{- $methods = $f -}}
{{- end -}}
{{- $curr := "£" -}}
{{- with where $page.Site.Data.locales "locale" $brand.defaultLocale -}}{{- $curr = (index . 0).currencySymbol -}}{{- end -}}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th scope="col">Method</th>
<th scope="col">Type</th>
<th scope="col">Min. deposit</th>
<th scope="col">Max. deposit</th>
<th scope="col">Processing time</th>
<th scope="col">Fees</th>
</tr>
</thead>
<tbody>
{{- range $methods -}}
<tr data-payment-row data-locales="{{ delimit .availableLocales "," }}">
<th scope="row" class="font-medium">
<span class="flex items-center gap-2">
<img src="{{ .logoPath | relURL }}" alt="{{ .name }} logo" width="40" height="24" loading="lazy" class="h-6 w-auto rounded-sm border border-secondary/10">
{{ .name }}
</span>
</th>
<td><span class="badge-muted capitalize">{{ .type }}</span></td>
<td><span data-locale-currency>{{ $curr }}</span>{{ .minDeposit }}</td>
<td><span data-locale-currency>{{ $curr }}</span>{{ .maxDeposit }}</td>
<td>{{ .processingTime }}</td>
<td>{{ .fees }}</td>
</tr>
{{- end -}}
</tbody>
</table>
</div>
<p class="mt-2 text-xs text-secondary/70">Availability and limits vary by region and are shown for your selected market. Select your region in the header to update this table.</p>
+26
View File
@@ -0,0 +1,26 @@
{{- /* pros-cons-table.html — reads .Params.pros and .Params.cons (arrays of
strings) from the page front matter and renders a two-column comparison. */ -}}
{{- $pros := .Params.pros -}}
{{- $cons := .Params.cons -}}
{{- if or $pros $cons -}}
<div class="grid gap-4 sm:grid-cols-2">
<div class="card border-emerald-200 bg-emerald-50">
<h3 class="flex items-center gap-2 text-lg font-bold text-emerald-800">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Pros
</h3>
<ul class="mt-3 space-y-2 text-sm text-emerald-900">
{{- range $pros }}<li class="flex gap-2"><span aria-hidden="true" class="text-emerald-500">&#10003;</span><span>{{ . }}</span></li>{{ end -}}
</ul>
</div>
<div class="card border-rose-200 bg-rose-50">
<h3 class="flex items-center gap-2 text-lg font-bold text-rose-800">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
Cons
</h3>
<ul class="mt-3 space-y-2 text-sm text-rose-900">
{{- range $cons }}<li class="flex gap-2"><span aria-hidden="true" class="text-rose-500">&#10007;</span><span>{{ . }}</span></li>{{ end -}}
</ul>
</div>
</div>
{{- end -}}
+21
View File
@@ -0,0 +1,21 @@
{{- /* rating-widget.html — editorial star rating. Uses page front matter
(rating / ratingCount) with a fallback to brand.yaml. */ -}}
{{- $brand := .Site.Data.brand -}}
{{- $rating := .Params.rating | default $brand.ratingValue -}}
{{- $count := .Params.ratingCount | default $brand.ratingCount -}}
{{- $rounded := int (math.Round $rating) -}}
<div class="card flex flex-wrap items-center gap-5">
<div class="text-5xl font-extrabold leading-none text-primary">
{{ $rating }}<span class="text-xl font-semibold text-secondary/40">/5</span>
</div>
<div>
<div class="flex gap-0.5" role="img" aria-label="Rated {{ $rating }} out of 5">
{{- range seq 5 -}}
<svg class="h-6 w-6 {{ if le . $rounded }}text-amber-400{{ else }}text-secondary/20{{ end }}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l2.9 6.3 6.9.7-5.1 4.7 1.4 6.8L12 17.8 5.9 20.5l1.4-6.8L2.2 9l6.9-.7L12 2z"/>
</svg>
{{- end -}}
</div>
<p class="mt-1 text-sm text-secondary/60">Independent editorial score based on {{ $count }} data points.</p>
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
{{- /* related-pages.html — renders the current page's relatedPages front-matter
(a list of slugs) as cards. Matches by .Slug or file base name. */ -}}
{{- $current := . -}}
{{- $related := .Params.relatedPages -}}
{{- if $related -}}
{{- $found := slice -}}
{{- range $slug := $related -}}
{{- range $p := $current.Site.Pages -}}
{{- if and (ne $p $current) (not $p.IsHome) -}}
{{- if or (eq $p.Slug $slug) (and $p.File (eq $p.File.ContentBaseName $slug)) -}}
{{- $found = $found | append $p -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- with $found -}}
<section class="site-container section" aria-labelledby="related-heading">
<h2 id="related-heading" class="text-xl font-bold">Related pages</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{- range . -}}
<a href="{{ .RelPermalink }}" class="card card-hover block no-underline">
<span class="badge-primary">{{ .LinkTitle }}</span>
<p class="mt-2 text-sm text-secondary/70">{{ .Params.description | default (.Summary | plainify) | truncate 110 }}</p>
<span class="mt-3 inline-block text-sm font-semibold text-primary">Read more &rarr;</span>
</a>
{{- end -}}
</div>
</section>
{{- end -}}
{{- end -}}
@@ -0,0 +1,64 @@
{{- /* responsible-gambling-block.html — locale-aware RG messaging.
Call with (dict "page" . "variant" "compact"|"full").
Server renders the DEFAULT locale; the locale switcher updates the
[data-age-value] / [data-rg-name] / [data-rg-link] elements client-side. */ -}}
{{- $page := .page -}}
{{- $variant := .variant | default "full" -}}
{{- $brand := $page.Site.Data.brand -}}
{{- $locales := $page.Site.Data.locales -}}
{{- $default := "" -}}
{{- with where $locales "locale" $brand.defaultLocale -}}{{- $default = index . 0 -}}{{- end -}}
{{- $age := $brand.ageRestrictionDefault -}}
{{- $orgName := "your national support service" -}}
{{- $orgUrl := "#" -}}
{{- with $default -}}
{{- $age = .ageRestriction -}}
{{- $orgName = .responsibleGamblingOrgName -}}
{{- $orgUrl = .responsibleGamblingUrl -}}
{{- end -}}
{{- if eq $variant "compact" -}}
<p>This site is intended for adults aged <span data-age-value>{{ $age }}</span>+ only. Gambling should be entertainment, never a way to make money.</p>
<p class="mt-2">Need help? Contact <a data-rg-link href="{{ $orgUrl }}" target="_blank" rel="noopener nofollow" class="font-semibold text-white underline"><span data-rg-name>{{ $orgName }}</span></a>.</p>
{{- else -}}
<div class="prose-content">
<p>
Gambling must stay fun. This site is intended for adults aged
<strong><span data-age-value>{{ $age }}</span>+</strong> only. Never bet more than you can afford to
lose, never chase losses, and never gamble to escape stress or debt. If it stops being enjoyable,
it is time to stop.
</p>
<h3>Tools to stay in control</h3>
<ul>
<li><strong>Deposit limits</strong> &mdash; cap how much you can add to your account per day, week or month.</li>
<li><strong>Loss &amp; wager limits</strong> &mdash; set a ceiling on stakes or net losses over a period.</li>
<li><strong>Reality checks</strong> &mdash; on-screen reminders of how long you have been playing.</li>
<li><strong>Time-outs</strong> &mdash; lock yourself out for 24 hours up to several weeks.</li>
<li><strong>Self-exclusion</strong> &mdash; block access for months or years, including national schemes.</li>
</ul>
<h3>Find local support</h3>
<p>
Your currently selected region recommends
<a data-rg-link href="{{ $orgUrl }}" target="_blank" rel="noopener nofollow"><span data-rg-name>{{ $orgName }}</span></a>.
Support services for every market we cover are listed below &mdash; all offer free, confidential help.
</p>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr><th scope="col">Region</th><th scope="col">Min. age</th><th scope="col">Support organisation</th></tr>
</thead>
<tbody>
{{- range $locales -}}
<tr>
<td>{{ .label | default .locale }}</td>
<td>{{ .ageRestriction }}+</td>
<td><a href="{{ .responsibleGamblingUrl }}" target="_blank" rel="noopener nofollow">{{ .responsibleGamblingOrgName }}</a></td>
</tr>
{{- end -}}
</tbody>
</table>
</div>
</div>
{{- end -}}
+46
View File
@@ -0,0 +1,46 @@
{{- /* responsive-image.html — safe image renderer.
Resize/Fit only work on raster resources (png/jpg/webp/gif). This partial:
- resizes + builds srcset for raster Hugo resources,
- falls back to a plain <img> for SVG resources or static paths,
- falls back to a CSS gradient placeholder when nothing resolves.
A missing image can therefore NEVER break the build.
Call with a dict. Provide ONE of:
resource : a Hugo resource (assets/ or page bundle), OR
src : a string path (e.g. "/images/logo.svg")
Optional: alt, class, loading ("lazy"|"eager"), sizes, widths (slice of ints). */ -}}
{{- $r := .resource -}}
{{- $src := .src -}}
{{- $alt := .alt | default "" -}}
{{- $class := .class | default "" -}}
{{- $loading := .loading | default "lazy" -}}
{{- $sizes := .sizes | default "100vw" -}}
{{- $widths := .widths | default (slice 400 800 1200) -}}
{{- if $r -}}
{{- $sub := $r.MediaType.SubType -}}
{{- if in (slice "png" "jpg" "jpeg" "webp" "gif") $sub -}}
{{- $ow := $r.Width -}}
{{- $srcset := slice -}}
{{- range $widths -}}
{{- if le . $ow -}}
{{- $img := $r.Resize (printf "%dx" .) -}}
{{- $srcset = $srcset | append (printf "%s %dw" $img.RelPermalink $img.Width) -}}
{{- end -}}
{{- end -}}
{{- $fallback := $r -}}
{{- if gt $ow 1200 -}}{{- $fallback = $r.Resize "1200x" -}}{{- end -}}
<img
src="{{ $fallback.RelPermalink }}"
{{ with $srcset }}srcset="{{ delimit . ", " }}" sizes="{{ $sizes }}"{{ end }}
width="{{ $fallback.Width }}" height="{{ $fallback.Height }}"
alt="{{ $alt }}" loading="{{ $loading }}" decoding="async"
{{ with $class }}class="{{ . }}"{{ end }}>
{{- else -}}
<img src="{{ $r.RelPermalink }}" alt="{{ $alt }}" loading="{{ $loading }}" decoding="async"{{ with $class }} class="{{ . }}"{{ end }}>
{{- end -}}
{{- else if $src -}}
<img src="{{ $src | relURL }}" alt="{{ $alt }}" loading="{{ $loading }}" decoding="async"{{ with $class }} class="{{ . }}"{{ end }}>
{{- else -}}
<div class="img-placeholder {{ $class }}" role="img" aria-label="{{ $alt }}"></div>
{{- end -}}
+57
View File
@@ -0,0 +1,57 @@
{{- /* schema-jsonld.html — emits JSON-LD based on the page's front-matter
schemaType. BreadcrumbList is emitted separately by breadcrumbs.html.
Uses dict + jsonify so all values are safely escaped. */ -}}
{{- $brand := .Site.Data.brand -}}
{{- $type := .Params.schemaType | default "none" -}}
{{- if eq $type "Organization" -}}
{{- $sameAs := slice -}}
{{- range $brand.socialLinks -}}
{{- $sameAs = $sameAs | append .url -}}
{{- end -}}
{{- $data := dict
"@context" "https://schema.org"
"@type" "Organization"
"name" $brand.name
"url" .Site.BaseURL
"logo" ($brand.logoPath | absURL)
"description" $brand.tagline
"email" $brand.contactEmail
"foundingDate" (printf "%d" (int $brand.foundedYear))
"sameAs" $sameAs
-}}
<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>
{{- else if eq $type "FAQPage" -}}
{{- $items := slice -}}
{{- range .Site.Data.faq -}}
{{- $items = $items | append (dict
"@type" "Question"
"name" .question
"acceptedAnswer" (dict "@type" "Answer" "text" .answer)
) -}}
{{- end -}}
{{- $data := dict
"@context" "https://schema.org"
"@type" "FAQPage"
"mainEntity" $items
-}}
<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>
{{- else if eq $type "Article" -}}
{{- /* The About page is an editorial review, so we emit a Review with the
promoted operator as the reviewed item. */ -}}
{{- $data := dict
"@context" "https://schema.org"
"@type" "Review"
"headline" (.Params.h1 | default .Title)
"url" .Permalink
"datePublished" (.Date.Format "2006-01-02")
"dateModified" (.Lastmod.Format "2006-01-02")
"author" (dict "@type" "Organization" "name" $brand.name)
"publisher" (dict "@type" "Organization" "name" $brand.name "logo" (dict "@type" "ImageObject" "url" ($brand.logoPath | absURL)))
"itemReviewed" (dict "@type" "Organization" "name" $brand.legalOperatorName)
"reviewRating" (dict "@type" "Rating" "ratingValue" $brand.ratingValue "bestRating" 5 "worstRating" 1)
-}}
<script type="application/ld+json">{{ $data | jsonify | safeJS }}</script>
{{- end -}}
+22
View File
@@ -0,0 +1,22 @@
{{- /* trust-badges.html — compliance/trust strip shown in the footer and reusable
elsewhere. Uses the licence authority + age from brand data. */ -}}
{{- $brand := .Site.Data.brand -}}
<div class="border-t border-white/10 bg-black/20">
<div class="site-container flex flex-wrap items-center justify-center gap-4 py-5 text-xs text-white/70">
<span class="inline-flex items-center gap-2 rounded-full border border-white/20 px-3 py-1 font-semibold">
<span class="grid h-5 w-5 place-items-center rounded-full bg-white/20"><span data-age-value>{{ $brand.ageRestrictionDefault }}</span></span>
Over-age play only
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-white/20 px-3 py-1">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3l7 4v5c0 4.4-3 8.3-7 9-4-0.7-7-4.6-7-9V7l7-4z"/></svg>
{{ $brand.licenseAuthority }}
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-white/20 px-3 py-1">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
SSL secured
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-white/20 px-3 py-1">
Independent affiliate &mdash; not the operator
</span>
</div>
</div>
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: {{ "sitemap.xml" | absURL }}
+10
View File
@@ -0,0 +1,10 @@
{{- /* {{< cta text="Play Now" style="primary" size="lg" subid="custom" >}}
Reuses partials/cta-button.html so the affiliate link is built in ONE place. */ -}}
<div class="my-6">
{{ partial "cta-button.html" (dict
"page" .Page
"text" (.Get "text" | default "Play Now")
"style" (.Get "style")
"size" (.Get "size")
"subid" (.Get "subid")) }}
</div>
+9
View File
@@ -0,0 +1,9 @@
{{- /* Paired shortcode for an inline, no-JS FAQ item:
{{< faq-item question="How do I ...?" >}}
Markdown answer body.
{{< /faq-item >}} */ -}}
{{- $q := .Get "question" | default (.Get 0) -}}
<details class="my-3 rounded-lg border border-secondary/10 p-4">
<summary class="cursor-pointer font-semibold text-secondary">{{ $q }}</summary>
<div class="prose-content mt-3">{{ .Inner | markdownify }}</div>
</details>
+5
View File
@@ -0,0 +1,5 @@
{{- /* {{< payment-table >}} or {{< payment-table locale="de-DE" >}}
Renders data/payment_methods.yaml via partials/payment-table.html. */ -}}
<div class="my-6">
{{ partial "payment-table.html" (dict "page" .Page "locale" (.Get "locale")) }}
</div>
+31
View File
@@ -0,0 +1,31 @@
{{ define "main" }}
{{ partial "breadcrumbs.html" . }}
<div class="site-container section">
<header class="mb-8 max-w-3xl">
<h1 class="text-3xl font-extrabold tracking-tight sm:text-4xl">{{ .Params.h1 | default .Title }}</h1>
{{ with .Params.description }}<p class="mt-3 text-lg text-secondary/70">{{ . }}</p>{{ end }}
</header>
{{ with .Content }}<div class="prose-content max-w-3xl">{{ . }}</div>{{ end }}
{{ $games := sort (where .Site.RegularPages "Section" "slots") "Title" }}
<section class="mt-10" aria-label="Game library">
{{ if $games }}
{{ partial "games-filter.html" (dict "page" . "games" $games) }}
{{ else }}
<p class="rounded-lg bg-secondary/5 p-6 text-secondary/70">No games have been added yet. Add page bundles under <code>content/slots/games/&lt;slug&gt;/index.md</code>.</p>
{{ end }}
</section>
{{ if not .Params.hideCta }}
<div class="mt-12 max-w-3xl rounded-2xl border border-primary/20 bg-primary/5 p-6">
<h2 class="text-xl font-bold">Ready to spin?</h2>
<p class="mt-1 text-secondary/70"><span data-age-value>{{ .Site.Data.brand.ageRestrictionDefault }}</span>+ only. T&amp;Cs apply. Please gamble responsibly.</p>
<div class="mt-4">{{ partial "cta-button.html" (dict "page" . "text" "Play the Slots" "size" "lg") }}</div>
</div>
{{ end }}
</div>
{{ partial "related-pages.html" . }}
{{ end }}
+3245
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "hugo-igaming-affiliate-template",
"version": "1.0.0",
"description": "Reusable Hugo Extended static-site template for single-brand iGaming (online casino) affiliate websites. One deployment = one brand, driven entirely by config + data + content.",
"private": true,
"license": "MIT",
"scripts": {
"gen:images": "node scripts/gen-images.js",
"build:css": "tailwindcss -i ./assets/css/tailwind.src.css -o ./dist/tailwind.standalone.css --minify",
"build:js": "node -e \"const fs=require('fs'),s='node_modules/alpinejs/dist/cdn.min.js',d='assets/js/vendor/alpine.js';if(fs.existsSync(s)){fs.mkdirSync('assets/js/vendor',{recursive:true});fs.copyFileSync(s,d);console.log('vendor: copied Alpine from node_modules')}else if(fs.existsSync(d)){console.log('vendor: node_modules/alpinejs absent; using committed '+d)}else{console.error('vendor: Alpine missing - run npm install');process.exit(1)}\"",
"build": "npm run gen:images && npm run build:css && npm run build:js",
"dev": "hugo server -D",
"hugo": "hugo",
"build:site": "hugo --minify",
"clean": "node -e \"const fs=require('fs');['public','resources','dist'].forEach(d=>fs.rmSync(d,{recursive:true,force:true}))\""
},
"keywords": [
"hugo",
"igaming",
"casino",
"affiliate",
"tailwindcss",
"static-site"
],
"devDependencies": {
"autoprefixer": "^10.4.20",
"hugo-extended": "^0.140.0",
"postcss": "^8.4.49",
"postcss-cli": "^11.0.0",
"tailwindcss": "^3.4.17"
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* PostCSS configuration used by Hugo Pipes (css.PostCSS) AND the standalone
* `npm run build:css` step. Requires the `postcss-cli` devDependency so Hugo
* can shell out to the local `postcss` binary during the asset pipeline.
*/
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* ci-build-brand.mjs — assemble ONE brand's Hugo build directory in CI and
* (optionally) build it. Portable (Linux runner), no hardcoded paths.
*
* It merges three sources into a clean build dir:
* 1. the TEMPLATE repo (engine): layouts/, assets/, static/, i18n/en.yaml,
* package.json(+lock), tailwind.config.js, postcss.config.js, and the
* VERIFIED, human-maintained data/locales.yaml + data/payment_methods.yaml
* (these are never LLM-generated and must come from the template).
* 2. the brand overlay from the content repo: repo/<brand>/{content,data,i18n,hugo.toml}
* 3. the template's verified data/ files are copied AFTER the brand overlay so
* they always win over anything the pipeline emitted.
*
* Usage:
* node ci-build-brand.mjs \
* --template <path-to-template-checkout> \
* --brand-dir <path-to repo/<brand>> \
* --out <build-dir> \
* [--base-url https://<brand>.lilsus.fun/]
*
* Env fallbacks: TEMPLATE_DIR, BRAND_DIR, BUILD_DIR, BASE_URL.
* Exits non-zero on any missing required input so CI fails loudly.
*/
import { existsSync, rmSync, mkdirSync, cpSync, copyFileSync, readdirSync } from 'node:fs';
import { join, basename } from 'node:path';
function arg(name, envName) {
const i = process.argv.indexOf('--' + name);
if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
if (envName && process.env[envName]) return process.env[envName];
return undefined;
}
const TEMPLATE = arg('template', 'TEMPLATE_DIR');
const BRAND_DIR = arg('brand-dir', 'BRAND_DIR');
const OUT = arg('out', 'BUILD_DIR');
const BASE_URL = arg('base-url', 'BASE_URL'); // optional; only echoed as a hint
function die(msg) { console.error('ci-build-brand: ' + msg); process.exit(1); }
if (!TEMPLATE || !existsSync(TEMPLATE)) die('missing/invalid --template (TEMPLATE_DIR): ' + TEMPLATE);
if (!BRAND_DIR || !existsSync(BRAND_DIR)) die('missing/invalid --brand-dir (BRAND_DIR): ' + BRAND_DIR);
if (!OUT) die('missing --out (BUILD_DIR)');
if (!existsSync(join(BRAND_DIR, 'hugo.toml'))) die('brand dir has no hugo.toml: ' + BRAND_DIR);
const brand = basename(BRAND_DIR);
console.log(`ci-build-brand: brand=${brand}`);
console.log(` template = ${TEMPLATE}`);
console.log(` brandDir = ${BRAND_DIR}`);
console.log(` out = ${OUT}`);
// Fresh build dir.
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
// 1. Template engine dirs + files.
const TEMPLATE_DIRS = ['layouts', 'assets', 'static', 'i18n', 'scripts'];
for (const d of TEMPLATE_DIRS) {
const s = join(TEMPLATE, d);
if (existsSync(s)) cpSync(s, join(OUT, d), { recursive: true });
}
const TEMPLATE_FILES = ['package.json', 'package-lock.json', 'tailwind.config.js', 'postcss.config.js'];
for (const f of TEMPLATE_FILES) {
const s = join(TEMPLATE, f);
if (existsSync(s)) copyFileSync(s, join(OUT, f));
}
// 2. Brand overlay: content, data, i18n, hugo.toml.
for (const d of ['content', 'data', 'i18n']) {
const s = join(BRAND_DIR, d);
if (existsSync(s)) cpSync(s, join(OUT, d), { recursive: true });
}
copyFileSync(join(BRAND_DIR, 'hugo.toml'), join(OUT, 'hugo.toml'));
// Drop the human-note marker if the pipeline emitted it (not valid Hugo data).
rmSync(join(OUT, 'data', 'NOTE-locales-payments.md'), { force: true });
// 3. VERIFIED data that must come from the template and always win.
mkdirSync(join(OUT, 'data'), { recursive: true });
for (const f of ['locales.yaml', 'payment_methods.yaml']) {
const s = join(TEMPLATE, 'data', f);
if (existsSync(s)) copyFileSync(s, join(OUT, 'data', f));
else console.warn(` WARN: template is missing data/${f} — RG/payment partials may fail`);
}
// Report.
function countMd(dir) {
if (!existsSync(dir)) return 0;
return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
}
console.log(` data files: ${existsSync(join(OUT, 'data')) ? readdirSync(join(OUT, 'data')).join(',') : 'none'}`);
console.log(` content top-level .md files: ${countMd(join(OUT, 'content'))}`);
console.log(`ci-build-brand: build dir ready at ${OUT}`);
if (BASE_URL) console.log(` (build with: hugo --source "${OUT}" --gc --minify --baseURL "${BASE_URL}")`);
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Site icon">
<rect width="32" height="32" rx="7" fill="#0057FF"/>
<circle cx="16" cy="16" r="9" fill="none" stroke="#ffffff" stroke-width="2" stroke-dasharray="3 3"/>
<circle cx="16" cy="16" r="3.5" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" role="img" aria-label="Brand logo mark">
<rect width="40" height="40" rx="9" fill="var(--brand-primary, #0057FF)"/>
<circle cx="20" cy="20" r="12" fill="none" stroke="#ffffff" stroke-width="2.5" stroke-dasharray="4 4"/>
<circle cx="20" cy="20" r="4.5" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Bitcoin">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<circle cx="28" cy="24" r="12" fill="#f7931a"/>
<text x="28" y="30" font-family="Arial, Helvetica, sans-serif" font-size="16" font-weight="700" text-anchor="middle" fill="#ffffff">B</text>
<text x="54" y="29" font-family="Arial, Helvetica, sans-serif" font-size="12" font-weight="700" text-anchor="middle" fill="#4d4d4d">BTC</text>
</svg>

After

Width:  |  Height:  |  Size: 510 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Mastercard">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<circle cx="34" cy="24" r="11" fill="#eb001b"/>
<circle cx="46" cy="24" r="11" fill="#f79e1b" fill-opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 295 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Neteller">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="30" font-family="Arial, Helvetica, sans-serif" font-size="13" font-weight="700" text-anchor="middle" fill="#83ba3b">NETELLER</text>
</svg>

After

Width:  |  Height:  |  Size: 323 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="PayPal">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="30" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700" font-style="italic" text-anchor="middle" fill="#003087">Pay<tspan fill="#009cde">Pal</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 369 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Paysafecard">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="21" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" text-anchor="middle" fill="#00285b">paysafe</text>
<text x="40" y="34" font-family="Arial, Helvetica, sans-serif" font-size="11" font-weight="700" text-anchor="middle" fill="#e30613">card</text>
</svg>

After

Width:  |  Height:  |  Size: 471 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Skrill">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="30" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700" text-anchor="middle" fill="#862165">Skrill</text>
</svg>

After

Width:  |  Height:  |  Size: 319 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Trustly">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="30" font-family="Arial, Helvetica, sans-serif" font-size="14" font-weight="700" text-anchor="middle" fill="#0ee06e">Trustly</text>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 48" role="img" aria-label="Visa">
<rect width="80" height="48" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
<text x="40" y="30" font-family="Arial, Helvetica, sans-serif" font-size="15" font-weight="700" font-style="italic" letter-spacing="0.5" text-anchor="middle" fill="#1a1f71">VISA</text>
</svg>

After

Width:  |  Height:  |  Size: 356 B

+41
View File
@@ -0,0 +1,41 @@
/**
* Tailwind CSS 3.x configuration.
*
* Brand colours are wired to CSS custom properties that Hugo injects into
* :root from data/brand.yaml (see assets/css/main.css). This means utility
* classes like `bg-primary` / `text-primary` automatically reflect the brand
* without any template edits — change data/brand.yaml and rebuild.
*/
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./layouts/**/*.html",
"./content/**/*.{html,md}",
],
theme: {
extend: {
colors: {
// Mapped to RGB-channel CSS vars injected from data/brand.yaml so that
// Tailwind opacity modifiers (e.g. bg-primary/10) work correctly.
primary: "rgb(var(--brand-primary-rgb) / <alpha-value>)",
secondary: "rgb(var(--brand-secondary-rgb) / <alpha-value>)",
"primary-contrast": "rgb(var(--brand-primary-contrast-rgb) / <alpha-value>)",
},
fontFamily: {
sans: [
"system-ui",
"-apple-system",
"Segoe UI",
"Roboto",
"Helvetica",
"Arial",
"sans-serif",
],
},
maxWidth: {
prose: "72ch",
},
},
},
plugins: [],
};