Add new background images for synopsis sections

This commit introduces several new background images in WebP format, including `synopsis-background-288.webp`, `synopsis-background-384.webp`, `synopsis-background-400.webp`, `synopsis-background-496.webp`, `synopsis-background-600.webp`, and `synopsis-background-700.webp`. Each image includes embedded metadata for creation and modification details.

These images are intended to enhance the visual presentation of the synopsis section within the application, improving the overall aesthetic appeal and user engagement.
This commit is contained in:
Nick 2025-09-10 10:38:50 +01:00
parent afe3c6cc78
commit 82cb5cde02
17 changed files with 602 additions and 247 deletions

View File

@ -182,9 +182,94 @@ namespace CatherineLynwood.Controllers
Retailer = "Barnes & Noble", Retailer = "Barnes & Noble",
Format = "Paperback", Format = "Paperback",
CountryGroup = "US" CountryGroup = "US"
},
// --- Apple Books (GB/US/CA/AU/IE) ---
["apple-ebook-gb"] = new BuyLink
{
Slug = "apple-ebook-gb",
Url = "https://books.apple.com/gb/book/the-alpha-flame/id6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "GB"
},
["apple-ebook-us"] = new BuyLink
{
Slug = "apple-ebook-us",
Url = "https://books.apple.com/us/book/the-alpha-flame/id6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "US"
},
["apple-ebook-ca"] = new BuyLink
{
Slug = "apple-ebook-ca",
Url = "https://books.apple.com/ca/book/the-alpha-flame/id6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "CA"
},
["apple-ebook-au"] = new BuyLink
{
Slug = "apple-ebook-au",
Url = "https://books.apple.com/au/book/the-alpha-flame/id6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "AU"
},
["apple-ebook-ie"] = new BuyLink
{
Slug = "apple-ebook-ie",
Url = "https://books.apple.com/ie/book/the-alpha-flame/id6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "IE"
},
// --- Kobo (GB/US/CA/AU/IE) ---
["kobo-ebook-gb"] = new BuyLink
{
Slug = "kobo-ebook-gb",
Url = "https://www.kobo.com/gb/en/ebook/the-alpha-flame",
Retailer = "Kobo",
Format = "eBook",
CountryGroup = "GB"
},
["kobo-ebook-us"] = new BuyLink
{
Slug = "kobo-ebook-us",
Url = "https://www.kobo.com/us/en/ebook/the-alpha-flame",
Retailer = "Kobo",
Format = "eBook",
CountryGroup = "US"
},
["kobo-ebook-ca"] = new BuyLink
{
Slug = "kobo-ebook-ca",
Url = "https://www.kobo.com/ca/en/ebook/the-alpha-flame",
Retailer = "Kobo",
Format = "eBook",
CountryGroup = "CA"
},
["kobo-ebook-au"] = new BuyLink
{
Slug = "kobo-ebook-au",
Url = "https://www.kobo.com/au/en/ebook/the-alpha-flame",
Retailer = "Kobo",
Format = "eBook",
CountryGroup = "AU"
},
["kobo-ebook-ie"] = new BuyLink
{
Slug = "kobo-ebook-ie",
Url = "https://www.kobo.com/ie/en/ebook/the-alpha-flame",
Retailer = "Kobo",
Format = "eBook",
CountryGroup = "IE"
} }
}; };
public BuyController(DataAccess dataAccess) public BuyController(DataAccess dataAccess)
{ {
_dataAccess = dataAccess; _dataAccess = dataAccess;
@ -193,30 +278,39 @@ namespace CatherineLynwood.Controllers
[HttpGet("{slug}")] [HttpGet("{slug}")]
public async Task<IActionResult> Go(string slug) public async Task<IActionResult> Go(string slug)
{ {
if (!Links.TryGetValue(slug, out var link)) if (!Links.TryGetValue(slug, out var link)) return NotFound();
return NotFound();
var referer = Request.Headers["Referer"].ToString(); var ua = Request.Headers["User-Agent"].ToString() ?? string.Empty;
var ua = Request.Headers["User-Agent"].ToString(); var referer = Request.Headers["Referer"].ToString() ?? string.Empty;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty; var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty;
var page = referer; // or: $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"
var countryQS = Request.Query["country"].ToString(); // if you pass ?country=GB for testing
// session id cookie if (HttpMethods.IsHead(Request.Method)) return Redirect(link.Url);
var sessionId = Request.Cookies["sid"] ?? Guid.NewGuid().ToString("N"); if (IsLikelyBot(ua)) return Redirect(link.Url);
if (!Request.Cookies.ContainsKey("sid"))
var fetchMode = Request.Headers["Sec-Fetch-Mode"].ToString();
if (!string.Equals(fetchMode, "navigate", StringComparison.OrdinalIgnoreCase))
return Redirect(link.Url);
if (!IsOurSiteReferer(referer, Request.Host.Host))
return Redirect(link.Url);
// Ensure we have a first-party session id
var sessionId = Request.Cookies["sid"];
if (string.IsNullOrEmpty(sessionId))
{ {
sessionId = Guid.NewGuid().ToString("N");
Response.Cookies.Append("sid", sessionId, new CookieOptions Response.Cookies.Append("sid", sessionId, new CookieOptions
{ {
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Lax, SameSite = SameSiteMode.Lax,
Secure = true, Secure = true,
MaxAge = TimeSpan.FromDays(365) MaxAge = TimeSpan.FromDays(365),
Path = "/"
}); });
// carry on and log this click
} }
// Save click (dont block the redirect if this fails)
_ = await _dataAccess.SaveBuyClick( _ = await _dataAccess.SaveBuyClick(
dateTimeUtc: DateTime.UtcNow, dateTimeUtc: DateTime.UtcNow,
slug: link.Slug, slug: link.Slug,
@ -224,17 +318,42 @@ namespace CatherineLynwood.Controllers
format: link.Format, format: link.Format,
countryGroup: link.CountryGroup, countryGroup: link.CountryGroup,
ip: ip, ip: ip,
country: string.IsNullOrWhiteSpace(countryQS) ? null : countryQS, // optional override country: Request.Query["country"].ToString(),
userAgent: ua, userAgent: ua,
referer: referer, referer: referer,
page: page, page: referer,
sessionId: sessionId, sessionId: sessionId,
queryString: qs, queryString: qs,
destinationUrl: link.Url destinationUrl: link.Url
); );
return Redirect(link.Url); // 302 return Redirect(link.Url);
} }
private static bool IsLikelyBot(string ua)
{
if (string.IsNullOrWhiteSpace(ua)) return true; // empty UA, treat as bot
var needles = new[]
{
"bot","crawler","spider","preview","fetch","scan","analyzer",
"httpclient","python-requests","facebookexternalhit","Slackbot",
"WhatsApp","TelegramBot","Googlebot","AdsBot","Amazonbot",
"bingbot","DuckDuckBot","YandexBot","AhrefsBot","SemrushBot",
"Applebot","LinkedInBot","Discordbot","Embedly","Pinterestbot"
};
ua = ua.ToLowerInvariant();
return needles.Any(n => ua.Contains(n.ToLowerInvariant()));
}
private static bool IsOurSiteReferer(string referer, string ourHost)
{
if (string.IsNullOrWhiteSpace(referer)) return false;
if (!Uri.TryCreate(referer, UriKind.Absolute, out var uri)) return false;
// handle www. and subdomains as you prefer
return uri.Host.EndsWith(ourHost, StringComparison.OrdinalIgnoreCase);
}
} }
} }

