feat: Criação de tutoriais e remoçaõ de anuncios.
All checks were successful
Deploy QR Rapido / test (push) Successful in 3m59s
Deploy QR Rapido / build-and-push (push) Successful in 12m31s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 2m17s

This commit is contained in:
Ricardo Carneiro 2025-10-18 23:18:12 -03:00
parent 232d4d6c54
commit 8b3da7cb0a
16 changed files with 725 additions and 85 deletions

View File

@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
namespace QRRapidoApp.Models.Ads
{
/// <summary>
/// Represents the content for a single affiliate advertisement.
/// </summary>
public class AffiliateAdContent
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public string ProductUrl { get; set; } = string.Empty;
public string? ImageUrl { get; set; }
public string? CtaText { get; set; }
public string? BadgeText { get; set; }
public string? PriceText { get; set; }
public string? Category { get; set; }
public bool IsEmpty()
{
return string.IsNullOrWhiteSpace(ProductUrl);
}
public AffiliateAdContent Clone()
{
return new AffiliateAdContent
{
Title = Title,
Description = Description,
ProductUrl = ProductUrl,
ImageUrl = ImageUrl,
CtaText = CtaText,
BadgeText = BadgeText,
PriceText = PriceText,
Category = Category
};
}
}
/// <summary>
/// Configuration for an ad slot, supporting AdSense or affiliate content.
/// </summary>
public class AdSlotConfiguration
{
/// <summary>
/// Provider type for the slot. Supported values: "AdSense", "Affiliate".
/// Defaults to "AdSense" to preserve current behaviour.
/// </summary>
public string Provider { get; set; } = "AdSense";
/// <summary>
/// Optional custom AdSense slot ID override.
/// </summary>
public string? AdSenseSlotId { get; set; }
/// <summary>
/// Affiliate content that will be rendered when <see cref="Provider"/> is "Affiliate".
/// </summary>
public AffiliateAdContent? Affiliate { get; set; }
public AdSlotConfiguration Clone()
{
return new AdSlotConfiguration
{
Provider = Provider,
AdSenseSlotId = AdSenseSlotId,
Affiliate = Affiliate?.Clone()
};
}
}
/// <summary>
/// Root options object bound from configuration for ad slots.
/// </summary>
public class AdsConfigurationOptions
{
private IDictionary<string, AdSlotConfiguration> _slots = new Dictionary<string, AdSlotConfiguration>(StringComparer.OrdinalIgnoreCase);
private IDictionary<string, IDictionary<string, AdSlotConfiguration>> _locales = new Dictionary<string, IDictionary<string, AdSlotConfiguration>>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, AdSlotConfiguration> Slots
{
get => _slots;
set => _slots = CreateSlotDictionary(value);
}
public IDictionary<string, IDictionary<string, AdSlotConfiguration>> Locales
{
get => _locales;
set
{
_locales = new Dictionary<string, IDictionary<string, AdSlotConfiguration>>(StringComparer.OrdinalIgnoreCase);
if (value == null)
{
return;
}
foreach (var locale in value)
{
_locales[locale.Key] = CreateSlotDictionary(locale.Value);
}
}
}
public AdSlotConfiguration GetSlot(string slotKey, string? cultureName = null)
{
if (string.IsNullOrWhiteSpace(slotKey))
{
slotKey = "header";
}
var localeSlot = TryGetLocaleSlot(slotKey, cultureName);
if (localeSlot != null)
{
return localeSlot.Clone();
}
if (_slots.TryGetValue(slotKey, out var config))
{
return config.Clone();
}
// Default to AdSense when not configured
return new AdSlotConfiguration();
}
private AdSlotConfiguration? TryGetLocaleSlot(string slotKey, string? cultureName)
{
if (string.IsNullOrWhiteSpace(cultureName) || _locales.Count == 0)
{
return null;
}
if (_locales.TryGetValue(cultureName, out var localeSlots) && localeSlots.TryGetValue(slotKey, out var slot))
{
return slot;
}
var neutralCulture = cultureName.Split('-')[0];
if (!string.Equals(neutralCulture, cultureName, StringComparison.OrdinalIgnoreCase)
&& _locales.TryGetValue(neutralCulture, out var neutralSlots)
&& neutralSlots.TryGetValue(slotKey, out var neutralSlot))
{
return neutralSlot;
}
return null;
}
private static IDictionary<string, AdSlotConfiguration> CreateSlotDictionary(IDictionary<string, AdSlotConfiguration>? source)
{
var dictionary = new Dictionary<string, AdSlotConfiguration>(StringComparer.OrdinalIgnoreCase);
if (source == null)
{
return dictionary;
}
foreach (var kvp in source)
{
if (kvp.Value != null)
{
dictionary[kvp.Key] = kvp.Value;
}
}
return dictionary;
}
}
/// <summary>
/// View model used by the affiliate ad partial.
/// </summary>
public class AffiliateAdViewModel
{
public string SlotKey { get; set; } = "header";
public string ContainerCssClass { get; set; } = string.Empty;
public AffiliateAdContent Content { get; set; } = new AffiliateAdContent();
}
}

