diff --git a/.gitignore b/.gitignore index 1fc477e..3a285cd 100644 --- a/.gitignore +++ b/.gitignore @@ -380,4 +380,5 @@ temp/ # Certificates *.pfx *.crt -*.key \ No newline at end of file +*.key +wwwroot/dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..edfd3c6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Core MVC code lives in `Controllers`, `Services`, `Models`, and `Middleware`, while Razor views sit in `Views` and static assets in `wwwroot`. Data access and caching helpers are under `Data` and `Providers`. Localized resources (PT-BR, ES, EN) reside in `Resources`. Tests target service logic in `Tests/Services` via `QRRapidoApp.Tests.csproj`. Runtime settings use the `appsettings.*.json` files, and Docker assets, including `docker-compose.yml`, remain at the repository root. + +## Build, Test, and Development Commands +Install dependencies with `dotnet restore`, then run `dotnet build` for a release-ready compile. Use `dotnet run` to launch the Kestrel server locally or `dotnet watch run` for hot reload during UI work. Execute `dotnet test` from the root to run xUnit tests; add `--collect:"XPlat Code Coverage"` when you need coverage reports. For full-stack parity, start infrastructure with `docker-compose up -d` and follow component logs through `docker-compose logs -f qrrapido`. + +## Coding Style & Naming Conventions +Stick to standard C# styling: four-space indentation, PascalCase for types and public members, camelCase for locals, and `I` prefixes for interfaces. Keep services focused; prefer small, testable classes instead of partials. Place shared copy in `Resources` and surface it via `IStringLocalizer`. Run `dotnet format` before committing to normalize imports, analyzer fixes, and whitespace. + +## Testing Guidelines +Keep unit and integration tests close to the subject, e.g., `Tests/Services/QRRapidoServiceTests.cs`. Name classes `Tests` and methods as scenario sentences such as `GenerateRapidAsync_WithValidRequest_ReturnsSuccess`. Use Moq for external dependencies and configure defaults in constructor helpers. Every new behavior should gain at least one happy-path and one guard test, and maintain quick, deterministic fixtures. + +## Commit & Pull Request Guidelines +Follow the Conventional Commit style observed here (`fix:`, `feat:`, `chore:`) with concise Portuguese summaries for user-facing updates. Group related changes per commit to simplify reversions. Pull requests should outline the context, enumerate key changes, attach `dotnet test` output or coverage deltas, and include UI screenshots or GIFs when behavior shifts. Reference Azure or GitHub issue IDs and call out configuration or migration steps prominently. + +## Security & Configuration Tips +Never commit secrets or the contents of `keys/`. Document new configuration values in the PR description and supply safe defaults in `appsettings.Development.json`. Protect added endpoints with existing rate-limiting and authorization middleware, and rotate published test keys after demos. diff --git a/Program.cs b/Program.cs index 518982c..6c49c0e 100644 --- a/Program.cs +++ b/Program.cs @@ -25,7 +25,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.RateLimiting; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Core; -using WebOptimizer; // Fix for WSL path issues - disable StaticWebAssets completely var options = new WebApplicationOptions @@ -96,22 +95,6 @@ Log.Logger = loggerConfig.CreateLogger(); builder.Host.UseSerilog(); // Add services to the container -builder.Services.AddWebOptimizer(pipelines => -{ - pipelines.AddCssBundle( - "/css/app.min.css", - "css/site.css", - "css/qrrapido-theme.css"); - - pipelines.AddJavaScriptBundle( - "/js/app.min.js", - "js/test.js", - "js/simple-opcacity.js", - "js/qr-speed-generator.js", - "js/language-switcher.js", - "js/theme-toggle.js", - "js/cookie-consent.js"); -}); builder.Services.AddControllersWithViews() .AddViewLocalization(Microsoft.AspNetCore.Mvc.Razor.LanguageViewLocationExpanderFormat.Suffix) @@ -316,8 +299,6 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); -app.UseWebOptimizer(); - app.UseStaticFiles(); // Language redirection middleware (before routing) diff --git a/QRRapidoApp.csproj b/QRRapidoApp.csproj index 453aba0..c62bbd9 100644 --- a/QRRapidoApp.csproj +++ b/QRRapidoApp.csproj @@ -9,7 +9,6 @@ - @@ -66,4 +65,10 @@ - \ No newline at end of file + + + + + + + diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index b34598d..3e4952e 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -1284,12 +1284,11 @@ @await Html.PartialAsync("_AdSpace", new { position = "footer" }) - - - - + }); + +} \ No newline at end of file + diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 92d8753..bd4cfa1 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -3,6 +3,11 @@ @using Microsoft.Extensions.Localization @inject AdDisplayService AdService @inject IStringLocalizer Localizer +@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment HostEnvironment + +@{ + var isDevelopment = HostEnvironment?.IsDevelopment() ?? false; +} @@ -49,8 +54,8 @@ - - + + - - - - - + @if (isDevelopment) + { + + + + + + + + } + else + { + + } @await RenderSectionAsync("Scripts", required: false) diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c34846 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "qrrapido-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite --config vite.config.js", + "build": "vite build --config vite.config.js", + "preview": "vite preview --config vite.config.js" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..a62e168 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; + +export default defineConfig(({ mode }) => ({ + root: '.', + publicDir: false, + build: { + target: 'es2020', + outDir: 'wwwroot/dist', + emptyOutDir: true, + assetsDir: '.', + sourcemap: true, + rollupOptions: { + input: 'wwwroot/js/app.entry.js', + output: { + format: 'iife', + entryFileNames: 'app.min.js', + assetFileNames: '[name][extname]' + } + } + } +})); diff --git a/wwwroot/js/app.entry.js b/wwwroot/js/app.entry.js new file mode 100644 index 0000000..2a0c445 --- /dev/null +++ b/wwwroot/js/app.entry.js @@ -0,0 +1,7 @@ +import './simple-opcacity.js'; +import './test.js'; +import './qr-speed-generator.js'; +import './language-switcher.js'; +import './theme-toggle.js'; +import './cookie-consent.js'; +import './performance-optimizations.js'; diff --git a/wwwroot/js/qr-speed-generator.js b/wwwroot/js/qr-speed-generator.js index 8f3cc90..a065dcb 100644 --- a/wwwroot/js/qr-speed-generator.js +++ b/wwwroot/js/qr-speed-generator.js @@ -1,4 +1,4 @@ -// QR Rapido Speed Generator +// QR Rapido Speed Generator class QRRapidoGenerator { constructor() { this.startTime = 0; @@ -42,6 +42,7 @@ class QRRapidoGenerator { this.currentLang = localStorage.getItem('qrrapido-lang') || 'pt-BR'; this.selectedType = null; + this.previousType = null; this.selectedStyle = 'classic'; // Estilo padrão this.contentValid = false; @@ -49,6 +50,7 @@ class QRRapidoGenerator { this.contentDelayTimer = null; this.hasShownContentToast = false; this.buttonReadyState = false; + this.urlPrefix = 'https://'; this.initializeEvents(); this.checkAdFreeStatus(); @@ -132,9 +134,6 @@ class QRRapidoGenerator { this.updateGenerateButton(); }, 200); // Additional URL validation delay } - - // Trigger UX improvements with delay - this.handleContentInputWithDelay(e.target.value); }, 300); }); } @@ -198,6 +197,146 @@ class QRRapidoGenerator { this.generateQRWithTimer(e); }); } + + this.setupUrlFieldHandlers(); + } + + setupUrlFieldHandlers() { + const contentField = document.getElementById('qr-content'); + if (!contentField) return; + + contentField.addEventListener('focus', () => { + if (this.selectedType === 'url') { + if (!contentField.value.trim()) { + contentField.value = this.urlPrefix; + } else { + contentField.value = this.ensureUrlPrefix(contentField.value); + } + + if (this.isProtocolOnly(contentField.value.trim())) { + this.clearValidationError(); + contentField.classList.remove('is-valid', 'is-invalid'); + this.contentValid = false; + this.updateGenerateButton(); + this.setCaretPosition(contentField, this.getProtocolLength(contentField.value)); + } + } + }); + + contentField.addEventListener('paste', (event) => { + if (this.selectedType !== 'url') return; + event.preventDefault(); + const text = (event.clipboardData || window.clipboardData)?.getData('text') || ''; + const normalized = this.normalizeUrlInput(text); + contentField.value = normalized; + this.setCaretPosition(contentField, normalized.length); + this.handleContentChange(normalized); + this.updateGenerateButton(); + }); + + contentField.addEventListener('input', () => { + if (this.selectedType !== 'url') return; + const currentValue = contentField.value; + const sanitized = this.ensureUrlPrefix(currentValue); + if (sanitized !== currentValue) { + const caret = typeof contentField.selectionStart === 'number' ? contentField.selectionStart : sanitized.length; + const delta = sanitized.length - currentValue.length; + contentField.value = sanitized; + const protocolLength = this.getProtocolLength(sanitized); + const newPos = Math.max(protocolLength, caret + delta); + this.setCaretPosition(contentField, newPos); + } + + if (this.isProtocolOnly(contentField.value.trim())) { + this.clearValidationError(); + contentField.classList.remove('is-valid', 'is-invalid'); + } + }); + } + + prefillContentField(type, previousType = null) { + const contentField = document.getElementById('qr-content'); + if (!contentField) return; + + if (type === 'url') { + const returningToUrl = previousType === 'url'; + const hasValue = !!contentField.value.trim(); + + if (!returningToUrl || !hasValue || this.isProtocolOnly(contentField.value.trim())) { + contentField.value = this.urlPrefix; + } else { + contentField.value = this.ensureUrlPrefix(contentField.value); + } + contentField.classList.remove('is-valid', 'is-invalid'); + this.clearValidationError(); + this.contentValid = false; + } else { + contentField.value = ''; + contentField.classList.remove('is-valid', 'is-invalid'); + this.clearValidationError(); + this.contentValid = false; + } + + this.updateGenerateButton(); + } + + isProtocolOnly(value) { + if (!value) return true; + const normalized = value.toString().trim().toLowerCase(); + return normalized === 'https://' || normalized === 'http://'; + } + + normalizeUrlInput(raw) { + if (!raw) { + return this.urlPrefix; + } + + let value = raw.toString().trim(); + if (!value) { + return this.urlPrefix; + } + + const protocolMatch = value.match(/^(https?:\/\/)/i); + let protocol = this.urlPrefix; + if (protocolMatch) { + protocol = protocolMatch[0].toLowerCase(); + value = value.slice(protocolMatch[0].length); + } + + value = value.replace(/^(https?:\/\/)/i, ''); + return protocol + value; + } + + ensureUrlPrefix(value) { + if (!value) { + return this.urlPrefix; + } + + let working = value.toString().trimStart(); + const protocolMatch = working.match(/^(https?:\/\/)/i); + let protocol = this.urlPrefix; + if (protocolMatch) { + protocol = protocolMatch[0].toLowerCase(); + working = working.slice(protocolMatch[0].length); + } + + working = working.replace(/^(https?:\/\/)/i, ''); + return protocol + working; + } + + getProtocolLength(value) { + if (!value) return this.urlPrefix.length; + if (value.startsWith('http://')) return 'http://'.length; + if (value.startsWith('https://')) return 'https://'.length; + return this.urlPrefix.length; + } + + setCaretPosition(input, position) { + if (!input || typeof input.setSelectionRange !== 'function') return; + const pos = Math.max(0, Math.min(position, input.value.length)); + requestAnimationFrame(() => { + input.setSelectionRange(pos, pos); + }); } setupDownloadButtons() { @@ -1597,6 +1736,7 @@ class QRRapidoGenerator { } handleTypeSelection(type) { + const previousType = this.selectedType; this.selectedType = type; // Reset UX improvements flags for new type selection @@ -1610,13 +1750,15 @@ class QRRapidoGenerator { this.removeInitialHighlight(); // Sempre habilitar campos de conteúdo após selecionar tipo this.enableContentFields(type); + this.prefillContentField(type, previousType); // Show guidance toast for the selected type this.showTypeGuidanceToast(type); } else { this.disableAllFields(); } - + this.updateGenerateButton(); + this.previousType = type; } handleStyleSelection(style) { @@ -1626,50 +1768,40 @@ class QRRapidoGenerator { handleContentChange(content) { const contentField = document.getElementById('qr-content'); - this.contentValid = this.validateContent(content); - + const trimmedContent = typeof content === 'string' ? content.trim() : ''; + + if (this.selectedType === 'url' && this.isProtocolOnly(trimmedContent)) { + this.contentValid = false; + } else { + this.contentValid = this.validateContent(content); + } + // Feedback visual para campo de conteúdo if (contentField) { - this.validateField(contentField, this.contentValid, window.QRRapidoTranslations?.validationContentMinLength || 'Content must have at least 3 characters'); + if (this.selectedType === 'url') { + contentField.classList.remove('is-valid'); + if (!trimmedContent || this.isProtocolOnly(trimmedContent)) { + contentField.classList.remove('is-invalid'); + } + } else { + this.validateField(contentField, this.contentValid, window.QRRapidoTranslations?.validationContentMinLength || 'Content must have at least 3 characters'); + } } - + this.updateGenerateButton(); } // UX Improvements - Intelligent delay system handleContentInputWithDelay(content) { - // Clear existing timer + // UX delay desabilitado para evitar correções automáticas tardias if (this.contentDelayTimer) { clearTimeout(this.contentDelayTimer); } - - // Only proceed with delay logic if we have valid content and selected type - if (this.selectedType && this.validateContent(content) && !this.hasShownContentToast) { - this.contentDelayTimer = setTimeout(() => { - this.triggerContentReadyUX(); - }, 7000); // 7 seconds delay after the initial 300ms debounce - } } triggerContentReadyUX() { - if (this.selectedType && this.contentValid && !this.hasShownContentToast) { - // Mark as shown to prevent multiple toasts - this.hasShownContentToast = true; - - const contentField = document.getElementById('qr-content'); - const originalValue = contentField.value.trim(); - const fixedValue = this.autoFixURL(originalValue); - if (originalValue !== fixedValue) { - contentField.value = fixedValue; - this.updateGenerateButton(); - } - - // Show educational toast - this.showContentAddedToast(); - - // Update button state with ready indicator - this.updateGenerateButtonToReady(); - } + // Função mantida por compatibilidade, sem efeitos colaterais + return; } showContentAddedToast() { @@ -1995,12 +2127,12 @@ class QRRapidoGenerator { const contentField = document.getElementById('qr-content'); const content = contentField?.value || ''; const trimmedContent = content.trim(); + const protocolOnly = this.isProtocolOnly(trimmedContent); - if (!trimmedContent) { - this.showValidationError('URL é obrigatória'); + if (!trimmedContent || protocolOnly) { + this.clearValidationError(); if (contentField) { - contentField.classList.remove('is-valid'); - contentField.classList.add('is-invalid'); + contentField.classList.remove('is-valid', 'is-invalid'); } isValid = false; } else if (!this.isValidURL(trimmedContent)) { @@ -3508,4 +3640,4 @@ window.trackUpgradeClick = function(location) { 'click_location': location }); } -}; \ No newline at end of file +}; diff --git a/wwwroot/js/simple-opcacity.js b/wwwroot/js/simple-opcacity.js index e6dde53..e714e65 100644 --- a/wwwroot/js/simple-opcacity.js +++ b/wwwroot/js/simple-opcacity.js @@ -1,4 +1,4 @@ -class SimpleOpacityController { +class SimpleOpacityController { constructor(controlSelector, targetSelector) { this.controlSelector = controlSelector; this.targetSelector = targetSelector; @@ -61,3 +61,6 @@ return false; } } +if (typeof window !== 'undefined') { + window.SimpleOpacityController = SimpleOpacityController; +}