Nick 4759fbbd69 Refactor buy link handling and add geo-resolution features
- Refactored `BuyController` for improved click logging and centralized link management.
- Updated `DiscoveryController` to enhance user request handling and added country context dependency.
- Introduced `ClicksController` for managing click tracking and redirects.
- Added `GeoResolutionMiddleware` to resolve user locations based on IP.
- Created `BuyCatalog` for centralized management of buy links.
- Introduced view models (`BuyLinksViewModel`, `DiscoveryPageViewModel`) for better data handling in views.
- Added new files for geographical data handling (`GeoIpResult`, `HttpContextItemKeys`, `LinkChoice`, etc.).
- Updated `_BuyBox.cshtml` to render buy options based on user location.
- Modified `DataAccess` for saving and retrieving geographical data.
2025-09-12 22:01:09 +01:00

367 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] | Catherine Lynwood</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/fontawesome.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/brands.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/duotone.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/plyr.min.css" asp-append-version="true" />
<script>
// Load Meta Pixel ONLY if consent was given
(function () {
if (localStorage.getItem('marketingConsent') !== 'yes') return;
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
n.push=n; n.loaded=!0; n.version='2.0'; n.queue=[]; t=b.createElement(e); t.async=!0;
t.src=v; s=b.getElementsByTagName(e)[0]; s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '556474687460834');
fbq('track', 'PageView', {
page_location: location.href,
page_path: location.pathname,
page_title: document.title,
referrer: document.referrer || null
});
})();
</script>
<noscript>
<!-- harmless if present; loads only when JS disabled -->
<img height="1" width="1" style="display:none"
src="https://www.facebook.com/tr?id=YOUR_PIXEL_ID&ev=PageView&noscript=1"/>
</noscript>
<!-- End Meta Pixel -->
<style>
.plyr--audio .plyr__controls {
background-color: rgba(255,255,255,1) !important; /* or set your page colour */
box-shadow: none !important; /* optional: remove shadow */
border-radius: 15px; /* optional: adjust corners */
border-color: black;
border-width: 2px;
border-style: solid;
}
.plyr--audio {
background-color: transparent !important; /* background of the whole player */
}
</style>
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<meta name="msapplication-TileColor" content="#17C8EB">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#17C8EB">
<link rel="alternate" type="application/rss+xml" title="Catherine Lynwood Blog Feed" href="https://www.catherinelynwood.com/feed">
<link rel="canonical" href="@($"https://www.catherinelynwood.com{Context.Request.Path}")" />
@RenderSection("Meta", required: false)
</head>
<body class="bg-primary text-white">
<div id="background-wrapper">
<div class="video-background">
<video id="siteBackgroundVideo"
autoplay
muted
loop
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-blank-400.webp">
<!-- Source will be injected by JS -->
</video>
<div class="video-overlay"></div>
</div>
</div>
<div class="content">
<!-- Content wrapper to keep everything on top of the background -->
<header>
<nav class="navbar fixed-top navbar-expand-sm navbar-toggleable-sm navbar-dark border-bottom border-2 border-primary box-shadow mb-3 bg-dark">
<div class="container-fluid">
<a class="navbar-brand pe-0 me-0" asp-area="" asp-controller="Home" asp-action="Index">
<img src="~/images/catherine-lynwood-banner-logo-small.png" alt="Catherine Lynwood Logo Banner" class="img-fluid" style="height: 45px; width: 265px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="AboutCatherineLynwood">About Catherine</a>
</li>
<li class="nav-item">
<a class="nav-link text-primary" asp-area="" asp-controller="TheAlphaFlame" asp-action="Blog">A Cuppa With Catherine</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-primary" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
The Alpha Flame
</a>
<ul class="dropdown-menu">
<li class="py-2">
<a class="dropdown-item" asp-controller="Discovery" asp-action="Index">Discovery (Book 1)</a>
</li>
<li class="py-2">
<a class="dropdown-item" asp-controller="Discovery" asp-action="Trailer">Discovery Release Trailer</a>
</li>
<li class="py-2">
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Index">The Trilogy</a>
</li>
<li class="py-2">
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="StoryMap">Story Map</a>
</li>
<li class="py-2">
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Characters">Meet the Characters</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="ArcReaderApplication">ARC Reader Application</a>
</li>
<li class="nav-item">
<a class="nav-link text-primary" asp-area="" asp-controller="Home" asp-action="VerosticGenre">The Verostic Genre</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link text-light" asp-controller="Admin" asp-action="Index" rel="nofollow" title="Admin">
<i class="fad fa-user-shield fa-lg"></i>
</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-lg" style="margin-top: 90px;">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<!-- Scroll hint -->
<div class="scroll-hint" id="scrollHint">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div> <!-- End of content wrapper -->
<footer class="border-top border-2 border-primary footer bg-dark py-3" id="site-footer">
<div class="container">
<div class="row justify-content-center align-items-center" >
<div class="col-md-6 text-center order-md-2">
<social-media-share title="Catherine Lynwood Blog" url="@Context.Request.Path" class="text-center m-3"></social-media-share>
&copy; 2024 - @DateTime.Now.Year - Catherine Lynwood
</div>
<div class="col-md-3 text-center order-md-1">
<p>
<a class="text-light" asp-area="" asp-controller="Home" asp-action="ContactCatherine">Contact Catherine</a>
</p>
<p>
<a class="text-light" asp-area="" asp-controller="Publishing" asp-action="Index">Catherine Lynwood Publishing</a>
</p>
</div>
<div class="col-md-3 text-center order-md-3">
<p>
<a class="text-light" asp-area="" asp-controller="Home" asp-action="AboutCatherineLynwood">About Catherine Lynwood</a>
</p>
<p>
<a class="text-light" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy Policy</a>
</p>
<p>
<a class="text-light" href="#" onclick="manageCookies();return false;">Manage cookies</a>
</p>
</div>
</div>
</div>
</footer>
<div class="modal fade"
id="cookieModal"
tabindex="-1"
aria-labelledby="cookieTitle"
aria-hidden="true"
data-bs-backdrop="static"
data-bs-keyboard="false"
data-nosnippet>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border border-3 border-dark rounded-4 shadow-lg" data-nosnippet>
<div class="modal-header">
<h5 class="modal-title text-dark" id="cookieTitle">Cookies & privacy</h5>
</div>
<div class="modal-body" data-nosnippet>
<p class="text-dark mb-2">We use necessary cookies and, with your permission, marketing cookies…</p>
<p class="small text-muted mb-0">You can change your choice anytime…</p>
</div>
<div class="modal-footer d-flex gap-2" data-nosnippet>
<button type="button" class="btn btn-outline-dark" id="cookieReject">Reject</button>
<button type="button" class="btn btn-dark" id="cookieAccept">Accept</button>
</div>
</div>
</div>
</div>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script src="~/js/plyr.js"></script>
@* <script>
const player = new Plyr('audio');
</script> *@
@RenderSection("Scripts", required: false)
<script>
window.addEventListener("load", () => {
setTimeout(() => {
const video = document.getElementById("siteBackgroundVideo");
const source = document.createElement("source");
source.src = "/videos/background-3.mp4";
source.type = "video/mp4";
video.appendChild(source);
video.load(); // Initiates download
video.play().catch(err => {
console.warn("Background video autoplay failed:", err);
});
}, 1500); // Adjust delay as needed (e.g. 10003000ms)
});
</script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service-worker.js')
.then(function (registration) {
console.log('ServiceWorker registered with scope:', registration.scope);
}, function (err) {
console.log('ServiceWorker registration failed:', err);
});
});
}
</script>
<script type="text/javascript">
window._mfq = window._mfq || [];
(function() {
var mf = document.createElement("script");
mf.type = "text/javascript"; mf.defer = true;
mf.src = "//cdn.mouseflow.com/projects/34f4429c-9f27-4c01-9d08-2ac8d7144273.js";
document.getElementsByTagName("head")[0].appendChild(mf);
})();
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var hint = document.getElementById('scrollHint');
var footer = document.getElementById('site-footer');
if (!hint || !footer) return;
var scrollTimer = null;
function docHeight() {
return Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
);
}
function canScroll() {
return docHeight() > window.innerHeight + 8;
}
function atBottom() {
return (window.scrollY + window.innerHeight) >= (docHeight() - 4);
}
function footerVisible() {
var rect = footer.getBoundingClientRect();
// footer considered visible if its top edge has entered the viewport
return rect.top < window.innerHeight;
}
function evaluateHint() {
// Show only if: page can scroll, not at bottom, and footer NOT yet visible
if (canScroll() && !atBottom() && !footerVisible()) {
hint.classList.add('show');
} else {
hint.classList.remove('show');
}
}
// Initial state
evaluateHint();
// Hide while actively scrolling; show again after a short pause
function onScroll() {
hint.classList.remove('show');
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(evaluateHint, 300);
}
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { setTimeout(evaluateHint, 120); });
hint.addEventListener('click', function () {
window.scrollBy({ top: Math.round(window.innerHeight * 0.8), behavior: 'smooth' });
});
});
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
if (!("ping" in HTMLAnchorElement.prototype) && navigator.sendBeacon) {
document.body.addEventListener("click", e => {
const a = e.target.closest("a[ping]");
if (!a) return;
const url = a.getAttribute("ping");
if (url) {
try {
navigator.sendBeacon(url);
} catch { /* ignore */ }
}
});
}
});
</script>
</body>
</html>