Refactor buy link handling and add geo-resolution features

- Refactored `BuyController` for improved click logging and centralized link management.
- Updated `DiscoveryController` to enhance user request handling and added country context dependency.
- Introduced `ClicksController` for managing click tracking and redirects.
- Added `GeoResolutionMiddleware` to resolve user locations based on IP.
- Created `BuyCatalog` for centralized management of buy links.
- Introduced view models (`BuyLinksViewModel`, `DiscoveryPageViewModel`) for better data handling in views.
- Added new files for geographical data handling (`GeoIpResult`, `HttpContextItemKeys`, `LinkChoice`, etc.).
- Updated `_BuyBox.cshtml` to render buy options based on user location.
- Modified `DataAccess` for saving and retrieving geographical data.
This commit is contained in:
Nick 2025-09-12 22:01:09 +01:00
parent b3cc5ccedd
commit 4759fbbd69
18 changed files with 1369 additions and 923 deletions

View File

@ -3,15 +3,14 @@ using CatherineLynwood.Services;
using Microsoft.AspNetCore.Mvc;
using System.Web;
namespace CatherineLynwood.Controllers
{
[Route("go")]
public class BuyController : Controller
[Route("")]
public class ClicksController : Controller
{
private readonly DataAccess _dataAccess;
// Replace with DB/service
// Central map of slugs -> destination URLs and metadata
private static readonly Dictionary<string, BuyLink> Links = new()
{
// --- Ingram direct (GB/US only) ---
@ -48,7 +47,7 @@ namespace CatherineLynwood.Controllers
CountryGroup = "US"
},
// --- Amazon (all countries) ---
// --- Amazon (GB/US/CA/AU) ---
["amazon-hardback-gb"] = new BuyLink
{
Slug = "amazon-hardback-gb",
@ -188,7 +187,7 @@ namespace CatherineLynwood.Controllers
["apple-ebook-gb"] = new BuyLink
{
Slug = "apple-ebook-gb",
Url = "https://books.apple.com/gb/book/the-alpha-flame/id6747852729",
Url = "https://books.apple.com/gb/book/the-alpha-flame/id/6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "GB"
@ -196,7 +195,7 @@ namespace CatherineLynwood.Controllers
["apple-ebook-us"] = new BuyLink
{
Slug = "apple-ebook-us",
Url = "https://books.apple.com/us/book/the-alpha-flame/id6747852729",
Url = "https://books.apple.com/us/book/the-alpha-flame/id/6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "US"
@ -204,7 +203,7 @@ namespace CatherineLynwood.Controllers
["apple-ebook-ca"] = new BuyLink
{
Slug = "apple-ebook-ca",
Url = "https://books.apple.com/ca/book/the-alpha-flame/id6747852729",
Url = "https://books.apple.com/ca/book/the-alpha-flame/id/6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "CA"
@ -212,7 +211,7 @@ namespace CatherineLynwood.Controllers
["apple-ebook-au"] = new BuyLink
{
Slug = "apple-ebook-au",
Url = "https://books.apple.com/au/book/the-alpha-flame/id6747852729",
Url = "https://books.apple.com/au/book/the-alpha-flame/id/6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "AU"
@ -220,7 +219,7 @@ namespace CatherineLynwood.Controllers
["apple-ebook-ie"] = new BuyLink
{
Slug = "apple-ebook-ie",
Url = "https://books.apple.com/ie/book/the-alpha-flame/id6747852729",
Url = "https://books.apple.com/ie/book/the-alpha-flame/id/6747852729",
Retailer = "Apple Books",
Format = "eBook",
CountryGroup = "IE"
@ -269,13 +268,15 @@ namespace CatherineLynwood.Controllers
}
};
public BuyController(DataAccess dataAccess)
public ClicksController(DataAccess dataAccess)
{
_dataAccess = dataAccess;
}
[HttpGet("{slug}")]
// ---------------------------------------------------------------------
// GET /go/{slug} -> logs the click server-side, then redirects
// ---------------------------------------------------------------------
[HttpGet("go/{slug}")]
public async Task<IActionResult> Go(string slug)
{
if (!Links.TryGetValue(slug, out var link)) return NotFound();
@ -285,6 +286,7 @@ namespace CatherineLynwood.Controllers
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty;
// Keep lightweight bot/protection checks
if (HttpMethods.IsHead(Request.Method)) return Redirect(link.Url);
if (IsLikelyBot(ua)) return Redirect(link.Url);
@ -292,25 +294,9 @@ namespace CatherineLynwood.Controllers
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
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Secure = true,
MaxAge = TimeSpan.FromDays(365),
Path = "/"
});
// carry on and log this click
}
EnsureSidCookie(out var sessionId);
// Persist the click
_ = await _dataAccess.SaveBuyClick(
dateTimeUtc: DateTime.UtcNow,
slug: link.Slug,
@ -330,6 +316,61 @@ namespace CatherineLynwood.Controllers
return Redirect(link.Url);
}
// ---------------------------------------------------------------------
// POST /track/click?slug=amazon-paperback-gb&src=discovery
// Receives <a ping="..."> pings and records them (204 No Content).
// ---------------------------------------------------------------------
[HttpPost("track/click")]
public async Task<IActionResult> TrackClick([FromQuery] string slug, [FromQuery] string src = "discovery")
{
if (string.IsNullOrWhiteSpace(slug)) return NoContent();
Links.TryGetValue(slug, out var link);
var ua = Request.Headers["User-Agent"].ToString() ?? string.Empty;
var referer = Request.Headers["Referer"].ToString() ?? string.Empty;
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty;
EnsureSidCookie(out var sessionId);
_ = await _dataAccess.SaveBuyClick(
dateTimeUtc: DateTime.UtcNow,
slug: slug,
retailer: link?.Retailer ?? "",
format: link?.Format ?? "",
countryGroup: link?.CountryGroup ?? "",
ip: ip,
country: "", // optional for ping
userAgent: ua,
referer: referer,
page: src,
sessionId: sessionId,
queryString: qs,
destinationUrl: link?.Url ?? ""
);
return NoContent();
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private void EnsureSidCookie(out string sessionId)
{
sessionId = Request.Cookies["sid"]!;
if (!string.IsNullOrEmpty(sessionId)) return;
sessionId = Guid.NewGuid().ToString("N");
Response.Cookies.Append("sid", sessionId, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Secure = true,
MaxAge = TimeSpan.FromDays(365),
Path = "/"
});
}
private static bool IsLikelyBot(string ua)
{
@ -337,23 +378,13 @@ namespace CatherineLynwood.Controllers
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"
"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()));
return needles.Any(n => ua.Contains(n));
}
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

@ -1,95 +1,55 @@
using CatherineLynwood.Models;
using System.Globalization;
using System.Text.RegularExpressions;
using CatherineLynwood.Models;
using CatherineLynwood.Services;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace CatherineLynwood.Controllers
{
[Route("the-alpha-flame/discovery")]
public class DiscoveryController : Controller
{
#region Private Fields
private readonly DataAccess _dataAccess;
private readonly ICountryContext _country;
private DataAccess _dataAccess;
#endregion Private Fields
#region Public Constructors
public DiscoveryController(DataAccess dataAccess)
public DiscoveryController(DataAccess dataAccess, ICountryContext country)
{
_dataAccess = dataAccess;
_country = country;
}
#endregion Public Constructors
#region Public Methods
[Route("chapters/chapter-1-beth")]
public IActionResult Chapter1()
{
return View();
}
[Route("chapters/chapter-13-susie")]
public IActionResult Chapter13()
{
return View();
}
[Route("chapters/chapter-2-maggie")]
public IActionResult Chapter2()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras/epilogue")]
public IActionResult Epilogue()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras")]
public IActionResult Extras()
{
return View();
}
[Route("how-to-buy")]
public IActionResult HowToBuy()
{
return View();
}
// -------------------------
// Pages
// -------------------------
[Route("")]
public async Task<IActionResult> Index()
{
// 1) Resolve country ISO2 (already set by middleware)
var iso2 = (_country.Iso2 ?? "GB").ToUpperInvariant();
if (iso2 == "UK") iso2 = "GB"; // normalise
// 2) Build server-side link slugs for this user
var buyLinks = BuildBuyLinksFor(iso2);
// 3) Load reviews (unchanged)
Reviews reviews = await _dataAccess.GetReviewsAsync();
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
return View("Index1", reviews);
}
[BookAccess(1, 1)]
[Route("extras/listen")]
public IActionResult Listen()
// 4) Compose page VM
var vm = new DiscoveryPageViewModel
{
return View();
}
Reviews = reviews,
UserIso2 = iso2,
Buy = buyLinks
};
[BookAccess(1, 1)]
[Route("extras/maggies-designs")]
public IActionResult MaggiesDesigns()
{
return View();
// Use your existing view (rename if you prefer): Index1.cshtml
return View("Index1", vm);
}
[Route("reviews")]
@ -97,67 +57,114 @@ namespace CatherineLynwood.Controllers
{
Reviews reviews = await _dataAccess.GetReviewsAsync();
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100);
// If you prefer a strongly-typed page VM here too, you can wrap, but returning Reviews keeps your existing view working
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();
[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()
{
return View();
}
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);
}
[Route("trailer")]
public async Task<IActionResult> Trailer()
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
// -------------------------
// Private helpers
// -------------------------
//ip = "77.104.168.236";
private static BuyLinksViewModel BuildBuyLinksFor(string iso2)
{
var cc = iso2.ToUpperInvariant();
if (cc == "UK") cc = "GB";
string country = "Unknown";
using (var client = new HttpClient())
{
try
{
var response = await client.GetStringAsync($"http://ip-api.com/json/{ip}");
var json = JsonDocument.Parse(response);
country = json.RootElement.GetProperty("countryCode").GetString();
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"; }
if (country == "GB")
string amazonHbSlug = cc switch
{
country = "UK";
}
}
catch (Exception ex)
"GB" => "amazon-hardback-gb",
"US" => "amazon-hardback-us",
"CA" => "amazon-hardback-ca",
"AU" => "amazon-hardback-au",
_ => "amazon-hardback-us"
};
string amazonPbSlug = cc switch
{
// Fail silently
}
"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) };
return 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)!
};
}
FlagSupportViewModel flagSupportViewModel = await _dataAccess.GetTrailerLikes();
foreach (var flag in flagSupportViewModel.FlagCounts)
{
flag.Selected = flag.Key.ToLower() == country.ToLower();
}
return View(flagSupportViewModel);
}
#endregion Public Methods
#region Private Methods
private string GenerateBookSchemaJsonLd(Reviews reviews, int take)
{
@ -189,16 +196,12 @@ namespace CatherineLynwood.Controllers
["url"] = baseUrl
};
// Add review section if there are reviews
// Reviews
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) total += review.RatingValue;
foreach (var review in reviews.Items.Take(take))
{
@ -230,7 +233,7 @@ namespace CatherineLynwood.Controllers
};
}
// Add work examples
// Work examples
schema["workExample"] = new List<Dictionary<string, object>>
{
new Dictionary<string, object>
@ -302,8 +305,7 @@ namespace CatherineLynwood.Controllers
return JsonConvert.SerializeObject(schema, Formatting.Indented);
}
private string StripHtml(string input) => string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty);
#endregion Private Methods
private static string StripHtml(string input) =>
string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty);
}
}