View File

@ -11,5 +11,6 @@ namespace QRRapidoApp.Models
public string Image { get; set; } = string.Empty;
public string Culture { get; set; } = "pt-BR";
public int ReadingTimeMinutes { get; set; }
public string Slug { get; set; } = string.Empty;
}
}

View File

@ -10,6 +10,8 @@ using QRRapidoApp.Data;
using QRRapidoApp.Middleware;
using QRRapidoApp.Providers;
using QRRapidoApp.Services;
using QRRapidoApp.Models.Ads;
using QRRapidoApp.Services.Ads;
using QRRapidoApp.Services.Monitoring;
using QRRapidoApp.Services.HealthChecks;
using StackExchange.Redis;
@ -138,6 +140,8 @@ else
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IDistributedCache, MemoryDistributedCacheWrapper>();
builder.Services.Configure<AdsConfigurationOptions>(builder.Configuration.GetSection("Ads"));
builder.Services.AddScoped<IAdSlotConfigurationProvider, ConfigurationAdSlotProvider>();
// ✅ DataProtection compartilhado via MongoDB (para múltiplas réplicas do Swarm)
if (!string.IsNullOrEmpty(mongoConnectionString))
@ -352,7 +356,7 @@ app.MapHealthChecks("/healthcheck");
// Language routes (must be first)
app.MapControllerRoute(
name: "localized",
pattern: "{culture:regex(^(es-PY)$)}/{controller=Home}/{action=Index}/{id?}");
pattern: "{culture:regex(^(pt-BR|es-PY)$)}/{controller=Home}/{action=Index}/{id?}");
// API routes
app.MapControllerRoute(

View File

@ -2015,4 +2015,20 @@
<data name="VCardUseCase3" xml:space="preserve">
<value>Firma de email profesional</value>
</data>
<!-- Tutorial Section -->
<data name="ViewTutorials" xml:space="preserve">
<value>Ver Tutoriales</value>
</data>
<data name="LearnMore" xml:space="preserve">
<value>Aprende Más</value>
</data>
<data name="CompleteGuidesAboutQRCodes" xml:space="preserve">
<value>Guías completas sobre códigos QR</value>
</data>
<data name="ViewAllTutorials" xml:space="preserve">
<value>Ver Todos los Tutoriales</value>
</data>
<data name="RealEstateAndBrokers" xml:space="preserve">
<value>Inmobiliaria y Corredores</value>
</data>
</root>

View File

@ -2105,4 +2105,20 @@
<data name="VCardUseCase3" xml:space="preserve">
<value>Assinatura de email profissional</value>
</data>
<!-- Tutorial Section -->
<data name="ViewTutorials" xml:space="preserve">
<value>Ver Tutoriais</value>
</data>
<data name="LearnMore" xml:space="preserve">
<value>Aprenda Mais</value>
</data>
<data name="CompleteGuidesAboutQRCodes" xml:space="preserve">
<value>Guias completos sobre QR Codes</value>
</data>
<data name="ViewAllTutorials" xml:space="preserve">
<value>Ver Todos os Tutoriais</value>
</data>
<data name="RealEstateAndBrokers" xml:space="preserve">
<value>Imóveis e Corretores</value>
</data>
</root>

View File

@ -109,10 +109,10 @@ namespace QRRapidoApp.Services
}
}
public void SetViewBagAds(dynamic viewBag)
{
public void SetViewBagAds(dynamic viewBag)
{
viewBag.AdSenseTag = _config["AdSense:ClientId"];
viewBag.AdSenseEnabled = _config["AdSense:Enabled"]=="True";
viewBag.AdSenseEnabled = bool.TryParse(_config["AdSense:Enabled"], out var enabled) && enabled;
}
}
}
}

