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