View File

@ -0,0 +1,82 @@
using CatherineLynwood.Models;
using CatherineLynwood.Services;
using System.Net;
namespace CatherineLynwood.Middleware
{
public sealed class GeoResolutionMiddleware
{
private readonly RequestDelegate _next;
private readonly IGeoResolver _resolver;
private readonly ILogger<GeoResolutionMiddleware> _logger;
private readonly IHostEnvironment _env;
public GeoResolutionMiddleware(
RequestDelegate next,
IGeoResolver resolver,
ILogger<GeoResolutionMiddleware> logger,
IHostEnvironment env)
{
_next = next; _resolver = resolver; _logger = logger; _env = env;
}
public async Task Invoke(HttpContext context)
{
// Basic trace to confirm middleware is running
_logger.LogInformation("GeoMW: path={Path}", context.Request.Path);
var ip = GetClientIp(context);
// In Development, replace loopback with a known public IP so ResolveAsync definitely runs
if (ip is null || IPAddress.IsLoopback(ip))
{
if (_env.IsDevelopment())
{
ip = IPAddress.Parse("81.145.211.224"); // UK
//ip = IPAddress.Parse("66.249.10.10"); // US
//ip = IPAddress.Parse("24.48.0.1"); // Canada
//ip = IPAddress.Parse("1.1.1.1"); // Australia
_logger.LogInformation("GeoMW: dev override IP -> {IP}", ip);
}
else
{
_logger.LogInformation("GeoMW: loopback or null IP in non-dev; skipping");
await _next(context);
return;
}
}
_logger.LogInformation("GeoMW: calling resolver for {IP}", ip);
var geo = await _resolver.ResolveAsync(ip);
if (geo is not null)
{
context.Items[HttpContextItemKeys.CountryIso2] = geo.Iso2;
context.Items[HttpContextItemKeys.CountryName] = geo.CountryName ?? "";
context.Response.Headers["X-Geo-Iso2"] = geo.Iso2; // debug aid
_logger.LogInformation("GeoMW: resolved {IP} -> {ISO2} ({Country})", ip, geo.Iso2, geo.CountryName);
}
else
{
context.Response.Headers["X-Geo-Iso2"] = "none"; // debug aid
_logger.LogWarning("GeoMW: resolver returned null for {IP}", ip);
}
await _next(context);
}
private static IPAddress? GetClientIp(HttpContext ctx)
{
// Prefer X-Forwarded-For when present
if (ctx.Request.Headers.TryGetValue("X-Forwarded-For", out var xff))
{
var first = xff.ToString().Split(',').Select(s => s.Trim()).FirstOrDefault();
if (IPAddress.TryParse(first, out var parsed)) return parsed;
}
return ctx.Connection.RemoteIpAddress;
}
}
}

