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 Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
using System.Web;
|
|
||||||
|
|
||||||
namespace CatherineLynwood.Controllers
|
namespace CatherineLynwood.Controllers
|
||||||
{
|
{
|
||||||
[Route("go")]
|
[Route("")]
|
||||||
public class BuyController : Controller
|
public class ClicksController : Controller
|
||||||
{
|
{
|
||||||
private readonly DataAccess _dataAccess;
|
private readonly DataAccess _dataAccess;
|
||||||
// Replace with DB/service
|
|
||||||
|
// Central map of slugs -> destination URLs and metadata
|
||||||
private static readonly Dictionary<string, BuyLink> Links = new()
|
private static readonly Dictionary<string, BuyLink> Links = new()
|
||||||
{
|
{
|
||||||
// --- Ingram direct (GB/US only) ---
|
// --- Ingram direct (GB/US only) ---
|
||||||
@ -48,7 +47,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
CountryGroup = "US"
|
CountryGroup = "US"
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Amazon (all countries) ---
|
// --- Amazon (GB/US/CA/AU) ---
|
||||||
["amazon-hardback-gb"] = new BuyLink
|
["amazon-hardback-gb"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "amazon-hardback-gb",
|
Slug = "amazon-hardback-gb",
|
||||||
@ -188,7 +187,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
["apple-ebook-gb"] = new BuyLink
|
["apple-ebook-gb"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "apple-ebook-gb",
|
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",
|
Retailer = "Apple Books",
|
||||||
Format = "eBook",
|
Format = "eBook",
|
||||||
CountryGroup = "GB"
|
CountryGroup = "GB"
|
||||||
@ -196,7 +195,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
["apple-ebook-us"] = new BuyLink
|
["apple-ebook-us"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "apple-ebook-us",
|
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",
|
Retailer = "Apple Books",
|
||||||
Format = "eBook",
|
Format = "eBook",
|
||||||
CountryGroup = "US"
|
CountryGroup = "US"
|
||||||
@ -204,7 +203,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
["apple-ebook-ca"] = new BuyLink
|
["apple-ebook-ca"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "apple-ebook-ca",
|
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",
|
Retailer = "Apple Books",
|
||||||
Format = "eBook",
|
Format = "eBook",
|
||||||
CountryGroup = "CA"
|
CountryGroup = "CA"
|
||||||
@ -212,7 +211,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
["apple-ebook-au"] = new BuyLink
|
["apple-ebook-au"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "apple-ebook-au",
|
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",
|
Retailer = "Apple Books",
|
||||||
Format = "eBook",
|
Format = "eBook",
|
||||||
CountryGroup = "AU"
|
CountryGroup = "AU"
|
||||||
@ -220,7 +219,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
["apple-ebook-ie"] = new BuyLink
|
["apple-ebook-ie"] = new BuyLink
|
||||||
{
|
{
|
||||||
Slug = "apple-ebook-ie",
|
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",
|
Retailer = "Apple Books",
|
||||||
Format = "eBook",
|
Format = "eBook",
|
||||||
CountryGroup = "IE"
|
CountryGroup = "IE"
|
||||||
@ -269,13 +268,15 @@ namespace CatherineLynwood.Controllers
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public ClicksController(DataAccess dataAccess)
|
||||||
public BuyController(DataAccess dataAccess)
|
|
||||||
{
|
{
|
||||||
_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)
|
public async Task<IActionResult> Go(string slug)
|
||||||
{
|
{
|
||||||
if (!Links.TryGetValue(slug, out var link)) return NotFound();
|
if (!Links.TryGetValue(slug, out var link)) return NotFound();
|
||||||
@ -285,6 +286,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var qs = Request.QueryString.HasValue ? Request.QueryString.Value : string.Empty;
|
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 (HttpMethods.IsHead(Request.Method)) return Redirect(link.Url);
|
||||||
if (IsLikelyBot(ua)) 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))
|
if (!string.Equals(fetchMode, "navigate", StringComparison.OrdinalIgnoreCase))
|
||||||
return Redirect(link.Url);
|
return Redirect(link.Url);
|
||||||
|
|
||||||
if (!IsOurSiteReferer(referer, Request.Host.Host))
|
EnsureSidCookie(out var sessionId);
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Persist the click
|
||||||
_ = await _dataAccess.SaveBuyClick(
|
_ = await _dataAccess.SaveBuyClick(
|
||||||
dateTimeUtc: DateTime.UtcNow,
|
dateTimeUtc: DateTime.UtcNow,
|
||||||
slug: link.Slug,
|
slug: link.Slug,
|
||||||
@ -330,6 +316,61 @@ namespace CatherineLynwood.Controllers
|
|||||||
return Redirect(link.Url);
|
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)
|
private static bool IsLikelyBot(string ua)
|
||||||
{
|
{
|
||||||
@ -337,23 +378,13 @@ namespace CatherineLynwood.Controllers
|
|||||||
var needles = new[]
|
var needles = new[]
|
||||||
{
|
{
|
||||||
"bot","crawler","spider","preview","fetch","scan","analyzer",
|
"bot","crawler","spider","preview","fetch","scan","analyzer",
|
||||||
"httpclient","python-requests","facebookexternalhit","Slackbot",
|
"httpclient","python-requests","facebookexternalhit","slackbot",
|
||||||
"WhatsApp","TelegramBot","Googlebot","AdsBot","Amazonbot",
|
"whatsapp","telegrambot","googlebot","adsbot","amazonbot",
|
||||||
"bingbot","DuckDuckBot","YandexBot","AhrefsBot","SemrushBot",
|
"bingbot","duckduckbot","yandexbot","ahrefsbot","semrushbot",
|
||||||
"Applebot","LinkedInBot","Discordbot","Embedly","Pinterestbot"
|
"applebot","linkedinbot","discordbot","embedly","pinterestbot"
|
||||||
};
|
};
|
||||||
ua = ua.ToLowerInvariant();
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,95 +1,55 @@
|
|||||||
using CatherineLynwood.Models;
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using CatherineLynwood.Models;
|
||||||
using CatherineLynwood.Services;
|
using CatherineLynwood.Services;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace CatherineLynwood.Controllers
|
namespace CatherineLynwood.Controllers
|
||||||
{
|
{
|
||||||
[Route("the-alpha-flame/discovery")]
|
[Route("the-alpha-flame/discovery")]
|
||||||
public class DiscoveryController : Controller
|
public class DiscoveryController : Controller
|
||||||
{
|
{
|
||||||
#region Private Fields
|
private readonly DataAccess _dataAccess;
|
||||||
|
private readonly ICountryContext _country;
|
||||||
|
|
||||||
private DataAccess _dataAccess;
|
public DiscoveryController(DataAccess dataAccess, ICountryContext country)
|
||||||
|
|
||||||
#endregion Private Fields
|
|
||||||
|
|
||||||
#region Public Constructors
|
|
||||||
|
|
||||||
public DiscoveryController(DataAccess dataAccess)
|
|
||||||
{
|
{
|
||||||
_dataAccess = dataAccess;
|
_dataAccess = dataAccess;
|
||||||
|
_country = country;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion Public Constructors
|
// -------------------------
|
||||||
|
// Pages
|
||||||
#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();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Route("")]
|
[Route("")]
|
||||||
public async Task<IActionResult> Index()
|
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 reviews = await _dataAccess.GetReviewsAsync();
|
||||||
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
|
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 3);
|
||||||
|
|
||||||
return View("Index1", reviews);
|
// 4) Compose page VM
|
||||||
}
|
var vm = new DiscoveryPageViewModel
|
||||||
|
{
|
||||||
|
Reviews = reviews,
|
||||||
|
UserIso2 = iso2,
|
||||||
|
Buy = buyLinks
|
||||||
|
};
|
||||||
|
|
||||||
[BookAccess(1, 1)]
|
// Use your existing view (rename if you prefer): Index1.cshtml
|
||||||
[Route("extras/listen")]
|
return View("Index1", vm);
|
||||||
public IActionResult Listen()
|
|
||||||
{
|
|
||||||
return View();
|
|
||||||
}
|
|
||||||
|
|
||||||
[BookAccess(1, 1)]
|
|
||||||
[Route("extras/maggies-designs")]
|
|
||||||
public IActionResult MaggiesDesigns()
|
|
||||||
{
|
|
||||||
return View();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("reviews")]
|
[Route("reviews")]
|
||||||
@ -97,67 +57,114 @@ namespace CatherineLynwood.Controllers
|
|||||||
{
|
{
|
||||||
Reviews reviews = await _dataAccess.GetReviewsAsync();
|
Reviews reviews = await _dataAccess.GetReviewsAsync();
|
||||||
reviews.SchemaJsonLd = GenerateBookSchemaJsonLd(reviews, 100);
|
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);
|
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)]
|
[BookAccess(1, 1)]
|
||||||
[Route("extras/scrap-book")]
|
[Route("extras/scrap-book")]
|
||||||
public IActionResult ScrapBook()
|
public IActionResult ScrapBook() => View();
|
||||||
{
|
|
||||||
return 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)]
|
[BookAccess(1, 1)]
|
||||||
[Route("extras/soundtrack")]
|
[Route("extras/soundtrack")]
|
||||||
public async Task<IActionResult> Soundtrack()
|
public async Task<IActionResult> Soundtrack()
|
||||||
{
|
{
|
||||||
List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack();
|
List<SoundtrackTrackModel> soundtrackTrackModels = await _dataAccess.GetSoundtrack();
|
||||||
|
|
||||||
return View(soundtrackTrackModels);
|
return View(soundtrackTrackModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("trailer")]
|
// -------------------------
|
||||||
public async Task<IActionResult> Trailer()
|
// Private helpers
|
||||||
|
// -------------------------
|
||||||
|
|
||||||
|
private static BuyLinksViewModel BuildBuyLinksFor(string iso2)
|
||||||
{
|
{
|
||||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var cc = iso2.ToUpperInvariant();
|
||||||
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
|
if (cc == "UK") cc = "GB";
|
||||||
|
|
||||||
//ip = "77.104.168.236";
|
string? ingHbSlug = null, ingPbSlug = null;
|
||||||
|
if (cc == "GB") { ingHbSlug = "ingram-hardback-gb"; ingPbSlug = "ingram-paperback-gb"; }
|
||||||
|
else if (cc == "US") { ingHbSlug = "ingram-hardback-us"; ingPbSlug = "ingram-paperback-us"; }
|
||||||
|
|
||||||
string country = "Unknown";
|
string amazonHbSlug = cc switch
|
||||||
using (var client = new HttpClient())
|
|
||||||
{
|
{
|
||||||
try
|
"GB" => "amazon-hardback-gb",
|
||||||
{
|
"US" => "amazon-hardback-us",
|
||||||
var response = await client.GetStringAsync($"http://ip-api.com/json/{ip}");
|
"CA" => "amazon-hardback-ca",
|
||||||
var json = JsonDocument.Parse(response);
|
"AU" => "amazon-hardback-au",
|
||||||
country = json.RootElement.GetProperty("countryCode").GetString();
|
_ => "amazon-hardback-us"
|
||||||
|
};
|
||||||
if (country == "GB")
|
string amazonPbSlug = cc switch
|
||||||
{
|
|
||||||
country = "UK";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Fail silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FlagSupportViewModel flagSupportViewModel = await _dataAccess.GetTrailerLikes();
|
|
||||||
|
|
||||||
foreach (var flag in flagSupportViewModel.FlagCounts)
|
|
||||||
{
|
{
|
||||||
flag.Selected = flag.Key.ToLower() == country.ToLower();
|
"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"
|
||||||
|
};
|
||||||
|
|
||||||
return View(flagSupportViewModel);
|
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)!
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion Public Methods
|
|
||||||
|
|
||||||
#region Private Methods
|
|
||||||
|
|
||||||
private string GenerateBookSchemaJsonLd(Reviews reviews, int take)
|
private string GenerateBookSchemaJsonLd(Reviews reviews, int take)
|
||||||
{
|
{
|
||||||
@ -189,16 +196,12 @@ namespace CatherineLynwood.Controllers
|
|||||||
["url"] = baseUrl
|
["url"] = baseUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add review section if there are reviews
|
// Reviews
|
||||||
if (reviews?.Items?.Any() == true)
|
if (reviews?.Items?.Any() == true)
|
||||||
{
|
{
|
||||||
var reviewObjects = new List<Dictionary<string, object>>();
|
var reviewObjects = new List<Dictionary<string, object>>();
|
||||||
double total = 0;
|
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))
|
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>>
|
schema["workExample"] = new List<Dictionary<string, object>>
|
||||||
{
|
{
|
||||||
new Dictionary<string, object>
|
new Dictionary<string, object>
|
||||||
@ -302,8 +305,7 @@ namespace CatherineLynwood.Controllers
|
|||||||
return JsonConvert.SerializeObject(schema, Formatting.Indented);
|
return JsonConvert.SerializeObject(schema, Formatting.Indented);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string StripHtml(string input) => string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty);
|
private static string StripHtml(string input) =>
|
||||||
|
string.IsNullOrWhiteSpace(input) ? string.Empty : Regex.Replace(input, "<.*?>", string.Empty);
|
||||||
#endregion Private Methods
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 CatherineLynwood.Services;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.ResponseCompression;
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
|
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
using WebMarkupMin.AspNetCoreLatest;
|
using WebMarkupMin.AspNetCoreLatest;
|
||||||
|
|
||||||
namespace CatherineLynwood
|
namespace CatherineLynwood
|
||||||
@ -15,21 +16,44 @@ namespace CatherineLynwood
|
|||||||
{
|
{
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Retrieve the connection string from appsettings.json
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||||||
|
|
||||||
|
// DAL
|
||||||
builder.Services.AddSingleton(new DataAccess(connectionString));
|
builder.Services.AddSingleton(new DataAccess(connectionString));
|
||||||
|
|
||||||
|
// MVC
|
||||||
builder.Services.AddControllersWithViews();
|
builder.Services.AddControllersWithViews();
|
||||||
|
|
||||||
// Add IHttpContextAccessor for accessing HTTP context in tag helpers
|
// HttpContext accessor
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
builder.Services.AddHostedService<IndexNowBackgroundService>();
|
// Memory cache for IP cache
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
// HttpClient for general use
|
||||||
builder.Services.AddHttpClient();
|
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 =>
|
builder.Services.AddSession(options =>
|
||||||
{
|
{
|
||||||
options.IdleTimeout = TimeSpan.FromMinutes(30);
|
options.IdleTimeout = TimeSpan.FromMinutes(30);
|
||||||
@ -37,7 +61,7 @@ namespace CatherineLynwood
|
|||||||
options.Cookie.IsEssential = true;
|
options.Cookie.IsEssential = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✅ Add authentication with cookie settings
|
// Auth
|
||||||
builder.Services.AddAuthentication("MyCookieAuth")
|
builder.Services.AddAuthentication("MyCookieAuth")
|
||||||
.AddCookie("MyCookieAuth", options =>
|
.AddCookie("MyCookieAuth", options =>
|
||||||
{
|
{
|
||||||
@ -48,79 +72,63 @@ namespace CatherineLynwood
|
|||||||
options.ExpireTimeSpan = TimeSpan.FromHours(12);
|
options.ExpireTimeSpan = TimeSpan.FromHours(12);
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
// Add RedirectsStore as singleton
|
// Response compression
|
||||||
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
|
|
||||||
builder.Services.AddResponseCompression(options =>
|
builder.Services.AddResponseCompression(options =>
|
||||||
{
|
{
|
||||||
options.EnableForHttps = true;
|
options.EnableForHttps = true;
|
||||||
options.Providers.Add<BrotliCompressionProvider>();
|
options.Providers.Add<BrotliCompressionProvider>();
|
||||||
options.Providers.Add<GzipCompressionProvider>();
|
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;
|
o.AllowMinificationInDevelopmentEnvironment = true;
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Level = CompressionLevel.Fastest;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add HTML minification
|
|
||||||
builder.Services.AddWebMarkupMin(options =>
|
|
||||||
{
|
|
||||||
options.AllowMinificationInDevelopmentEnvironment = true;
|
|
||||||
})
|
})
|
||||||
.AddHtmlMinification()
|
.AddHtmlMinification()
|
||||||
.AddHttpCompression()
|
.AddHttpCompression()
|
||||||
.AddXmlMinification()
|
.AddXmlMinification()
|
||||||
.AddXhtmlMinification();
|
.AddXhtmlMinification();
|
||||||
|
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
// Kestrel limits
|
||||||
{
|
builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 40 * 1024 * 1024; });
|
||||||
options.Limits.MaxRequestBodySize = 40 * 1024 * 1024; // 40MB
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Errors, HSTS
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Home/Error");
|
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<SpamAndSecurityMiddleware>();
|
||||||
app.UseMiddleware<HoneypotLoggingMiddleware>();
|
app.UseMiddleware<HoneypotLoggingMiddleware>();
|
||||||
app.UseMiddleware<RedirectMiddleware>();
|
app.UseMiddleware<RedirectMiddleware>();
|
||||||
app.UseMiddleware<EnsureSidMiddleware>();
|
app.UseMiddleware<EnsureSidMiddleware>();
|
||||||
|
|
||||||
|
// Resolve ISO2 once per request, stored in HttpContext.Items via GeoResolutionMiddleware
|
||||||
|
app.UseMiddleware<GeoResolutionMiddleware>();
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseResponseCompression();
|
app.UseResponseCompression();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseWebMarkupMin();
|
app.UseWebMarkupMin();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseSession();
|
|
||||||
|
|
||||||
// ✅ Authentication must come before Authorization
|
app.UseSession();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
@ -129,6 +137,7 @@ namespace CatherineLynwood
|
|||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
|
|
||||||
app.Run();
|
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;
|
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)
|
public async Task<bool> Addhoneypot(DateTime dateTime, string ip, string country, string userAgent, string referer)
|
||||||
{
|
{
|
||||||
bool success = true;
|
bool success = true;
|
||||||
@ -155,122 +97,6 @@ namespace CatherineLynwood.Services
|
|||||||
return success;
|
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)
|
public async Task<bool> AddMarketingAsync(Marketing marketing)
|
||||||
{
|
{
|
||||||
bool success = true;
|
bool success = true;
|
||||||
@ -415,47 +241,6 @@ namespace CatherineLynwood.Services
|
|||||||
return accessCodes;
|
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()
|
public async Task<ARCReaderList> GetAllARCReadersAsync()
|
||||||
{
|
{
|
||||||
ARCReaderList arcReaderList = new ARCReaderList();
|
ARCReaderList arcReaderList = new ARCReaderList();
|
||||||
@ -502,6 +287,47 @@ namespace CatherineLynwood.Services
|
|||||||
return arcReaderList;
|
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()
|
public async Task<BlogAdminIndex> GetBlogAdminIndexAsync()
|
||||||
{
|
{
|
||||||
BlogAdminIndex blogAdminIndex = new BlogAdminIndex();
|
BlogAdminIndex blogAdminIndex = new BlogAdminIndex();
|
||||||
@ -758,6 +584,47 @@ namespace CatherineLynwood.Services
|
|||||||
return blogPosts;
|
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()
|
public async Task<Questions> GetQuestionsAsync()
|
||||||
{
|
{
|
||||||
Questions questions = new Questions();
|
Questions questions = new Questions();
|
||||||
@ -874,6 +741,85 @@ namespace CatherineLynwood.Services
|
|||||||
return reviews;
|
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)
|
public async Task<bool> MarkAsNotifiedAsync(int blogID)
|
||||||
{
|
{
|
||||||
bool success = true;
|
bool success = true;
|
||||||
@ -901,73 +847,6 @@ namespace CatherineLynwood.Services
|
|||||||
return success;
|
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)
|
public async Task<bool> SaveARCReaderApplication(ArcReaderApplicationModel arcReaderApplication)
|
||||||
{
|
{
|
||||||
bool success = true;
|
bool success = true;
|
||||||
@ -1009,7 +888,195 @@ namespace CatherineLynwood.Services
|
|||||||
return success;
|
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)
|
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";
|
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">
|
<div class="row">
|
||||||
@ -76,148 +76,17 @@
|
|||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<!-- Buy Box -->
|
<!-- Buy Box -->
|
||||||
<div id="buyBox" class="border border-2 border-dark rounded-4 p-3 bg-light mt-auto">
|
@* buyBox: server-side slugs + <a ping> tracking *@
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
|
@* Model: CatherineLynwood.Models.DiscoveryPageViewModel *@
|
||||||
<h3 class="h5 mb-2 mb-sm-0">Buy the Book</h3>
|
@{
|
||||||
<small id="buyCountryHint" class="text-muted d-flex align-items-center">
|
var L = Model.Buy;
|
||||||
<img id="buyCountryFlag" class="me-1 d-none" alt="" width="20" height="14" loading="lazy">
|
string pingBase = "/track/click";
|
||||||
<span id="buyCountryText">Best options for your country</span>
|
string countryIso2 = Model.UserIso2 ?? "GB";
|
||||||
</small>
|
string flagPathSvg = $"/images/flags/{countryIso2}.svg";
|
||||||
</div>
|
string flagPathPng = $"/images/flags/{countryIso2}.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_BuyBox" />
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,7 +105,7 @@
|
|||||||
<!-- Social proof: show one standout review near the top -->
|
<!-- Social proof: show one standout review near the top -->
|
||||||
@if (showReviews)
|
@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 fullStars = (int)Math.Floor(top.RatingValue);
|
||||||
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
|
var hasHalfStar = top.RatingValue - fullStars >= 0.5;
|
||||||
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
|
var emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
|
||||||
@ -278,7 +147,7 @@
|
|||||||
<span class="text-muted smaller">, @reviewDate</span>
|
<span class="text-muted smaller">, @reviewDate</span>
|
||||||
</footer>
|
</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
@if (Model.Items.Count > 1)
|
@if (Model.Reviews.Items.Count > 1)
|
||||||
{
|
{
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
|
<a asp-action="Reviews" class="btn btn-outline-secondary btn-sm">Read more reviews</a>
|
||||||
@ -473,251 +342,6 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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 {
|
@section Meta {
|
||||||
@ -737,6 +361,6 @@
|
|||||||
twitter-creator-handle="@@CathLynwood" />
|
twitter-creator-handle="@@CathLynwood" />
|
||||||
|
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
@Html.Raw(Model.SchemaJsonLd)
|
@Html.Raw(Model.Reviews.SchemaJsonLd)
|
||||||
</script>
|
</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> *@
|
</script> *@
|
||||||
@RenderSection("Scripts", required: false)
|
@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>
|
<script>
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
@ -382,6 +343,24 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user