505 lines
19 KiB
C#
505 lines
19 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;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace CatherineLynwood.Controllers
|
||
{
|
||
[Route("the-alpha-flame/discovery")]
|
||
public class DiscoveryController : 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 DiscoveryController(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";
|
||
|
||
// Buy links
|
||
var buyLinks = BuildBuyLinksFor(iso2);
|
||
|
||
// Reviews
|
||
Reviews reviews = await _dataAccess.GetReviewsAsync();
|
||
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
|
||
|
||
string src = variant switch
|
||
{
|
||
Variant.A => "DiscoveryA",
|
||
Variant.B => "DiscoveryB",
|
||
_ => "DiscoveryC"
|
||
};
|
||
|
||
|
||
// VM
|
||
var vm = new DiscoveryPageViewModel
|
||
{
|
||
Reviews = reviews,
|
||
UserIso2 = iso2,
|
||
Buy = buyLinks,
|
||
Src = src
|
||
};
|
||
|
||
// View mapping:
|
||
// - A and B are the two MOBILE variants
|
||
// - C is the DESKTOP variant
|
||
string viewName = variant switch
|
||
{
|
||
Variant.A => "IndexMobileA", // mobile layout A
|
||
Variant.B => "IndexMobileB", // mobile layout B
|
||
_ => "IndexDesktop" // desktop layout C
|
||
};
|
||
|
||
return View(viewName, vm);
|
||
}
|
||
|
||
[Route("reviews")]
|
||
public async Task<IActionResult> Reviews()
|
||
{
|
||
Reviews reviews = await _dataAccess.GetReviewsAsync();
|
||
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100);
|
||
return View(reviews);
|
||
}
|
||
|
||
[Route("how-to-buy")]
|
||
public IActionResult HowToBuy() => View();
|
||
|
||
[Route("chapters/chapter-1-beth")]
|
||
public IActionResult Chapter1() => View();
|
||
|
||
[Route("chapters/chapter-2-maggie")]
|
||
public IActionResult Chapter2() => View();
|
||
|
||
[Route("chapters/chapter-13-susie")]
|
||
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)]
|
||
[Route("extras")]
|
||
public IActionResult Extras() => View();
|
||
|
||
[BookAccess(1, 1)]
|
||
[Route("extras/epilogue")]
|
||
public IActionResult Epilogue() => View();
|
||
|
||
[BookAccess(1, 1)]
|
||
[Route("extras/scrap-book")]
|
||
public IActionResult ScrapBook() => View();
|
||
|
||
[BookAccess(1, 1)]
|
||
[Route("extras/listen")]
|
||
public IActionResult Listen() => View();
|
||
|
||
[BookAccess(1, 1)]
|
||
[Route("extras/maggies-designs")]
|
||
public IActionResult MaggiesDesigns() => View();
|
||
|
||
[BookAccess(1, 1)]
|
||
[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 static BuyLinksViewModel BuildBuyLinksFor(string iso2)
|
||
{
|
||
var cc = iso2.ToUpperInvariant();
|
||
if (cc == "UK") cc = "GB";
|
||
|
||
string? ingHbSlug = null, ingPbSlug = null;
|
||
if (cc == "GB") { ingHbSlug = "ingram-hardback-gb"; ingPbSlug = "ingram-paperback-gb"; }
|
||
else if (cc == "US") { ingHbSlug = "ingram-hardback-us"; ingPbSlug = "ingram-paperback-us"; }
|
||
|
||
string amazonHbSlug = cc switch
|
||
{
|
||
"GB" => "amazon-hardback-gb",
|
||
"US" => "amazon-hardback-us",
|
||
"CA" => "amazon-hardback-ca",
|
||
"AU" => "amazon-hardback-au",
|
||
_ => "amazon-hardback-us"
|
||
};
|
||
string amazonPbSlug = cc switch
|
||
{
|
||
"GB" => "amazon-paperback-gb",
|
||
"US" => "amazon-paperback-us",
|
||
"CA" => "amazon-paperback-ca",
|
||
"AU" => "amazon-paperback-au",
|
||
_ => "amazon-paperback-us"
|
||
};
|
||
string amazonKindleSlug = cc switch
|
||
{
|
||
"GB" => "amazon-kindle-gb",
|
||
"US" => "amazon-kindle-us",
|
||
"CA" => "amazon-kindle-ca",
|
||
"AU" => "amazon-kindle-au",
|
||
_ => "amazon-kindle-us"
|
||
};
|
||
|
||
string? natHbSlug = null, natPbSlug = null, natLabel = null;
|
||
if (cc == "GB") { natHbSlug = "waterstones-hardback-gb"; natPbSlug = "waterstones-paperback-gb"; natLabel = "Waterstones"; }
|
||
else if (cc == "US") { natHbSlug = "bn-hardback-us"; natPbSlug = "bn-paperback-us"; natLabel = "B&N"; }
|
||
|
||
var ccLower = cc.ToLowerInvariant();
|
||
var appleSlug = ccLower is "gb" or "us" or "ca" or "au" or "ie" ? $"apple-ebook-{ccLower}" : "apple-ebook-gb";
|
||
var koboSlug = ccLower is "gb" or "us" or "ca" or "au" or "ie" ? $"kobo-ebook-{ccLower}" : "kobo-ebook-gb";
|
||
|
||
LinkChoice? mk(string? slug) => string.IsNullOrEmpty(slug) ? null : new LinkChoice { Slug = slug, Url = BuyCatalog.Url(slug) };
|
||
|
||
var vm = new BuyLinksViewModel
|
||
{
|
||
Country = cc,
|
||
IngramHardback = mk(ingHbSlug),
|
||
IngramPaperback = mk(ingPbSlug),
|
||
AmazonHardback = mk(amazonHbSlug)!,
|
||
AmazonPaperback = mk(amazonPbSlug)!,
|
||
AmazonKindle = mk(amazonKindleSlug)!,
|
||
NationalHardback = mk(natHbSlug),
|
||
NationalPaperback = mk(natPbSlug),
|
||
NationalLabel = natLabel,
|
||
Apple = mk(appleSlug)!,
|
||
Kobo = mk(koboSlug)!
|
||
};
|
||
|
||
if (cc == "GB")
|
||
{
|
||
vm.IngramHardbackPrice = "£16.99";
|
||
vm.IngramPaperbackPrice = "£12.99";
|
||
}
|
||
else if (cc == "US")
|
||
{
|
||
vm.IngramHardbackPrice = "$24.99";
|
||
vm.IngramPaperbackPrice = "$16.99";
|
||
}
|
||
|
||
return vm;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|