View File

@ -0,0 +1,49 @@
using System;
using System.Globalization;
using Microsoft.Extensions.Options;
using QRRapidoApp.Models.Ads;
namespace QRRapidoApp.Services.Ads
{
public class ConfigurationAdSlotProvider : IAdSlotConfigurationProvider
{
private readonly IOptionsSnapshot<AdsConfigurationOptions> _options;
public ConfigurationAdSlotProvider(IOptionsSnapshot<AdsConfigurationOptions> options)
{
_options = options;
}
public AdSlotConfiguration GetSlot(string slotKey, string? cultureName = null)
{
var resolvedCulture = cultureName;
if (string.IsNullOrWhiteSpace(resolvedCulture))
{
resolvedCulture = CultureInfo.CurrentUICulture?.Name;
}
var slot = _options.Value.GetSlot(slotKey, resolvedCulture);
if (string.Equals(slot.Provider, "Affiliate", StringComparison.OrdinalIgnoreCase))
{
if (slot.Affiliate == null || slot.Affiliate.IsEmpty())
{
// Fallback gracefully to AdSense if affiliate content is not properly configured.
return new AdSlotConfiguration
{
Provider = "AdSense",
AdSenseSlotId = slot.AdSenseSlotId
};
}
slot.Provider = "Affiliate"; // normalize casing
}
else
{
slot.Provider = "AdSense";
}
return slot;
}
}
}

View File

@ -0,0 +1,9 @@
using QRRapidoApp.Models.Ads;
namespace QRRapidoApp.Services.Ads
{
public interface IAdSlotConfigurationProvider
{
AdSlotConfiguration GetSlot(string slotKey, string? cultureName = null);
}
}

View File

@ -127,6 +127,7 @@ namespace QRRapidoApp.Services
if (article?.Metadata != null)
{
article.Metadata.Culture = culture;
article.Metadata.Slug = slug;
articles.Add(article.Metadata);
}
}
@ -189,6 +190,7 @@ namespace QRRapidoApp.Services
if (article?.Metadata != null)
{
article.Metadata.Culture = culture;
article.Metadata.Slug = slug;
var fileInfo = new FileInfo(file);
article.Metadata.LastMod = fileInfo.LastWriteTimeUtc;
allArticles.Add(article.Metadata);

View File

@ -1207,6 +1207,30 @@
</div>
}
<!-- Tutorials Card -->
@{
var tutorialCulture = System.Globalization.CultureInfo.CurrentUICulture.Name;
}
<div class="card border-primary mb-4">
<div class="card-header bg-primary text-white">
<h6 class="mb-0">
<i class="fas fa-book-open"></i> @Localizer["LearnMore"]
</h6>
</div>
<div class="card-body">
<p class="small mb-2">@Localizer["CompleteGuidesAboutQRCodes"]</p>
<ul class="list-unstyled small mb-3">
<li class="mb-1"><i class="fas fa-check text-primary"></i> WhatsApp Business</li>
<li class="mb-1"><i class="fas fa-check text-primary"></i> @Localizer["WiFi"] Networks</li>
<li class="mb-1"><i class="fas fa-check text-primary"></i> @Localizer["VCard"]</li>
<li class="mb-1"><i class="fas fa-check text-primary"></i> @Localizer["RealEstateAndBrokers"]</li>
</ul>
<a href="/@tutorialCulture/tutoriais" class="btn btn-primary btn-sm w-100">
<i class="fas fa-graduation-cap"></i> @Localizer["ViewAllTutorials"]
</a>
</div>
</div>
<!-- Speed Tips Card -->
<div class="card bg-light mb-4">
<div class="card-header">

View File

