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:
parent
b3cc5ccedd
commit
4759fbbd69
@ -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()));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
return needles.Any(n => ua.Contains(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
82
CatherineLynwood/Middleware/GeoResolutionMiddleware.cs
Normal file
82
CatherineLynwood/Middleware/GeoResolutionMiddleware.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
270
CatherineLynwood/Models/BuyCatalog.cs
Normal file
270
CatherineLynwood/Models/BuyCatalog.cs
Normal 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 : "";
|
||||
}
|
||||
}
|
||||
35
CatherineLynwood/Models/BuyLinksViewModel.cs
Normal file
35
CatherineLynwood/Models/BuyLinksViewModel.cs
Normal 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
|
||||
}
|
||||
}
|
||||
16
CatherineLynwood/Models/DIscoveryPageViewModel.cs
Normal file
16
CatherineLynwood/Models/DIscoveryPageViewModel.cs
Normal 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
|
||||
}
|
||||
}
|
||||
19
CatherineLynwood/Models/GeoIpResult.cs
Normal file
19
CatherineLynwood/Models/GeoIpResult.cs
Normal 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
|
||||
}
|
||||
}
|
||||
13
CatherineLynwood/Models/HttpContextItemKeys.cs
Normal file
13
CatherineLynwood/Models/HttpContextItemKeys.cs
Normal 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
|
||||
}
|
||||
}
|
||||
13
CatherineLynwood/Models/LinkChoice.cs
Normal file
13
CatherineLynwood/Models/LinkChoice.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
CatherineLynwood/Services/CountryContext.cs
Normal file
20
CatherineLynwood/Services/CountryContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
{
|
||||
|
||||
77
CatherineLynwood/Services/GeoResolver.cs
Normal file
77
CatherineLynwood/Services/GeoResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
13
CatherineLynwood/Services/ICountryContext.cs
Normal file
13
CatherineLynwood/Services/ICountryContext.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace CatherineLynwood.Services
|
||||
{
|
||||
public interface ICountryContext
|
||||
{
|
||||
#region Public Properties
|
||||
|
||||
string? CountryName { get; }
|
||||
|
||||
string? Iso2 { get; }
|
||||
|
||||
#endregion Public Properties
|
||||
}
|
||||
}
|
||||
11
CatherineLynwood/Services/IGeoResolver.cs
Normal file
11
CatherineLynwood/Services/IGeoResolver.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 you’ve 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>
|
||||
}
|
||||
|
||||
165
CatherineLynwood/Views/Discovery/_BuyBox.cshtml
Normal file
165
CatherineLynwood/Views/Discovery/_BuyBox.cshtml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user