View File

@ -195,10 +195,13 @@ namespace CatherineLynwood.Controllers
var reviewObjects = new List<Dictionary<string, object>>(); var reviewObjects = new List<Dictionary<string, object>>();
double total = 0; double total = 0;
foreach (var review in reviews.Items.Take(take)) foreach (var review in reviews.Items)
{ {
total += review.RatingValue; total += review.RatingValue;
}
foreach (var review in reviews.Items.Take(take))
{
reviewObjects.Add(new Dictionary<string, object> reviewObjects.Add(new Dictionary<string, object>
{ {
["@type"] = "Review", ["@type"] = "Review",

View File

@ -0,0 +1,35 @@
namespace CatherineLynwood.Middleware
{
public class EnsureSidMiddleware
{
private readonly RequestDelegate _next;
public EnsureSidMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext ctx)
{
if (!ctx.Request.Cookies.ContainsKey("sid") &&
string.Equals(ctx.Request.Method, "GET", StringComparison.OrdinalIgnoreCase))
{
var accept = ctx.Request.Headers["Accept"].ToString();
var fetchMode = ctx.Request.Headers["Sec-Fetch-Mode"].ToString();
// Only issue on real navigations to HTML
if (accept.Contains("text/html", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fetchMode, "navigate", StringComparison.OrdinalIgnoreCase))
{
ctx.Response.Cookies.Append("sid", Guid.NewGuid().ToString("N"), new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Secure = true,
MaxAge = TimeSpan.FromDays(365),
Path = "/"
});
}
}
await _next(ctx);
}
}
}

View File

@ -111,6 +111,7 @@ namespace CatherineLynwood
app.UseMiddleware<SpamAndSecurityMiddleware>(); app.UseMiddleware<SpamAndSecurityMiddleware>();
app.UseMiddleware<HoneypotLoggingMiddleware>(); app.UseMiddleware<HoneypotLoggingMiddleware>();
app.UseMiddleware<RedirectMiddleware>(); app.UseMiddleware<RedirectMiddleware>();
app.UseMiddleware<EnsureSidMiddleware>();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseResponseCompression(); app.UseResponseCompression();

View File

@ -40,32 +40,53 @@
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<!-- Trailer --> <!-- Trailer -->
<div class="trailer-wrapper mb-3"> <div class="trailer-wrapper mb-3">
<video id="trailerVideo" playsinline preload="none" class="w-100"></video> <!-- Mobile / tablet: PORTRAIT -->
<video id="trailerPortrait"
class="w-100 d-block d-lg-none"
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-trailer-portrait-400.webp"
controls>
<source src="/videos/the-alpha-flame-discovery-trailer-portrait.mp4" type="video/mp4">
Sorry, your browser doesn't support embedded video.
</video>
<!-- Desktop: LANDSCAPE -->
<video id="trailerLandscape"
class="w-100 d-none d-lg-block"
playsinline
preload="none"
poster="/images/webp/the-alpha-flame-discovery-trailer-landscape-1400.webp"
controls>
<source src="/videos/the-alpha-flame-discovery-trailer-landscape.mp4" type="video/mp4">
Sorry, your browser doesn't support embedded video.
</video>
<button id="trailerPlayBtn" class="trailer-play-btn"> <button id="trailerPlayBtn" class="trailer-play-btn">
<i class="fad fa-play"></i> <i class="fad fa-play"></i>
</button> </button>
</div> </div>
<!-- Compact trust panel --> <!-- If JS is off, hide the custom play button so native controls are used -->
<div class="mb-3"> <noscript>
<div class="border border-2 border-dark rounded-4 p-3 bg-light"> <style>
<div class="small text-muted text-uppercase mb-2">Formats & delivery</div> #trailerPlayBtn {
<ul class="list-unstyled small mb-0"> display: none !important
<li class="mb-1"><i class="fad fa-gem me-1"></i> Hardback, collectors edition</li> }</style>
<li class="mb-1"><i class="fad fa-book me-1"></i> Paperback, bookshop edition</li> </noscript>
<li class="mb-1"><i class="fad fa-shipping-fast me-1"></i> Fast fulfilment in GB and US via our print partner</li>
<li><i class="fad fa-store me-1"></i> Also available from Amazon, Waterstones, Barnes & Noble</li>
</ul>
</div>
</div>
<!-- Buy Box --> <!-- Buy Box -->
<div id="buyBox" class="border border-2 border-dark rounded-4 p-3 bg-light mt-auto"> <div id="buyBox" class="border border-2 border-dark rounded-4 p-3 bg-light mt-auto">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2"> <div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
<h3 class="h5 mb-2 mb-sm-0">Buy the Book</h3> <h3 class="h5 mb-2 mb-sm-0">Buy the Book</h3>
<small class="text-muted">Best options for your country</small> <small id="buyCountryHint" class="text-muted d-flex align-items-center">
<img id="buyCountryFlag" class="me-1 d-none" alt="" width="20" height="14" loading="lazy">
<span id="buyCountryText">Best options for your country</span>
</small>
</div> </div>
<!-- Row 1: Direct via Ingram (GB/US only) --> <!-- Row 1: Direct via Ingram (GB/US only) -->
<div id="rowDirect" class="mb-3"> <div id="rowDirect" class="mb-3">
<div class="d-flex align-items-center gap-2 mb-2"> <div class="d-flex align-items-center gap-2 mb-2">
@ -78,6 +99,7 @@
<a id="hardbackLinkSelf" <a id="hardbackLinkSelf"
href="/go/ingram-hardback-gb" href="/go/ingram-hardback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-dark w-100" class="btn btn-dark w-100"
data-retailer="Ingram" data-format="Hardback"> data-retailer="Ingram" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, direct <i class="fad fa-gem me-1"></i> Hardback, direct
@ -87,6 +109,7 @@
<a id="paperbackLinkSelf" <a id="paperbackLinkSelf"
href="/go/ingram-paperback-gb" href="/go/ingram-paperback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-dark w-100" class="btn btn-dark w-100"
data-retailer="Ingram" data-format="Paperback"> data-retailer="Ingram" data-format="Paperback">
<i class="fad fa-book me-1"></i> Paperback, direct <i class="fad fa-book me-1"></i> Paperback, direct
@ -94,7 +117,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge bg-dark">Other retailers</span>
<span class="small text-muted">From other retailers</span>
</div>
<!-- Row 2: Amazon --> <!-- Row 2: Amazon -->
<div id="rowAmazon" class="mb-3"> <div id="rowAmazon" class="mb-3">
<div class="row g-2"> <div class="row g-2">
@ -102,6 +128,7 @@
<a id="hardbackLink" <a id="hardbackLink"
href="/go/amazon-hardback-gb" href="/go/amazon-hardback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100" class="btn btn-outline-dark w-100"
data-retailer="Amazon" data-format="Hardback"> data-retailer="Amazon" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, Amazon <i class="fad fa-gem me-1"></i> Hardback, Amazon
@ -111,6 +138,7 @@
<a id="paperbackLink" <a id="paperbackLink"
href="/go/amazon-paperback-gb" href="/go/amazon-paperback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100" class="btn btn-outline-dark w-100"
data-retailer="Amazon" data-format="Paperback"> data-retailer="Amazon" data-format="Paperback">
<i class="fad fa-book me-1"></i> Paperback, Amazon <i class="fad fa-book me-1"></i> Paperback, Amazon
@ -126,6 +154,7 @@
<a id="hardbackNational" <a id="hardbackNational"
href="/go/waterstones-hardback-gb" href="/go/waterstones-hardback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100" class="btn btn-outline-dark w-100"
data-retailer="National" data-format="Hardback"> data-retailer="National" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, Waterstones <i class="fad fa-gem me-1"></i> Hardback, Waterstones
@ -135,6 +164,7 @@
<a id="paperbackNational" <a id="paperbackNational"
href="/go/waterstones-paperback-gb" href="/go/waterstones-paperback-gb"
target="_blank" target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100" class="btn btn-outline-dark w-100"
data-retailer="National" data-format="Paperback"> data-retailer="National" data-format="Paperback">
<i class="fad fa-book-open me-1"></i> Paperback, Waterstones <i class="fad fa-book-open me-1"></i> Paperback, Waterstones
@ -143,10 +173,46 @@
</div> </div>
</div> </div>
<!-- Quiet eBook --> <!-- Row 4: eBook stores -->
<div class="mt-1 small"> <div id="rowEbooks" class="mb-2">
Prefer Kindle? <a id="kindleLink" href="/go/amazon-kindle-gb" target="_blank" class="link-dark" data-retailer="Amazon" data-format="Kindle">Buy the eBook</a> <div class="d-flex align-items-center gap-2 mb-2">
<span class="badge bg-dark">Instant reading</span>
<span class="small text-muted">Choose your preferred store</span>
</div> </div>
<div class="row g-2">
<div class="col-12 col-sm-4">
<a id="ebookApple"
href="/go/apple-ebook-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="Apple Books" data-format="eBook">
<i class="fab fa-apple me-1"></i> Apple Books
</a>
</div>
<div class="col-12 col-sm-4">
<a id="ebookKobo"
href="/go/kobo-ebook-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="Kobo" data-format="eBook">
<i class="fad fa-book-open me-1"></i> Kobo
</a>
</div>
<div class="col-12 col-sm-4">
<a id="ebookKindle"
href="/go/amazon-kindle-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="Amazon" data-format="Kindle">
<i class="fab fa-amazon me-1"></i> Kindle
</a>
</div>
</div>
</div>
<div class="mt-2"> <div class="mt-2">
<a asp-action="HowToBuy" class="link-dark small">See all buying options</a> <a asp-action="HowToBuy" class="link-dark small">See all buying options</a>
@ -170,7 +236,7 @@
<!-- Social proof: show one standout review near the top --> <!-- Social proof: show one standout review near the top -->
@if (showReviews) @if (showReviews)
{ {
var top = Model.Items.First(); var top = Model.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var fullStars = (int)Math.Floor(top.RatingValue); var fullStars = (int)Math.Floor(top.RatingValue);
var hasHalfStar = top.RatingValue - fullStars >= 0.5; var hasHalfStar = top.RatingValue - fullStars >= 0.5;
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0); var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
@ -223,13 +289,14 @@
</section> </section>
} }
<!-- Synopsis: short teaser with collapse for the full version --> <!-- Synopsis: open on mobile, collapsible on desktop -->
<section id="synopsis" class="mb-4"> <section id="synopsis" class="mb-4">
<div class="card character-card"> <div class="card character-card text-white" style="background: url('/images/webp/synopsis-background-960.webp'); background-position: center; background-size: cover;">
<div class="card-header"> <div class="card-header">
<h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2> <h2 class="card-title h1 mb-0">The Alpha Flame: <span class="fw-light">Discovery</span></h2>
<p class="mb-0">Survival, secrets, and sisters in 1983 Birmingham.</p> <p class="mb-0">Survival, secrets, and sisters in 1983 Birmingham.</p>
</div> </div>
<div class="card-body" id="synopsis-body"> <div class="card-body" id="synopsis-body">
<!-- Audio blurb --> <!-- Audio blurb -->
<div class="row align-items-center mb-3"> <div class="row align-items-center mb-3">
@ -243,23 +310,28 @@
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
</div> </div>
<p class="text-center text-muted small mb-0">Listen to Catherine talking about the book</p> <p class="text-center text-white small mb-0">Listen to Catherine talking about the book</p>
</div> </div>
</div> </div>
<!-- Teaser paragraph --> <!-- Teaser -->
<p class="card-text"> <p class="card-text">
Set in 1983 Birmingham, The Alpha Flame: Discovery follows the lives of two young women, Beth and Maggie, as they uncover dark family secrets and fight to survive. Gritty and emotionally charged, it explores the bond between two women who refuse to be broken. Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, The Alpha Flame: Discovery follows the lives of two young women, Beth and Maggie, as they uncover dark family secrets and fight to survive. Gritty and emotionally charged, it explores the bond between two women who refuse to be broken.
</p> </p>
<!-- Collapse trigger --> <!-- Desktop-only: collapse trigger -->
<p class="mb-2"> <p class="mb-2 d-none d-md-block">
<a class="btn btn-outline-dark btn-sm" data-bs-toggle="collapse" href="#fullSynopsis" role="button" aria-expanded="false" aria-controls="fullSynopsis"> <a class="btn btn-outline-light btn-sm"
data-bs-toggle="collapse"
href="#fullSynopsis"
role="button"
aria-expanded="false"
aria-controls="fullSynopsis">
Read full synopsis Read full synopsis
</a> </a>
</p> </p>
<!-- Full synopsis in collapse --> <!-- One copy of the full synopsis -->
<div class="collapse" id="fullSynopsis"> <div class="collapse" id="fullSynopsis">
<div class="mt-2"> <div class="mt-2">
<p class="card-text">For Beth, the world is a cold and unforgiving place. Devastation strikes in a single moment, leaving her isolated, shattered, and vulnerable. Alone in the bleak shadows of a city that offers neither refuge nor redemption, she is forced to navigate a relentless cycle of desperation and despair. Every step of her journey tests the limits of her endurance, pushing her into harrowing situations where survival feels like a hollow victory. Beths existence is marked by loss, betrayal, and an almost suffocating loneliness that threatens to consume her entirely. Yet, even in the darkest corners of her ordeal, a fragile ember of defiance smoulders within her, a quiet, stubborn refusal to let the world destroy her completely.</p> <p class="card-text">For Beth, the world is a cold and unforgiving place. Devastation strikes in a single moment, leaving her isolated, shattered, and vulnerable. Alone in the bleak shadows of a city that offers neither refuge nor redemption, she is forced to navigate a relentless cycle of desperation and despair. Every step of her journey tests the limits of her endurance, pushing her into harrowing situations where survival feels like a hollow victory. Beths existence is marked by loss, betrayal, and an almost suffocating loneliness that threatens to consume her entirely. Yet, even in the darkest corners of her ordeal, a fragile ember of defiance smoulders within her, a quiet, stubborn refusal to let the world destroy her completely.</p>
@ -274,6 +346,8 @@
</div> </div>
</section> </section>
<!-- Chapter Previews --> <!-- Chapter Previews -->
<section id="chapters" class="mt-4"> <section id="chapters" class="mt-4">
<h2>Chapter Previews</h2> <h2>Chapter Previews</h2>
@ -297,7 +371,7 @@
</a> </a>
<div class="card-body border-top border-3 border-dark"> <div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 2: The Last Lesson — Maggie</h3> <h3 class="card-title">Chapter 2: The Last Lesson — Maggie</h3>
<p class="card-text">On Christmas Eve, Maggie nervously heads out for her driving test...</p> <p class="card-text">On Christmas Eve, Maggie nervously heads out for her driving test not knowing the story that will pan out before her...</p>
<div class="text-end"><a asp-action="Chapter2" class="btn btn-dark">Read More</a></div> <div class="text-end"><a asp-action="Chapter2" class="btn btn-dark">Read More</a></div>
</div> </div>
</div> </div>
@ -326,149 +400,247 @@
<!-- Trailer source selection + play button --> <!-- Trailer source selection + play button -->
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const video = document.getElementById("trailerVideo"); const vPortrait = document.getElementById("trailerPortrait");
const vLandscape = document.getElementById("trailerLandscape");
const playBtn = document.getElementById("trailerPlayBtn"); const playBtn = document.getElementById("trailerPlayBtn");
if (!video || !playBtn) return; if (!playBtn) return;
const isDesktop = window.matchMedia("(min-width: 400px)").matches; // Which video is currently visible according to Bootstraps lg breakpoint (>=992px)?
video.poster = isDesktop const isDesktop = () => window.matchMedia("(min-width: 992px)").matches;
? "/images/webp/the-alpha-flame-discovery-trailer-landscape-1400.webp" const activeVideo = () => isDesktop() ? vLandscape : vPortrait;
: "/images/webp/the-alpha-flame-discovery-trailer-portrait-400.webp";
const src = isDesktop // Hide native controls when JS is active; custom button will start playback
? "/videos/the-alpha-flame-discovery-trailer-landscape.mp4" [vPortrait, vLandscape].forEach(v => { if (v) v.controls = false; });
: "/videos/the-alpha-flame-discovery-trailer-portrait.mp4";
const sourceEl = document.createElement("source");
sourceEl.src = src;
sourceEl.type = "video/mp4";
video.appendChild(sourceEl);
// Start playback on whichever video is visible
playBtn.addEventListener("click", () => { playBtn.addEventListener("click", () => {
video.muted = false; const v = activeVideo();
video.volume = 1.0; if (!v) return;
video.play().then(() => { v.muted = false;
v.volume = 1.0;
v.play().then(() => {
playBtn.style.display = "none"; playBtn.style.display = "none";
}).catch(err => { }).catch(err => console.warn("Video play failed:", err));
console.warn("Video play failed:", err);
}); });
// If the user starts via OS overlay/natives, also hide the custom button
[vPortrait, vLandscape].forEach(v => {
if (!v) return;
v.addEventListener("play", () => { playBtn.style.display = "none"; }, { once: true });
}); });
}); });
</script> </script>
<script> <script>
document.addEventListener('click', function(e){ document.addEventListener('DOMContentLoaded', function () {
const a = e.target.closest('a[href^="/go/"]'); const el = document.getElementById('fullSynopsis');
if (!a || typeof fbq !== 'function') return; if (!el || !window.bootstrap?.Collapse) return;
// Extract slug, retailer/format from attributes if present // Initialise without auto-toggling
const slug = a.getAttribute('href').split('/').pop(); const c = new bootstrap.Collapse(el, { toggle: false });
const retailer = a.dataset.retailer || null; const mq = window.matchMedia('(min-width: 768px)'); // Bootstrap md
const format = a.dataset.format || null;
// Event ID for matching with server-side (CAPI) event function setInitial() {
const eventId = crypto && crypto.randomUUID ? crypto.randomUUID() : (Date.now() + '-' + Math.random()); if (mq.matches) {
// Desktop: keep collapsed
c.hide();
} else {
// Mobile: open by default
c.show();
}
}
// Attach eventId so the server can read it on the next request (optional) setInitial();
a.dataset.eventId = eventId; // Optional: if user resizes across the breakpoint, adjust state
mq.addEventListener?.('change', setInitial);
fbq('trackCustom', 'BuyClick', {
slug, retailer, format, page: location.pathname
}, {eventID: eventId});
}); });
</script> </script>
<script>
(function(){
// map formats to IDs for richer reporting
const CONTENT_IDS = {
Hardback: '9781068225802',
Paperback: '9781068225819',
Kindle: 'B0FBS427VD'
};
function utms() {
const p = new URLSearchParams(location.search);
return {
utm_source: p.get('utm_source') || null,
utm_medium: p.get('utm_medium') || null,
utm_campaign: p.get('utm_campaign') || null,
utm_content: p.get('utm_content') || null,
utm_term: p.get('utm_term') || null
};
}
document.addEventListener('click', function(e){
const a = e.target.closest('a[data-go-slug]');
if (!a) return;
const slug = a.getAttribute('data-go-slug');
const goUrl = '/go/' + encodeURIComponent(slug);
const target = a.getAttribute('target');
const retailer = a.dataset.retailer || null;
const format = a.dataset.format || null;
const cid = CONTENT_IDS[format] || null;
// fire FB event if available
const params = {
content_type: 'product',
content_ids: cid ? [cid] : null,
content_name: 'The Alpha Flame: Discovery',
content_category: 'Books',
slug, retailer, format,
destination_hint: a.href,
page_location: location.href,
page_path: location.pathname,
page_title: document.title,
referrer: document.referrer || null,
...utms()
};
try { if (typeof fbq === 'function') fbq('trackCustom', 'BuyClick', params); } catch {}
// Left click without modifiers → route via /go/
const isLeft = e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
if (isLeft) {
e.preventDefault();
setTimeout(() => {
if (target === '_blank') {
window.open(goUrl, '_blank', 'noopener');
} else {
window.location.href = goUrl;
}
}, 150);
return;
}
// Middle click or modifier keys → open /go/ in a new tab
if (e.button === 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
e.preventDefault();
window.open(goUrl, '_blank', 'noopener');
}
});
})();
</script>
<!-- Geo-based slug swap for /go/{retailer}-{format}-{country} + dev override --> <!-- Geo-based slug swap for /go/{retailer}-{format}-{country} + dev override -->
<script> <script>
(function() { (function(){
function qsCountryOverride() { // Direct IngramSpark URLs
const qs = new URLSearchParams(location.search); const INGRAM_PB = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ";
const c = qs.get('country'); const INGRAM_HB = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO";
return c ? c.toUpperCase() : null;
// Elements
const elKindle = document.getElementById('kindleLink');
const elPbSelf = document.getElementById('paperbackLinkSelf');
const elHbSelf = document.getElementById('hardbackLinkSelf');
const elPbAmazon = document.getElementById('paperbackLink'); // optional
const elHbAmazon = document.getElementById('hardbackLink'); // optional
const elHbNat = document.getElementById('hardbackNational');
const elPbNat = document.getElementById('paperbackNational');
function show(el){ if (el) el.classList.remove('d-none'); }
function hide(el){ if (el) el.classList.add('d-none'); }
// helper to set both direct href and go slug
function setLink(el, directHref, slug) {
if (!el) return;
el.href = directHref;
el.setAttribute('data-go-slug', slug);
} }
// Replace trailing "-gb" in slugs with target country code window.applyLinks = function applyLinks(code) {
function swapCountrySlug(a, toCountry) { const cc = (code || '').toUpperCase();
if (!a || !a.href) return;
try { // Defaults to US Amazon
const url = new URL(a.href, location.origin); let kindleHref = "https://www.amazon.com/dp/B0FBS427VD";
// Slugs are like: /go/amazon-hardback-gb let pbAmzHref = "https://www.amazon.com/dp/1068225815";
url.pathname = url.pathname.replace(/-(gb|us|ca|au)$/i, '-' + toCountry.toLowerCase()); let hbAmzHref = "https://www.amazon.com/dp/1068225807";
// Also allow passing country through to /go for logging/testing
url.searchParams.set('country', toCountry); // National retailer defaults (GB Waterstones; US B&N)
a.href = url.pathname + url.search; let hbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802";
} catch(e) { /* noop */ } let pbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819";
let hbNatSlug = "waterstones-hardback-gb";
let pbNatSlug = "waterstones-paperback-gb";
switch (cc) {
case 'GB':
kindleHref = "https://www.amazon.co.uk/dp/B0FBS427VD";
pbAmzHref = "https://www.amazon.co.uk/dp/1068225815";
hbAmzHref = "https://www.amazon.co.uk/dp/1068225807";
hbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802";
pbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819";
hbNatSlug = "waterstones-hardback-gb";
pbNatSlug = "waterstones-paperback-gb";
break;
case 'US':
// keep US Amazon defaults
hbNatHref = "https://www.barnesandnoble.com/s/9781068225802";
pbNatHref = "https://www.barnesandnoble.com/s/9781068225819";
hbNatSlug = "bn-hardback-us";
pbNatSlug = "bn-paperback-us";
break;
case 'CA':
kindleHref = "https://www.amazon.ca/dp/B0FBS427VD";
pbAmzHref = "https://www.amazon.ca/dp/1068225815";
hbAmzHref = "https://www.amazon.ca/dp/1068225807";
// keep GB Waterstones/B&N as-is if you prefer not to show national links in CA
break;
case 'AU':
kindleHref = "https://www.amazon.com.au/dp/B0FBS427VD";
pbAmzHref = "https://www.amazon.com.au/dp/1068225815";
hbAmzHref = "https://www.amazon.com.au/dp/1068225807";
break;
case 'IE': // Ireland → use Amazon UK by default
kindleHref = "https://www.amazon.co.uk/dp/B0FBS427VD";
pbAmzHref = "https://www.amazon.co.uk/dp/1068225815";
hbAmzHref = "https://www.amazon.co.uk/dp/1068225807";
// keep Waterstones GB for national if you show it
hbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802";
pbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819";
hbNatSlug = "waterstones-hardback-gb";
pbNatSlug = "waterstones-paperback-gb";
break;
default:
break;
} }
fetch('https://ipapi.co/json/') // Kindle: direct Amazon, route via /go/ on click
.then(r => r.json()) setLink(elKindle, kindleHref, `amazon-kindle-${cc || 'us'}`);
.then(data => {
const override = qsCountryOverride();
const country = (override || data.country_code || 'US').toUpperCase();
// Core anchors // Ingram “Self” buttons: direct Ingram in GB/US only; hide elsewhere
const elKindle = document.getElementById("kindleLink"); if (cc === 'GB' || cc === 'US') {
setLink(elPbSelf, INGRAM_PB, `ingram-paperback-${cc.toLowerCase()}`);
const hbAmz = document.getElementById("hardbackLink"); setLink(elHbSelf, INGRAM_HB, `ingram-hardback-${cc.toLowerCase()}`);
const pbAmz = document.getElementById("paperbackLink"); show(elPbSelf); show(elHbSelf);
const hbNat = document.getElementById("hardbackNational");
const pbNat = document.getElementById("paperbackNational");
const rowNat = document.getElementById("rowNational");
const rowDirect = document.getElementById("rowDirect");
const hbSelf = document.getElementById("hardbackLinkSelf");
const pbSelf = document.getElementById("paperbackLinkSelf");
// Always swap Amazon & Kindle to target country
[hbAmz, pbAmz, elKindle].forEach(a => swapCountrySlug(a, country));
// Ingram Direct row only for GB/US
const showDirect = country === "GB" || country === "US";
if (rowDirect) rowDirect.classList.toggle("d-none", !showDirect);
if (showDirect) {
[hbSelf, pbSelf].forEach(a => swapCountrySlug(a, country)); // swap to -gb or -us
} else { } else {
// If direct hidden, make Amazon buttons solid hide(elPbSelf); hide(elHbSelf);
[hbAmz, pbAmz].forEach(el => el && el.classList.replace("btn-outline-dark", "btn-dark"));
} }
// National row: Waterstones (GB) or Barnes & Noble (US). Hidden elsewhere. // Amazon print buttons (if present): direct Amazon, route via /go/
if (country === "GB") { setLink(elPbAmazon, pbAmzHref, `amazon-paperback-${cc || 'us'}`);
// Keep Waterstones slugs (already -gb), but ensure '?country=GB' param is present setLink(elHbAmazon, hbAmzHref, `amazon-hardback-${cc || 'us'}`);
[hbNat, pbNat].forEach(a => swapCountrySlug(a, country));
if (hbNat) hbNat.textContent = "Hardback, Waterstones"; // National retailer row: direct URLs, route via /go/
if (pbNat) pbNat.textContent = "Paperback, Waterstones"; setLink(elHbNat, hbNatHref, hbNatSlug);
if (rowNat) rowNat.classList.remove("d-none"); setLink(elPbNat, pbNatHref, pbNatSlug);
} else if (country === "US") {
// Switch retailer in slug: waterstones-...-gb -> bn-...-us // Toggle any Ingram-only containers youve marked
function switchToBN(a, format) { document.querySelectorAll('[data-ingram-only="true"]').forEach(x => {
if (!a) return; if (cc === 'GB' || cc === 'US') x.classList.remove('d-none'); else x.classList.add('d-none');
try {
const url = new URL(a.href, location.origin);
url.pathname = url.pathname
.replace(/waterstones-(hardback|paperback)-(gb|us|ca|au)/i, (m, _fmt) => {
return `bn-${format.toLowerCase()}-us`;
});
url.searchParams.set('country', 'US');
a.href = url.pathname + url.search;
} catch(e) {}
}
switchToBN(hbNat, "Hardback");
switchToBN(pbNat, "Paperback");
if (hbNat) hbNat.textContent = "Hardback, Barnes & Noble";
if (pbNat) pbNat.textContent = "Paperback, Barnes & Noble";
if (rowNat) rowNat.classList.remove("d-none");
} else {
if (rowNat) rowNat.classList.add("d-none");
}
})
.catch(() => {
// If geo fails, GB defaults remain; users can still click, and /go logs with country=GB
}); });
};
})(); })();
</script> </script>
} }
@section Meta { @section Meta {
@ -481,7 +653,7 @@
meta-image-alt="Maggie from 'The Alpha Flame: Discovery' by Catherine Lynwood" meta-image-alt="Maggie from 'The Alpha Flame: Discovery' by Catherine Lynwood"
og-site-name="Catherine Lynwood - The Alpha Flame: Discovery" og-site-name="Catherine Lynwood - The Alpha Flame: Discovery"
article-published-time="@new DateTime(2024, 11, 20)" article-published-time="@new DateTime(2024, 11, 20)"
article-modified-time="@new DateTime(2025, 09, 06)" article-modified-time="@new DateTime(2025, 09, 10)"
twitter-card-type="summary_large_image" twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood" twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" /> twitter-creator-handle="@@CathLynwood" />

View File

@ -23,7 +23,12 @@
'https://connect.facebook.net/en_US/fbevents.js'); 'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '556474687460834'); fbq('init', '556474687460834');
fbq('track', 'PageView'); fbq('track', 'PageView', {
page_location: location.href,
page_path: location.pathname,
page_title: document.title,
referrer: document.referrer || null
});
})(); })();
</script> </script>
<noscript> <noscript>
@ -195,33 +200,41 @@
<p> <p>
<a class="text-light" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy Policy</a> <a class="text-light" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy Policy</a>
</p> </p>
<p>
<a class="text-light" href="#" onclick="manageCookies();return false;">Manage cookies</a>
</p>
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
<div id="cookieBanner" <div class="modal fade"
class="d-none position-fixed bottom-0 start-0 end-0 bg-light border-top border-3 border-dark shadow-lg" id="cookieModal"
style="z-index: 1080;"> tabindex="-1"
<div class="container py-3"> aria-labelledby="cookieTitle"
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2"> aria-hidden="true"
<div class="me-md-3"> data-bs-backdrop="static"
<strong class=text-dark>Cookies & privacy.</strong> data-bs-keyboard="false"
<span class="text-muted small"> data-nosnippet>
We use necessary cookies and, with your permission, marketing cookies (e.g. Meta Pixel) to measure and improve. <div class="modal-dialog modal-dialog-centered">
</span> <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>
<div class="d-flex gap-2"> <div class="modal-body" data-nosnippet>
<button id="cookieReject" type="button" class="btn btn-outline-dark btn-sm">Reject</button> <p class="text-dark mb-2">We use necessary cookies and, with your permission, marketing cookies…</p>
<button id="cookieAccept" type="button" class="btn btn-dark btn-sm">Accept</button> <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>
</div> </div>
</div> </div>
<script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.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="~/js/site.js" asp-append-version="true"></script>
@ -237,32 +250,44 @@
<script> <script>
(function () { (function () {
const banner = document.getElementById('cookieBanner'); const key = 'marketingConsent';
const consent = localStorage.getItem('marketingConsent'); const choice = localStorage.getItem(key);
const modalEl = document.getElementById('cookieModal');
// Show banner if no decision yet // Bootstrap modal API
if (!consent) banner.classList.remove('d-none'); const modal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
document.getElementById('cookieAccept')?.addEventListener('click', function () { // Show only if not decided
localStorage.setItem('marketingConsent', 'yes'); if (!choice) {
banner.classList.add('d-none'); modal.show();
// reload so the pixel loader in <head> runs
location.reload(); // (Optional) nudge after 3s: shake Accept button a bit
setTimeout(() => {
document.getElementById('cookieAccept')?.classList.add('btn-pulse');
setTimeout(()=>document.getElementById('cookieAccept')?.classList.remove('btn-pulse'), 1200);
}, 3000);
}
document.getElementById('cookieAccept')?.addEventListener('click', () => {
localStorage.setItem(key, 'yes');
modal.hide();
location.reload(); // loads pixel
}); });
document.getElementById('cookieReject')?.addEventListener('click', function () { document.getElementById('cookieReject')?.addEventListener('click', () => {
localStorage.setItem('marketingConsent', 'no'); localStorage.setItem(key, 'no');
banner.classList.add('d-none'); modal.hide();
}); });
// Optional: expose a simple “Manage cookies” helper you can call from a footer link // Expose a footer hook to reopen
window.manageCookies = function () { window.manageCookies = function () {
banner.classList.remove('d-none'); modal.show();
}; };
})(); })();
</script> </script>
<script> <script>
window.addEventListener("load", () => { window.addEventListener("load", () => {
setTimeout(() => { setTimeout(() => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long