@ -1,89 +1,152 @@
@using QRRapidoApp.Services
@using QRRapidoApp.Services.Ads
@using QRRapidoApp.Models.Ads
@using Microsoft.Extensions.Localization
@using System.Globalization
@model dynamic
@inject AdDisplayService AdService
@inject IAdSlotConfigurationProvider SlotProvider
@inject IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
@{
var userId = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var showAds = await AdService.ShouldShowAds(userId);
var position = ViewBag.position ?? Model?.position ?? "header";
var tagAdSense = ViewBag.AdSenseTag;
<!-- AdSense -->
@Html.Raw(ViewBag.AdSenseScript);
var rawPosition = (ViewBag.position ?? Model?.position ?? "header")?.ToString()?.ToLowerInvariant();
var position = string.IsNullOrWhiteSpace(rawPosition) ? "header" : rawPosition;
var currentCulture = CultureInfo.CurrentUICulture?.Name;
var slotConfig = SlotProvider.GetSlot(position, currentCulture);
var adSenseClientId = ViewBag.AdSenseTag as string;
var adSenseEnabled = ViewBag.AdSenseEnabled is bool enabled && enabled;
var defaultAdSenseSlot = position switch
{
"header" => "QR19750801",
"sidebar" => "QR19750802",
"footer" => "QR19750803",
"content" => "QR19750804",
_ => "QR19750801"
};
var adSenseSlot = slotConfig.AdSenseSlotId ?? defaultAdSenseSlot;
var isAffiliateProvider = string.Equals(slotConfig.Provider, "Affiliate", StringComparison.OrdinalIgnoreCase);
var affiliateContent = isAffiliateProvider ? slotConfig.Affiliate : null;
var containerCss = position switch
{
"header" => "ad-container ad-header mb-4",
"sidebar" => "ad-container ad-sidebar mb-4",
"footer" => "ad-container ad-footer mt-5 mb-4",
"content" => "ad-container ad-content my-4",
_ => "ad-container mb-4"
};
var shouldRenderAdSense = !isAffiliateProvider && adSenseEnabled && !string.IsNullOrWhiteSpace(adSenseClientId);
var shouldRenderAffiliate = isAffiliateProvider && affiliateContent != null && !affiliateContent.IsEmpty();
var renderedAdSense = false;
}
@if (showAds)
{
@switch (position)
switch (position)
{
case "header":
<div class="ad-container ad-header mb-4">
<div class="ad-label">@Localizer["Advertisement"]</div>
<ins class="adsbygoogle"
style="display:inline-block;width:728px;height:90px"
data-ad-client="@tagAdSense"
data-ad-slot="QR19750801"></ins>
</div>
if (shouldRenderAffiliate)
{
@await Html.PartialAsync("_AffiliateAd", new AffiliateAdViewModel
{
SlotKey = position,
ContainerCssClass = containerCss,
Content = affiliateContent!
})
}
else if (shouldRenderAdSense)
{
<!-- Placeholder for Adsterra ad - 728x90 Banner -->
<div class="@containerCss" style="display: none;">
<!-- Adsterra code will go here when approved -->
</div>
}
break;
case "sidebar":
<div class="ad-container ad-sidebar mb-4">
<div class="ad-label">@Localizer["Advertisement"]</div>
<ins class="adsbygoogle"
style="display:inline-block;width:300px;height:250px"
data-ad-client="@tagAdSense"
data-ad-slot="QR19750802"></ins>
</div>
if (shouldRenderAffiliate)
{
@await Html.PartialAsync("_AffiliateAd", new AffiliateAdViewModel
{
SlotKey = position,
ContainerCssClass = containerCss,
Content = affiliateContent!
})
}
else if (shouldRenderAdSense)
{
<!-- Placeholder for Adsterra ad - 300x250 Rectangle -->
<div class="@containerCss" style="display: none;">
<!-- Adsterra code will go here when approved -->
</div>
}
break;
case "footer":
<div class="ad-container ad-footer mt-5 mb-4">
<div class="ad-label">@Localizer["Advertisement"]</div>
<ins class="adsbygoogle"
style="display:inline-block;width:728px;height:90px"
data-ad-client="@tagAdSense"
data-ad-slot="QR19750803"></ins>
</div>
if (shouldRenderAffiliate)
{
@await Html.PartialAsync("_AffiliateAd", new AffiliateAdViewModel
{
SlotKey = position,
ContainerCssClass = containerCss,
Content = affiliateContent!
})
}
else if (shouldRenderAdSense)
{
<!-- Placeholder for Adsterra ad - 728x90 Banner -->
<div class="@containerCss" style="display: none;">
<!-- Adsterra code will go here when approved -->
</div>
}
break;
case "content":
<div class="ad-container ad-content my-4">
<div class="ad-label">@Localizer["Advertisement"]</div>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="@tagAdSense"
data-ad-slot="QR19750804"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
</div>
if (shouldRenderAffiliate)
{
@await Html.PartialAsync("_AffiliateAd", new AffiliateAdViewModel
{
SlotKey = position,
ContainerCssClass = containerCss,
Content = affiliateContent!
})
}
else if (shouldRenderAdSense)
{
<!-- Placeholder for Adsterra ad - Responsive -->
<div class="@containerCss" style="display: none;">
<!-- Adsterra code will go here when approved -->
</div>
}
break;
default:
if (shouldRenderAffiliate)
{
@await Html.PartialAsync("_AffiliateAd", new AffiliateAdViewModel
{
SlotKey = position,
ContainerCssClass = containerCss,
Content = affiliateContent!
})
}
else if (shouldRenderAdSense)
{
<!-- Placeholder for Adsterra ad - 728x90 Banner -->
<div class="@containerCss" style="display: none;">
<!-- Adsterra code will go here when approved -->
</div>
}
break;
}
<script defer>
// Lazy load AdSense to improve LCP
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
(adsbygoogle = window.adsbygoogle || []).push({});
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('.adsbygoogle').forEach(ad => observer.observe(ad));
} else {
// Fallback for older browsers
(adsbygoogle = window.adsbygoogle || []).push({});
}
</script>
@* AdSense lazy-load script removed - will be replaced with Adsterra when approved *@
}
else if (User.Identity.IsAuthenticated)
{
var isPremium = await AdService.HasValidPremiumSubscription(userId);
if (isPremium)
{
<!-- Premium User Message -->
<div class="alert alert-success ad-free-notice mb-3">
<i class="fas fa-crown text-warning"></i>
<span><strong>@Localizer["PremiumUserNoAds"]</strong></span>
@ -91,7 +154,6 @@ else if (User.Identity.IsAuthenticated)
}
else
{
<!-- Upgrade to Premium Message -->
<div class="alert alert-info upgrade-notice mb-3">
<i class="fas fa-star text-warning"></i>
<span><strong>@Localizer["UpgradePremiumRemoveAds"]</strong></span>
@ -100,4 +162,4 @@ else if (User.Identity.IsAuthenticated)
</a>
</div>
}
}
}

