diff --git a/AoSelecionarES.png b/AoSelecionarES.png new file mode 100644 index 0000000..d53f7f8 Binary files /dev/null and b/AoSelecionarES.png differ diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index 487db03..787a0a9 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -33,8 +33,12 @@ namespace QRRapidoApp.Controllers _markdownService = markdownService; } - // Default fallback route handled by Program.cs map + // Home page routes + // "/" → Portuguese (canonical) + // "/es-PY" → Spanish [HttpGet] + [Route("/")] + [Route("es-PY")] public async Task Index(string? qrType = null) { var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; @@ -106,37 +110,42 @@ namespace QRRapidoApp.Controllers return View("Index"); } - // Dedicated SEO Routes - These act as virtual pages + // Dedicated SEO Routes - Landing pages for each QR type + // Portuguese (default): /pix, /wifi, etc. (no culture prefix) + // Spanish: /es-PY/pix, /es-PY/wifi, etc. + [Route("pix")] - [Route("{culture}/pix")] + [Route("es-PY/pix")] public async Task Pix() => await Index("pix"); [Route("wifi")] - [Route("{culture}/wifi")] + [Route("es-PY/wifi")] public async Task Wifi() => await Index("wifi"); [Route("vcard")] - [Route("{culture}/vcard")] + [Route("es-PY/vcard")] public async Task VCard() => await Index("vcard"); [Route("whatsapp")] - [Route("{culture}/whatsapp")] + [Route("es-PY/whatsapp")] public async Task WhatsApp() => await Index("whatsapp"); [Route("email")] - [Route("{culture}/email")] + [Route("es-PY/email")] public async Task Email() => await Index("email"); [Route("sms")] - [Route("{culture}/sms")] + [Route("es-PY/sms")] public async Task Sms() => await Index("sms"); + [Route("texto")] [Route("text")] - [Route("{culture}/text")] + [Route("es-PY/texto")] + [Route("es-PY/text")] public async Task Text() => await Index("text"); [Route("url")] - [Route("{culture}/url")] + [Route("es-PY/url")] public async Task UrlType() => await Index("url"); public IActionResult Privacy() @@ -315,43 +324,46 @@ namespace QRRapidoApp.Controllers } // Core entry points + // "/" is canonical for Portuguese, "/es-PY" for Spanish AppendUrl("/", "daily", "1.0"); - AppendUrl("/pt-BR", "daily", "0.9"); AppendUrl("/es-PY", "daily", "0.9"); - // Tools (Virtual Pages) - var tools = new[] { "pix", "wifi", "vcard", "whatsapp", "email", "sms", "text", "url" }; - var cultures = new[] { "pt-BR", "es-PY" }; + // Tools (Landing Pages) - Portuguese without prefix, Spanish with /es-PY + var tools = new[] { "pix", "wifi", "vcard", "whatsapp", "email", "sms", "texto", "url" }; - foreach (var culture in cultures) + // Portuguese tools (canonical, no prefix) + foreach (var tool in tools) { - foreach (var tool in tools) - { - AppendUrl($"/{culture}/{tool}", "weekly", "0.9"); - } + AppendUrl($"/{tool}", "weekly", "0.9"); } + // Spanish tools (with /es-PY prefix) + foreach (var tool in tools) + { + AppendUrl($"/es-PY/{tool}", "weekly", "0.8"); + } + + // Informational pages var informationalPages = new[] { - new { Path = "Home/About", ChangeFreq = "monthly", Priority = "0.8" }, - new { Path = "Home/Contact", ChangeFreq = "monthly", Priority = "0.8" }, - new { Path = "Home/FAQ", ChangeFreq = "weekly", Priority = "0.9" }, + new { Path = "Home/About", ChangeFreq = "monthly", Priority = "0.7" }, + new { Path = "Home/Contact", ChangeFreq = "monthly", Priority = "0.7" }, + new { Path = "Home/FAQ", ChangeFreq = "weekly", Priority = "0.8" }, new { Path = "Home/HowToUse", ChangeFreq = "weekly", Priority = "0.8" }, - new { Path = "Home/Privacy", ChangeFreq = "monthly", Priority = "0.5" }, - new { Path = "Home/Terms", ChangeFreq = "monthly", Priority = "0.5" } + new { Path = "Home/Privacy", ChangeFreq = "monthly", Priority = "0.4" }, + new { Path = "Home/Terms", ChangeFreq = "monthly", Priority = "0.4" } }; + // Portuguese informational pages (no prefix) foreach (var page in informationalPages) { AppendUrl($"/{page.Path}", page.ChangeFreq, page.Priority); } - foreach (var culture in cultures) + // Spanish informational pages + foreach (var page in informationalPages) { - foreach (var page in informationalPages) - { - AppendUrl($"/{culture}/{page.Path}", page.ChangeFreq, page.Priority); - } + AppendUrl($"/es-PY/{page.Path}", page.ChangeFreq, page.Priority); } // Dynamic tutorial pages @@ -368,7 +380,12 @@ namespace QRRapidoApp.Controllers var encodedSlug = System.Uri.EscapeDataString(slug); var lastMod = article.LastMod.ToString("yyyy-MM-dd"); - AppendUrl($"/{article.Culture}/tutoriais/{encodedSlug}", "weekly", "0.8", lastMod); + // pt-BR tutorials go under /tutoriais/, es-PY under /es-PY/tutoriais/ + var tutorialPath = article.Culture == "pt-BR" + ? $"/tutoriais/{encodedSlug}" + : $"/{article.Culture}/tutoriais/{encodedSlug}"; + + AppendUrl(tutorialPath, "weekly", "0.8", lastMod); } _logger.LogInformation("Generated sitemap with {Count} tutorial articles", allArticles.Count); diff --git a/Controllers/TutoriaisController.cs b/Controllers/TutoriaisController.cs index 16b63b9..8b6c553 100644 --- a/Controllers/TutoriaisController.cs +++ b/Controllers/TutoriaisController.cs @@ -27,11 +27,19 @@ namespace QRRapidoApp.Controllers _config = config; } - [Route("{culture:regex(^(pt-BR|es-PY)$)}/tutoriais/{slug}")] - public async Task Article(string slug, string culture) + // Portuguese: /tutoriais/{slug} (canonical, no prefix) + // Spanish: /es-PY/tutoriais/{slug} + [Route("tutoriais/{slug}")] + [Route("es-PY/tutoriais/{slug}")] + public async Task Article(string slug, string? culture = null) { try { + // Determine culture from URL path if not provided + culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true + ? "es-PY" + : "pt-BR"; + var article = await _markdownService.GetArticleAsync(slug, culture); if (article == null) @@ -77,11 +85,19 @@ namespace QRRapidoApp.Controllers } } - [Route("{culture:regex(^(pt-BR|es-PY)$)}/tutoriais")] - public async Task Index(string culture) + // Portuguese: /tutoriais (canonical, no prefix) + // Spanish: /es-PY/tutoriais + [Route("tutoriais")] + [Route("es-PY/tutoriais")] + public async Task Index(string? culture = null) { try { + // Determine culture from URL path if not provided + culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true + ? "es-PY" + : "pt-BR"; + var articles = await _markdownService.GetAllArticlesAsync(culture); // Set ViewBag diff --git a/Middleware/LanguageRedirectionMiddleware.cs b/Middleware/LanguageRedirectionMiddleware.cs index 58a6e13..c92a218 100644 --- a/Middleware/LanguageRedirectionMiddleware.cs +++ b/Middleware/LanguageRedirectionMiddleware.cs @@ -3,19 +3,22 @@ using System.Linq; namespace QRRapidoApp.Middleware { + /// + /// Middleware de redirecionamento de idioma otimizado para SEO. + /// + /// Comportamento: + /// - "/" → Retorna 200 OK em Português (canonical) + /// - "/pt-BR" ou "/pt-BR/*" → Redireciona 301 para "/" ou "/*" (sem prefixo) + /// - "/es-PY" ou "/es-PY/*" → Retorna 200 OK em Espanhol (mantém URL) + /// - "/pix", "/wifi", etc. → Retorna 200 OK em Português (sem redirect) + /// public class LanguageRedirectionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly string[] _supportedCultures = { "pt-BR", "es-PY" }; - private readonly Dictionary _cultureAliases = new(StringComparer.OrdinalIgnoreCase) - { - { "pt", "pt-BR" }, - { "pt-br", "pt-BR" }, - { "es", "es-PY" }, - { "es-py", "es-PY" } - }; private const string DefaultCulture = "pt-BR"; + private const string CookieName = ".AspNetCore.Culture"; public LanguageRedirectionMiddleware(RequestDelegate next, ILogger logger) { @@ -27,84 +30,109 @@ namespace QRRapidoApp.Middleware { var path = context.Request.Path.Value?.TrimStart('/') ?? ""; - if (TryHandleCultureAlias(context, path)) - { - return; - } - - if (HasCultureInPath(path) || IsSpecialRoute(path)) + // Skip special routes (static files, API, auth callbacks, etc.) + if (IsSpecialRoute(path)) { await _next(context); return; } - var detectedCulture = DetectBrowserLanguage(context); - - // ALWAYS Redirect to include culture in path for consistency and SEO - // This ensures /pix becomes /pt-BR/pix or /es-PY/pix - - var redirectUrl = $"/{detectedCulture}"; - if (!string.IsNullOrEmpty(path)) - { - redirectUrl += $"/{path}"; - } - - if (context.Request.QueryString.HasValue) - { - redirectUrl += context.Request.QueryString.Value; - } - - _logger.LogInformation("Redirecting to localized URL: {RedirectUrl} (detected culture: {Culture})", - redirectUrl, detectedCulture); - - context.Response.Redirect(redirectUrl, permanent: false); - } - - private bool HasCultureInPath(string path) - { - if (string.IsNullOrEmpty(path)) - return false; - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - return false; + var firstSegment = segments.Length > 0 ? segments[0] : ""; - return _supportedCultures.Contains(segments[0]); - } - - private bool TryHandleCultureAlias(HttpContext context, string path) - { - if (string.IsNullOrEmpty(path)) + // Check if URL starts with a culture prefix + if (IsCultureSegment(firstSegment)) { - return false; - } - - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - { - return false; - } - - var firstSegment = segments[0]; - if (_cultureAliases.TryGetValue(firstSegment, out var mappedCulture)) - { - // Don't redirect if already using the canonical culture (case-sensitive check) - if (firstSegment == mappedCulture) + // /pt-BR/* → Redirect 301 to /* (remove pt-BR prefix, it's the default) + if (string.Equals(firstSegment, "pt-BR", StringComparison.OrdinalIgnoreCase)) { - return false; + var remainingPath = segments.Length > 1 + ? "/" + string.Join('/', segments.Skip(1)) + : "/"; + + if (context.Request.QueryString.HasValue) + { + remainingPath += context.Request.QueryString.Value; + } + + _logger.LogInformation("Redirecting /pt-BR{Path} to {RedirectUrl} (canonical)", + remainingPath == "/" ? "" : remainingPath, remainingPath); + + context.Response.Redirect(remainingPath, permanent: true); + return; } - var remainingSegments = segments.Length > 1 - ? "/" + string.Join('/', segments.Skip(1)) - : string.Empty; + // /es-PY/* → Continue normally (Spanish has its own URLs) + if (string.Equals(firstSegment, "es-PY", StringComparison.OrdinalIgnoreCase)) + { + // Set culture for the request + SetCulture(context, "es-PY"); + await _next(context); + return; + } + + // Handle lowercase aliases: /pt-br → /pt-BR, /es-py → /es-PY + if (TryHandleCultureAlias(context, firstSegment, segments)) + { + return; + } + } + + // No culture prefix → Serve in Portuguese (default) + // URLs like /, /pix, /wifi, /vcard etc. + SetCulture(context, DefaultCulture); + await _next(context); + } + + private bool IsCultureSegment(string segment) + { + if (string.IsNullOrEmpty(segment)) return false; + + // Check exact matches and aliases + return string.Equals(segment, "pt-BR", StringComparison.OrdinalIgnoreCase) || + string.Equals(segment, "es-PY", StringComparison.OrdinalIgnoreCase) || + string.Equals(segment, "pt", StringComparison.OrdinalIgnoreCase) || + string.Equals(segment, "es", StringComparison.OrdinalIgnoreCase); + } + + private bool TryHandleCultureAlias(HttpContext context, string firstSegment, string[] segments) + { + string? targetCulture = null; + string? targetPrefix = null; + + // Map aliases to canonical forms + if (string.Equals(firstSegment, "pt", StringComparison.OrdinalIgnoreCase) || + (string.Equals(firstSegment, "pt-br", StringComparison.Ordinal) && firstSegment != "pt-BR")) + { + // /pt/* or /pt-br/* → Redirect to /* (Portuguese is canonical without prefix) + targetCulture = "pt-BR"; + targetPrefix = ""; // No prefix for Portuguese + } + else if (string.Equals(firstSegment, "es", StringComparison.OrdinalIgnoreCase) || + (string.Equals(firstSegment, "es-py", StringComparison.OrdinalIgnoreCase) && firstSegment != "es-PY")) + { + // /es/* or /es-py/* → Redirect to /es-PY/* + targetCulture = "es-PY"; + targetPrefix = "/es-PY"; + } + + if (targetCulture != null) + { + var remainingPath = segments.Length > 1 + ? "/" + string.Join('/', segments.Skip(1)) + : ""; + + var redirectUrl = targetPrefix + remainingPath; + if (string.IsNullOrEmpty(redirectUrl)) redirectUrl = "/"; - var redirectUrl = $"/{mappedCulture}{remainingSegments}"; if (context.Request.QueryString.HasValue) { redirectUrl += context.Request.QueryString.Value; } - _logger.LogInformation("Redirecting alias '{Alias}' to canonical culture URL: {RedirectUrl}", firstSegment, redirectUrl); + _logger.LogInformation("Redirecting alias '{Alias}' to canonical URL: {RedirectUrl}", + firstSegment, redirectUrl); + context.Response.Redirect(redirectUrl, permanent: true); return true; } @@ -112,6 +140,16 @@ namespace QRRapidoApp.Middleware return false; } + private void SetCulture(HttpContext context, string culture) + { + var cultureInfo = new CultureInfo(culture); + CultureInfo.CurrentCulture = cultureInfo; + CultureInfo.CurrentUICulture = cultureInfo; + + // Store in HttpContext for downstream use + context.Items["Culture"] = culture; + } + private bool IsSpecialRoute(string path) { var specialRoutes = new[] @@ -125,38 +163,5 @@ namespace QRRapidoApp.Middleware return specialRoutes.Any(route => path.StartsWith(route, StringComparison.OrdinalIgnoreCase)); } - - private string DetectBrowserLanguage(HttpContext context) - { - var acceptLanguage = context.Request.GetTypedHeaders().AcceptLanguage; - - if (acceptLanguage != null && acceptLanguage.Any()) - { - foreach (var lang in acceptLanguage.OrderByDescending(x => x.Quality ?? 1.0)) - { - var langCode = lang.Value.Value; - - // Exact match for es-PY - if (string.Equals(langCode, "es-PY", StringComparison.OrdinalIgnoreCase)) - { - return "es-PY"; - } - - // Generic 'es' maps to 'es-PY' - if (langCode.StartsWith("es-", StringComparison.OrdinalIgnoreCase) || string.Equals(langCode, "es", StringComparison.OrdinalIgnoreCase)) - { - return "es-PY"; - } - - // Check for pt-BR - if (langCode.StartsWith("pt-", StringComparison.OrdinalIgnoreCase) || string.Equals(langCode, "pt", StringComparison.OrdinalIgnoreCase)) - { - return "pt-BR"; - } - } - } - - return DefaultCulture; - } } } diff --git a/Providers/CustomRouteDataRequestCultureProvider.cs b/Providers/CustomRouteDataRequestCultureProvider.cs index e6d1d1a..0ce8fbc 100644 --- a/Providers/CustomRouteDataRequestCultureProvider.cs +++ b/Providers/CustomRouteDataRequestCultureProvider.cs @@ -6,6 +6,17 @@ namespace QRRapidoApp.Providers { public Task DetermineProviderCultureResult(HttpContext httpContext) { + // First check if the middleware has already determined the culture (e.g. for static routes like /es-PY/pix) + if (httpContext.Items.TryGetValue("Culture", out var cultureItem) && cultureItem is string cultureFromMiddleware) + { + var supportedCultures = new[] { "pt-BR", "es-PY" }; + if (supportedCultures.Contains(cultureFromMiddleware)) + { + return Task.FromResult(new ProviderCultureResult(cultureFromMiddleware, cultureFromMiddleware)); + } + } + + // Fallback to route data (standard routing) var routeValues = httpContext.GetRouteData()?.Values; if (routeValues != null && routeValues.TryGetValue("culture", out var cultureValue)) { diff --git a/Resources/SharedResource.es-PY.resx b/Resources/SharedResource.es-PY.resx index aa9a548..9d6e985 100644 --- a/Resources/SharedResource.es-PY.resx +++ b/Resources/SharedResource.es-PY.resx @@ -2150,4 +2150,16 @@ Los códigos QR estáticos son ideales para información permanente como WiFi, tarjetas de visita, URLs de sitios web y contactos. + + Generador de PIX + + + Cree códigos QR para recibir pagos instantáneos. Compatible con todos los bancos brasileños. + + + ¿Vende muchos productos? + + + Suscríbase a nuestro plan mensual y gestione códigos QR exclusivos para cada producto de su catálogo. + diff --git a/Resources/SharedResource.pt-BR.resx b/Resources/SharedResource.pt-BR.resx index a6f40a8..bf1b498 100644 --- a/Resources/SharedResource.pt-BR.resx +++ b/Resources/SharedResource.pt-BR.resx @@ -2303,4 +2303,16 @@ QR codes estáticos são ideais para informações permanentes como WiFi, cartões de visita, URLs de sites, e contatos. + + Gerador de PIX + + + Crie QR Codes para receber pagamentos instantâneos. Compatível com todos os bancos brasileiros. + + + Vende muitos produtos? + + + Assine nosso plano mensal e gerencie QR Codes exclusivos para cada produto do seu catálogo. + diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index 83f3af5..ae6c7c8 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -7,6 +7,34 @@ ViewData["Title"] = "Home"; var userId = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; Layout = "~/Views/Shared/_Layout.cshtml"; + + // Server-Side Rendering: Determine which accordion to open based on qrType + var selectedQRType = (ViewBag.SelectedQRType as string)?.ToLowerInvariant() ?? ""; + + // Map qrType to accordion ID suffix (e.g., "pix" -> "pixQR") + var accordionIdMap = new Dictionary + { + { "url", "url" }, + { "text", "text" }, + { "texto", "text" }, + { "whatsapp", "whatsapp" }, + { "email", "email" }, + { "pix", "pix" }, + { "sms", "sms" }, + { "wifi", "wifi" }, + { "vcard", "vcard" } + }; + + // Helper function to determine if this accordion should be open + string GetAccordionClass(string accordionType) => + accordionIdMap.TryGetValue(selectedQRType, out var mapped) && mapped == accordionType + ? "accordion-collapse collapse show" + : "accordion-collapse collapse"; + + string GetButtonClass(string accordionType) => + accordionIdMap.TryGetValue(selectedQRType, out var mapped) && mapped == accordionType + ? "accordion-button" + : "accordion-button collapsed"; } @@ -512,8 +540,8 @@
-
Gerador de PIX
-