View File

@ -0,0 +1,270 @@
using System.Collections.ObjectModel;
namespace CatherineLynwood.Models
{
public static class BuyCatalog
{
private static readonly Dictionary<string, BuyLink> Links = new()
{
// --- Ingram direct (GB/US only) ---
["ingram-hardback-gb"] = new BuyLink
{
Slug = "ingram-hardback-gb",
Url = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO",
Retailer = "Ingram",
Format = "Hardback",
CountryGroup = "GB"
},
["ingram-paperback-gb"] = new BuyLink
{
Slug = "ingram-paperback-gb",
Url = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ",
Retailer = "Ingram",
Format = "Paperback",
CountryGroup = "GB"
},
["ingram-hardback-us"] = new BuyLink
{
Slug = "ingram-hardback-us",
Url = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO",
Retailer = "Ingram",
Format = "Hardback",
CountryGroup = "US"
},
["ingram-paperback-us"] = new BuyLink
{
Slug = "ingram-paperback-us",
Url = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ",
Retailer = "Ingram",
Format = "Paperback",
CountryGroup = "US"
},
// --- Amazon (GB/US/CA/AU) ---
["amazon-hardback-gb"] = new BuyLink
{
Slug = "amazon-hardback-gb",
Url = "https://www.amazon.co.uk/dp/1068225807",
Retailer = "Amazon",
Format = "Hardback",
CountryGroup = "GB"
},
["amazon-paperback-gb"] = new BuyLink
{
Slug = "amazon-paperback-gb",
Url = "https://www.amazon.co.uk/dp/1068225815",
Retailer = "Amazon",
Format = "Paperback",
CountryGroup = "GB"
},
["amazon-kindle-gb"] = new BuyLink
{
Slug = "amazon-kindle-gb",
Url = "https://www.amazon.co.uk/dp/B0FBS427VD",
Retailer = "Amazon",
Format = "Kindle",
CountryGroup = "GB"
},
["amazon-hardback-us"] = new BuyLink
{
Slug = "amazon-hardback-us",
Url = "https://www.amazon.com/dp/1068225807",
Retailer = "Amazon",
Format = "Hardback",
CountryGroup = "US"
},
["amazon-paperback-us"] = new BuyLink
{
Slug = "amazon-paperback-us",
Url = "https://www.amazon.com/dp/1068225815",
Retailer = "Amazon",
Format = "Paperback",
CountryGroup = "US"
},
["amazon-kindle-us"] = new BuyLink
{
Slug = "amazon-kindle-us",
Url = "https://www.amazon.com/dp/B0FBS427VD",
Retailer = "Amazon",
Format = "Kindle",
CountryGroup = "US"
},
["amazon-hardback-ca"] = new BuyLink
{
Slug = "amazon-hardback-ca",
Url = "https://www.amazon.ca/dp/1068225807",
Retailer = "Amazon",
Format = "Hardback",
CountryGroup = "CA"
},
["amazon-paperback-ca"] = new BuyLink
{
Slug = "amazon-paperback-ca",
Url = "https://www.amazon.ca/dp/1068225815",
Retailer = "Amazon",
Format = "Paperback",
CountryGroup = "CA"
},
["amazon-kindle-ca"] = new BuyLink
{
Slug = "amazon-kindle-ca",
Url = "https://www.amazon.ca/dp/B0FBS427VD",
Retailer = "Amazon",
Format = "Kindle",
CountryGroup = "CA"
},
["amazon-hardback-au"] = new BuyLink
{
Slug = "amazon-hardback-au",
Url = "https://www.amazon.com.au/dp/1068225807",
Retailer = "Amazon",
Format = "Hardback",
CountryGroup = "AU"
},
["amazon-paperback-au"] = new BuyLink
{
Slug = "amazon-paperback-au",
Url = "https://www.amazon.com.au/dp/1068225815",
Retailer = "Amazon",
Format = "Paperback",
CountryGroup = "AU"
},
["amazon-kindle-au"] = new BuyLink
{
Slug = "amazon-kindle-au",
Url = "https://www.amazon.com.au/dp/B0FBS427VD",
Retailer = "Amazon",
Format = "Kindle",
CountryGroup = "AU"
},
// --- National retailers ---
["waterstones-hardback-gb"] = new BuyLink
{
Slug = "waterstones-hardback-gb",
Url = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802",
Retailer = "Waterstones",
Format = "Hardback",
CountryGroup = "GB"
},
["waterstones-paperback-gb"] = new BuyLink
{
Slug = "waterstones-paperback-gb",
Url = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225819",
Retailer = "Waterstones",
Format = "Paperback",
CountryGroup = "GB"
},
["bn-hardback-us"] = new BuyLink
{
Slug = "bn-hardback-us",
Url = "https://www.barnesandnoble.com/s/9781068225802",
Retailer = "Barnes & Noble",
Format = "Hardback",
CountryGroup = "US"
},
["bn-paperback-us"] = new BuyLink
{
Slug = "bn-paperback-us",
Url = "https://www.barnesandnoble.com/s/9781068225819",
Retailer = "Barnes & Noble",
Format = "Paperback",
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/id/6747852729",
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/id/6747852729",
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/id/6747852729",
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/id/6747852729",
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/id/6747852729",
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 static BuyLink? Find(string slug) =>
Links.TryGetValue(slug, out var link) ? link : null;
public static string Url(string slug) =>
Links.TryGetValue(slug, out var link) ? link.Url : "";
}
}

View File

@ -0,0 +1,35 @@
namespace CatherineLynwood.Models
{
public sealed class BuyLinksViewModel
{
#region Public Properties
// Amazon (always)
public LinkChoice AmazonHardback { get; set; } = new();
public LinkChoice AmazonKindle { get; set; } = new();
public LinkChoice AmazonPaperback { get; set; } = new();
// eBooks (always)
public LinkChoice Apple { get; set; } = new();
public string Country { get; set; } = "GB";
// Direct (optional)
public LinkChoice? IngramHardback { get; set; }
public LinkChoice? IngramPaperback { get; set; }
public LinkChoice Kobo { get; set; } = new();
// National (optional)
public LinkChoice? NationalHardback { get; set; }
public string? NationalLabel { get; set; }
public LinkChoice? NationalPaperback { get; set; }
#endregion Public Properties
}
}

View File

@ -0,0 +1,16 @@
namespace CatherineLynwood.Models
{
public sealed class DiscoveryPageViewModel
{
#region Public Properties
// All slugs are for /go/{slug} and /track/click?slug={slug}
public BuyLinksViewModel Buy { get; set; } = new BuyLinksViewModel();
public Reviews Reviews { get; set; } = new Reviews();
public string UserIso2 { get; set; } = "GB";
#endregion Public Properties
}
}

View File

@ -0,0 +1,19 @@
namespace CatherineLynwood.Models
{
public class GeoIpResult
{
#region Public Properties
public string CountryName { get; set; } = "";
public string Ip { get; set; } = "";
public string Iso2 { get; set; } = "";
public DateTime LastSeenUtc { get; set; }
public string Source { get; set; }
#endregion Public Properties
}
}

View File

@ -0,0 +1,13 @@
namespace CatherineLynwood.Models
{
public static class HttpContextItemKeys
{
#region Public Fields
public const string CountryIso2 = "CountryIso2";
public const string CountryName = "CountryName";
#endregion Public Fields
}
}

View File

@ -0,0 +1,13 @@
namespace CatherineLynwood.Models
{
public sealed class LinkChoice
{
#region Public Properties
public string Slug { get; set; } = "";
public string Url { get; set; } = "";
#endregion Public Properties
}
}

View File

@ -1,10 +1,11 @@
using System.IO.Compression;
using CatherineLynwood.Middleware;
using CatherineLynwood.Middleware;
using CatherineLynwood.Services;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using System.IO.Compression;
using WebMarkupMin.AspNetCoreLatest;
namespace CatherineLynwood
@ -15,21 +16,44 @@ namespace CatherineLynwood
{
var builder = WebApplication.CreateBuilder(args);
// Retrieve the connection string from appsettings.json
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// DAL
builder.Services.AddSingleton(new DataAccess(connectionString));
// MVC
builder.Services.AddControllersWithViews();
// Add IHttpContextAccessor for accessing HTTP context in tag helpers
// HttpContext accessor
builder.Services.AddHttpContextAccessor();
builder.Services.AddHostedService<IndexNowBackgroundService>();
// Memory cache for IP cache
builder.Services.AddMemoryCache();
// HttpClient for general use
builder.Services.AddHttpClient();
// ✅ Add session services (in-memory only)
// Named HttpClient for the GeoResolver with short timeout
builder.Services.AddHttpClient(nameof(GeoResolver), c => c.Timeout = TimeSpan.FromSeconds(3));
// Geo services
builder.Services.AddSingleton<IGeoResolver, GeoResolver>();
builder.Services.AddScoped<ICountryContext, CountryContext>();
// Background and other services you already use
builder.Services.AddHostedService<IndexNowBackgroundService>();
builder.Services.AddSingleton<RedirectsStore>(sp =>
{
var logger = sp.GetRequiredService<ILogger<RedirectsStore>>();
return new RedirectsStore("redirects.json", logger);
});
builder.Services.AddScoped<IAccessCodeService, AccessCodeService>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddSingleton<ChapterAudioMapCache>();
builder.Services.AddHostedService<ChapterAudioMapService>();
builder.Services.AddSingleton<AudioTokenService>();
// Session
builder.Services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromMinutes(30);
@ -37,7 +61,7 @@ namespace CatherineLynwood
options.Cookie.IsEssential = true;
});
// Add authentication with cookie settings
// Auth
builder.Services.AddAuthentication("MyCookieAuth")
.AddCookie("MyCookieAuth", options =>
{
@ -48,79 +72,63 @@ namespace CatherineLynwood
options.ExpireTimeSpan = TimeSpan.FromHours(12);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
// Add RedirectsStore as singleton
builder.Services.AddSingleton<RedirectsStore>(sp =>
{
var logger = sp.GetRequiredService<ILogger<RedirectsStore>>();
return new RedirectsStore("redirects.json", logger);
});
// ✅ Register the book access code service
builder.Services.AddScoped<IAccessCodeService, AccessCodeService>();
builder.Services.AddScoped<IEmailService, SmtpEmailService>();
builder.Services.AddSingleton<ChapterAudioMapCache>();
builder.Services.AddHostedService<ChapterAudioMapService>();
builder.Services.AddSingleton<AudioTokenService>();
// Add response compression services
// Response compression
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
// HTML minification
builder.Services.AddWebMarkupMin(o =>
{
options.Level = CompressionLevel.Fastest;
});
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});
// Add HTML minification
builder.Services.AddWebMarkupMin(options =>
{
options.AllowMinificationInDevelopmentEnvironment = true;
o.AllowMinificationInDevelopmentEnvironment = true;
})
.AddHtmlMinification()
.AddHttpCompression()
.AddXmlMinification()
.AddXhtmlMinification();
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 40 * 1024 * 1024; // 40MB
});
// Kestrel limits
builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 40 * 1024 * 1024; });
var app = builder.Build();
// Configure the HTTP request pipeline.
// Errors, HSTS
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts(); // Adds the HSTS (HTTP Strict Transport Security) header
app.UseHsts();
}
// If behind a proxy or CDN, capture real client IPs
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
ForwardLimit = 2
});
// Your existing custom middleware
app.UseMiddleware<SpamAndSecurityMiddleware>();
app.UseMiddleware<HoneypotLoggingMiddleware>();
app.UseMiddleware<RedirectMiddleware>();
app.UseMiddleware<EnsureSidMiddleware>();
// Resolve ISO2 once per request, stored in HttpContext.Items via GeoResolutionMiddleware
app.UseMiddleware<GeoResolutionMiddleware>();
app.UseHttpsRedirection();
app.UseResponseCompression();
app.UseStaticFiles();
app.UseWebMarkupMin();
app.UseRouting();
app.UseSession();
// ✅ Authentication must come before Authorization
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
@ -129,6 +137,7 @@ namespace CatherineLynwood
pattern: "{controller=Home}/{action=Index}/{id?}");
app.Run();
}
}
}