View File

@ -0,0 +1,97 @@
@model QRRapidoApp.Models.Ads.AffiliateAdViewModel
@inject Microsoft.Extensions.Localization.IStringLocalizer<QRRapidoApp.Resources.SharedResource> Localizer
@{
var content = Model.Content;
var containerClass = string.IsNullOrWhiteSpace(Model.ContainerCssClass)
? "ad-container affiliate-ad"
: $"{Model.ContainerCssClass} affiliate-ad";
var ctaText = string.IsNullOrWhiteSpace(content.CtaText) ? "Ver oferta" : content.CtaText;
var slotKey = Model.SlotKey?.ToLowerInvariant();
var isWideSlot = string.Equals(slotKey, "header") || string.Equals(slotKey, "footer");
var imageStyle = slotKey switch
{
"header" => "max-height:140px;object-fit:contain;width:100%;",
"footer" => "max-height:140px;object-fit:contain;width:100%;",
"sidebar" => "max-height:200px;object-fit:contain;width:100%;",
"content" => "max-height:220px;object-fit:contain;width:100%;",
_ => "max-height:220px;object-fit:contain;width:100%;"
};
var hasImage = !string.IsNullOrWhiteSpace(content.ImageUrl);
if (isWideSlot)
{
containerClass = $"{containerClass} affiliate-ad-wide";
}
}
<div class="@containerClass" data-slot="@Model.SlotKey">
<div class="ad-label">@Localizer["Advertisement"]</div>
@if (isWideSlot)
{
<div class="card border-0 shadow-sm h-100 affiliate-ad-card">
<div class="card-body">
<div class="affiliate-ad-card-content">
@if (hasImage)
{
<div class="affiliate-ad-media">
<img src="@content.ImageUrl" alt="@content.Title" class="affiliate-ad-image img-fluid"
style="@imageStyle" loading="lazy" />
@if (!string.IsNullOrWhiteSpace(content.BadgeText))
{
<span class="affiliate-ad-partner badge bg-warning text-dark small mt-2">@content.BadgeText</span>
}
</div>
}
<div class="affiliate-ad-details">
@if (!hasImage && !string.IsNullOrWhiteSpace(content.BadgeText))
{
<span class="affiliate-ad-partner badge bg-warning text-dark">@content.BadgeText</span>
}
<h6 class="card-title mb-0">@content.Title</h6>
@if (!string.IsNullOrWhiteSpace(content.Description))
{
<p class="card-text text-muted small mb-0">@content.Description</p>
}
@if (!string.IsNullOrWhiteSpace(content.PriceText))
{
<div class="affiliate-ad-price fw-semibold text-success">@content.PriceText</div>
}
<div class="affiliate-ad-actions">
<a href="@content.ProductUrl" target="_blank" rel="noopener sponsored" class="btn btn-sm btn-warning affiliate-ad-cta">
@ctaText
</a>
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="card border-0 shadow-sm h-100 affiliate-ad-card">
@if (hasImage)
{
<img src="@content.ImageUrl" alt="@content.Title" class="card-img-top rounded-top affiliate-ad-image"
style="@imageStyle" loading="lazy" />
}
<div class="card-body text-center">
@if (!string.IsNullOrWhiteSpace(content.BadgeText))
{
<span class="badge bg-warning text-dark small mb-2">@content.BadgeText</span>
}
<h6 class="card-title mb-2">@content.Title</h6>
@if (!string.IsNullOrWhiteSpace(content.Description))
{
<p class="card-text text-muted small mb-2">@content.Description</p>
}
@if (!string.IsNullOrWhiteSpace(content.PriceText))
{
<div class="fw-semibold text-success mb-3">@content.PriceText</div>
}
<a href="@content.ProductUrl" target="_blank" rel="noopener sponsored" class="btn btn-sm btn-warning w-100">
@ctaText
</a>
</div>
</div>
}
</div>