Crie QR Codes para receber pagamentos instantâneos. Compatível com todos os bancos brasileiros.

+
@Localizer["PixGeneratorTitle"]
+

@Localizer["PixGeneratorDesc"]

@@ -521,8 +549,8 @@
- Vende muitos produtos? -

Assine nosso plano mensal e gerencie QR Codes exclusivos para cada produto do seu catálogo.

+ @Localizer["SellManyProductsTitle"] +

Assine nosso plano mensal e gerencie QR Codes exclusivos para cada produto do seu catálogo.

@@ -867,12 +895,12 @@

-

-
+
@@ -910,12 +938,12 @@

-

-
+
@@ -951,12 +979,12 @@

-

-
+
@@ -991,12 +1019,12 @@

-

-
+
@@ -1031,12 +1059,12 @@

-

-
+
@@ -1070,12 +1098,12 @@

-

-
+
@@ -1110,12 +1138,12 @@

-

-
+
@@ -1151,12 +1179,12 @@

-

-
+
diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 0000c0d..1f7d30a 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -18,6 +18,34 @@ var appEnvironment = Configuration["App:Environment"] ?? HostEnvironment?.EnvironmentName ?? "Unknown"; var secretsLoaded = Configuration.GetValue("App:SecretsLoaded"); + // SEO: Determine if current page is Spanish (for URL building) + var requestPath = Context.Request.Path.Value ?? "/"; + var isSpanish = requestPath.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase); + + // Get path without culture prefix for building alternate URLs + var pathWithoutCulture = requestPath; + if (isSpanish && requestPath.Length > 6) + { + pathWithoutCulture = requestPath.Substring(6); // Remove "/es-PY" + } + else if (isSpanish) + { + pathWithoutCulture = "/"; + } + if (string.IsNullOrEmpty(pathWithoutCulture)) pathWithoutCulture = "/"; + + // Canonical URL - for Portuguese it's without prefix, for Spanish it's with /es-PY + var canonicalUrl = isSpanish + ? $"https://qrrapido.site/es-PY{(pathWithoutCulture == "/" ? "" : pathWithoutCulture)}" + : $"https://qrrapido.site{pathWithoutCulture}"; + + // Alternate URLs for hreflang + var ptUrl = $"https://qrrapido.site{pathWithoutCulture}"; + var esUrl = $"https://qrrapido.site/es-PY{(pathWithoutCulture == "/" ? "" : pathWithoutCulture)}"; + + // Culture prefix for internal links + var culturePrefix = isSpanish ? "/es-PY" : ""; + if (User?.Identity?.IsAuthenticated == true) { var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; @@ -47,27 +75,27 @@ - - + + - + - - - - + + + + - - + + - + - + @@ -378,11 +406,8 @@

@Localizer["AverageTimePrefix"] @Localizer["AverageTimeValue"] @Localizer["AverageTimeSuffix"]

- @{ - var currentCulture = System.Globalization.CultureInfo.CurrentUICulture.Name; - } @@ -400,18 +425,30 @@