View File

@ -0,0 +1,20 @@
using CatherineLynwood.Models;
namespace CatherineLynwood.Services
{
public sealed class CountryContext : ICountryContext
{
private readonly IHttpContextAccessor _http;
public CountryContext(IHttpContextAccessor http)
{
_http = http;
}
public string? Iso2 =>
_http.HttpContext?.Items[HttpContextItemKeys.CountryIso2] as string;
public string? CountryName =>
_http.HttpContext?.Items[HttpContextItemKeys.CountryName] as string;
}
}

View File

@ -65,64 +65,6 @@ namespace CatherineLynwood.Services
return visible;
}
public async Task<bool> SaveBuyClick(
DateTime dateTimeUtc,
string slug,
string retailer,
string format,
string countryGroup,
string ip,
string country,
string userAgent,
string referer,
string page, // the page on your site where the click happened
string sessionId,
string queryString, // original request query (e.g., utms, country override)
string destinationUrl // the final retailer URL you redirect to
)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveBuyClick";
// Required
cmd.Parameters.AddWithValue("@DateTimeUtc", dateTimeUtc);
cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Retailer", (object?)retailer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Format", (object?)format ?? DBNull.Value);
cmd.Parameters.AddWithValue("@CountryGroup", (object?)countryGroup ?? DBNull.Value);
// Signals / attribution
cmd.Parameters.AddWithValue("@IP", (object?)ip ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Country", (object?)country ?? DBNull.Value);
cmd.Parameters.AddWithValue("@UserAgent", (object?)userAgent ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Referer", (object?)referer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Page", (object?)page ?? DBNull.Value);
cmd.Parameters.AddWithValue("@SessionId", (object?)sessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@QueryString", (object?)queryString ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DestinationUrl", (object?)destinationUrl ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception)
{
success = false;
// optional: log the exception somewhere central
}
}
return success;
}
public async Task<bool> Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer)
{
bool success = true;
@ -155,122 +97,6 @@ namespace CatherineLynwood.Services
return success;
}
public async Task<int> SaveFlagClick(string country)
{
int likes = 0;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveTrailerLike";
cmd.Parameters.AddWithValue("@Country", country);
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
likes = GetDataInt(rdr, "Likes");
}
}
}
catch (Exception ex)
{
}
}
}
return likes;
}
public async Task<List<SoundtrackTrackModel>> GetSoundtrack()
{
List<SoundtrackTrackModel> soundtrackTrackModels = new List<SoundtrackTrackModel>();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetSoundtrack";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
soundtrackTrackModels.Add(new SoundtrackTrackModel
{
AudioUrl = GetDataString(rdr, "AudioUrl"),
Chapter = GetDataString(rdr, "Chapter"),
Description = GetDataString(rdr, "Description"),
ImageUrl = GetDataString(rdr, "ImageUrl"),
LyricsHtml = GetDataString(rdr, "LyricsHtml"),
Title = GetDataString(rdr, "Title")
});
}
}
}
catch (Exception ex)
{
}
}
}
return soundtrackTrackModels;
}
public async Task<FlagSupportViewModel> GetTrailerLikes()
{
FlagSupportViewModel flagSupportViewModel = new FlagSupportViewModel();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetTrailerLikes";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
string country = GetDataString(rdr, "Country");
int likes = GetDataInt(rdr, "Likes");
flagSupportViewModel.FlagCounts.Add(new FlagCount
{
Key = country,
Value = likes
});
}
}
}
catch (Exception ex)
{
}
}
}
return flagSupportViewModel;
}
public async Task<bool> AddMarketingAsync(Marketing marketing)
{
bool success = true;
@ -415,47 +241,6 @@ namespace CatherineLynwood.Services
return accessCodes;
}
public async Task<List<BlogSummaryResponse>> GetAllBlogsAsync()
{
List<BlogSummaryResponse> list = new List<BlogSummaryResponse>();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetAllBlogs";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
list.Add(new BlogSummaryResponse
{
AiSummary = GetDataString(rdr, "AiSummary"),
BlogUrl = GetDataString(rdr, "BlogUrl"),
Draft = GetDataBool(rdr, "Draft"),
IndexText = GetDataString(rdr, "IndexText"),
PublishDate = GetDataDate(rdr, "PublishDate"),
SubTitle = GetDataString(rdr, "SubTitle"),
Title = GetDataString(rdr, "Title"),
});
}
}
}
catch (Exception ex)
{
}
}
}
return list;
}
public async Task<ARCReaderList> GetAllARCReadersAsync()
{
ARCReaderList arcReaderList = new ARCReaderList();
@ -502,6 +287,47 @@ namespace CatherineLynwood.Services
return arcReaderList;
}
public async Task<List<BlogSummaryResponse>> GetAllBlogsAsync()
{
List<BlogSummaryResponse> list = new List<BlogSummaryResponse>();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetAllBlogs";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
list.Add(new BlogSummaryResponse
{
AiSummary = GetDataString(rdr, "AiSummary"),
BlogUrl = GetDataString(rdr, "BlogUrl"),
Draft = GetDataBool(rdr, "Draft"),
IndexText = GetDataString(rdr, "IndexText"),
PublishDate = GetDataDate(rdr, "PublishDate"),
SubTitle = GetDataString(rdr, "SubTitle"),
Title = GetDataString(rdr, "Title"),
});
}
}
}
catch (Exception ex)
{
}
}
}
return list;
}
public async Task<BlogAdminIndex> GetBlogAdminIndexAsync()
{
BlogAdminIndex blogAdminIndex = new BlogAdminIndex();
@ -758,6 +584,47 @@ namespace CatherineLynwood.Services
return blogPosts;
}
public async Task<GeoIpResult?> GetGeoIpAsync(string ip)
{
GeoIpResult? result = null;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetGeoIp"; // your stored procedure name
cmd.Parameters.AddWithValue("@Ip", ip);
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
if (await rdr.ReadAsync())
{
result = new GeoIpResult
{
Ip = GetDataString(rdr, "IP"),
Iso2 = GetDataString(rdr, "ISO2"),
CountryName = GetDataString(rdr, "CountryName"),
LastSeenUtc = GetDataDate(rdr, "LastSeenUtc"),
Source = GetDataString(rdr, "Source")
};
}
}
}
catch (Exception ex)
{
// log if you want
}
}
}
return result;
}
public async Task<Questions> GetQuestionsAsync()
{
Questions questions = new Questions();
@ -874,6 +741,85 @@ namespace CatherineLynwood.Services
return reviews;
}
public async Task<List<SoundtrackTrackModel>> GetSoundtrack()
{
List<SoundtrackTrackModel> soundtrackTrackModels = new List<SoundtrackTrackModel>();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetSoundtrack";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
soundtrackTrackModels.Add(new SoundtrackTrackModel
{
AudioUrl = GetDataString(rdr, "AudioUrl"),
Chapter = GetDataString(rdr, "Chapter"),
Description = GetDataString(rdr, "Description"),
ImageUrl = GetDataString(rdr, "ImageUrl"),
LyricsHtml = GetDataString(rdr, "LyricsHtml"),
Title = GetDataString(rdr, "Title")
});
}
}
}
catch (Exception ex)
{
}
}
}
return soundtrackTrackModels;
}
public async Task<FlagSupportViewModel> GetTrailerLikes()
{
FlagSupportViewModel flagSupportViewModel = new FlagSupportViewModel();
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "GetTrailerLikes";
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
string country = GetDataString(rdr, "Country");
int likes = GetDataInt(rdr, "Likes");
flagSupportViewModel.FlagCounts.Add(new FlagCount
{
Key = country,
Value = likes
});
}
}
}
catch (Exception ex)
{
}
}
}
return flagSupportViewModel;
}
public async Task<bool> MarkAsNotifiedAsync(int blogID)
{
bool success = true;
@ -901,73 +847,6 @@ namespace CatherineLynwood.Services
return success;
}
public async Task<bool> SaveBlogToDatabase(BlogPostRequest blog)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveBlog";
cmd.Parameters.AddWithValue("@AiSummary", blog.AiSummary);
cmd.Parameters.AddWithValue("@BlogUrl", blog.BlogUrl);
cmd.Parameters.AddWithValue("@ContentBottom", blog.ContentBottom);
cmd.Parameters.AddWithValue("@ContentTop", blog.ContentTop);
cmd.Parameters.AddWithValue("@ImageAlt", blog.ImageAlt);
cmd.Parameters.AddWithValue("@ImageDescription", blog.ImageDescription);
cmd.Parameters.AddWithValue("@ImageFirst", blog.ImagePosition.ToLower() == "left");
cmd.Parameters.AddWithValue("@ImagePrompt", blog.ImagePrompt);
cmd.Parameters.AddWithValue("@IndexText", blog.IndexText);
cmd.Parameters.AddWithValue("@PublishDate", blog.PublishDate);
cmd.Parameters.AddWithValue("@ResponderID", blog.ResponderID);
cmd.Parameters.AddWithValue("@SubTitle", blog.SubTitle);
cmd.Parameters.AddWithValue("@Title", blog.Title);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
}
}
}
return success;
}
public async Task<bool> SaveContact(Contact contact, bool subscribe = false)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveContact";
cmd.Parameters.AddWithValue("@Name", contact.Name);
cmd.Parameters.AddWithValue("@EmailAddress", contact.EmailAddress);
cmd.Parameters.AddWithValue("@Subscribe", subscribe);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
success = false;
}
}
}
return success;
}
public async Task<bool> SaveARCReaderApplication(ArcReaderApplicationModel arcReaderApplication)
{
bool success = true;
@ -1009,7 +888,195 @@ namespace CatherineLynwood.Services
return success;
}
public async Task<bool> SaveBlogToDatabase(BlogPostRequest blog)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveBlog";
cmd.Parameters.AddWithValue("@AiSummary", blog.AiSummary);
cmd.Parameters.AddWithValue("@BlogUrl", blog.BlogUrl);
cmd.Parameters.AddWithValue("@ContentBottom", blog.ContentBottom);
cmd.Parameters.AddWithValue("@ContentTop", blog.ContentTop);
cmd.Parameters.AddWithValue("@ImageAlt", blog.ImageAlt);
cmd.Parameters.AddWithValue("@ImageDescription", blog.ImageDescription);
cmd.Parameters.AddWithValue("@ImageFirst", blog.ImagePosition.ToLower() == "left");
cmd.Parameters.AddWithValue("@ImagePrompt", blog.ImagePrompt);
cmd.Parameters.AddWithValue("@IndexText", blog.IndexText);
cmd.Parameters.AddWithValue("@PublishDate", blog.PublishDate);
cmd.Parameters.AddWithValue("@ResponderID", blog.ResponderID);
cmd.Parameters.AddWithValue("@SubTitle", blog.SubTitle);
cmd.Parameters.AddWithValue("@Title", blog.Title);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
}
}
}
return success;
}
public async Task<bool> SaveBuyClick(
DateTime dateTimeUtc,
string slug,
string retailer,
string format,
string countryGroup,
string ip,
string country,
string userAgent,
string referer,
string page, // the page on your site where the click happened
string sessionId,
string queryString, // original request query (e.g., utms, country override)
string destinationUrl // the final retailer URL you redirect to
)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveBuyClick";
// Required
cmd.Parameters.AddWithValue("@DateTimeUtc", dateTimeUtc);
cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Retailer", (object?)retailer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Format", (object?)format ?? DBNull.Value);
cmd.Parameters.AddWithValue("@CountryGroup", (object?)countryGroup ?? DBNull.Value);
// Signals / attribution
cmd.Parameters.AddWithValue("@IP", (object?)ip ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Country", (object?)country ?? DBNull.Value);
cmd.Parameters.AddWithValue("@UserAgent", (object?)userAgent ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Referer", (object?)referer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Page", (object?)page ?? DBNull.Value);
cmd.Parameters.AddWithValue("@SessionId", (object?)sessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@QueryString", (object?)queryString ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DestinationUrl", (object?)destinationUrl ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception)
{
success = false;
// optional: log the exception somewhere central
}
}
return success;
}
public async Task<bool> SaveContact(Contact contact, bool subscribe = false)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveContact";
cmd.Parameters.AddWithValue("@Name", contact.Name);
cmd.Parameters.AddWithValue("@EmailAddress", contact.EmailAddress);
cmd.Parameters.AddWithValue("@Subscribe", subscribe);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
success = false;
}
}
}
return success;
}
public async Task<int> SaveFlagClick(string country)
{
int likes = 0;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveTrailerLike";
cmd.Parameters.AddWithValue("@Country", country);
using (SqlDataReader rdr = await cmd.ExecuteReaderAsync())
{
while (await rdr.ReadAsync())
{
likes = GetDataInt(rdr, "Likes");
}
}
}
catch (Exception ex)
{
}
}
}
return likes;
}
public async Task<bool> SaveGeoIpAsync(GeoIpResult geo)
{
bool success = true;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
using (SqlCommand cmd = new SqlCommand())
{
try
{
await conn.OpenAsync();
cmd.Connection = conn;
cmd.CommandType = CommandType.StoredProcedure;
cmd.CommandText = "SaveGeoIp"; // your stored procedure name
cmd.Parameters.AddWithValue("@Ip", geo.Ip);
cmd.Parameters.AddWithValue("@Iso2", geo.Iso2);
cmd.Parameters.AddWithValue("@CountryName", geo.CountryName);
cmd.Parameters.AddWithValue("@LastSeenUtc", geo.LastSeenUtc);
cmd.Parameters.AddWithValue("@Source", geo.Source ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
success = false;
}
}
}
return success;
}
public async Task<bool> UpdateBlogAsync(Blog blog)
{

View File

@ -0,0 +1,77 @@
using CatherineLynwood.Models;
using Microsoft.Extensions.Caching.Memory;
using System.Net;
using System.Text.Json;
namespace CatherineLynwood.Services
{
public sealed class GeoResolver : IGeoResolver
{
private readonly IMemoryCache _cache;
private readonly DataAccess _dataAccess;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<GeoResolver> _logger;
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(6);
public GeoResolver(IMemoryCache cache, DataAccess dataAccess, IHttpClientFactory httpClientFactory, ILogger<GeoResolver> logger)
{
_cache = cache;
_dataAccess = dataAccess;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<GeoIpResult?> ResolveAsync(IPAddress ip, CancellationToken ct = default)
{
var ipStr = ip.ToString();
if (_cache.TryGetValue<GeoIpResult>(ipStr, out var cached))
return cached;
// 1) Database lookup
var db = await _dataAccess.GetGeoIpAsync(ipStr);
if (db is not null)
{
_cache.Set(ipStr, db, CacheTtl);
return db;
}
// 2) External API fallback
try
{
var client = _httpClientFactory.CreateClient(nameof(GeoResolver));
var json = await client.GetStringAsync($"http://ip-api.com/json/{ipStr}?fields=status,country,countryCode", ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.GetProperty("status").GetString() == "success")
{
var iso2 = root.GetProperty("countryCode").GetString() ?? "UN";
var country = root.GetProperty("country").GetString();
var geo = new GeoIpResult
{
Ip = ipStr,
Iso2 = iso2,
CountryName = country ?? "",
LastSeenUtc = DateTime.UtcNow,
Source = "ip-api"
};
await _dataAccess.SaveGeoIpAsync(geo);
_cache.Set(ipStr, geo, CacheTtl);
return geo;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Geo lookup failed for {IP}", ipStr);
}
return null;
}
}
}

View File

@ -0,0 +1,13 @@
namespace CatherineLynwood.Services
{
public interface ICountryContext
{
#region Public Properties
string? CountryName { get; }
string? Iso2 { get; }
#endregion Public Properties
}
}

View File

@ -0,0 +1,11 @@
using CatherineLynwood.Models;
using System.Net;
namespace CatherineLynwood.Services
{
public interface IGeoResolver
{
Task<GeoIpResult?> ResolveAsync(IPAddress ip, CancellationToken ct = default);
}
}

View File

@ -1,8 +1,8 @@
@model CatherineLynwood.Models.Reviews
@model CatherineLynwood.Models.DiscoveryPageViewModel
@{
ViewData["Title"] = "The Alpha Flame: A Gritty 1980s Birmingham Crime Novel about Twin Sisters";
bool showReviews = Model.Items.Any();
bool showReviews = Model.Reviews.Items.Any();
}
<div class="row">
@ -76,148 +76,17 @@
</noscript>
<!-- Buy Box -->
<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">
<h3 class="h5 mb-2 mb-sm-0">Buy the Book</h3>
<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>
@* 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";
}
<!-- Row 1: Direct via Ingram (GB/US only) -->
<div id="rowDirect" class="mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge bg-dark">Best price</span>
<span class="small text-muted">Direct via print partner</span>
</div>
<div class="row g-2">
<div class="col-12 col-sm-6">
<!-- Default GB slug; script swaps to -us where applicable -->
<a id="hardbackLinkSelf"
href="/go/ingram-hardback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-dark w-100"
data-retailer="Ingram" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, direct
</a>
</div>
<div class="col-12 col-sm-6">
<a id="paperbackLinkSelf"
href="/go/ingram-paperback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-dark w-100"
data-retailer="Ingram" data-format="Paperback">
<i class="fad fa-book me-1"></i> Paperback, direct
</a>
</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 -->
<div id="rowAmazon" class="mb-3">
<div class="row g-2">
<div class="col-12 col-sm-6">
<a id="hardbackLink"
href="/go/amazon-hardback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="Amazon" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, Amazon
</a>
</div>
<div class="col-12 col-sm-6">
<a id="paperbackLink"
href="/go/amazon-paperback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="Amazon" data-format="Paperback">
<i class="fad fa-book me-1"></i> Paperback, Amazon
</a>
</div>
</div>
</div>
<!-- Row 3: National retailer (Waterstones in GB, B&N in US) -->
<div id="rowNational" class="mb-2">
<div class="row g-2">
<div class="col-12 col-sm-6">
<a id="hardbackNational"
href="/go/waterstones-hardback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="National" data-format="Hardback">
<i class="fad fa-gem me-1"></i> Hardback, Waterstones
</a>
</div>
<div class="col-12 col-sm-6">
<a id="paperbackNational"
href="/go/waterstones-paperback-gb"
target="_blank"
rel="nofollow noindex"
class="btn btn-outline-dark w-100"
data-retailer="National" data-format="Paperback">
<i class="fad fa-book-open me-1"></i> Paperback, Waterstones
</a>
</div>
</div>
</div>
<!-- Row 4: eBook stores -->
<div id="rowEbooks" class="mb-2">
<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 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">
<a asp-action="HowToBuy" class="link-dark small">See all buying options</a>
</div>
</div>
<partial name="_BuyBox" />
</div>
</div>
@ -236,7 +105,7 @@
<!-- Social proof: show one standout review near the top -->
@if (showReviews)
{
var top = Model.Items.Where(x => x.RatingValue == 5).OrderByDescending(y => y.DatePublished).First();
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);
@ -278,7 +147,7 @@
<span class="text-muted smaller">, @reviewDate</span>
</footer>
</blockquote>
@if (Model.Items.Count > 1)
@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>
@ -473,251 +342,6 @@
});
</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 -->
<script>
(function(){
// Direct IngramSpark URLs
const INGRAM_PB = "https://shop.ingramspark.com/b/084?params=6easpH54PaugzXFKdF4Tu4Izb0cvkMqbj3ZNlaYBKMJ";
const INGRAM_HB = "https://shop.ingramspark.com/b/084?params=GC1p1c8b66Rhfoy6Tq97SJmmhdZSEYuxBcCY5zxNstO";
// 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');
// Hint + flag
const elText = document.getElementById('buyCountryText');
const elFlag = document.getElementById('buyCountryFlag');
// Countries we show custom text + flag for
const targeted = new Set(['GB','US','CA','AU','IE']);
const countryNames = {
GB: 'United Kingdom',
US: 'United States',
CA: 'Canada',
AU: 'Australia',
IE: 'Ireland'
};
function show(el){ if (el) el.classList.remove('d-none'); }
function hide(el){ if (el) el.classList.add('d-none'); }
// Set both the visible direct URL and the /go slug used by your click router
function setLink(el, directHref, slug) {
if (!el) return;
el.href = directHref;
el.setAttribute('data-go-slug', slug);
}
function setHint(code, name) {
if (elText) elText.textContent = `Best options for ${name || 'your country'}`;
if (!elFlag) return;
const cc = (code || '').toUpperCase();
if (!cc) { elFlag.classList.add('d-none'); return; }
const base = `/images/flags/${cc}`;
elFlag.src = `${base}.svg`;
elFlag.alt = `${name || cc} flag`;
elFlag.classList.remove('d-none');
elFlag.onerror = function () {
if (this.src.endsWith('.svg')) this.src = `${base}.png`;
else this.classList.add('d-none');
};
}
// Expose for reuse if needed elsewhere
window.applyLinks = function applyLinks(code) {
const cc = (code || '').toUpperCase();
// Defaults to US Amazon
let kindleHref = "https://www.amazon.com/dp/B0FBS427VD";
let pbAmzHref = "https://www.amazon.com/dp/1068225815";
let hbAmzHref = "https://www.amazon.com/dp/1068225807";
// National retailer defaults (GB Waterstones; US B&N)
let hbNatHref = "https://www.waterstones.com/book/the-alpha-flame/catherine-lynwood/9781068225802";
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";
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";
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;
}
// Kindle: direct Amazon, route via /go/ on click
setLink(elKindle, kindleHref, `amazon-kindle-${cc || 'us'}`);
// Ingram “Self” buttons: direct Ingram in GB/US only; hide elsewhere
if (cc === 'GB' || cc === 'US') {
setLink(elPbSelf, INGRAM_PB, `ingram-paperback-${cc.toLowerCase()}`);
setLink(elHbSelf, INGRAM_HB, `ingram-hardback-${cc.toLowerCase()}`);
show(elPbSelf); show(elHbSelf);
} else {
hide(elPbSelf); hide(elHbSelf);
}
// Amazon print buttons (if present): direct Amazon, route via /go/
setLink(elPbAmazon, pbAmzHref, `amazon-paperback-${cc.toLowerCase() || 'us'}`);
setLink(elHbAmazon, hbAmzHref, `amazon-hardback-${cc.toLowerCase() || 'us'}`);
// National retailer row: direct URLs, route via /go/
setLink(elHbNat, hbNatHref, hbNatSlug);
setLink(elPbNat, pbNatHref, pbNatSlug);
// Toggle any Ingram-only containers youve marked
document.querySelectorAll('[data-ingram-only="true"]').forEach(x => {
if (cc === 'GB' || cc === 'US') x.classList.remove('d-none'); else x.classList.add('d-none');
});
};
function updateForCountry(code, nameFromAPI) {
const cc = (code || '').toUpperCase();
// Update links
window.applyLinks(cc);
// Update hint + flag only for targeted set; leave default text for others
if (targeted.has(cc)) {
setHint(cc, countryNames[cc] || nameFromAPI || cc);
}
}
// Dev override: ?country=CA
const params = new URLSearchParams(location.search);
const override = params.get('country');
if (override) {
const cc = override.toUpperCase();
updateForCountry(cc, countryNames[cc] || cc);
return;
}
// Single geo lookup
fetch('https://ipapi.co/json/')
.then(r => r.json())
.then(d => {
const code = d && d.country_code ? String(d.country_code).toUpperCase() : '';
const name = d && d.country_name ? d.country_name : code;
updateForCountry(code, name);
})
.catch(() => {
// Leave defaults if geo fails
window.applyLinks('');
});
})();
</script>
}
@section Meta {
@ -737,6 +361,6 @@
twitter-creator-handle="@@CathLynwood" />
<script type="application/ld+json">
@Html.Raw(Model.SchemaJsonLd)
@Html.Raw(Model.Reviews.SchemaJsonLd)
</script>
}