View File

@ -140,14 +140,7 @@
};
</script>
@if (ViewBag.AdSenseEnabled)
{
var tagAdSense = $"https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client={ViewBag.AdSenseTag}";
var adSenseScript = $"";
<!-- AdSense - Optimized with defer and crossorigin -->
<script defer src='@tagAdSense' crossorigin='anonymous'></script>
}
@* AdSense removed - Preparing for Adsterra integration *@
<!-- Bootstrap 5 - Optimized loading -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" media="print" onload="this.media='all'">
@ -328,6 +321,14 @@
<p class="mb-0 opacity-75">
@Localizer["AverageTimePrefix"] <strong>@Localizer["AverageTimeValue"]</strong> @Localizer["AverageTimeSuffix"]
</p>
@{
var currentCulture = System.Globalization.CultureInfo.CurrentUICulture.Name;
}
<div class="mt-3">
<a href="/@currentCulture/tutoriais" class="btn btn-sm btn-light">
<i class="fas fa-graduation-cap"></i> @Localizer["ViewTutorials"]
</a>
</div>
</div>
</section>

View File

@ -43,7 +43,7 @@
</small>
</div>
<a href="/@ViewBag.Culture/tutoriais/@article.Title.ToLower().Replace(" ", "-")"
<a href="/@ViewBag.Culture/tutoriais/@article.Slug"
class="btn btn-primary btn-block">
@(ViewBag.Culture == "pt-BR" ? "Ler Tutorial" : "Leer Tutorial")
<i class="fas fa-arrow-right ml-1"></i>

View File

