This commit is contained in:
Nick 2025-09-17 09:57:12 +01:00
parent 3ce32496e8
commit 8215d83f63
13 changed files with 667 additions and 62 deletions

View File

@ -1,12 +1,16 @@
using System.Globalization; using CatherineLynwood.Models;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Text;
using CatherineLynwood.Models;
using CatherineLynwood.Services; using CatherineLynwood.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace CatherineLynwood.Controllers namespace CatherineLynwood.Controllers
{ {
[Route("the-alpha-flame/discovery")] [Route("the-alpha-flame/discovery")]
@ -48,12 +52,21 @@ namespace CatherineLynwood.Controllers
Reviews reviews = await _dataAccess.GetReviewsAsync(); Reviews reviews = await _dataAccess.GetReviewsAsync();
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3); reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
string src = variant switch
{
Variant.A => "DiscoveryA",
Variant.B => "DiscoveryB",
_ => "DiscoveryC"
};
// VM // VM
var vm = new DiscoveryPageViewModel var vm = new DiscoveryPageViewModel
{ {
Reviews = reviews, Reviews = reviews,
UserIso2 = iso2, UserIso2 = iso2,
Buy = buyLinks Buy = buyLinks,
Src = src
}; };
// View mapping: // View mapping:
@ -89,6 +102,23 @@ namespace CatherineLynwood.Controllers
[Route("chapters/chapter-13-susie")] [Route("chapters/chapter-13-susie")]
public IActionResult Chapter13() => View(); public IActionResult Chapter13() => View();
[Route("trailer")]
public async Task<IActionResult> Trailer()
{
// Country ISO2
var iso2 = (_country.Iso2 ?? "UK").ToUpperInvariant();
if (iso2 == "GB") iso2 = "UK";
FlagSupportViewModel flagSupportViewModel = await _dataAccess.GetTrailerLikes();
foreach (var flag in flagSupportViewModel.FlagCounts)
{
flag.Selected = flag.Key.ToLower() == iso2.ToLower();
}
return View(flagSupportViewModel);
}
[BookAccess(1, 1)] [BookAccess(1, 1)]
[Route("extras")] [Route("extras")]
public IActionResult Extras() => View(); public IActionResult Extras() => View();

View File

@ -28,6 +28,7 @@ namespace CatherineLynwood.Middleware
_logger.LogInformation("GeoMW: path={Path}", context.Request.Path); _logger.LogInformation("GeoMW: path={Path}", context.Request.Path);
var ip = GetClientIp(context); var ip = GetClientIp(context);
var userAgent = context.Request.Headers["User-Agent"].ToString();
// In Development, replace loopback with a known public IP so ResolveAsync definitely runs // In Development, replace loopback with a known public IP so ResolveAsync definitely runs
if (ip is null || IPAddress.IsLoopback(ip)) if (ip is null || IPAddress.IsLoopback(ip))
@ -50,7 +51,7 @@ namespace CatherineLynwood.Middleware
_logger.LogInformation("GeoMW: calling resolver for {IP}", ip); _logger.LogInformation("GeoMW: calling resolver for {IP}", ip);
var geo = await _resolver.ResolveAsync(ip); var geo = await _resolver.ResolveAsync(ip, userAgent);
if (geo is not null) if (geo is not null)
{ {

View File

@ -11,6 +11,8 @@
public string UserIso2 { get; set; } = "GB"; public string UserIso2 { get; set; } = "GB";
public string Src = "Discovery";
#endregion Public Properties #endregion Public Properties
} }
} }

View File

@ -14,6 +14,8 @@
public string Source { get; set; } public string Source { get; set; }
public string UserAgent { get; set; }
#endregion Public Properties #endregion Public Properties
} }
} }

View File

@ -610,7 +610,8 @@ namespace CatherineLynwood.Services
Iso2 = GetDataString(rdr, "ISO2"), Iso2 = GetDataString(rdr, "ISO2"),
CountryName = GetDataString(rdr, "CountryName"), CountryName = GetDataString(rdr, "CountryName"),
LastSeenUtc = GetDataDate(rdr, "LastSeenUtc"), LastSeenUtc = GetDataDate(rdr, "LastSeenUtc"),
Source = GetDataString(rdr, "Source") Source = GetDataString(rdr, "Source"),
UserAgent = GetDataString(rdr, "UserAgent")
}; };
} }
} }
@ -1065,6 +1066,7 @@ namespace CatherineLynwood.Services
cmd.Parameters.AddWithValue("@CountryName", geo.CountryName); cmd.Parameters.AddWithValue("@CountryName", geo.CountryName);
cmd.Parameters.AddWithValue("@LastSeenUtc", geo.LastSeenUtc); cmd.Parameters.AddWithValue("@LastSeenUtc", geo.LastSeenUtc);
cmd.Parameters.AddWithValue("@Source", geo.Source ?? (object)DBNull.Value); cmd.Parameters.AddWithValue("@Source", geo.Source ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue("@UserAgent", geo.UserAgent ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(); await cmd.ExecuteNonQueryAsync();
} }