View File

@ -0,0 +1,165 @@
@model CatherineLynwood.Models.DiscoveryPageViewModel
@{
var L = Model?.Buy ?? new CatherineLynwood.Models.BuyLinksViewModel();
var iso2 = (Model?.UserIso2 ?? "GB").ToUpperInvariant();
if (iso2 == "UK") { iso2 = "GB"; }
var pingBase = "/track/click";
var flagSvg = $"/images/flags/{iso2}.svg";
var flagPng = $"/images/flags/{iso2}.png";
}
<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">
<h3 class="h5 mb-2 mb-sm-0">Buy the Book</h3>
<small id="buyCountryHint" class="text-muted d-flex align-items-center">
<img id="buyCountryFlag"
class="me-1"
alt=""
width="20"
height="14"
loading="lazy"
src="@flagSvg"
onerror="this.onerror=null;this.src='@flagPng';" />
<span id="buyCountryText">Best options for @iso2</span>
</small>
</div>
@* ---------------------------
Row 1: Direct via printers (GB/US only)
--------------------------- *@
@if (L.IngramHardback != null || L.IngramPaperback != null)
{
<div id="rowDirect" class="mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<span>Best price direct from our printers</span>
</div>
<div class="row g-2">
@if (L.IngramHardback != null)
{
<div class="col-12 col-sm-6">
<a class="btn btn-dark w-100"
href="@L.IngramHardback.Url"
ping="@($"{pingBase}?slug={L.IngramHardback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, direct
<span class="price-chip d-none" aria-hidden="true"></span>
</a>
</div>
}
@if (L.IngramPaperback != null)
{
<div class="col-12 col-sm-6">
<a class="btn btn-dark w-100"
href="@L.IngramPaperback.Url"
ping="@($"{pingBase}?slug={L.IngramPaperback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-book me-1"></i> Paperback, direct
<span class="price-chip d-none" aria-hidden="true"></span>
</a>
</div>
}
</div>
</div>
}
<div class="d-flex align-items-center gap-2 mb-2">
<span>From other retailers</span>
</div>
@* ---------------------------
Row 2: Amazon (always present)
--------------------------- *@
<div id="rowAmazon" class="mb-3">
<div class="row g-2">
<div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100"
href="@L.AmazonHardback.Url"
ping="@($"{pingBase}?slug={L.AmazonHardback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, Amazon
</a>
</div>
<div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100"
href="@L.AmazonPaperback.Url"
ping="@($"{pingBase}?slug={L.AmazonPaperback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-book me-1"></i> Paperback, Amazon
</a>
</div>
</div>
</div>
@* ---------------------------
Row 3: National retailer (conditional)
--------------------------- *@
@if (L.NationalHardback != null || L.NationalPaperback != null)
{
<div id="rowNational" class="mb-2">
<div class="row g-2">
@if (L.NationalHardback != null)
{
<div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100"
href="@L.NationalHardback.Url"
ping="@($"{pingBase}?slug={L.NationalHardback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-gem me-1"></i> Hardback, @(L.NationalLabel ?? "National")
</a>
</div>
}
@if (L.NationalPaperback != null)
{
<div class="col-12 col-sm-6">
<a class="btn btn-outline-dark w-100"
href="@L.NationalPaperback.Url"
ping="@($"{pingBase}?slug={L.NationalPaperback.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-book-open me-1"></i> Paperback, @(L.NationalLabel ?? "National")
</a>
</div>
}
</div>
</div>
}
@* ---------------------------
Row 4: eBook stores (Apple, Kobo, Kindle)
--------------------------- *@
<div id="rowEbooks" class="mb-2">
<div class="d-flex align-items-center gap-2 mb-2">
<span>Choose your preferred e-book store</span>
</div>
<div class="row g-2">
<div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100"
href="@L.Apple.Url"
ping="@($"{pingBase}?slug={L.Apple.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fab fa-apple me-1"></i> Apple Books
</a>
</div>
<div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100"
href="@L.Kobo.Url"
ping="@($"{pingBase}?slug={L.Kobo.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fad fa-book-open me-1"></i> Kobo
</a>
</div>
<div class="col-12 col-sm-4">
<a class="btn btn-outline-dark w-100"
href="@L.AmazonKindle.Url"
ping="@($"{pingBase}?slug={L.AmazonKindle.Slug}&src=discovery")"
rel="nofollow noindex">
<i class="fab fa-amazon me-1"></i> Kindle
</a>
</div>
</div>
</div>
<div class="mt-2">
<a asp-action="HowToBuy" class="link-dark small">See all buying options</a>
</div>
</div>

View File

@ -248,45 +248,6 @@
</script> *@
@RenderSection("Scripts", required: false)
<script>
(function () {
const key = 'marketingConsent';
const choice = localStorage.getItem(key);
const modalEl = document.getElementById('cookieModal');
// Bootstrap modal API
const modal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
// Show only if not decided
if (!choice) {
modal.show();
// (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', () => {
localStorage.setItem(key, 'no');
modal.hide();
});
// Expose a footer hook to reopen
window.manageCookies = function () {
modal.show();
};
})();
</script>
<script>
window.addEventListener("load", () => {
@ -382,6 +343,24 @@
});
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
if (!("ping" in HTMLAnchorElement.prototype) && navigator.sendBeacon) {
document.body.addEventListener("click", e => {
const a = e.target.closest("a[ping]");
if (!a) return;
const url = a.getAttribute("ping");
if (url) {
try {
navigator.sendBeacon(url);
} catch { /* ignore */ }
}
});
}
});
</script>
</body>
</html>