@ -40,7 +40,72 @@
},
"AdSense": {
"ClientId": "ca-pub-3475956393038764",
"Enabled": true
"Enabled": false
},
"Ads": {
"Slots": {
"header": {
"Provider": "AdSense",
"AdSenseSlotId": "QR19750801"
},
"sidebar": {
"Provider": "AdSense",
"AdSenseSlotId": "QR19750802"
},
"content": {
"Provider": "AdSense",
"AdSenseSlotId": "QR19750804"
},
"footer": {
"Provider": "AdSense",
"AdSenseSlotId": "QR19750803"
}
},
"Locales": {
"pt-BR": {
"sidebar": {
"Provider": "None"
},
"header": {
"Provider": "None"
},
"content": {
"Provider": "None"
},
"footer": {
"Provider": "None"
}
},
"es-PY": {
"sidebar": {
"Provider": "None"
},
"header": {
"Provider": "None"
},
"content": {
"Provider": "None"
},
"footer": {
"Provider": "None"
}
},
"es": {
"sidebar": {
"Provider": "Affiliate",
"Affiliate": {
"Title": "Pack de etiquetas adhesivas para QR",
"Description": "Rollo con 500 etiquetas mate listas para imprimir códigos QR",
"ProductUrl": "https://marketplace-ejemplo.com/afiliados/etiquetas-qr",
"ImageUrl": "https://cdn.example.com/qr-labels-pack.jpg",
"CtaText": "Explorar",
"BadgeText": "Oferta destacada",
"PriceText": "$ 12.90",
"Category": "insumos"
}
}
}
}
},
"Performance": {
"QRGenerationTimeoutMs": 2000,
@ -128,4 +193,4 @@
}
},
"AllowedHosts": "*"
}
}

View File

@ -518,14 +518,129 @@ footer a:hover {
position: relative;
}
.ad-label {
color: #495057 !important;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.ad-label {
color: #495057 !important;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.affiliate-ad-wide {
text-align: left;
}
.affiliate-ad-wide .affiliate-ad-card {
max-width: 100%;
}
.affiliate-ad-wide .affiliate-ad-card .card-body {
padding: 1.5rem;
}
.affiliate-ad-wide .affiliate-ad-card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
width: 100%;
}
.affiliate-ad-wide .affiliate-ad-media {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.affiliate-ad-wide .affiliate-ad-media img {
display: block;
max-width: 240px;
width: 100%;
}
.affiliate-ad-wide .affiliate-ad-partner {
font-weight: 600;
}
.affiliate-ad-wide .affiliate-ad-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
align-items: center;
}
.affiliate-ad-wide .affiliate-ad-details .card-title {
font-size: 1rem;
}
.affiliate-ad-wide .affiliate-ad-price {
font-size: 0.95rem;
}
.affiliate-ad-wide .affiliate-ad-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
align-items: stretch;
}
.affiliate-ad-wide .affiliate-ad-cta {
width: 100%;
}
@media (min-width: 576px) {
.affiliate-ad-wide .affiliate-ad-actions {
align-items: center;
}
}
@media (min-width: 992px) {
.affiliate-ad-wide .affiliate-ad-card {
max-width: 1000px;
margin: 0 auto;
}
.affiliate-ad-wide .affiliate-ad-card .card-body {
padding: 1.75rem 2rem;
}
.affiliate-ad-wide .affiliate-ad-card-content {
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
align-items: center;
column-gap: 2.5rem;
}
.affiliate-ad-wide .affiliate-ad-media {
align-items: flex-start;
text-align: left;
}
.affiliate-ad-wide .affiliate-ad-media img {
max-width: 280px;
}
.affiliate-ad-wide .affiliate-ad-details {
text-align: left;
align-items: flex-start;
}
.affiliate-ad-wide .affiliate-ad-actions {
flex-direction: row;
align-items: flex-start;
width: auto;
gap: 1rem;
}
.affiliate-ad-wide .affiliate-ad-cta {
width: auto;
}
}
/* =================================
PLACEHOLDER QR - CONTRASTE SUPERIOR
@ -1287,4 +1402,4 @@ html[data-theme="dark"] @keyframes buttonPulse {
transform: scale(1.02);
box-shadow: 0 0 10px rgba(96, 165, 250, 0.4);
}
}
}