View File

@ -23,7 +23,7 @@ namespace CatherineLynwood.Services
_logger = logger; _logger = logger;
} }
public async Task<GeoIpResult?> ResolveAsync(IPAddress ip, CancellationToken ct = default) public async Task<GeoIpResult?> ResolveAsync(IPAddress ip, string userAgent, CancellationToken ct = default)
{ {
var ipStr = ip.ToString(); var ipStr = ip.ToString();
@ -56,7 +56,8 @@ namespace CatherineLynwood.Services
Iso2 = iso2, Iso2 = iso2,
CountryName = country ?? "", CountryName = country ?? "",
LastSeenUtc = DateTime.UtcNow, LastSeenUtc = DateTime.UtcNow,
Source = "ip-api" Source = "ip-api",
UserAgent = userAgent
}; };
await _dataAccess.SaveGeoIpAsync(geo); await _dataAccess.SaveGeoIpAsync(geo);

View File

@ -6,6 +6,6 @@ namespace CatherineLynwood.Services
{ {
public interface IGeoResolver public interface IGeoResolver
{ {
Task<GeoIpResult?> ResolveAsync(IPAddress ip, CancellationToken ct = default); Task<GeoIpResult?> ResolveAsync(IPAddress ip, string userAgent, CancellationToken ct = default);
} }
} }

View File

