nick e4605e473e Removed redundant pages and restructured buy links
Remove Reckoning previews and chapter sample content

Removed all public preview/sample chapter routes and views for "The Alpha Flame: Reckoning," including interactive and pre-release landing pages. Also refactored buy panel markup for purchase links. This prepares the site for a new release strategy and restricts access to pre-release material.
2026-04-03 10:40:39 +01:00

415 lines
15 KiB
C#
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.

using CatherineLynwood.Models;
using CatherineLynwood.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
namespace CatherineLynwood.Controllers
{
[Route("the-alpha-flame/reckoning")]
public class ReckoningController : Controller
{
private readonly DataAccess _dataAccess;
private readonly ICountryContext _country;
// A/B/C constants
private const string VariantCookie = "af_disc_abc";
private const string VariantQuery = "ab"; // ?ab=A or B or C
public ReckoningController(DataAccess dataAccess, ICountryContext country)
{
_dataAccess = dataAccess;
_country = country;
}
[Route("")]
public async Task<IActionResult> Index()
{
// Prevent caches or CDNs from cross-serving variants
Response.Headers["Vary"] = "Cookie, User-Agent";
// Decide device class first
var device = ResolveDeviceClass();
// Decide variant
var variant = ResolveVariant(device);
// Country ISO2
var iso2 = (_country.Iso2 ?? "GB").ToUpperInvariant();
if (iso2 == "UK") iso2 = "GB";
// Reviews
Reviews reviews = await _dataAccess.GetReviewsAsync("Reckoning");
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
string src = variant switch
{
Variant.A => "Mobile",
Variant.B => "Mobile",
_ => "Desktop"
};
// VM
var vm = new TitlePageViewModel
{
Reviews = reviews,
UserIso2 = iso2,
Src = src,
Title = "Reckoning"
};
// View mapping:
// - A and B are the two MOBILE variants
// - C is the DESKTOP variant
string viewName = variant switch
{
Variant.A => "IndexMobile", // mobile layout A
Variant.B => "IndexMobile", // mobile layout B
_ => "Index" // desktop layout C
};
return View(viewName, vm);
}
[Route("reviews")]
public async Task<IActionResult> Reviews()
{
Reviews reviews = await _dataAccess.GetReviewsAsync("Reckoning");
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100);
return View(reviews);
}
[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, 2)]
[Route("extras")]
public IActionResult Extras() => View();
[BookAccess(1, 2)]
[Route("extras/epilogue")]
public IActionResult Epilogue() => View();
[BookAccess(1, 2)]
[Route("extras/scrap-book")]
public IActionResult ScrapBook() => View();
[BookAccess(1, 2)]
[Route("extras/listen")]
public IActionResult Listen() => View();
[BookAccess(1, 2)]
[Route("extras/maggies-designs")]
public IActionResult MaggiesDesigns() => View();
[BookAccess(1, 2)]
[Route("extras/soundtrack")]
public async Task<IActionResult> Soundtrack()
{
List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack();
return View(soundtrackTrackModels);
}
// =========================
// A/B/C selection
// =========================
private enum Variant { A, B, C }
private enum DeviceClass { Mobile, Desktop }
private Variant ResolveVariant(DeviceClass device)
{
// 0) Query override: ?ab=A|B|C forces and persists
if (Request.Query.TryGetValue(VariantQuery, out var qs))
{
var parsed = ParseVariant(qs.ToString());
if (parsed.HasValue)
{
// If they force A or B but device is desktop, we still respect C logic by default.
// Allow override to win for testing convenience.
WriteVariantCookie(parsed.Value);
return parsed.Value;
}
}
// 1) Existing cookie
if (Request.Cookies.TryGetValue(VariantCookie, out var cookieVal))
{
var parsed = ParseVariant(cookieVal);
if (parsed.HasValue)
{
// If cookie says A/B but device is desktop, upgrade to C to keep desktop experience consistent.
if (device == DeviceClass.Desktop && parsed.Value != Variant.C)
{
WriteVariantCookie(Variant.C);
return Variant.C;
}
// If cookie says C but device is mobile, you can either keep C or reassign to A/B.
// We keep C only for desktops. On mobile we reassess to A/B the first time after cookie mismatch.
if (device == DeviceClass.Mobile && parsed.Value == Variant.C)
{
var reassigned = AssignMobileVariant();
WriteVariantCookie(reassigned);
return reassigned;
}
return parsed.Value;
}
}
// 2) Fresh assignment
if (device == DeviceClass.Desktop)
{
WriteVariantCookie(Variant.C);
return Variant.C;
}
else
{
var assigned = AssignMobileVariant(); // A or B
WriteVariantCookie(assigned);
return assigned;
}
}
private static Variant AssignMobileVariant(int bPercent = 50)
{
bPercent = Math.Clamp(bPercent, 0, 100);
var roll = RandomNumberGenerator.GetInt32(0, 100); // 0..99
return roll < bPercent ? Variant.B : Variant.A;
}
private static Variant? ParseVariant(string value)
{
if (string.Equals(value, "A", StringComparison.OrdinalIgnoreCase)) return Variant.A;
if (string.Equals(value, "B", StringComparison.OrdinalIgnoreCase)) return Variant.B;
if (string.Equals(value, "C", StringComparison.OrdinalIgnoreCase)) return Variant.C;
return null;
}
private void WriteVariantCookie(Variant variant)
{
var opts = new CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(90),
HttpOnly = false, // set true if you do not need analytics to read it
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/"
};
Response.Cookies.Append(VariantCookie, variant.ToString(), opts);
}
// =========================
// Device detection
// =========================
private DeviceClass ResolveDeviceClass()
{
// Simple and robust server-side approach using User-Agent.
// Chrome UA reduction still leaves Mobile hint for Android; iOS strings include iPhone/iPad.
var ua = Request.Headers.UserAgent.ToString();
if (IsMobileUserAgent(ua))
return DeviceClass.Mobile;
return DeviceClass.Desktop;
}
private static bool IsMobileUserAgent(string ua)
{
if (string.IsNullOrEmpty(ua)) return false;
// Common mobile indicators
// Android phones include "Android" and "Mobile"
// iPhone includes "iPhone"; iPad includes "iPad" (treat as mobile for your layouts)
// Many mobile browsers include "Mobile"
// Exclude obvious desktop platforms
ua = ua.ToLowerInvariant();
if (ua.Contains("ipad") || ua.Contains("iphone") || ua.Contains("ipod"))
return true;
if (ua.Contains("android") && ua.Contains("mobile"))
return true;
if (ua.Contains("android") && !ua.Contains("mobile"))
{
// Likely a tablet; treat as mobile for your use case
return true;
}
if (ua.Contains("mobile"))
return true;
// Desktop hints
if (ua.Contains("windows nt") || ua.Contains("macintosh") || ua.Contains("x11"))
return false;
// Fallback
return false;
}
// =========================
// Existing helpers
// =========================
private string GenerateBookSchemaJsonLd(Reviews reviews, int take)
{
const string imageUrl = "https://www.catherinelynwood.com/images/webp/the-alpha-flame-discovery-cover-1200.webp";
const string baseUrl = "https://www.catherinelynwood.com/the-alpha-flame/discovery";
var schema = new Dictionary<string, object>
{
["@context"] = "https://schema.org",
["@type"] = "Book",
["name"] = "The Alpha Flame: Discovery",
["alternateName"] = "The Alpha Flame Book 1",
["image"] = imageUrl,
["author"] = new Dictionary<string, object>
{
["@type"] = "Person",
["name"] = "Catherine Lynwood",
["url"] = "https://www.catherinelynwood.com"
},
["publisher"] = new Dictionary<string, object>
{
["@type"] = "Organization",
["name"] = "Catherine Lynwood"
},
["datePublished"] = "2025-08-21",
["description"] = "The Alpha Flame: Discovery is a powerful, character-driven novel set in 1983 Birmingham, following Maggie Grant and Beth, two young women separated by fate, reunited by truth, and bound by secrets...",
["genre"] = "Women's Fiction, Mystery, Contemporary Historical",
["inLanguage"] = "en-GB",
["url"] = baseUrl
};
if (reviews?.Items?.Any() == true)
{
var reviewObjects = new List<Dictionary<string, object>>();
double total = 0;
foreach (var review in reviews.Items) total += review.RatingValue;
foreach (var review in reviews.Items.Take(take))
{
reviewObjects.Add(new Dictionary<string, object>
{
["@type"] = "Review",
["author"] = new Dictionary<string, object>
{
["@type"] = "Person",
["name"] = review.AuthorName
},
["datePublished"] = review.DatePublished.ToString("yyyy-MM-dd"),
["reviewBody"] = StripHtml(review.ReviewBody),
["reviewRating"] = new Dictionary<string, object>
{
["@type"] = "Rating",
["ratingValue"] = review.RatingValue,
["bestRating"] = "5"
}
});
}
schema["review"] = reviewObjects;
schema["aggregateRating"] = new Dictionary<string, object>
{
["@type"] = "AggregateRating",
["ratingValue"] = (total / reviews.Items.Count).ToString("0.0", CultureInfo.InvariantCulture),
["reviewCount"] = reviews.Items.Count
};
}
schema["workExample"] = new List<Dictionary<string, object>>
{
new Dictionary<string, object>
{
["@type"] = "Book",
["bookFormat"] = "https://schema.org/Hardcover",
["isbn"] = "978-1-0682258-0-2",
["name"] = "The Alpha Flame: Discovery Collector's Edition",
["image"] = imageUrl,
["offers"] = new Dictionary<string, object>
{
["@type"] = "Offer",
["price"] = "23.99",
["priceCurrency"] = "GBP",
["availability"] = "https://schema.org/InStock",
["url"] = baseUrl
}
},
new Dictionary<string, object>
{
["@type"] = "Book",
["bookFormat"] = "https://schema.org/Paperback",
["isbn"] = "978-1-0682258-1-9",
["name"] = "The Alpha Flame: Discovery Bookshop Edition",
["image"] = imageUrl,
["offers"] = new Dictionary<string, object>
{
["@type"] = "Offer",
["price"] = "17.99",
["priceCurrency"] = "GBP",
["availability"] = "https://schema.org/InStock",
["url"] = baseUrl
}
},
new Dictionary<string, object>
{
["@type"] = "Book",
["bookFormat"] = "https://schema.org/Paperback",
["isbn"] = "978-1-0682258-2-6",
["name"] = "The Alpha Flame: Discovery Amazon Edition",
["image"] = imageUrl,
["offers"] = new Dictionary<string, object>
{
["@type"] = "Offer",
["price"] = "13.99",
["priceCurrency"] = "GBP",
["availability"] = "https://schema.org/InStock",
["url"] = baseUrl
}
},
new Dictionary<string, object>
{
["@type"] = "Book",
["bookFormat"] = "https://schema.org/EBook",
["isbn"] = "978-1-0682258-3-3",
["name"] = "The Alpha Flame: Discovery eBook",
["image"] = imageUrl,
["offers"] = new Dictionary<string, object>
{
["@type"] = "Offer",
["price"] = "3.95",
["priceCurrency"] = "GBP",
["availability"] = "https://schema.org/InStock",
["url"] = baseUrl
}
}
};
return JsonConvert.SerializeObject(schema, Formatting.Indented);
}
private static string StripHtml(string input) =>
string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty);
}
}