@ -0,0 +1,605 @@
@model CatherineLynwood.Models.DiscoveryPageViewModel
@{
ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters";
bool showReviews = Model.Reviews.Items.Any();
}
<div class="row">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item active" aria-current="page">Discovery</li>
</ol>
</nav>
</div>
</div>
<!-- Interactive Trailer (Variant D) -->
<section id="interactive-trailer" class="my-4">
<div class="card character-card overflow-hidden">
<div class="it-stage" id="itStage" aria-live="polite" aria-label="Interactive trailer">
<!-- Background image -->
<img id="itBgA" class="it-bg show" alt="" src="/images/webp/the-alpha-flame-discovery-cover-1200.webp" />
<img id="itBgB" class="it-bg" alt="" />
<!-- Detail image (hotspot reveal) -->
<img id="itDetail" class="it-detail d-none" alt="" />
<!-- Timed text container -->
<div id="itText" class="it-text"></div>
<!-- Hotspot layer -->
<div id="itHotspots" class="it-hotspots" aria-hidden="false"></div>
</div>
<!-- Tap-to-begin overlay (autoplay unlock) -->
<div class="it-overlay" id="itOverlay" role="dialog" aria-modal="true">
<div class="it-overlay-inner text-center">
<h3 id="itOverlayHeadline" class="mb-2">Do you dare to listen?</h3>
<p id="itOverlaySub" class="text-muted mb-3">Tap to begin your 60-second descent.</p>
<button id="itBegin" class="btn btn-dark btn-sm">Begin</button>
</div>
</div>
<!-- Progress bar -->
<div class="it-progress" aria-hidden="true">
<div class="it-progress-bar" id="itProgress"></div>
</div>
<!-- End CTA (shown when timeline completes) -->
<div class="it-end d-none" id="itEnd" aria-live="polite">
<h3 id="itEndHeadline" class="mb-2">The story starts here.</h3>
<a id="itEndBtn" href="#buyBox" class="btn btn-dark btn-sm">Buy now</a>
</div>
</div>
</section>
<!-- Synopsis first (story-first layout) -->
<section id="synopsis" class="mb-4">
<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">
<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>
</div>
<div class="card-body" id="synopsis-body">
<!-- Audio blurb -->
<div class="row align-items-center mb-3">
<div class="col-2">
<responsive-image src="catherine-lynwood-16.png" class="img-fluid rounded-circle border border-2 border-dark" alt="Catherine Lynwood" display-width-percentage="20"></responsive-image>
</div>
<div class="col-10">
<div class="audio-player text-center">
<audio id="player">
<source src="/audio/the-alpha-flame-discovery-catherine.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<p class="text-center text-white small mb-0">Listen to Catherine talking about the book</p>
</div>
</div>
<!-- Teaser -->
<p class="card-text">
Set in 1983 Birmingham, nearby Redditch, and Barmouth in Wales, The Alpha Flame: Discovery follows 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>
<!-- One copy of the full synopsis -->
<div>
<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">Maggie, by contrast, is a force of nature, a woman who thrives on her unshakable drive and an unrelenting belief in her own power. Behind her fiery red hair and disarming charm lies a storm of determination and ferocity. Maggie doesnt just live; she races through life, fuelled by a need for speed and the thrill of freedom. Her Triumph TR6 isnt just a car; its an extension of her spirit, sleek, powerful, and unapologetically bold. On the open road, with the engine roaring and the world blurring past her, she feels invincible. But Maggies intensity doesnt stop at the wheel. Her relationships burn just as brightly. As a lover, she is dominant, passionate, and unafraid to embrace her darker desires. While fiercely loving and loyal, Maggie is also formidable; crossing her isnt a mistake anyone makes twice.</p>
<p class="card-text">When fate brings Beth and Maggie together, their connection is explosive, a union of two polar opposites that burns with both tenderness and raw power. For Beth, Maggie represents a lifeline, a reminder that love and trust still exist, even in a world that has betrayed her at every turn. For Maggie, Beth awakens a fierce protectiveness and vulnerability shes rarely allowed herself to feel. Together, they ignite a flame that challenges them to confront their own fears, desires, and limitations.</p>
<p class="card-text">Set against the kaleidoscope of 1983, where synthesised anthems provide a pulsing soundtrack and the streets are alive with the bold styles and rebellious energy of the decade, their story unfolds in a city teeming with danger and intrigue. From high speed chases along winding roads to dimly lit clubs and desolate alleyways, the heroines journey is a visceral exploration of survival and freedom. The neon haze of the era contrasts sharply with the stark realities they face, painting a vivid picture of a world where strength and vulnerability coexist.</p>
<p class="card-text">As secrets surface and danger tightens its grip, Beth and Maggie must confront not only the challenges around them but the truths within themselves. Their bond is tested by betrayal, desire, and the shadows of their pasts, but through it all, their flame burns brighter, illuminating their courage and the unbreakable spirit of two heroines determined to rewrite their fates.</p>
<p class="card-text">At its heart, The Alpha Flame is a story of survival, passion, and empowerment. It captures the raw power of human connection against the gritty, vibrant backdrop of an unforgettable era. With its blend of drama, intensity, and unapologetic emotion, this is a story that will leave its mark long after the final page.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Social proof near top -->
@if (showReviews)
{
var top = Model.Reviews.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
var fullStars = (int)Math.Floor(top.RatingValue);
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
var reviewDate = top.DatePublished.ToString("d MMMM yyyy");
<section class="mb-4">
<div class="card character-card">
<div class="card-body">
<h3 class="h6 text-uppercase text-muted mb-2">★ Reader Praise ★</h3>
<blockquote class="blockquote mb-2">
<span class="mb-2 text-warning d-inline-block">
@for (int i = 0; i < fullStars; i++)
{
<i class="fad fa-star"></i>
}
@if (hasHalfStar)
{
<i class="fad fa-star-half-alt"></i>
}
@for (int i = 0; i < emptyStars; i++)
{
<i class="fad fa-star" style="--fa-primary-opacity:0.2;--fa-secondary-opacity:0.2;"></i>
}
</span>
@Html.Raw(top.ReviewBody)
<footer>
@top.AuthorName on
<cite title="@top.SiteName">
@if (string.IsNullOrEmpty(top.URL))
{
@top.SiteName
}
else
{
<a href="@top.URL" target="_blank">@top.SiteName</a>
}
</cite>
<span class="text-muted smaller">, @reviewDate</span>
</footer>
</blockquote>
@if (Model.Reviews.Items.Count > 1)
{
<div class="text-end">
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
</div>
}
</div>
</div>
</section>
}
<!-- Buy Box now AFTER synopsis/review -->
<section class="mb-4" id="buy">
<div class="row">
<div class="col-12 d-flex">
<div class="card character-card h-100 flex-fill" id="buy-card">
<div class="card-body d-flex flex-column">
@* buyBox: server-side slugs + <a ping> tracking *@
@* Model: CatherineLynwood.Models.DiscoveryPageViewModel *@
@{
var L = Model.Buy;
string pingBase = "/track/click";
string countryIso2 = Model.UserIso2 ?? "GB";
string flagPathSvg = $"/images/flags/{countryIso2}.svg";
string flagPathPng = $"/images/flags/{countryIso2}.png";
}
<partial name="_BuyBox" />
</div>
</div>
</div>
</div>
</section>
<!-- Sticky mobile buy bar (global, still points to #buyBox inside the partial) -->
<div id="mobileBuyBar" class="d-md-none fixed-bottom bg-dark text-white py-2 border-top border-3 border-light" style="z-index:1030;">
<div class="container d-flex justify-content-between align-items-center">
<span class="small">The Alpha Flame: Discovery</span>
<a href="#buyBox" class="btn btn-light btn-sm">Buy now</a>
</div>
</div>
<!-- Chapter Previews -->
<section id="chapters" class="mt-4">
<h2>Chapter Previews</h2>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter1">
<responsive-image src="beth-stood-in-bathroom.png" class="card-img-top" alt="Beth's Bathroom" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 1, Drowning in Silence, Beth</h3>
<p class="card-text">Beth returns home to find her mother lifeless in the bath...</p>
<div class="text-end"><a asp-action="Chapter1" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter2">
<responsive-image src="maggie-with-her-tr6-2.png" class="fit-image" alt="Maggie With Her TR6" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<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 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>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 character-card">
<a asp-action="Chapter13">
<responsive-image src="pub-from-chapter-13.png" class="fit-image" alt="Pub from Chapter 13" display-width-percentage="50"></responsive-image>
</a>
<div class="card-body border-top border-3 border-dark">
<h3 class="card-title">Chapter 13, A Name She Never Owned, Susie</h3>
<p class="card-text">Susie goes out for a drink with a punter. What on earth could go wrong...</p>
<div class="text-end"><a asp-action="Chapter13" class="btn btn-dark">Read More</a></div>
</div>
</div>
</div>
</div>
</section>
@section Scripts {
<!-- Plyr for audio -->
<script>
const player = new Plyr('audio');
</script>
<script type="application/json" id="itConfig">
{
"version": 1,
"cover": {
"mobileSrc": "/images/webp/the-alpha-flame-discovery-cover-1200.webp",
"desktopSrc": "/images/webp/the-alpha-flame-discovery-cover-1200.webp",
"alt": "The Alpha Flame: Discovery by Catherine Lynwood"
},
"coverHoldMs": 2200,
"tapToBegin": {
"enabled": true,
"headline": "Do you dare to listen?",
"subline": "Tap to begin your 60-second descent."
},
"endCta": {
"headline": "The story starts here.",
"buttonText": "Buy now",
"buttonHref": "#buyBox"
},
"segments": [
{
"id": "beth-hallway",
"background": {
"mobileSrc": "/images/webp/a-letter-to-readers-before-launch-960.webp",
"desktopSrc": "/images/webp/a-letter-to-readers-before-launch-960.webp",
"alt": "Dim hallway inside Beth's flat"
},
"audio": { "src": "/audio/trailer/test.mp3", "gain": 1.0 },
"durationMs": 12000,
"text": {
"position": "bottom",
"align": "center",
"lines": [
{ "t": "The water had gone cold.", "atMs": 300, "outMs": 3500 },
{ "t": "She hadnt moved.", "atMs": 3800, "outMs": 7800 }
]
},
"hotspots": [
{
"id": "door",
"rectPct": { "x": 58, "y": 36, "w": 18, "h": 32 },
"detail": {
"mobileSrc": "/images/webp/art-of-pacing-a-thriller-1200.webp",
"desktopSrc": "/images/webp/art-of-pacing-a-thriller-1200.webp",
"alt": "Beth's bedroom beyond the doorway",
"holdMs": 2800
},
"hints": { "pulse": true, "label": "Look closer" },
"analyticsLabel": "hs_beth_door"
}
]
},
{
"id": "bathroom-mirror",
"background": {
"mobileSrc": "/images/webp/asking-for-reviews-1200.webp",
"desktopSrc": "/images/webp/asking-for-reviews-1200.webp",
"alt": "Steamed bathroom mirror"
},
"audio": { "src": "/audio/trailer/test.mp3" },
"durationMs": 12000,
"text": {
"position": "middle",
"align": "center",
"lines": [
{ "t": "If I tell you this, you will not think less of me, will you…", "atMs": 300, "outMs": 5500 }
]
},
"hotspots": [
{
"id": "mirror-smudge",
"rectPct": { "x": 40, "y": 25, "w": 20, "h": 18 },
"detail": {
"mobileSrc": "/images/webp/beth-6-1200.webp",
"desktopSrc": "/images/webp/beth-6-1200.webp",
"alt": "Handprint on the mirror",
"holdMs": 2200
},
"analyticsLabel": "hs_mirror"
}
]
},
{
"id": "maggie-road",
"background": {
"mobileSrc": "/images/webp/beth-9-1200.webp",
"desktopSrc": "/images/webp/beth-9-1200.webp",
"alt": "Headlights on a dark road"
},
"audio": { "src": "/audio/trailer/test.mp3" },
"durationMs": 12000,
"text": {
"position": "top",
"align": "left",
"lines": [
{ "t": "Some people survive.", "atMs": 300, "outMs": 2600 },
{ "t": "She lives.", "atMs": 2800, "outMs": 5200 }
]
},
"hotspots": [
{
"id": "headlights",
"rectPct": { "x": 18, "y": 52, "w": 28, "h": 22 },
"detail": {
"mobileSrc": "/images/webp/beth-11-1200.webp",
"desktopSrc": "/images/webp/beth-11-1200.webp",
"alt": "Dashboard POV racing forward",
"holdMs": 2600
},
"analyticsLabel": "hs_headlights"
}
]
},
{
"id": "club-neon",
"background": {
"mobileSrc": "/images/webp/beth-12-1200.webp",
"desktopSrc": "/images/webp/beth-12-1200.webp",
"alt": "Neon sign outside a club"
},
"audio": { "src": "/audio/trailer/test.mp3" },
"durationMs": 12000,
"text": {
"position": "bottom",
"align": "center",
"lines": [
{ "t": "Secrets buried for years are about to set this city on fire.", "atMs": 300, "outMs": 6500 }
]
},
"hotspots": [
{
"id": "neon",
"rectPct": { "x": 62, "y": 22, "w": 22, "h": 20 },
"detail": {
"mobileSrc": "/images/webp/beth-1200.webp",
"desktopSrc": "/images/webp/beth-1200.webp",
"alt": "Inside the club, smoky silhouettes",
"holdMs": 2200
},
"analyticsLabel": "hs_neon"
}
]
}
]
}
</script>
<script>
document.addEventListener('DOMContentLoaded', function(){
;(function IT_Run(){
"use strict";
// --- utils ---
const byId=(id)=>document.getElementById(id);
const wait=(ms)=>new Promise(r=>setTimeout(r,ms));
const isDesk=()=>window.matchMedia("(min-width: 992px)").matches;
const pickSrc=(o)=>!o?null:(o.mobileSrc||o.desktopSrc)?(isDesk()?(o.desktopSrc||o.mobileSrc):(o.mobileSrc||o.desktopSrc)):(o.src||null);
// --- config ---
const cfgEl=byId("itConfig"); if(!cfgEl){console.warn("[IT] Missing #itConfig");return;}
let cfg={}; try{cfg=JSON.parse(cfgEl.textContent||"{}");}catch(e){console.error("[IT] Bad JSON",e);return;}
// --- dom ---
const stage=byId("itStage"), bgA=byId("itBgA"), bgB=byId("itBgB"),
detail=byId("itDetail"), textBox=byId("itText"), hotspots=byId("itHotspots"),
overlay=byId("itOverlay"), beginBtn=byId("itBegin"), progress=byId("itProgress"),
endWrap=byId("itEnd"), endH=byId("itEndHeadline"), endBtn=byId("itEndBtn"),
ovH=byId("itOverlayHeadline"), ovS=byId("itOverlaySub");
if(!stage||!bgA||!bgB||!detail||!textBox||!hotspots||!overlay||!beginBtn||!progress||!endWrap){
console.error("[IT] Required nodes missing"); return;
}
// crossfade buffers
let activeBg=bgA, idleBg=bgB;
// show cover behind overlay (no black)
const initialCover=pickSrc(cfg.cover||{})||activeBg.getAttribute("src");
if(initialCover){
[bgA,bgB].forEach(img=>{img.loading="eager"; img.decoding="async";});
activeBg.src=initialCover;
if(cfg.cover?.alt) activeBg.alt=cfg.cover.alt;
activeBg.classList.add("show");
idleBg.classList.remove("show");
}
// overlay + CTA text
if(ovH) ovH.textContent=cfg.tapToBegin?.headline||"Tap to begin";
if(ovS) ovS.textContent=cfg.tapToBegin?.subline||"";
if(cfg.tapToBegin?.enabled===false) overlay.classList.add("hidden");
if(cfg.endCta?.headline) endH.textContent=cfg.endCta.headline;
if(cfg.endCta?.buttonText) endBtn.textContent=cfg.endCta.buttonText;
if(cfg.endCta?.buttonHref) endBtn.href=cfg.endCta.buttonHref;
// preload
const audioMap=new Map();
async function preloadAudios(){
const segs=cfg.segments||[];
await Promise.all(segs.map((seg,i)=>new Promise(res=>{
const key=seg.id||`seg_${i}`, src=seg.audio?.src; if(!src){res();return;}
const a=new Audio(src); a.preload="auto";
const done=()=>res(); a.addEventListener("loadedmetadata",done,{once:true});
a.addEventListener("error",done,{once:true}); audioMap.set(key,a); a.load();
})));
}
function preloadImages(){
const urls=[], add=u=>{if(u&&!urls.includes(u)) urls.push(u);};
add(pickSrc(cfg.cover||{}));
(cfg.segments||[]).forEach(seg=>{ add(pickSrc(seg.background||{})); (seg.hotspots||[]).forEach(h=>add(pickSrc(h.detail||{}))); });
urls.forEach(u=>{const im=new Image(); im.src=u;});
}
// durations + progress
function estimateDurations(){
const segs=cfg.segments||[];
const durs=segs.map((seg,i)=>{
if(typeof seg.durationMs==="number") return seg.durationMs;
const a=audioMap.get(seg.id||`seg_${i}`);
return (a && isFinite(a.duration) && a.duration>0)? Math.round(a.duration*1000)+350 : 10000;
});
const coverHold=Math.max(0,cfg.coverHoldMs||0);
const total=coverHold+durs.reduce((a,b)=>a+b,0);
return {durs,total,coverHold};
}
const prog={id:0};
function startProgress(totalMs){
const t0=performance.now();
const tick=()=>{const p=(performance.now()-t0)/totalMs*100; progress.style.width=Math.max(0,Math.min(100,p))+"%";
if(p<100) prog.id=requestAnimationFrame(tick);
};
prog.id=requestAnimationFrame(tick);
}
function stopProgress(){ cancelAnimationFrame(prog.id); }
// text + hotspots
function clearNode(el){ while(el.firstChild) el.removeChild(el.firstChild); }
function scheduleText(seg){
textBox.className="it-text";
textBox.classList.add(seg.text?.position||"bottom");
textBox.classList.add(seg.text?.align||"center");
const timers=[]; clearNode(textBox);
(seg.text?.lines||[]).forEach(line=>{
const p=document.createElement("p"); p.className="line"; p.textContent=line.t||""; textBox.appendChild(p);
timers.push(setTimeout(()=>p.classList.add("show"), Math.max(0,line.atMs||0)));
if(typeof line.outMs==="number") timers.push(setTimeout(()=>p.classList.remove("show"), Math.max(0,line.outMs)));
});
return ()=>timers.forEach(t=>clearTimeout(t));
}
function pctToRect(pct, c){ const w=c.clientWidth,h=c.clientHeight;
return{left:(pct.x/100)*w, top:(pct.y/100)*h, width:(pct.w/100)*w, height:(pct.h/100)*h};
}
function renderHotspots(seg){
clearNode(hotspots);
if(!seg.hotspots||!seg.hotspots.length) return;
const btns=[];
seg.hotspots.forEach(h=>{
const btn=document.createElement("button"); btn.type="button";
btn.setAttribute("aria-label", h.hints?.label||"Reveal detail");
if(h.hints?.pulse!==false) btn.classList.add("pulse");
let hint; if(h.hints?.label){ hint=document.createElement("span"); hint.className="hint"; hint.textContent=h.hints.label; btn.appendChild(hint); }
const place=()=>{ const r=pctToRect(h.rectPct,stage);
btn.style.left=r.left+"px"; btn.style.top=r.top+"px"; btn.style.width=r.width+"px"; btn.style.height=r.height+"px"; };
place(); window.addEventListener("resize", place);
btn.addEventListener("click", ()=>{
const dSrc=pickSrc(h.detail||{}); if(!dSrc) return;
detail.src=dSrc; if(h.detail?.alt) detail.alt=h.detail.alt;
detail.classList.remove("d-none"); detail.style.opacity="0";
requestAnimationFrame(()=>{ detail.style.opacity="1"; });
const hold=Math.max(800,h.detail?.holdMs||2000);
setTimeout(()=>{ detail.style.opacity="0"; setTimeout(()=>detail.classList.add("d-none"),450); }, hold);
}, {passive:true});
hotspots.appendChild(btn); btns.push({hint});
});
// brief hint reveal
btns.forEach(({hint})=>{ if(hint) hint.style.opacity="1"; });
setTimeout(()=>btns.forEach(({hint})=>{ if(hint) hint.style.opacity=""; }), 1800);
}
// --- CROSSFADE: wait for decode() to avoid flashes ---
async function setBackground(segBg){
const src=pickSrc(segBg||{}); if(!src) return;
const next=idleBg, current=activeBg;
// prepare next layer hidden
next.classList.remove("show");
// set source
if(next.src!==src){
next.src=src;
if(segBg?.alt) next.alt=segBg.alt||"";
}
// ensure pixel-ready before swapping (prevents flicker)
try{
if(next.decode) { await next.decode(); }
else {
// fallback: if already complete, continue; otherwise wait for load
if(!next.complete) await new Promise(res=>{ next.addEventListener("load", ()=>res(), {once:true}); });
}
}catch(_e){ /* decode can reject on cached images in some browsers; ignore */ }
// do the crossfade on the next frame
requestAnimationFrame(()=>{
next.classList.add("show");
current.classList.remove("show");
});
// swap buffers
activeBg=next; idleBg=current;
}
async function runSegments(durs){
const segs=cfg.segments||[];
for(let i=0;i<segs.length;i++){
const seg=segs[i], key=seg.id||`seg_${i}`;
await setBackground(seg.background);
renderHotspots(seg);
const a=audioMap.get(key);
if(a){ try{ a.currentTime=0; a.volume=Math.max(0,Math.min(1, seg.audio?.gain??1.0)); await a.play(); }catch{} }
const clearTimers=scheduleText(seg);
await wait(durs[i]||10000);
clearTimers(); clearNode(hotspots); if(a) a.pause(); detail.classList.add("d-none");
}
}
async function run(){
overlay.classList.add("hidden");
await preloadAudios(); preloadImages();
const {durs,total,coverHold}=estimateDurations();
startProgress(total);
await wait(coverHold);
await runSegments(durs);
stopProgress();
endWrap.classList.remove("d-none");
}
beginBtn.addEventListener("click", run, {passive:true});
overlay.addEventListener("click", (e)=>{ if(e.target===overlay) run(); }, {passive:true});
})();
});
</script>
}
@section Meta {
<MetaTag meta-title="The Alpha Flame: Discovery by Catherine Lynwood"
meta-description="A gritty 1980s Birmingham psycological crime novel about two girls uncovering dark family secrets and surviving abuse. Realistic, powerful, and unflinching... discover The Alpha Flame today."
meta-keywords="The Alpha Flame Discovery, Catherine Lynwood, 1983 novel, twin sisters, suspense fiction, Rubery, Birmingham fiction, historical drama, family secrets"
meta-author="Catherine Lynwood"
meta-url="https://www.catherinelynwood.com/the-alpha-flame/discovery"
meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp"
meta-image-png="https://www.catherinelynwood.com/images/the-alpha-flame-discovery-cover.png"
meta-image-alt="Maggie from 'The Alpha Flame: Discovery' by Catherine Lynwood"
og-site-name="Catherine Lynwood - The Alpha Flame: Discovery"
article-published-time="@new DateTime(2024, 11, 20)"
article-modified-time="@new DateTime(2025, 09, 10)"
twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" />
<script type="application/ld+json">
@Html.Raw(Model.Reviews.SchemaJsonLd)
</script>
}

View File

@ -40,7 +40,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-dark w-100" <a class="btn btn-dark w-100"
href="@L.IngramHardback.Url" href="@L.IngramHardback.Url"
ping="@($"/track/click?slug={L.IngramHardback.Slug}&src=discovery")" ping="@($"/track/click?slug={L.IngramHardback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, direct <i class="fad fa-gem me-1"></i> Hardback, direct
@if (!string.IsNullOrWhiteSpace(L.IngramHardbackPrice)) @if (!string.IsNullOrWhiteSpace(L.IngramHardbackPrice))
@ -55,7 +55,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-dark w-100" <a class="btn btn-dark w-100"
href="@L.IngramPaperback.Url" href="@L.IngramPaperback.Url"
ping="@($"/track/click?slug={L.IngramPaperback.Slug}&src=discovery")" ping="@($"/track/click?slug={L.IngramPaperback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-book me-1"></i> Paperback, direct <i class="fad fa-book me-1"></i> Paperback, direct
@if (!string.IsNullOrWhiteSpace(L.IngramPaperbackPrice)) @if (!string.IsNullOrWhiteSpace(L.IngramPaperbackPrice))
@ -82,7 +82,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.AmazonHardback.Url" href="@L.AmazonHardback.Url"
ping="@($"{pingBase}?slug={L.AmazonHardback.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.AmazonHardback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, Amazon <i class="fad fa-gem me-1"></i> Hardback, Amazon
</a> </a>
@ -90,7 +90,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.AmazonPaperback.Url" href="@L.AmazonPaperback.Url"
ping="@($"{pingBase}?slug={L.AmazonPaperback.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.AmazonPaperback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-book me-1"></i> Paperback, Amazon <i class="fad fa-book me-1"></i> Paperback, Amazon
</a> </a>
@ -110,7 +110,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.NationalHardback.Url" href="@L.NationalHardback.Url"
ping="@($"{pingBase}?slug={L.NationalHardback.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.NationalHardback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, @(L.NationalLabel ?? "National") <i class="fad fa-gem me-1"></i> Hardback, @(L.NationalLabel ?? "National")
</a> </a>
@ -121,7 +121,7 @@
<div class="col-12 col-sm-6"> <div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.NationalPaperback.Url" href="@L.NationalPaperback.Url"
ping="@($"{pingBase}?slug={L.NationalPaperback.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.NationalPaperback.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-book-open me-1"></i> Paperback, @(L.NationalLabel ?? "National") <i class="fad fa-book-open me-1"></i> Paperback, @(L.NationalLabel ?? "National")
</a> </a>
@ -142,7 +142,7 @@
<div class="col-12 col-sm-4"> <div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.Apple.Url" href="@L.Apple.Url"
ping="@($"{pingBase}?slug={L.Apple.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.Apple.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fab fa-apple me-1"></i> Apple Books <i class="fab fa-apple me-1"></i> Apple Books
</a> </a>
@ -150,7 +150,7 @@
<div class="col-12 col-sm-4"> <div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.Kobo.Url" href="@L.Kobo.Url"
ping="@($"{pingBase}?slug={L.Kobo.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.Kobo.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fad fa-book-open me-1"></i> Kobo <i class="fad fa-book-open me-1"></i> Kobo
</a> </a>
@ -158,7 +158,7 @@
<div class="col-12 col-sm-4"> <div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100" <a class="btn btn-outline-dark w-100"
href="@L.AmazonKindle.Url" href="@L.AmazonKindle.Url"
ping="@($"{pingBase}?slug={L.AmazonKindle.Slug}&src=discovery")" ping="@($"{pingBase}?slug={L.AmazonKindle.Slug}&src={Model.Src}")"
rel="nofollow noindex"> rel="nofollow noindex">
<i class="fab fa-amazon me-1"></i> Kindle <i class="fab fa-amazon me-1"></i> Kindle
</a> </a>

View File

@ -11,34 +11,6 @@
<link rel="stylesheet" href="~/css/duotone.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" /> <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> <style>
.plyr--audio .plyr__controls { .plyr--audio .plyr__controls {
@ -129,9 +101,9 @@
<li class="py-2"> <li class="py-2">
<a class="dropdown-item" asp-controller="Discovery" asp-action="Index">Discovery (Book 1)</a> <a class="dropdown-item" asp-controller="Discovery" asp-action="Index">Discovery (Book 1)</a>
</li> </li>
<li class="py-2"> @* <li class="py-2">
<a class="dropdown-item" asp-controller="Discovery" asp-action="Trailer">Discovery Release Trailer</a> <a class="dropdown-item" asp-controller="Discovery" asp-action="Trailer">Discovery Release Trailer</a>
</li> </li> *@
<li class="py-2"> <li class="py-2">
<a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Index">The Trilogy</a> <a class="dropdown-item" asp-controller="TheAlphaFlame" asp-action="Index">The Trilogy</a>
</li> </li>
@ -278,16 +250,6 @@
} }
</script> </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> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var hint = document.getElementById('scrollHint'); var hint = document.getElementById('scrollHint');

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long