├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── .gitignore ├── .eslintrc.json ├── .github └── workflows │ └── lint.yml ├── package.json ├── LICENSE ├── js ├── utils.js ├── translations.js ├── logger.js ├── qr-generator.js └── app.js ├── VALIDATION.md ├── README.md ├── css └── style.css ├── CHANGELOG.md └── index.html /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | *.min.js 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | *.min.js 5 | *.min.css 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 4, 7 | "useTabs": false, 8 | "arrowParens": "always", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | 5 | # OS 6 | .DS_Store 7 | Thumbs.db 8 | 9 | # IDE 10 | .vscode/ 11 | .idea/ 12 | *.swp 13 | *.swo 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Temporary files 20 | *.tmp 21 | *.temp 22 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": "latest", 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | "indent": ["error", 4], 13 | "linebreak-style": ["error", "unix"], 14 | "quotes": ["error", "single"], 15 | "semi": ["error", "always"], 16 | "no-unused-vars": "warn", 17 | "no-console": "off", 18 | "no-undef": "warn" 19 | }, 20 | "globals": { 21 | "QRCode": "readonly" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Code 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '18' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run ESLint 26 | run: npm run lint 27 | continue-on-error: true 28 | 29 | - name: Run Prettier check 30 | run: npx prettier --check "js/**/*.js" "*.html" "*.md" 31 | continue-on-error: true 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qr-code-generator", 3 | "version": "2.0.0", 4 | "description": "Free QR Code Generator with customization options", 5 | "type": "module", 6 | "scripts": { 7 | "lint": "eslint js/**/*.js", 8 | "lint:fix": "eslint js/**/*.js --fix", 9 | "format": "prettier --write \"js/**/*.js\" \"*.html\" \"*.md\"", 10 | "check": "npm run lint && npm run format" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/j2teamnnl/qr-code-generator.git" 15 | }, 16 | "keywords": [ 17 | "qr-code", 18 | "qr-generator", 19 | "javascript", 20 | "tailwindcss", 21 | "open-source" 22 | ], 23 | "author": "j2teamnnl", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "eslint": "^8.57.0", 27 | "prettier": "^3.2.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 QR Code Generator Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | // Utility functions 2 | 3 | // Dark mode detection and management 4 | const ThemeManager = { 5 | init() { 6 | // Check system preference 7 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 8 | const savedTheme = localStorage.getItem('darkMode'); 9 | 10 | // Priority: saved preference > system preference 11 | const isDark = savedTheme !== null ? savedTheme === 'true' : prefersDark; 12 | 13 | this.setTheme(isDark); 14 | 15 | // Listen for system theme changes 16 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { 17 | if (localStorage.getItem('darkMode') === null) { 18 | this.setTheme(e.matches); 19 | } 20 | }); 21 | }, 22 | 23 | setTheme(isDark) { 24 | document.documentElement.classList.toggle('dark', isDark); 25 | const icon = document.getElementById('darkModeIcon'); 26 | if (icon) { 27 | icon.textContent = isDark ? '☀️' : '🌙'; 28 | } 29 | }, 30 | 31 | toggle() { 32 | const isDark = !document.documentElement.classList.contains('dark'); 33 | this.setTheme(isDark); 34 | localStorage.setItem('darkMode', isDark); 35 | }, 36 | }; 37 | 38 | // Language management 39 | const LanguageManager = { 40 | current: 'vi', 41 | 42 | init() { 43 | const saved = localStorage.getItem('language') || 'vi'; 44 | this.switch(saved); 45 | }, 46 | 47 | switch(lang) { 48 | this.current = lang; 49 | document.documentElement.lang = lang; 50 | localStorage.setItem('language', lang); 51 | 52 | const langText = document.getElementById('langText'); 53 | if (langText) { 54 | langText.textContent = lang.toUpperCase(); 55 | } 56 | 57 | this.updateUI(); 58 | }, 59 | 60 | toggle() { 61 | this.switch(this.current === 'vi' ? 'en' : 'vi'); 62 | }, 63 | 64 | translate(key) { 65 | const { translations } = window; 66 | if (!translations) return key; 67 | return translations[this.current][key] || key; 68 | }, 69 | 70 | updateUI() { 71 | const { translations } = window; 72 | if (!translations) return; 73 | 74 | document.querySelectorAll('[data-i18n]').forEach(el => { 75 | const key = el.getAttribute('data-i18n'); 76 | if (translations[this.current][key]) { 77 | if (el.tagName === 'INPUT' && el.placeholder !== undefined) { 78 | el.placeholder = translations[this.current][key]; 79 | } else { 80 | el.textContent = translations[this.current][key]; 81 | } 82 | } 83 | }); 84 | 85 | // Handle placeholder translations separately 86 | document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { 87 | const key = el.getAttribute('data-i18n-placeholder'); 88 | if (translations[this.current][key]) { 89 | el.placeholder = translations[this.current][key]; 90 | } 91 | }); 92 | 93 | // Re-render dynamic fields if needed 94 | if (typeof updateFields === 'function') { 95 | updateFields(); 96 | } 97 | }, 98 | }; 99 | 100 | export { ThemeManager, LanguageManager }; 101 | -------------------------------------------------------------------------------- /VALIDATION.md: -------------------------------------------------------------------------------- 1 | # 🔍 Validation System - Đa ngôn ngữ 2 | 3 | ## ✨ Tính năng 4 | 5 | ### Smart Validation (v2.2.0) 6 | - ✅ **Validate on blur** - Chỉ validate khi focus ra ngoài input 7 | - ✅ **No realtime validation** - Không validate khi đang gõ (tránh làm phiền) 8 | - ✅ **Enter key support** - Nhấn Enter để validate ngay 9 | - ✅ **Auto QR generation** - Tự động tạo QR sau khi validate thành công 10 | - ✅ Visual feedback: border colors & error messages 11 | 12 | ### Đa ngôn ngữ (i18n) 13 | - ✅ Error messages hỗ trợ Tiếng Việt & English 14 | - ✅ Tự động switch theo ngôn ngữ hiện tại 15 | - ✅ Dùng translation keys thay vì hardcode 16 | 17 | ## 📝 Validators 18 | 19 | ### URL 20 | ```javascript 21 | validators.url(value) 22 | // ✓ https://example.com 23 | // ✓ example.com → auto-add https:// 24 | // ✗ invalid-url 25 | ``` 26 | **Error key**: `error_url_invalid` 27 | 28 | ### Email 29 | ```javascript 30 | validators.email(value) 31 | // ✓ user@example.com → lowercase 32 | // ✗ invalid@email 33 | ``` 34 | **Error key**: `error_email_invalid` 35 | 36 | ### Phone 37 | ```javascript 38 | validators.phone(value) 39 | // ✓ +84 123 456 789 → clean spaces 40 | // ✗ abc123 41 | ``` 42 | **Error key**: `error_phone_invalid` 43 | 44 | ### WhatsApp 45 | ```javascript 46 | validators.whatsapp(value) 47 | // ✓ +84123456789 48 | // ✗ 123456 (cần country code) 49 | ``` 50 | **Error key**: `error_whatsapp_invalid` 51 | 52 | ### Social Media 53 | - **TikTok**: `@username` hoặc full URL → `error_tiktok_invalid` 54 | - **Instagram**: `@username` hoặc full URL → `error_instagram_invalid` 55 | - **Telegram**: `@username` hoặc `t.me/username` → `error_telegram_invalid` 56 | - **Spotify**: Full URL → `error_spotify_invalid` 57 | 58 | ## 🔧 Cách thêm validator mới 59 | 60 | ### 1. Thêm validator function 61 | ```javascript 62 | // js/app.js 63 | const validators = { 64 | myValidator: (value) => { 65 | if (!isValid(value)) { 66 | return { valid: false, messageKey: 'error_my_validator' }; 67 | } 68 | return { valid: true, processed: cleanValue }; 69 | } 70 | } 71 | ``` 72 | 73 | ### 2. Thêm translation keys 74 | ```javascript 75 | // js/translations.js 76 | vi: { 77 | error_my_validator: 'Lỗi tiếng Việt' 78 | }, 79 | en: { 80 | error_my_validator: 'Error in English' 81 | } 82 | ``` 83 | 84 | ### 3. Map validator trong validateStep1() 85 | ```javascript 86 | if (fieldName === 'myfield') { 87 | validator = validators.myValidator; 88 | } 89 | ``` 90 | 91 | ## 📊 Flow hoạt động 92 | 93 | ``` 94 | User nhập input 95 | ↓ 96 | Debounce 300ms 97 | ↓ 98 | validateStep1() 99 | ↓ 100 | Gọi validator(value) 101 | ↓ 102 | { valid, messageKey, processed } 103 | ↓ 104 | Translate messageKey → error message 105 | ↓ 106 | Show error + visual feedback 107 | ↓ 108 | Enable/Disable Next button 109 | ``` 110 | 111 | ## 🎯 Test Cases 112 | 113 | ### Test đa ngôn ngữ 114 | 1. Nhập URL sai → Error tiếng Việt 115 | 2. Switch sang English → Error chuyển sang English 116 | 3. Switch lại Việt → Error chuyển lại tiếng Việt 117 | 118 | ### Test validation 119 | 1. URL: `example` → ✗ Error 120 | 2. URL: `example.com` → ✓ Auto-add https:// 121 | 3. Email: `test@test.com` → ✓ Lowercase 122 | 4. Phone: `+84 123 456` → ✓ Clean spaces 123 | 5. Social: `@username` → ✓ Convert to full URL 124 | 125 | ## 📁 Files liên quan 126 | 127 | - `js/app.js` (dòng 34-139): Validators 128 | - `js/app.js` (dòng 495-575): validateStep1() 129 | - `js/translations.js` (dòng 73-82, 154-163): Error messages 130 | - `js/utils.js` (dòng 64-68): LanguageManager.translate() 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎯 QR Code Generator 2 | 3 |
4 | 5 | ![Version](https://img.shields.io/badge/version-2.1.0-blue.svg) 6 | ![License](https://img.shields.io/badge/license-MIT-green.svg) 7 | ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) 8 | 9 | **Công cụ tạo mã QR miễn phí, mã nguồn mở với nhiều tùy chọn tùy chỉnh** 10 | 11 |
12 | 13 | --- 14 | 15 | ## ✨ Tính năng 16 | 17 | ### 📱 Hỗ trợ nhiều loại dữ liệu 18 | 19 | Tạo QR code cho nhiều mục đích khác nhau: 20 | 21 | - **URL** - Liên kết website 22 | - **Plain Text** - Văn bản thuần 23 | - **Email** - Địa chỉ email 24 | - **Phone** - Số điện thoại 25 | - **SMS** - Tin nhắn SMS 26 | - **WiFi Login** - Thông tin đăng nhập WiFi 27 | - **WhatsApp** - Số WhatsApp 28 | - **YouTube** - Kênh/Video YouTube 29 | - **Instagram** - Tài khoản Instagram 30 | - **LinkedIn** - Profile LinkedIn 31 | - **Facebook** - Trang Facebook 32 | - **X (Twitter)** - Tài khoản X 33 | - **Discord** - Server/User Discord 34 | - **Telegram** - Tài khoản Telegram 35 | - **TikTok** - Tài khoản TikTok 36 | - **Spotify** - Playlist/Artist Spotify 37 | 38 | ### 🎨 Tùy chỉnh nâng cao 39 | 40 | - **📏 Kích thước QR** - Tùy chỉnh size từ 100px đến 1000px (khuyến nghị: 200-500px cho web, 300-800px cho in ấn) 41 | - **📷 Logo tùy chỉnh** - Upload ảnh logo để đặt ở giữa QR code (khuyến nghị: ảnh vuông 1:1, tối thiểu 200x200px) 42 | - **✏️ Text tùy chỉnh** - Thêm text với màu sắc tùy chọn 43 | - **🌈 Màu sắc** - Tùy chỉnh màu QR và màu nền với kiểm tra độ tương phản 44 | - **📊 Google Campaign Tracking** - Tự động thêm tham số UTM cho marketing 45 | - **⚡ Smart Validation** - Validate khi blur/nhấn Enter, không làm phiền khi đang gõ 46 | - **🌙 Dark Mode** - Tự động nhận diện theo hệ thống hoặc chọn thủ công 47 | - **🌐 Đa ngôn ngữ** - Tiếng Việt & English (100% i18n) 48 | - **💾 Export đa dạng** - PNG, SVG, PDF (bao gồm logo/text) 49 | - **🐛 Error Reporting** - Hệ thống báo lỗi tích hợp, tracking user activities 50 | 51 | ### 🚀 Ưu điểm 52 | 53 | - ✅ **Miễn phí 100%** - Không giới hạn số lượng QR code 54 | - ✅ **Bảo mật** - Xử lý hoàn toàn trên trình duyệt, không lưu trữ dữ liệu 55 | - ✅ **Không cần cài đặt** - Chạy trực tiếp trên trình duyệt 56 | - ✅ **Responsive** - Hoạt động tốt trên desktop & mobile 57 | - ✅ **Mobile Optimized** - Download & preview QR tối ưu cho điện thoại 58 | - ✅ **Icon Fallback** - Emoji fallback nếu CDN bị chặn 59 | - ✅ **Open Source** - Mã nguồn mở, có thể tùy chỉnh 60 | 61 | --- 62 | 63 | ## 🚀 Bắt đầu 64 | 65 | ### 🌐 Sử dụng trực tiếp 66 | 67 | **Live Demo:** [https://j2teamnnl.github.io/qr-code-generator](https://j2teamnnl.github.io/qr-code-generator) 68 | 69 | --- 70 | 71 | ## 📖 Hướng dẫn sử dụng 72 | 73 | ### Tạo QR Code nhanh chóng - Single Page! 74 | 75 | Không còn step wizard phức tạp, tất cả trên 1 trang: 76 | 77 | 1. **Chọn loại dữ liệu** 78 | - Click vào 1 trong các card: URL, Text, Email, WhatsApp, Instagram, v.v. 79 | 80 | 2. **Nhập thông tin** 81 | - Điền thông tin tương ứng vào form 82 | - VD: URL, email, số điện thoại, username, v.v. 83 | 84 | 3. **Tùy chỉnh (tuỳ chọn)** 85 | - Chọn màu sắc QR code và background 86 | - Thêm logo (auto-crop vuông 1:1) hoặc text ở giữa 87 | - Bật UTM tracking cho URL marketing 88 | 89 | 4. **Live Preview** 90 | - QR code tự động hiển thị khi bạn nhập/thay đổi thông tin 91 | - Không cần bấm nút Generate - tự động update! 92 | 93 | 5. **Download** 94 | - Chọn format: PNG, SVG, hoặc PDF 95 | - Tải về máy tuỳ ý 96 | 97 | ### 🐛 Báo lỗi 98 | 99 | - Nút "🐛 Báo Lỗi" floating ở góc phải màn hình 100 | - Tự động tracking tất cả hành động của bạn 101 | - Copy report và gửi qua GitHub Issues hoặc Messenger 102 | 103 | ## 🛠️ Công nghệ 104 | 105 | | Công nghệ | Mục đích | 106 | | ------------------------ | -------------------- | 107 | | **HTML5** | Cấu trúc trang web | 108 | | **JavaScript (Vanilla)** | Logic xử lý | 109 | | **Tailwind CSS** | Styling & UI | 110 | | **QRCode.js** | Thư viện tạo QR code | 111 | | **jsPDF** | Export PDF | 112 | 113 | --- 114 | 115 | ## 📁 Cấu trúc dự án 116 | 117 | ``` 118 | qr-code-generator/ 119 | ├── index.html # File HTML chính 120 | ├── js/ 121 | │ ├── app.js # Logic chính, event handlers 122 | │ ├── qr-generator.js # QR generation & styling 123 | │ ├── translations.js # Multi-language data 124 | │ ├── utils.js # Theme, Language, Wizard controllers 125 | │ └── logger.js # Activity logger & error reporting 126 | ├── css/ 127 | │ └── style.css # Custom styles, responsive 128 | ├── package.json # Dependencies & scripts 129 | ├── .eslintrc.json # ESLint config 130 | ├── .prettierrc.json # Prettier config 131 | ├── README.md # Tài liệu 132 | ├── CHANGELOG.md # Lịch sử thay đổi 133 | ├── TODO.md # Pending tasks 134 | └── LICENSE # MIT License 135 | ``` 136 | 137 | --- 138 | 139 | ## 🛠️ Development 140 | 141 | ### Setup 142 | 143 | ```bash 144 | # Clone repository 145 | git clone https://github.com/j2teamnnl/qr-code-generator.git 146 | cd qr-code-generator 147 | 148 | # Install dependencies 149 | npm install 150 | ``` 151 | 152 | ### Code Quality 153 | 154 | ```bash 155 | # Run ESLint 156 | npm run lint 157 | 158 | # Auto-fix ESLint errors 159 | npm run lint:fix 160 | 161 | # Format code with Prettier 162 | npm run format 163 | 164 | # Run all checks 165 | npm run check 166 | ``` 167 | 168 | ### ESLint & Prettier 169 | 170 | - **ESLint**: Kiểm tra lỗi code, coding standards 171 | - **Prettier**: Auto-format code theo chuẩn 172 | - Config files: `.eslintrc.json`, `.prettierrc.json` 173 | - Chạy `npm run lint:fix` trước khi commit 174 | 175 | --- 176 | 177 | ## 🤝 Đóng góp 178 | 179 | Chúng tôi rất hoan nghênh mọi đóng góp! 180 | 181 | --- 182 | 183 | ## 📄 License 184 | 185 | Dự án này được phân phối dưới **MIT License** - xem file [LICENSE](LICENSE) để biết thêm chi tiết. 186 | 187 | --- 188 | 189 | ## 💖 Cảm ơn 190 | 191 | - Windsurf - Claude Sonnet 192 | - [QRCode.js](https://github.com/davidshimjs/qrcodejs) - Thư viện tạo QR code 193 | - [Tailwind CSS](https://tailwindcss.com/) - CSS framework 194 | - Tất cả contributors đã đóng góp cho dự án 195 | 196 | --- 197 | 198 |
199 | 200 | **⭐ Nếu thấy hữu ích, hãy star repo này! ⭐** 201 | 202 | Vibe code with ❤️ by [J2TeamNNL](https://github.com/j2teamnnl) 203 | 204 |
205 | -------------------------------------------------------------------------------- /js/translations.js: -------------------------------------------------------------------------------- 1 | // Translations data 2 | const translations = { 3 | vi: { 4 | title: 'QR Code Generator', 5 | subtitle: 'Tạo mã QR miễn phí với nhiều tùy chọn tùy chỉnh', 6 | 7 | // Headers 8 | select_data_type: 'Chọn loại dữ liệu', 9 | customize_qr: 'Tùy chỉnh giao diện QR', 10 | tab_data_input: 'Nhập dữ liệu', 11 | tab_customize: 'Tùy chỉnh', 12 | 13 | // QR Colors 14 | qr_colors: 'Màu sắc QR Code:', 15 | qr_color: 'Màu QR:', 16 | bg_color: 'Màu nền:', 17 | color_warning: '⚠️ Lưu ý: Dùng màu có độ tương phản cao (đen/trắng) để đảm bảo quét được tốt nhất. Màu sáng hoặc màu tương tự nhau có thể làm giảm khả năng scan.', 18 | 19 | // QR Size 20 | qr_size: 'Kích thước QR Code:', 21 | qr_size_input: 'Nhập kích thước (px):', 22 | qr_size_hint: 'Khuyến nghị: 200-500px cho web, 300-800px cho in ấn', 23 | 24 | // Center customization 25 | customize_center: 'Tùy chỉnh giữa QR:', 26 | add_logo: '📷 Thêm Ảnh', 27 | add_logo_hint: 'Gợi ý: Ảnh vuông (1:1), tối thiểu 200x200px, nền trong suốt tốt nhất', 28 | add_text: '✏️ Thêm Văn bản', 29 | no_add: '🚫 Không thêm gì', 30 | none: 'Không thêm gì', 31 | enter_text: 'Nhập text...', 32 | text_color: 'Màu text:', 33 | 34 | // Campaign 35 | advanced_settings: 'Cài đặt nâng cao', 36 | campaign_desc: 'Thêm UTM parameters', 37 | 38 | // Export 39 | export_format: 'Chọn định dạng file:', 40 | qr_preview: 'Xem trước QR code:', 41 | qr_preview_here: 'QR code sẽ hiển thị ở đây', 42 | 43 | // Field Labels 44 | field_url: 'URL', 45 | field_text: 'Văn bản', 46 | field_email: 'Email', 47 | field_phone: 'Số điện thoại', 48 | field_message: 'Tin nhắn', 49 | field_ssid: 'Tên WiFi (SSID)', 50 | field_password: 'Mật khẩu', 51 | field_security: 'Bảo mật', 52 | field_username: 'Tên người dùng', 53 | field_file: 'Chọn file', 54 | field_address: 'Địa chỉ', 55 | field_invite: 'Mã mời Discord', 56 | 57 | // Placeholders 58 | placeholder_url: 'https://example.com', 59 | placeholder_text: 'Nhập văn bản...', 60 | placeholder_email: 'example@email.com', 61 | placeholder_phone: '+84123456789', 62 | placeholder_message: 'Nội dung tin nhắn', 63 | placeholder_ssid: 'My WiFi', 64 | placeholder_password: 'password123', 65 | placeholder_username: 'username', 66 | placeholder_address: '123 Đường ABC, TP.HCM', 67 | placeholder_invite: 'abc123xyz', 68 | 69 | // Validation Errors 70 | error_url_invalid: 'URL không hợp lệ. Vui lòng nhập đúng định dạng: https://example.com', 71 | error_email_invalid: 'Email không hợp lệ', 72 | error_phone_invalid: 'Số điện thoại không hợp lệ', 73 | error_whatsapp_invalid: 'Số WhatsApp không hợp lệ. Vui lòng bao gồm mã quốc gia (+84...)', 74 | error_username_invalid: 'Vui lòng nhập username hoặc link profile hợp lệ', 75 | error_tiktok_invalid: 'Vui lòng nhập username TikTok hoặc link profile', 76 | error_instagram_invalid: 'Vui lòng nhập username Instagram hoặc link profile', 77 | error_telegram_invalid: 'Vui lòng nhập username Telegram', 78 | error_spotify_invalid: 'Vui lòng nhập link Spotify hợp lệ', 79 | 80 | // Alert messages 81 | alert_please_fill: 'Vui lòng nhập đầy đủ thông tin!', 82 | alert_data_too_long: 'Dữ liệu quá dài! Vui lòng rút ngắn nội dung.', 83 | alert_logo_load_failed: 'Không thể tải logo. Vui lòng thử file khác.', 84 | alert_image_process_failed: 'Không thể xử lý ảnh. Vui lòng thử file khác.', 85 | 86 | // Logo processing messages 87 | logo_processing: '⏳ Đang xử lý ảnh...', 88 | logo_cropped: '✓ Ảnh đã được crop vuông:', 89 | logo_error: '❌ Lỗi xử lý ảnh', 90 | 91 | // Error report 92 | error_report_title: 'Báo Lỗi', 93 | error_report_desc: 'Dưới đây là thông tin chi tiết về hoạt động của bạn. Hãy copy và gửi cho chúng tôi!', 94 | error_report_copy: 'Copy Báo Cáo', 95 | error_report_close: 'Đóng', 96 | error_report_copied: 'Đã Copy!', 97 | error_report_button: 'Báo Lỗi', 98 | error_report_button_title: 'Báo lỗi hoặc gửi feedback', 99 | }, 100 | en: { 101 | title: 'QR Code Generator', 102 | subtitle: 'Create free QR codes with multiple customization options', 103 | 104 | // Headers 105 | select_data_type: 'Select data type', 106 | customize_qr: 'Customize QR appearance', 107 | tab_data_input: 'Data Input', 108 | tab_customize: 'Customize', 109 | 110 | // QR Colors 111 | qr_colors: 'QR Code Colors:', 112 | qr_color: 'QR Color:', 113 | bg_color: 'Background Color:', 114 | color_warning: '⚠️ Note: Use high contrast colors (black/white) for best scanability. Light or similar colors may reduce scanning ability.', 115 | 116 | // QR Size 117 | qr_size: 'QR Code Size:', 118 | qr_size_input: 'Enter size (px):', 119 | qr_size_hint: 'Recommended: 200-500px for web, 300-800px for print', 120 | 121 | // Center customization 122 | customize_center: 'Customize center:', 123 | add_logo: '📷 Add Logo', 124 | add_logo_hint: 'Tip: Square image (1:1), minimum 200x200px, transparent background recommended', 125 | add_text: '✏️ Add Text', 126 | no_add: '🚫 None', 127 | none: 'None', 128 | enter_text: 'Enter text...', 129 | text_color: 'Text color:', 130 | 131 | // Campaign 132 | advanced_settings: 'Advanced Settings', 133 | campaign_desc: 'Add UTM parameters', 134 | 135 | // Export 136 | export_format: 'Choose file format:', 137 | qr_preview: 'QR code Preview:', 138 | qr_preview_here: 'QR code will be displayed here', 139 | 140 | // Field Labels 141 | field_url: 'URL', 142 | field_text: 'Text', 143 | field_email: 'Email', 144 | field_phone: 'Phone Number', 145 | field_message: 'Message', 146 | field_ssid: 'WiFi Name (SSID)', 147 | field_password: 'Password', 148 | field_security: 'Security', 149 | field_username: 'Username', 150 | field_file: 'Choose file', 151 | field_address: 'Address', 152 | field_invite: 'Discord Invite Code', 153 | 154 | // Placeholders 155 | placeholder_url: 'https://example.com', 156 | placeholder_text: 'Enter text...', 157 | placeholder_email: 'example@email.com', 158 | placeholder_phone: '+1234567890', 159 | placeholder_message: 'Message content', 160 | placeholder_ssid: 'My WiFi', 161 | placeholder_password: 'password123', 162 | placeholder_username: 'username', 163 | placeholder_address: '123 Main St, City', 164 | placeholder_invite: 'abc123xyz', 165 | 166 | // Validation Errors 167 | error_url_invalid: 'Invalid URL. Please enter correct format: https://example.com', 168 | error_email_invalid: 'Invalid email address', 169 | error_phone_invalid: 'Invalid phone number', 170 | error_whatsapp_invalid: 'Invalid WhatsApp number. Please include country code (+1...)', 171 | error_username_invalid: 'Please enter valid username or profile link', 172 | error_tiktok_invalid: 'Please enter TikTok username or profile link', 173 | error_instagram_invalid: 'Please enter Instagram username or profile link', 174 | error_telegram_invalid: 'Please enter Telegram username', 175 | error_spotify_invalid: 'Please enter valid Spotify link', 176 | 177 | // Alert messages 178 | alert_please_fill: 'Please fill in all required information!', 179 | alert_data_too_long: 'Data is too long! Please shorten the content.', 180 | alert_logo_load_failed: 'Failed to load logo. Please try another file.', 181 | alert_image_process_failed: 'Failed to process image. Please try another file.', 182 | 183 | // Logo processing messages 184 | logo_processing: '⏳ Processing image...', 185 | logo_cropped: '✓ Image cropped to square:', 186 | logo_error: '❌ Image processing error', 187 | 188 | // Error report 189 | error_report_title: 'Error Report', 190 | error_report_desc: 'Below is detailed information about your activity. Please copy and send it to us!', 191 | error_report_copy: 'Copy Report', 192 | error_report_close: 'Close', 193 | error_report_copied: 'Copied!', 194 | error_report_button: 'Report Error', 195 | error_report_button_title: 'Report error or send feedback', 196 | }, 197 | }; 198 | 199 | export { translations }; 200 | -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | /* QR Code Generator - Custom Styles */ 2 | 3 | /* CSS Variables */ 4 | :root { 5 | --bg-light-from: #f8fafc; 6 | --bg-light-via: #dbeafe; 7 | --bg-light-to: #e0e7ff; 8 | --bg-dark-from: #1e293b; 9 | --bg-dark-via: #0f172a; 10 | --bg-dark-to: #1e1b4b; 11 | } 12 | 13 | /* Body Backgrounds */ 14 | body { 15 | transition: background 0.3s ease, color 0.3s ease; 16 | background: linear-gradient(135deg, var(--bg-light-from) 0%, var(--bg-light-via) 50%, var(--bg-light-to) 100%) !important; 17 | min-height: 100vh; 18 | } 19 | 20 | .dark body { 21 | background: linear-gradient(135deg, var(--bg-dark-from) 0%, var(--bg-dark-via) 50%, var(--bg-dark-to) 100%) !important; 22 | } 23 | 24 | /* Light Mode - Default Styles */ 25 | .card-bg { 26 | background-color: #ffffff !important; 27 | border-color: #e5e7eb !important; 28 | } 29 | 30 | .input-bg, 31 | input[type="text"], 32 | input[type="email"], 33 | input[type="url"], 34 | input[type="tel"], 35 | input[type="file"], 36 | select, 37 | textarea { 38 | background-color: #ffffff !important; 39 | border-color: #d1d5db !important; 40 | color: #111827 !important; 41 | } 42 | 43 | input::placeholder, 44 | textarea::placeholder { 45 | color: #6b7280 !important; 46 | } 47 | 48 | /* Light mode labels and text */ 49 | label { 50 | color: #374151 !important; 51 | } 52 | 53 | .text-gray-600 { 54 | color: #4b5563 !important; 55 | } 56 | 57 | .text-gray-700 { 58 | color: #374151 !important; 59 | } 60 | 61 | .text-gray-800 { 62 | color: #1f2937 !important; 63 | } 64 | 65 | /* Dark Mode */ 66 | .dark { 67 | color-scheme: dark; 68 | color: #e5e7eb; 69 | } 70 | 71 | .dark label { 72 | color: #e5e7eb !important; 73 | } 74 | 75 | .dark .card-bg { 76 | background-color: #1e293b !important; 77 | border-color: #334155 !important; 78 | } 79 | 80 | .dark .input-bg { 81 | background-color: #0f172a !important; 82 | border-color: #475569 !important; 83 | color: #e2e8f0 !important; 84 | } 85 | 86 | .dark .input-bg:hover, 87 | .dark .input-bg:focus { 88 | background-color: #1e293b !important; 89 | border-color: #6366f1 !important; 90 | } 91 | 92 | .dark input[type="text"], 93 | .dark input[type="email"], 94 | .dark input[type="url"], 95 | .dark input[type="tel"], 96 | .dark input[type="file"], 97 | .dark select, 98 | .dark textarea { 99 | background-color: #0f172a !important; 100 | border-color: #475569 !important; 101 | color: #e2e8f0 !important; 102 | } 103 | 104 | .dark input::placeholder { 105 | color: #64748b; 106 | } 107 | 108 | /* Text Colors - Dark Mode */ 109 | .dark .text-gray-400 { 110 | color: #9ca3af !important; 111 | } 112 | 113 | .dark .text-gray-500 { 114 | color: #6b7280 !important; 115 | } 116 | 117 | .dark .text-gray-600 { 118 | color: #cbd5e1 !important; 119 | } 120 | 121 | .dark .text-gray-700 { 122 | color: #e2e8f0 !important; 123 | } 124 | 125 | .dark .text-gray-800 { 126 | color: #f1f5f9 !important; 127 | } 128 | 129 | .dark .text-gray-900 { 130 | color: #f8fafc !important; 131 | } 132 | 133 | /* Border Colors - Dark Mode */ 134 | .dark .border-gray-100 { 135 | border-color: #334155 !important; 136 | } 137 | 138 | .dark .border-gray-200 { 139 | border-color: #475569 !important; 140 | } 141 | 142 | .dark .border-gray-300 { 143 | border-color: #64748b !important; 144 | } 145 | 146 | .dark .border-gray-600 { 147 | border-color: #475569 !important; 148 | } 149 | 150 | /* Background Colors - Dark Mode */ 151 | .dark .bg-gray-50 { 152 | background-color: #0f172a !important; 153 | } 154 | 155 | .dark .bg-gray-100 { 156 | background-color: #1e293b !important; 157 | } 158 | 159 | .dark .bg-gray-200 { 160 | background-color: #334155 !important; 161 | } 162 | 163 | /* Warning Box */ 164 | .dark .bg-yellow-50 { 165 | background-color: rgba(254, 243, 199, 0.1) !important; 166 | } 167 | 168 | .dark .text-yellow-800 { 169 | color: #fef3c7 !important; 170 | } 171 | 172 | .dark .border-yellow-200 { 173 | border-color: rgba(254, 243, 199, 0.3) !important; 174 | } 175 | 176 | .dark .border-yellow-800 { 177 | border-color: rgba(254, 243, 199, 0.2) !important; 178 | } 179 | 180 | /* Hover States - Dark Mode */ 181 | .dark .hover\:bg-gray-300:hover { 182 | background-color: #475569 !important; 183 | } 184 | 185 | .dark .hover\:border-indigo-500:hover { 186 | border-color: #6366f1 !important; 187 | } 188 | 189 | /* Navigation Buttons - Light mode overrides */ 190 | .bg-gray-200 { 191 | background-color: #e5e7eb !important; 192 | } 193 | 194 | .hover\:bg-gray-300:hover { 195 | background-color: #d1d5db !important; 196 | } 197 | 198 | .dark .bg-gray-700 { 199 | background-color: #374151 !important; 200 | } 201 | 202 | /* Step Wizard Tabs */ 203 | .tab { 204 | position: relative; 205 | padding: 0.75rem 1rem; 206 | cursor: pointer; 207 | transition: all 0.3s ease; 208 | border-bottom: 3px solid transparent; 209 | } 210 | 211 | .tab.active { 212 | color: #6366f1; 213 | font-weight: 600; 214 | border-bottom-color: #6366f1; 215 | } 216 | 217 | .tab:not(.active) { 218 | color: #6b7280; 219 | } 220 | 221 | .tab:not(.active):hover { 222 | background-color: rgba(99, 102, 241, 0.05); 223 | color: #6366f1; 224 | } 225 | 226 | .tab-content { 227 | display: block; 228 | } 229 | 230 | .tab-content.hidden { 231 | display: none; 232 | } 233 | 234 | /* Data Type Cards */ 235 | .data-card { 236 | cursor: pointer; 237 | transition: all 0.2s ease; 238 | background-color: #ffffff; 239 | border-color: #e5e7eb; 240 | } 241 | 242 | .data-card:hover { 243 | transform: translateY(-2px); 244 | box-shadow: 0 10px 25px rgba(99, 102, 241, 0.3); 245 | border-color: #c7d2fe; 246 | } 247 | 248 | .data-card.active-card { 249 | border-color: #6366f1 !important; 250 | background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%); 251 | box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); 252 | } 253 | 254 | .dark .data-card { 255 | background-color: #1e293b; 256 | border-color: #475569; 257 | } 258 | 259 | .dark .data-card:hover { 260 | border-color: #6366f1; 261 | box-shadow: 0 10px 25px rgba(99, 102, 241, 0.5); 262 | } 263 | 264 | .dark .data-card.active-card { 265 | background: linear-gradient(135deg, #312e81 0%, #3730a3 100%); 266 | border-color: #818cf8 !important; 267 | } 268 | 269 | /* Mobile: Smaller icons in data cards */ 270 | @media (max-width: 768px) { 271 | .data-card .text-4xl { 272 | font-size: 1.75rem !important; 273 | line-height: 1.75rem !important; 274 | } 275 | 276 | .data-card { 277 | padding: 0.75rem !important; 278 | } 279 | 280 | .data-card .text-sm { 281 | font-size: 0.7rem !important; 282 | } 283 | } 284 | 285 | /* Icon Fallback System */ 286 | .icon-fallback { 287 | display: none; 288 | } 289 | 290 | /* Show emoji fallback if Font Awesome fails to load */ 291 | body.no-icons i { 292 | display: none !important; 293 | } 294 | 295 | body.no-icons .icon-fallback { 296 | display: inline !important; 297 | } 298 | 299 | /* QR Code Responsive */ 300 | #qrcode { 301 | max-width: 100%; 302 | display: flex; 303 | justify-content: center; 304 | align-items: center; 305 | } 306 | 307 | #qrcode canvas, 308 | #qrcode img { 309 | max-width: 100% !important; 310 | height: auto !important; 311 | width: auto !important; 312 | display: block; 313 | } 314 | 315 | /* Desktop: respect user-selected size up to 500px */ 316 | @media (min-width: 769px) { 317 | #qrcode canvas, 318 | #qrcode img { 319 | max-width: 500px !important; 320 | max-height: 500px !important; 321 | } 322 | } 323 | 324 | /* Mobile: limit to smaller sizes for better display */ 325 | @media (max-width: 768px) { 326 | #qrcode canvas, 327 | #qrcode img { 328 | max-width: 350px !important; 329 | max-height: 350px !important; 330 | } 331 | 332 | /* Adjust preview container on mobile */ 333 | #qrcode { 334 | min-height: 300px !important; 335 | } 336 | } 337 | 338 | /* Extra small mobile */ 339 | @media (max-width: 480px) { 340 | #qrcode canvas, 341 | #qrcode img { 342 | max-width: 280px !important; 343 | max-height: 280px !important; 344 | } 345 | 346 | #qrcode { 347 | min-height: 260px !important; 348 | } 349 | } 350 | 351 | /* Header buttons responsive */ 352 | .header-buttons { 353 | z-index: 10; 354 | } 355 | 356 | @media (max-width: 768px) { 357 | .header-buttons { 358 | position: absolute !important; 359 | top: 0; 360 | right: 0; 361 | justify-content: flex-end; 362 | } 363 | 364 | .header-buttons button { 365 | font-size: 0.875rem; 366 | } 367 | } 368 | 369 | /* Error report button responsive */ 370 | #errorReportBtn { 371 | transition: all 0.3s ease; 372 | } 373 | 374 | @media (max-width: 768px) { 375 | #errorReportBtn { 376 | bottom: 1rem; 377 | right: 1rem; 378 | padding: 0.75rem; 379 | } 380 | 381 | #errorReportBtn span { 382 | display: none !important; 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [2.2.0] - 2025-11-06 ✅ COMPLETED 6 | 7 | ### ✨ New Features 8 | 9 | #### 📏 QR Size Customization 10 | - **Input type="number"** - Người dùng tự do nhập size từ 100-1000px 11 | - **Gợi ý thông minh** - "200-500px cho web, 300-800px cho in ấn" 12 | - **Responsive limits** - Desktop: 500px max, Mobile: 350px/280px max 13 | - **Validation on blur** - Chỉ validate khi focus ra ngoài (không làm phiền khi gõ) 14 | 15 | #### 🎯 UX Improvements 16 | - **Smart validation** - Chuyển từ `input` → `blur` event cho tất cả fields 17 | - **Không validate realtime** - Để người dùng nhập xong mới validate 18 | - **Enter to validate** - Nhấn Enter cũng trigger validation ngay 19 | 20 | #### 🐛 Bug Fixes 21 | - **Logo/text in exports** - Đã fix logo/text hiển thị đầy đủ khi download PNG/PDF 22 | - **Download logic** - Dùng `img.src` thay vì `canvas.toBlob()` để giữ logo/text 23 | - **i18n hardcode** - Xóa toàn bộ hardcode text, 100% dùng i18n keys 24 | 25 | ### 🔧 Technical Changes 26 | 27 | #### Files Modified: 28 | - `index.html` - Đổi slider → number input, xóa hardcode text, thêm i18n attributes 29 | - `js/qr-generator.js` - Fix download() dùng img.src, accept dynamic size param 30 | - `js/app.js` - Blur validation, size validation (100-1000px) 31 | - `js/translations.js` - Thêm `qr_size`, `qr_size_input`, `qr_size_hint` 32 | - `js/utils.js` - Support `data-i18n-placeholder` attribute 33 | - `css/style.css` - Xóa slider CSS (-46 lines), update responsive max-width 34 | 35 | #### Code Quality: 36 | - **-29 lines total** - Cleaner, more maintainable code 37 | - **No linter errors** - ESLint pass ✅ 38 | - **100% i18n** - Không còn hardcode Vietnamese text 39 | 40 | --- 41 | 42 | ## [2.1.0] - 2025-10-19 ✅ COMPLETED 43 | 44 | ### ✨ UX Improvements 45 | 46 | #### 🎯 Single-Page Flow - No More Steps! 47 | - **Bỏ hẳn Step Wizard** - Không còn tabs, không còn navigation 48 | - **One-page layout** - Tất cả hiển thị trên cung 1 trang: 49 | 1. Chọn loại dữ liệu (URL, Text, Email, WhatsApp, v.v.) 50 | 2. Nhập thông tin cần thiết 51 | 3. Tùy chỉnh màu sắc, logo/text, UTM tracking 52 | 4. **Live Preview** tự động update ở dưới cùng 53 | 5. Download (PNG/SVG/PDF) 54 | - **Scroll-based UX** - Không cần click Next/Prev, chỉ cần scroll xuống 55 | - **True Live Preview** - QR code tự động update khi thay đổi bất kỳ input nào (debounce 500ms) 56 | - **No validation blocking** - Không còn disable controls hay lock sections 57 | 58 | #### 🐛 Debug & Error Reporting System 59 | - **Nút "Báo Lỗi"** ở góc phải màn hình (floating button) 60 | - **Activity Logger** - Tự động lưu tất cả actions của người dùng: 61 | - Data type selection 62 | - Input changes 63 | - Customization options 64 | - QR generation events 65 | - Download actions 66 | - **Error Report Dialog** - Popup textarea hiển thị: 67 | - User actions log 68 | - Console errors 69 | - System info (browser, screen size) 70 | - QR configuration 71 | - **Quick Share** - Copy để gửi qua: 72 | - GitHub Issues 73 | - Messenger (direct link) 74 | 75 | #### 📱 Mobile Responsiveness 76 | - **QR Code Responsive Fixed** - Đảm bảo QR luôn fit màn hình 77 | - CSS `max-width: 100%` cho canvas và image 78 | - Mobile: giới hạn max 280px x 280px 79 | - Desktop: max 300px x 300px 80 | - Auto scale với `width: auto`, `height: auto` 81 | 82 | #### ✅ Quality Checks 83 | - **Logo/Text Insertion** - Verified working correctly 84 | - Logo: 20% QR size với white background 85 | - Text: 18% QR size với truncate (max 10 chars) 86 | - Border và padding phù hợp 87 | - Error Correction Level H khi có logo/text 88 | 89 | ### 🔧 Technical Changes 90 | 91 | #### Files Created: 92 | - `js/logger.js` - Activity Logger & Error Reporting System 93 | 94 | #### Files Modified: 95 | - `index.html` - Removed step tabs/wizard/Generate button, single-page layout, live preview at bottom 96 | - `js/app.js` - Removed WizardController, added auto-generate with debounce (500ms) 97 | - `js/utils.js` - Removed WizardController completely 98 | - `js/translations.js` - Removed unused keys (step titles, navigation buttons) 99 | - `css/style.css` - Enhanced QR responsive with breakpoints (300px/280px/240px) 100 | - `README.md` - Updated documentation with live preview flow 101 | - `CHANGELOG.md` - This file 102 | 103 | #### Key Changes: 104 | - Activity tracking system với console intercept 105 | - Error boundary handling với global error listeners 106 | - User action logger với timestamp (max 100 logs) 107 | - **Single-page layout**: Input → Customization → Live Preview (bottom) 108 | - **No step wizard**: Bỏ hẳn tabs, navigation buttons, validation blocking 109 | - **True Live Preview**: Auto-generate khi thay đổi input/color/logo/text (debounce 500ms) 110 | - CSS: Responsive breakpoints cho desktop/mobile/extra-small 111 | 112 | --- 113 | 114 | ## [2.0.0] - 2025-10-17 ✅ COMPLETED 115 | 116 | ### 🎉 Final Release Notes 117 | 118 | **Kim chỉ nam**: Scanability > Everything else 119 | Tất cả features đã hoàn thành và tested. 120 | 121 | ### ✨ Latest Updates (Final Polish) 122 | 123 | #### 🎨 UI Improvements 124 | - **Tách CSS ra file riêng** - `css/style.css` 125 | - **Font Awesome Icons** - Thay tất cả emoji bằng brand icons chuyên nghiệp 126 | - **Light Mode Fixed** - Input fields, labels, buttons đều readable 127 | - **Dark Mode Perfect** - Contrast tốt, all elements visible 128 | - **Nút Quay lại Fixed** - Proper styling cho cả light/dark mode 129 | - Proper color inheritance cho cả 2 themes 130 | 131 | #### 🌐 Internationalization (Best Practice) 132 | - **Refactored field labels** - Không còn hard-code tiếng Việt 133 | - **Translation Keys** - Dùng `labelKey` và `placeholderKey` 134 | - **Dynamic Labels** - All field labels & placeholders support VI/EN 135 | - **28 translation keys** - field_*, placeholder_* 136 | - **Clean Architecture** - Separation of concerns 137 | 138 | ### ⚠️ IMPORTANT FIXES 139 | 140 | #### 🔍 QR Code Scanability - FIXED! 141 | - **REMOVED custom QR styles** (dots, rounded) - Gây lỗi QR structure, không scan được 142 | - **ENABLED color customization** - Với contrast validation (WCAG standard) 143 | - **ENABLED logo/text** - Với size limit an toàn: 144 | - Logo: 20% of QR (giảm từ 25%) 145 | - Text: 18% of QR với truncate (max 10 chars) 146 | - Border & padding tăng lên để dễ đọc 147 | - **Error Correction Level H** khi có logo/text 148 | - **Standard square QR only** - Đảm bảo scanability 100% 149 | - **Contrast ratio validation** - Warn nếu < 4.5 150 | - Test button để debug dễ dàng 151 | - Console logs chi tiết 152 | 153 | ### 🎯 Major Refactor 154 | 155 | #### 🪜 Step Wizard Interface 156 | - **3-step wizard tabs** - Guided experience 157 | - Step 1: Data type selection với validation (phải điền đủ mới next) 158 | - Step 2: Customization (colors, logo/text) 159 | - Step 3: Preview & Export (QR preview + export buttons ngay) 160 | - Smooth transitions between steps 161 | - Validation: Next button disabled cho đến khi điền đầy đủ input 162 | 163 | #### 📦 Modular Architecture 164 | - **Tách code thành modules** - Clean separation of concerns 165 | - `js/app.js` - Main logic, data fields, event handlers 166 | - `js/qr-generator.js` - QR generation & styling 167 | - `js/utils.js` - ThemeManager, LanguageManager, WizardController 168 | - `js/translations.js` - Multi-language data 169 | - ES6 modules với import/export 170 | 171 | #### 🌙 Dark Mode Improvements 172 | - **Auto-detect system theme** - `prefers-color-scheme` detection 173 | - Optimized dark colors - Better contrast & readability 174 | - Fixed all dark mode styling issues 175 | - Smooth color transitions 176 | - Persistent preference in localStorage 177 | 178 | #### 🎨 UI/UX Enhancements 179 | - Cleaner, more organized layout 180 | - Better color contrast in dark mode 181 | - Hover effects on interactive elements 182 | - Loading states & transitions 183 | - Responsive grid for data type selection 184 | - Improved card styling 185 | 186 | ### 🔧 Technical Improvements 187 | - ES6 modules architecture 188 | - Better error handling 189 | - Cleaner event management 190 | - Separated concerns (UI, logic, data) 191 | - Performance optimizations 192 | - Code reusability 193 | 194 | ### 📁 File Structure 195 | ``` 196 | js/ 197 | ├── app.js # Main app logic 198 | ├── qr-generator.js # QR generation 199 | ├── utils.js # Helper functions 200 | └── translations.js # i18n data 201 | ``` 202 | 203 | --- 204 | 205 | ## [1.1.0] - 2025-10-17 206 | 207 | ### ✨ Added 208 | 209 | #### 🌐 Multi-language Support 210 | - Tiếng Việt (VI) và English (EN) 211 | - Language toggle button ở header 212 | - Tự động dịch UI elements 213 | 214 | #### 🌙 Dark Mode 215 | - Dark theme với gradient tối 216 | - Toggle button ở header 217 | - Lưu preference vào localStorage 218 | - Smooth transitions 219 | 220 | #### 🎨 QR Code Styles 221 | - **Square** - Kiểu vuông truyền thống 222 | - **Dots** - Chấm tròn hiện đại 223 | - **Rounded** - Bo góc mềm mại 224 | - Custom rendering engine 225 | 226 | #### 🌈 Color Customization 227 | - Tùy chỉnh màu nền trước (QR code) 228 | - Tùy chỉnh màu nền sau (background) 229 | - Color picker với preview realtime 230 | 231 | #### 💾 Multiple Export Formats 232 | - **PNG** - Raster image format 233 | - **SVG** - Vector format (scalable) 234 | - **PDF** - Document format với jsPDF 235 | - Canvas to SVG conversion 236 | 237 | ### 🔧 Technical Improvements 238 | - Thêm jsPDF library cho PDF export 239 | - Refactor generateQR() function 240 | - Thêm applyQRStyle() function 241 | - Thêm canvasToSVG() helper 242 | - Improved error handling 243 | - Better code organization 244 | 245 | ### 🎨 UI/UX Enhancements 246 | - Responsive controls ở header 247 | - Icon-based buttons 248 | - Gradient colors cho export buttons 249 | - Improved dark mode styling 250 | - Better visual hierarchy 251 | 252 | --- 253 | 254 | ## [1.0.0] - 2025-10-17 255 | 256 | ### ✨ Initial Release 257 | 258 | #### Core Features 259 | - 19 loại dữ liệu hỗ trợ 260 | - Logo customization 261 | - Center text với color picker 262 | - Google Campaign Tracking (UTM) 263 | - Modern UI với Tailwind CSS 264 | - Responsive design 265 | - Client-side processing (bảo mật) 266 | 267 | #### Supported Data Types 268 | - URL, Plain Text, Email, Phone, SMS 269 | - WiFi Login, Snapchat, File, E-Address 270 | - WhatsApp, YouTube, Instagram, LinkedIn 271 | - Facebook, X (Twitter), Discord, Telegram 272 | - TikTok, Spotify 273 | 274 | #### Advanced Options 275 | - Upload logo image 276 | - Custom center text 277 | - Text color customization 278 | - UTM campaign tracking 279 | - Radio button selection 280 | 281 | --- 282 | 283 | ## Future Plans 284 | 285 | ### 🚀 Upcoming Features (v1.2.0) 286 | - [ ] Batch QR code generation 287 | - [ ] QR code scanner 288 | - [ ] History & Templates 289 | - [ ] More export formats (EPS, WebP) 290 | - [ ] QR code analytics 291 | - [ ] Custom branding options 292 | 293 | ### 💡 Ideas 294 | - QR code with gradients 295 | - Animated QR codes 296 | - QR code with images/patterns 297 | - Bulk upload from CSV 298 | - API integration 299 | - Chrome extension 300 | 301 | --- 302 | 303 | ## Notes 304 | 305 | - Sử dụng semantic versioning 306 | - Breaking changes sẽ increment major version 307 | - New features increment minor version 308 | - Bug fixes increment patch version 309 | -------------------------------------------------------------------------------- /js/logger.js: -------------------------------------------------------------------------------- 1 | // Activity Logger & Error Reporting System 2 | import { LanguageManager } from './utils.js'; 3 | 4 | const ActivityLogger = { 5 | logs: [], 6 | errors: [], 7 | maxLogs: 100, 8 | 9 | init() { 10 | console.log('🔍 Activity Logger initialized'); 11 | this.interceptConsole(); 12 | this.trackErrors(); 13 | }, 14 | 15 | // Log user action with timestamp 16 | log(action, details = {}) { 17 | const timestamp = new Date().toISOString(); 18 | const entry = { 19 | timestamp, 20 | action, 21 | details, 22 | }; 23 | 24 | this.logs.push(entry); 25 | 26 | // Keep only last N logs 27 | if (this.logs.length > this.maxLogs) { 28 | this.logs.shift(); 29 | } 30 | 31 | console.log(`[ACTION] ${action}`, details); 32 | }, 33 | 34 | // Intercept console errors 35 | interceptConsole() { 36 | const originalError = console.error; 37 | console.error = (...args) => { 38 | this.errors.push({ 39 | timestamp: new Date().toISOString(), 40 | message: args.join(' '), 41 | }); 42 | originalError.apply(console, args); 43 | }; 44 | }, 45 | 46 | // Track global errors 47 | trackErrors() { 48 | window.addEventListener('error', (event) => { 49 | this.errors.push({ 50 | timestamp: new Date().toISOString(), 51 | message: event.message, 52 | source: event.filename, 53 | line: event.lineno, 54 | col: event.colno, 55 | }); 56 | }); 57 | 58 | window.addEventListener('unhandledrejection', (event) => { 59 | this.errors.push({ 60 | timestamp: new Date().toISOString(), 61 | message: `Unhandled Promise Rejection: ${event.reason}`, 62 | }); 63 | }); 64 | }, 65 | 66 | // Get system info 67 | getSystemInfo() { 68 | return { 69 | userAgent: navigator.userAgent, 70 | language: navigator.language, 71 | screenSize: `${window.screen.width}x${window.screen.height}`, 72 | viewportSize: `${window.innerWidth}x${window.innerHeight}`, 73 | timestamp: new Date().toISOString(), 74 | }; 75 | }, 76 | 77 | // Generate error report 78 | generateReport() { 79 | const systemInfo = this.getSystemInfo(); 80 | 81 | let report = '=== BÁO LỖI QR CODE GENERATOR ===\n\n'; 82 | report += '📋 THÔNG TIN HỆ THỐNG:\n'; 83 | report += `- Trình duyệt: ${systemInfo.userAgent}\n`; 84 | report += `- Ngôn ngữ: ${systemInfo.language}\n`; 85 | report += `- Màn hình: ${systemInfo.screenSize}\n`; 86 | report += `- Viewport: ${systemInfo.viewportSize}\n`; 87 | report += `- Thời gian: ${systemInfo.timestamp}\n\n`; 88 | 89 | if (this.errors.length > 0) { 90 | report += '❌ LỖI ĐÃ GHI NHẬN:\n'; 91 | this.errors.slice(-10).forEach((err, idx) => { 92 | report += `${idx + 1}. [${err.timestamp}] ${err.message}\n`; 93 | if (err.source) { 94 | report += ` Nguồn: ${err.source}:${err.line}:${err.col}\n`; 95 | } 96 | }); 97 | report += '\n'; 98 | } else { 99 | report += '✅ Không có lỗi được ghi nhận\n\n'; 100 | } 101 | 102 | report += '📝 HOẠT ĐỘNG GẦN ĐÂY (10 actions cuối):\n'; 103 | this.logs.slice(-10).forEach((log, idx) => { 104 | report += `${idx + 1}. [${log.timestamp}] ${log.action}\n`; 105 | if (Object.keys(log.details).length > 0) { 106 | report += ` Chi tiết: ${JSON.stringify(log.details)}\n`; 107 | } 108 | }); 109 | 110 | report += '\n=== KẾT THÚC BÁO CÁO ===\n'; 111 | report += '\n📬 Gửi báo cáo này qua:\n'; 112 | report += '- GitHub: https://github.com/j2teamnnl/qr-code-generator/issues\n'; 113 | report += '- Messenger: https://www.messenger.com/t/j2teamnnl/\n'; 114 | 115 | return report; 116 | }, 117 | 118 | // Show error report dialog 119 | showReportDialog() { 120 | const report = this.generateReport(); 121 | 122 | // Create modal 123 | const modal = document.createElement('div'); 124 | modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'; 125 | modal.innerHTML = ` 126 |
127 |
128 |

129 | 🐛 130 | ${LanguageManager.translate('error_report_title')} 131 |

132 |

133 | ${LanguageManager.translate('error_report_desc')} 134 |

135 |
136 | 137 |
138 | 143 |
144 | 145 |
146 |
147 | 156 | 163 |
164 | 165 | 187 |
188 |
189 | `; 190 | 191 | document.body.appendChild(modal); 192 | 193 | // Update translations for dynamic content 194 | const titleSpan = modal.querySelector('[data-i18n-dynamic="error_report_title"]'); 195 | const descP = modal.querySelector('[data-i18n-dynamic="error_report_desc"]'); 196 | const copySpan = modal.querySelector('[data-i18n-dynamic="error_report_copy"]'); 197 | const closeBtn = modal.querySelector('[data-i18n-dynamic="error_report_close"]'); 198 | 199 | if (titleSpan) titleSpan.textContent = LanguageManager.translate('error_report_title'); 200 | if (descP) descP.textContent = LanguageManager.translate('error_report_desc'); 201 | if (copySpan) copySpan.textContent = LanguageManager.translate('error_report_copy'); 202 | if (closeBtn) closeBtn.textContent = LanguageManager.translate('error_report_close'); 203 | 204 | // Event listeners 205 | document.getElementById('copyReportBtn').addEventListener('click', () => { 206 | const textarea = document.getElementById('errorReport'); 207 | textarea.select(); 208 | document.execCommand('copy'); 209 | 210 | // Visual feedback 211 | const btn = document.getElementById('copyReportBtn'); 212 | const originalText = btn.innerHTML; 213 | btn.innerHTML = ` ${LanguageManager.translate('error_report_copied')}`; 214 | setTimeout(() => { 215 | btn.innerHTML = originalText; 216 | }, 2000); 217 | }); 218 | 219 | document.getElementById('closeReportBtn').addEventListener('click', () => { 220 | document.body.removeChild(modal); 221 | }); 222 | 223 | // Close on backdrop click 224 | modal.addEventListener('click', (e) => { 225 | if (e.target === modal) { 226 | document.body.removeChild(modal); 227 | } 228 | }); 229 | }, 230 | }; 231 | 232 | // Create floating error report button 233 | function createErrorReportButton() { 234 | const button = document.createElement('button'); 235 | button.id = 'errorReportBtn'; 236 | button.className = 'fixed bottom-6 right-6 bg-gradient-to-r from-red-500 to-pink-600 hover:from-red-600 hover:to-pink-700 text-white font-bold p-4 rounded-full shadow-2xl transition-all transform hover:scale-110 z-40 flex items-center gap-2'; 237 | const updateButtonText = () => { 238 | button.innerHTML = ` 239 | 240 | 241 | 242 | 243 | `; 244 | button.title = LanguageManager.translate('error_report_button_title'); 245 | }; 246 | updateButtonText(); 247 | 248 | // Update button text when language changes 249 | const originalUpdateUI = LanguageManager.updateUI; 250 | LanguageManager.updateUI = function() { 251 | originalUpdateUI.call(this); 252 | if (document.getElementById('errorReportBtn')) { 253 | updateButtonText(); 254 | } 255 | }; 256 | 257 | button.addEventListener('click', () => { 258 | ActivityLogger.log('Opened error report dialog'); 259 | ActivityLogger.showReportDialog(); 260 | }); 261 | 262 | document.body.appendChild(button); 263 | } 264 | 265 | export { ActivityLogger, createErrorReportButton }; 266 | -------------------------------------------------------------------------------- /js/qr-generator.js: -------------------------------------------------------------------------------- 1 | // QR Code Generator & Styling 2 | import { LanguageManager } from './utils.js'; 3 | 4 | const QRGenerator = { 5 | canvas: null, 6 | qrCodeInstance: null, 7 | 8 | // Calculate color contrast ratio (WCAG standard) 9 | getContrast(color1, color2) { 10 | const getLuminance = (hex) => { 11 | const rgb = parseInt(hex.slice(1), 16); 12 | const r = (rgb >> 16) & 0xff; 13 | const g = (rgb >> 8) & 0xff; 14 | const b = (rgb >> 0) & 0xff; 15 | 16 | const [rs, gs, bs] = [r, g, b].map(c => { 17 | c = c / 255; 18 | return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 19 | }); 20 | 21 | return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; 22 | }; 23 | 24 | const lum1 = getLuminance(color1); 25 | const lum2 = getLuminance(color2); 26 | const lighter = Math.max(lum1, lum2); 27 | const darker = Math.min(lum1, lum2); 28 | 29 | return (lighter + 0.05) / (darker + 0.05); 30 | }, 31 | 32 | async generate(data, options = {}) { 33 | const qrContainer = document.getElementById('qrcode'); 34 | if (!qrContainer) return; 35 | 36 | qrContainer.innerHTML = ''; 37 | 38 | if (!data) { 39 | alert(LanguageManager.translate('alert_please_fill')); 40 | return; 41 | } 42 | 43 | // DEBUG: Log data to console 44 | console.log('QR Data:', data); 45 | console.log('Data type:', typeof data); 46 | console.log('Data length:', data.length); 47 | 48 | const { 49 | colorDark = '#000000', 50 | colorLight = '#ffffff', 51 | hasLogo = false, 52 | hasText = false, 53 | correctLevel = QRCode.CorrectLevel.M, 54 | size = 300, 55 | } = options; 56 | 57 | // Validate color contrast 58 | const contrast = this.getContrast(colorDark, colorLight); 59 | console.log('Color contrast ratio:', contrast.toFixed(2)); 60 | 61 | if (contrast < 4.5) { 62 | console.warn('⚠️ Low contrast! May affect scanability. Recommended: 4.5+'); 63 | } 64 | 65 | try { 66 | // Step 1: Generate base QR code with user colors and dynamic size 67 | this.qrCodeInstance = new QRCode(qrContainer, { 68 | text: String(data), 69 | width: size, 70 | height: size, 71 | colorDark: colorDark, 72 | colorLight: colorLight, 73 | // Use High error correction if adding logo/text 74 | correctLevel: (hasLogo || hasText) ? QRCode.CorrectLevel.H : correctLevel, 75 | }); 76 | 77 | await this.waitForRender(); 78 | 79 | // Step 2: Add logo/text if needed (with careful sizing) 80 | if (hasLogo || hasText) { 81 | console.log('Adding customizations...'); 82 | await this.waitForRender(); 83 | this.addCustomizations(); 84 | } 85 | 86 | document.getElementById('downloadSection').classList.remove('hidden'); 87 | } catch (error) { 88 | alert(LanguageManager.translate('alert_data_too_long')); 89 | console.error(error); 90 | } 91 | }, 92 | 93 | waitForRender() { 94 | return new Promise(resolve => setTimeout(resolve, 300)); 95 | }, 96 | 97 | // Crop image to square and resize 98 | async cropAndResizeImage(file, targetSize = 200) { 99 | return new Promise((resolve, reject) => { 100 | const img = new Image(); 101 | img.onload = () => { 102 | const canvas = document.createElement('canvas'); 103 | const ctx = canvas.getContext('2d'); 104 | 105 | // Calculate crop dimensions (center crop to square) 106 | const size = Math.min(img.width, img.height); 107 | const offsetX = (img.width - size) / 2; 108 | const offsetY = (img.height - size) / 2; 109 | 110 | // Set canvas to target size 111 | canvas.width = targetSize; 112 | canvas.height = targetSize; 113 | 114 | // Draw cropped and resized image 115 | ctx.drawImage( 116 | img, 117 | offsetX, offsetY, size, size, // Source crop 118 | 0, 0, targetSize, targetSize // Destination 119 | ); 120 | 121 | // Convert to blob 122 | canvas.toBlob((blob) => { 123 | resolve(blob); 124 | }, 'image/png'); 125 | }; 126 | img.onerror = reject; 127 | img.src = URL.createObjectURL(file); 128 | }); 129 | }, 130 | 131 | addCustomizations() { 132 | const qrCanvas = document.querySelector('#qrcode canvas'); 133 | const qrImg = document.querySelector('#qrcode img'); 134 | 135 | if (!qrCanvas || !qrImg) { 136 | setTimeout(() => this.addCustomizations(), 200); 137 | return; 138 | } 139 | 140 | const canvas = document.createElement('canvas'); 141 | canvas.width = qrCanvas.width; 142 | canvas.height = qrCanvas.height; 143 | const ctx = canvas.getContext('2d'); 144 | 145 | ctx.drawImage(qrCanvas, 0, 0); 146 | 147 | const centerOption = document.querySelector('input[name="centerOption"]:checked')?.value; 148 | const logoFile = document.getElementById('logoFile')?.files[0]; 149 | const centerText = document.getElementById('centerText')?.value; 150 | const textColor = document.getElementById('centerTextColor')?.value; 151 | 152 | if (centerOption === 'logo' && logoFile) { 153 | // Auto-crop and resize logo 154 | this.cropAndResizeImage(logoFile, 200).then((croppedBlob) => { 155 | const logo = new Image(); 156 | logo.onload = () => { 157 | // SAFE SIZE: 20% of QR (was 25%) - less coverage = better scanability 158 | const logoSize = canvas.width * 0.20; 159 | const x = (canvas.width - logoSize) / 2; 160 | const y = (canvas.height - logoSize) / 2; 161 | const padding = 10; // Increased padding for better readability 162 | 163 | // White background with slight border 164 | ctx.fillStyle = '#ffffff'; 165 | ctx.fillRect(x - padding, y - padding, logoSize + padding * 2, logoSize + padding * 2); 166 | 167 | // Optional: Add border for better visibility 168 | ctx.strokeStyle = '#e5e7eb'; 169 | ctx.lineWidth = 2; 170 | ctx.strokeRect(x - padding, y - padding, logoSize + padding * 2, logoSize + padding * 2); 171 | 172 | ctx.drawImage(logo, x, y, logoSize, logoSize); 173 | 174 | qrImg.src = canvas.toDataURL(); 175 | console.log('✓ Logo added (auto-cropped to square) - size: 20%'); 176 | }; 177 | logo.onerror = () => { 178 | console.error('Failed to load logo'); 179 | alert(LanguageManager.translate('alert_logo_load_failed')); 180 | }; 181 | logo.src = URL.createObjectURL(croppedBlob); 182 | }).catch((error) => { 183 | console.error('Failed to crop image:', error); 184 | alert(LanguageManager.translate('alert_image_process_failed')); 185 | }); 186 | } else if (centerOption === 'text' && centerText) { 187 | // SAFE SIZE: 18% of QR for text 188 | const size = canvas.width * 0.18; 189 | const x = (canvas.width - size) / 2; 190 | const y = (canvas.height - size) / 2; 191 | const padding = 10; 192 | 193 | // White background 194 | ctx.fillStyle = '#ffffff'; 195 | ctx.fillRect(x - padding, y - padding, size + padding * 2, size + padding * 2); 196 | 197 | // Border 198 | ctx.strokeStyle = '#e5e7eb'; 199 | ctx.lineWidth = 2; 200 | ctx.strokeRect(x - padding, y - padding, size + padding * 2, size + padding * 2); 201 | 202 | // Text (smaller font) 203 | ctx.fillStyle = textColor || '#000000'; 204 | ctx.font = 'bold 16px Arial'; // Reduced from 18px 205 | ctx.textAlign = 'center'; 206 | ctx.textBaseline = 'middle'; 207 | 208 | // Truncate text if too long 209 | const maxLength = 10; 210 | const displayText = centerText.length > maxLength ? centerText.substring(0, maxLength) + '...' : centerText; 211 | ctx.fillText(displayText, canvas.width / 2, canvas.height / 2); 212 | 213 | qrImg.src = canvas.toDataURL(); 214 | console.log('✓ Text added - size: 18%'); 215 | } 216 | }, 217 | 218 | download(format = 'png') { 219 | const img = document.querySelector('#qrcode img'); 220 | 221 | if (!img) return; 222 | 223 | if (format === 'png') { 224 | // Use img element to ensure logo/text is included 225 | const a = document.createElement('a'); 226 | a.href = img.src; 227 | a.download = 'qrcode.png'; 228 | a.style.display = 'none'; 229 | document.body.appendChild(a); 230 | a.click(); 231 | 232 | // Cleanup 233 | setTimeout(() => { 234 | document.body.removeChild(a); 235 | }, 100); 236 | } else if (format === 'svg') { 237 | // For SVG, need to convert from img src 238 | const canvas = document.querySelector('#qrcode canvas'); 239 | if (!canvas) return; 240 | 241 | const svgData = this.canvasToSVG(canvas); 242 | const blob = new Blob([svgData], { type: 'image/svg+xml' }); 243 | const url = URL.createObjectURL(blob); 244 | const a = document.createElement('a'); 245 | a.href = url; 246 | a.download = 'qrcode.svg'; 247 | a.style.display = 'none'; 248 | document.body.appendChild(a); 249 | a.click(); 250 | 251 | // Cleanup 252 | setTimeout(() => { 253 | document.body.removeChild(a); 254 | URL.revokeObjectURL(url); 255 | }, 100); 256 | } else if (format === 'pdf') { 257 | const { jsPDF } = window.jspdf; 258 | const pdf = new jsPDF({ 259 | orientation: 'portrait', 260 | unit: 'mm', 261 | format: 'a4', 262 | }); 263 | 264 | // Use img src to include logo/text 265 | const imgData = img.src; 266 | const pageWidth = pdf.internal.pageSize.getWidth(); 267 | const imgWidth = 80; 268 | const imgHeight = 80; 269 | const x = (pageWidth - imgWidth) / 2; 270 | const y = 20; 271 | 272 | pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight); 273 | pdf.save('qrcode.pdf'); 274 | } 275 | }, 276 | 277 | canvasToSVG(canvas) { 278 | const ctx = canvas.getContext('2d'); 279 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 280 | const data = imageData.data; 281 | const cellSize = canvas.width / 33; 282 | 283 | let svgContent = ``; 284 | svgContent += ``; 285 | 286 | const style = document.getElementById('qrStyle')?.value || 'square'; 287 | const colorDark = document.getElementById('qrColorDark')?.value || '#000000'; 288 | 289 | for (let y = 0; y < 33; y++) { 290 | for (let x = 0; x < 33; x++) { 291 | const px = Math.floor(x * cellSize + cellSize / 2); 292 | const py = Math.floor(y * cellSize + cellSize / 2); 293 | const idx = (py * canvas.width + px) * 4; 294 | 295 | if (data[idx] < 128) { 296 | const posX = x * cellSize; 297 | const posY = y * cellSize; 298 | 299 | if (style === 'dots') { 300 | svgContent += ``; 301 | } else if (style === 'rounded') { 302 | const radius = cellSize / 4; 303 | const rectX = posX + cellSize / 6; 304 | const rectY = posY + cellSize / 6; 305 | const rectW = cellSize * 0.67; 306 | const rectH = cellSize * 0.67; 307 | svgContent += ``; 308 | } else { 309 | svgContent += ``; 310 | } 311 | } 312 | } 313 | } 314 | 315 | svgContent += ''; 316 | return svgContent; 317 | }, 318 | }; 319 | 320 | export { QRGenerator }; 321 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | QR Code Generator 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 |
20 | 21 | 25 | 26 | 27 | 30 |
31 | 32 |

QR Code Generator v2.2.0

33 |

34 | 35 | 36 | Star on GitHub 37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 | 47 | 50 |
51 | 52 | 53 |
54 | 55 |
56 |

57 | 58 | 59 |
60 | 61 |
62 | 63 | 64 |
65 |
66 |
67 | 68 | 69 | 168 | 169 | 170 |
171 |

Live Preview

172 | 173 | 174 |
175 | 176 |
177 |

178 |
179 |
180 | 181 | 182 | 199 |
200 |
201 |
202 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | // Main application logic 2 | import { translations } from './translations.js'; 3 | import { ThemeManager, LanguageManager } from './utils.js'; 4 | import { QRGenerator } from './qr-generator.js'; 5 | import { ActivityLogger, createErrorReportButton } from './logger.js'; 6 | 7 | // Make translations available globally 8 | window.translations = translations; 9 | 10 | // Data types configuration (controls which types are shown in UI) 11 | const dataTypes = { 12 | url: { icon: 'fas fa-link text-indigo-600', iconFallback: '🔗', label: 'URL', enabled: true }, 13 | text: { icon: 'fas fa-font text-gray-700', iconFallback: '📝', label: 'Text', enabled: true }, 14 | email: { icon: 'fas fa-envelope text-red-500', iconFallback: '📧', label: 'Email', enabled: true }, 15 | phone: { icon: 'fas fa-phone text-green-600', iconFallback: '📞', label: 'Phone', enabled: true }, 16 | sms: { icon: 'fas fa-sms text-blue-500', iconFallback: '💬', label: 'SMS', enabled: true }, 17 | wifi: { icon: 'fas fa-wifi text-cyan-600', iconFallback: '📶', label: 'WiFi', enabled: true }, 18 | whatsapp: { icon: 'fab fa-whatsapp text-green-500', iconFallback: '💚', label: 'WhatsApp', enabled: true }, 19 | youtube: { icon: 'fab fa-youtube text-red-600', iconFallback: '▶️', label: 'YouTube', enabled: true }, 20 | instagram: { icon: 'fab fa-instagram text-pink-600', iconFallback: '📷', label: 'Instagram', enabled: true }, 21 | linkedin: { icon: 'fab fa-linkedin text-blue-700', iconFallback: '💼', label: 'LinkedIn', enabled: true }, 22 | facebook: { icon: 'fab fa-facebook text-blue-600', iconFallback: '👥', label: 'Facebook', enabled: true }, 23 | x: { icon: 'fab fa-x-twitter text-gray-800', iconFallback: '✖️', label: 'X (Twitter)', enabled: true }, 24 | discord: { icon: 'fab fa-discord text-indigo-600', iconFallback: '🎮', label: 'Discord', enabled: true }, 25 | telegram: { icon: 'fab fa-telegram text-blue-500', iconFallback: '✈️', label: 'Telegram', enabled: true }, 26 | tiktok: { icon: 'fab fa-tiktok text-gray-800', iconFallback: '🎵', label: 'TikTok', enabled: true }, 27 | spotify: { icon: 'fab fa-spotify text-green-600', iconFallback: '🎧', label: 'Spotify', enabled: true }, 28 | // Disabled types (set enabled: false to hide) 29 | snapchat: { icon: 'fab fa-snapchat text-yellow-400', iconFallback: '👻', label: 'Snapchat', enabled: false }, 30 | file: { icon: 'fas fa-file text-purple-600', iconFallback: '📄', label: 'File', enabled: false }, 31 | address: { icon: 'fas fa-map-marker-alt text-red-600', iconFallback: '📍', label: 'Address', enabled: false }, 32 | }; 33 | 34 | // Validation & Pre-processing functions 35 | const validators = { 36 | url: (value) => { 37 | const urlPattern = /^(https?:\/\/)?([\w-]+(\.\w+)+)([\w.,@?^=%&:/~+#-]*)?$/; 38 | if (!urlPattern.test(value)) { 39 | return { valid: false, messageKey: 'error_url_invalid' }; 40 | } 41 | // Auto-add https:// if missing 42 | if (!value.startsWith('http://') && !value.startsWith('https://')) { 43 | return { valid: true, processed: 'https://' + value }; 44 | } 45 | return { valid: true, processed: value }; 46 | }, 47 | 48 | email: (value) => { 49 | const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 50 | if (!emailPattern.test(value)) { 51 | return { valid: false, messageKey: 'error_email_invalid' }; 52 | } 53 | return { valid: true, processed: value.toLowerCase() }; 54 | }, 55 | 56 | phone: (value) => { 57 | const phonePattern = /^[\d\s+()-]+$/; 58 | if (!phonePattern.test(value)) { 59 | return { valid: false, messageKey: 'error_phone_invalid' }; 60 | } 61 | // Remove spaces for consistency 62 | return { valid: true, processed: value.replace(/\s/g, '') }; 63 | }, 64 | 65 | tiktok: (value) => { 66 | // Extract username from TikTok URL or profile link 67 | const patterns = [ 68 | /tiktok\.com\/@([a-zA-Z0-9_.]+)/, // @username 69 | /^@?([a-zA-Z0-9_.]+)$/, // Direct username 70 | ]; 71 | 72 | for (const pattern of patterns) { 73 | const match = value.match(pattern); 74 | if (match) { 75 | const username = match[1].replace(/^@/, ''); 76 | return { valid: true, processed: `https://www.tiktok.com/@${username}` }; 77 | } 78 | } 79 | 80 | return { valid: false, messageKey: 'error_tiktok_invalid' }; 81 | }, 82 | 83 | instagram: (value) => { 84 | const patterns = [ 85 | /instagram\.com\/([a-zA-Z0-9_.]+)/, 86 | /^@?([a-zA-Z0-9_.]+)$/, 87 | ]; 88 | 89 | for (const pattern of patterns) { 90 | const match = value.match(pattern); 91 | if (match) { 92 | const username = match[1].replace(/^@/, ''); 93 | return { valid: true, processed: `https://www.instagram.com/${username}` }; 94 | } 95 | } 96 | 97 | return { valid: false, messageKey: 'error_instagram_invalid' }; 98 | }, 99 | 100 | youtube: (value) => { 101 | // Extract channel/video ID from YouTube URL 102 | if (value.includes('youtube.com') || value.includes('youtu.be')) { 103 | return { valid: true, processed: value }; 104 | } 105 | // Assume it's a channel name 106 | return { valid: true, processed: `https://www.youtube.com/@${value}` }; 107 | }, 108 | 109 | whatsapp: (value) => { 110 | const cleaned = value.replace(/[\s()-]/g, ''); 111 | const phonePattern = /^\+?\d{10,15}$/; 112 | if (!phonePattern.test(cleaned)) { 113 | return { valid: false, messageKey: 'error_whatsapp_invalid' }; 114 | } 115 | return { valid: true, processed: cleaned }; 116 | }, 117 | 118 | telegram: (value) => { 119 | const patterns = [ 120 | /t\.me\/([a-zA-Z0-9_]+)/, 121 | /^@?([a-zA-Z0-9_]+)$/, 122 | ]; 123 | 124 | for (const pattern of patterns) { 125 | const match = value.match(pattern); 126 | if (match) { 127 | const username = match[1].replace(/^@/, ''); 128 | return { valid: true, processed: `https://t.me/${username}` }; 129 | } 130 | } 131 | 132 | return { valid: false, messageKey: 'error_telegram_invalid' }; 133 | }, 134 | 135 | spotify: (value) => { 136 | if (value.includes('spotify.com')) { 137 | return { valid: true, processed: value }; 138 | } 139 | return { valid: false, messageKey: 'error_spotify_invalid' }; 140 | }, 141 | }; 142 | 143 | // Data fields configuration 144 | const fields = { 145 | url: [ 146 | { 147 | name: 'url', 148 | labelKey: 'field_url', 149 | type: 'url', 150 | placeholderKey: 'placeholder_url', 151 | hasCampaign: true, 152 | }, 153 | ], 154 | text: [ 155 | { 156 | name: 'text', 157 | labelKey: 'field_text', 158 | type: 'text', 159 | placeholderKey: 'placeholder_text', 160 | }, 161 | ], 162 | email: [ 163 | { 164 | name: 'email', 165 | labelKey: 'field_email', 166 | type: 'email', 167 | placeholderKey: 'placeholder_email', 168 | }, 169 | ], 170 | phone: [ 171 | { 172 | name: 'phone', 173 | labelKey: 'field_phone', 174 | type: 'tel', 175 | placeholderKey: 'placeholder_phone', 176 | }, 177 | ], 178 | sms: [ 179 | { 180 | name: 'phone', 181 | labelKey: 'field_phone', 182 | type: 'tel', 183 | placeholderKey: 'placeholder_phone', 184 | }, 185 | { 186 | name: 'message', 187 | labelKey: 'field_message', 188 | type: 'text', 189 | placeholderKey: 'placeholder_message', 190 | }, 191 | ], 192 | wifi: [ 193 | { 194 | name: 'ssid', 195 | labelKey: 'field_ssid', 196 | type: 'text', 197 | placeholderKey: 'placeholder_ssid', 198 | }, 199 | { 200 | name: 'password', 201 | labelKey: 'field_password', 202 | type: 'text', 203 | placeholderKey: 'placeholder_password', 204 | }, 205 | { 206 | name: 'security', 207 | labelKey: 'field_security', 208 | type: 'select', 209 | options: ['WPA', 'WEP', 'nopass'], 210 | }, 211 | ], 212 | whatsapp: [ 213 | { 214 | name: 'phone', 215 | labelKey: 'field_phone', 216 | type: 'tel', 217 | placeholderKey: 'placeholder_phone', 218 | }, 219 | { 220 | name: 'message', 221 | labelKey: 'field_message', 222 | type: 'text', 223 | placeholderKey: 'placeholder_message', 224 | }, 225 | ], 226 | youtube: [ 227 | { 228 | name: 'url', 229 | labelKey: 'field_url', 230 | type: 'url', 231 | placeholderKey: 'placeholder_url', 232 | hasCampaign: true, 233 | }, 234 | ], 235 | instagram: [ 236 | { 237 | name: 'username', 238 | labelKey: 'field_username', 239 | type: 'text', 240 | placeholderKey: 'placeholder_username', 241 | }, 242 | ], 243 | linkedin: [ 244 | { 245 | name: 'url', 246 | labelKey: 'field_url', 247 | type: 'url', 248 | placeholderKey: 'placeholder_url', 249 | hasCampaign: true, 250 | }, 251 | ], 252 | facebook: [ 253 | { 254 | name: 'url', 255 | labelKey: 'field_url', 256 | type: 'url', 257 | placeholderKey: 'placeholder_url', 258 | hasCampaign: true, 259 | }, 260 | ], 261 | // snapchat: [ 262 | // { 263 | // name: 'username', 264 | // labelKey: 'field_username', 265 | // type: 'text', 266 | // placeholderKey: 'placeholder_username', 267 | // }, 268 | // ], 269 | telegram: [ 270 | { 271 | name: 'username', 272 | labelKey: 'field_username', 273 | type: 'text', 274 | placeholderKey: 'placeholder_username', 275 | }, 276 | ], 277 | tiktok: [ 278 | { 279 | name: 'username', 280 | labelKey: 'field_username', 281 | type: 'text', 282 | placeholderKey: 'placeholder_username', 283 | }, 284 | ], 285 | discord: [ 286 | { 287 | name: 'invite', 288 | labelKey: 'field_invite', 289 | type: 'text', 290 | placeholderKey: 'placeholder_invite', 291 | }, 292 | ], 293 | spotify: [ 294 | { 295 | name: 'url', 296 | labelKey: 'field_url', 297 | type: 'url', 298 | placeholderKey: 'placeholder_url', 299 | hasCampaign: true, 300 | }, 301 | ], 302 | x: [ 303 | { 304 | name: 'username', 305 | labelKey: 'field_username', 306 | type: 'text', 307 | placeholderKey: 'placeholder_username', 308 | }, 309 | ], 310 | // file: [ 311 | // { 312 | // name: 'file', 313 | // labelKey: 'field_file', 314 | // type: 'file', 315 | // }, 316 | // ], 317 | // address: [ 318 | // { 319 | // name: 'address', 320 | // labelKey: 'field_address', 321 | // type: 'text', 322 | // placeholderKey: 'placeholder_address', 323 | // }, 324 | // ], 325 | }; 326 | 327 | let selectedDataType = 'url'; 328 | 329 | // Auto-generate QR with debounce 330 | let generateTimeout; 331 | const autoGenerateQR = () => { 332 | clearTimeout(generateTimeout); 333 | generateTimeout = setTimeout(() => { 334 | window.generateQR(); 335 | }, 500); // Debounce 500ms 336 | }; 337 | 338 | // Initialize app 339 | function init() { 340 | // Initialize logger first 341 | ActivityLogger.init(); 342 | ActivityLogger.log('App initialized'); 343 | 344 | ThemeManager.init(); 345 | LanguageManager.init(); 346 | 347 | // Render data types from config 348 | renderDataTypes(); 349 | 350 | setupEventListeners(); 351 | updateFields(); 352 | 353 | // Create error report button 354 | createErrorReportButton(); 355 | 356 | ActivityLogger.log('UI rendered', { dataTypesCount: Object.keys(dataTypes).length }); 357 | } 358 | 359 | // Expose functions to global scope for onclick handlers 360 | window.toggleDarkMode = () => { 361 | ActivityLogger.log('Theme toggled'); 362 | ThemeManager.toggle(); 363 | }; 364 | window.toggleLanguage = () => { 365 | ActivityLogger.log('Language toggled', { to: LanguageManager.current === 'vi' ? 'en' : 'vi' }); 366 | LanguageManager.toggle(); 367 | }; 368 | 369 | // Test function for debugging 370 | window.testQR = async () => { 371 | console.log('=== TESTING QR with simple URL ==='); 372 | await QRGenerator.generate('https://google.com'); 373 | }; 374 | 375 | // Render data type cards from config 376 | function renderDataTypes() { 377 | const container = document.getElementById('dataTypeGrid'); 378 | if (!container) return; 379 | 380 | // Clear existing cards 381 | container.innerHTML = ''; 382 | 383 | // Render only enabled types 384 | let isFirst = true; 385 | Object.entries(dataTypes).forEach(([type, config]) => { 386 | if (!config.enabled) return; 387 | 388 | const card = document.createElement('div'); 389 | card.className = 'data-card card-bg p-4 rounded-xl border-2 border-gray-200 text-center'; 390 | card.dataset.type = type; 391 | 392 | // First enabled card is active by default 393 | if (isFirst) { 394 | card.classList.add('active-card'); 395 | selectedDataType = type; 396 | isFirst = false; 397 | } 398 | 399 | card.innerHTML = ` 400 |
401 | 402 | ${config.iconFallback} 403 |
404 |
${config.label}
405 | `; 406 | 407 | container.appendChild(card); 408 | }); 409 | } 410 | 411 | // Tab switching function 412 | window.switchTab = (tabName) => { 413 | ActivityLogger.log('Tab switched', { tab: tabName }); 414 | 415 | // Hide all tab contents 416 | document.querySelectorAll('.tab-content').forEach(content => { 417 | content.classList.add('hidden'); 418 | }); 419 | 420 | // Remove active from all tabs 421 | document.querySelectorAll('.tab').forEach(tab => { 422 | tab.classList.remove('active'); 423 | }); 424 | 425 | // Show selected tab content 426 | const selectedContent = document.getElementById(`tab-${tabName}`); 427 | if (selectedContent) { 428 | selectedContent.classList.remove('hidden'); 429 | } 430 | 431 | // Add active to selected tab 432 | const selectedTab = document.querySelector(`[data-tab="${tabName}"]`); 433 | if (selectedTab) { 434 | selectedTab.classList.add('active'); 435 | } 436 | }; 437 | 438 | // Setup all event listeners 439 | function setupEventListeners() { 440 | // QR Generation 441 | window.generateQR = async () => { 442 | const data = await getData(); 443 | if (!data) { 444 | return; // Silently fail if no data 445 | } 446 | 447 | const centerOption = document.querySelector('input[name="centerOption"]:checked')?.value; 448 | const hasLogo = centerOption === 'logo' && document.getElementById('logoFile')?.files[0]; 449 | const hasText = centerOption === 'text' && document.getElementById('centerText')?.value; 450 | const colorDark = document.getElementById('qrColorDark')?.value || '#000000'; 451 | const colorLight = document.getElementById('qrColorLight')?.value || '#ffffff'; 452 | const size = parseInt(document.getElementById('qrSize')?.value || 300); 453 | 454 | ActivityLogger.log('QR generation started', { 455 | dataType: selectedDataType, 456 | dataLength: data.length, 457 | centerOption, 458 | hasLogo, 459 | hasText, 460 | colorDark, 461 | colorLight, 462 | size, 463 | }); 464 | 465 | try { 466 | await QRGenerator.generate(data, { 467 | colorDark, 468 | colorLight, 469 | hasLogo, 470 | hasText, 471 | size, 472 | correctLevel: (hasLogo || hasText) ? QRCode.CorrectLevel.H : QRCode.CorrectLevel.M, 473 | }); 474 | 475 | ActivityLogger.log('QR generation successful'); 476 | } catch (error) { 477 | ActivityLogger.log('QR generation error', { error: error.message }); 478 | console.error('QR Generation Error:', error); 479 | } 480 | }; 481 | 482 | // Download 483 | window.downloadQR = (format) => { 484 | ActivityLogger.log('Download QR', { format }); 485 | QRGenerator.download(format); 486 | }; 487 | 488 | // Data type selection 489 | document.querySelectorAll('.data-card').forEach(card => { 490 | card.addEventListener('click', () => { 491 | // Remove active from all cards 492 | document.querySelectorAll('.data-card').forEach(c => c.classList.remove('active-card')); 493 | 494 | // Add active to clicked card 495 | card.classList.add('active-card'); 496 | 497 | // Update selected type and show inputs 498 | selectedDataType = card.dataset.type; 499 | ActivityLogger.log('Data type selected', { type: selectedDataType }); 500 | updateFields(); 501 | }); 502 | }); 503 | 504 | // Center option radios 505 | document.querySelectorAll('input[name="centerOption"]').forEach(radio => { 506 | radio.addEventListener('change', function() { 507 | ActivityLogger.log('Center option changed', { option: this.value }); 508 | 509 | const logoFile = document.getElementById('logoFile'); 510 | const centerText = document.getElementById('centerText'); 511 | const centerTextColor = document.getElementById('centerTextColor'); 512 | 513 | if (logoFile) logoFile.disabled = this.value !== 'logo'; 514 | if (centerText) centerText.disabled = this.value !== 'text'; 515 | if (centerTextColor) centerTextColor.disabled = this.value !== 'text'; 516 | 517 | // Auto-generate when option changes 518 | autoGenerateQR(); 519 | }); 520 | }); 521 | 522 | // Logo file upload with preview 523 | const logoFileInput = document.getElementById('logoFile'); 524 | if (logoFileInput) { 525 | logoFileInput.addEventListener('change', async function(e) { 526 | const file = e.target.files[0]; 527 | if (!file) return; 528 | 529 | // Show processing message 530 | const preview = document.getElementById('logoPreview'); 531 | if (preview) { 532 | preview.classList.remove('hidden'); 533 | preview.innerHTML = `

${LanguageManager.translate('logo_processing')}

`; 534 | } 535 | 536 | try { 537 | // Crop and resize 538 | const croppedBlob = await QRGenerator.cropAndResizeImage(file, 200); 539 | 540 | // Show preview 541 | const img = document.createElement('img'); 542 | img.src = URL.createObjectURL(croppedBlob); 543 | img.className = 'w-20 h-20 rounded-lg border-2 border-gray-300 object-cover'; 544 | 545 | if (preview) { 546 | preview.classList.remove('hidden'); 547 | preview.innerHTML = `

${LanguageManager.translate('logo_cropped')}

`; 548 | preview.appendChild(img); 549 | } 550 | 551 | ActivityLogger.log('Logo uploaded and cropped', { fileName: file.name, fileSize: file.size }); 552 | console.log('✓ Logo cropped and ready'); 553 | 554 | // Auto-generate after logo upload 555 | autoGenerateQR(); 556 | } catch (error) { 557 | console.error('Failed to process logo:', error); 558 | ActivityLogger.log('Logo processing error', { error: error.message }); 559 | if (preview) { 560 | preview.classList.remove('hidden'); 561 | preview.innerHTML = `

${LanguageManager.translate('logo_error')}

`; 562 | } 563 | } 564 | }); 565 | } 566 | 567 | // Campaign tracking 568 | const enableCampaign = document.getElementById('enableCampaign'); 569 | if (enableCampaign) { 570 | enableCampaign.addEventListener('change', function() { 571 | ActivityLogger.log('Campaign tracking toggled', { enabled: this.checked }); 572 | document.getElementById('campaignFields')?.classList.toggle('hidden', !this.checked); 573 | autoGenerateQR(); 574 | }); 575 | } 576 | 577 | // Color pickers - auto-generate on change 578 | const colorDark = document.getElementById('qrColorDark'); 579 | const colorLight = document.getElementById('qrColorLight'); 580 | if (colorDark) colorDark.addEventListener('input', autoGenerateQR); 581 | if (colorLight) colorLight.addEventListener('input', autoGenerateQR); 582 | 583 | // QR Size input - validate and auto-generate on blur 584 | const qrSize = document.getElementById('qrSize'); 585 | if (qrSize) { 586 | qrSize.addEventListener('blur', function() { 587 | let size = parseInt(this.value); 588 | // Validate size range 589 | if (isNaN(size) || size < 100) { 590 | size = 100; 591 | this.value = 100; 592 | } else if (size > 1000) { 593 | size = 1000; 594 | this.value = 1000; 595 | } 596 | ActivityLogger.log('QR size changed', { size }); 597 | autoGenerateQR(); 598 | }); 599 | // Also trigger on Enter key 600 | qrSize.addEventListener('keypress', function(e) { 601 | if (e.key === 'Enter') { 602 | this.blur(); 603 | } 604 | }); 605 | } 606 | 607 | // Center text - auto-generate on change 608 | const centerText = document.getElementById('centerText'); 609 | const centerTextColor = document.getElementById('centerTextColor'); 610 | if (centerText) centerText.addEventListener('input', autoGenerateQR); 611 | if (centerTextColor) centerTextColor.addEventListener('input', autoGenerateQR); 612 | 613 | // UTM fields - auto-generate on change 614 | const utmSource = document.getElementById('utmSource'); 615 | const utmMedium = document.getElementById('utmMedium'); 616 | const utmCampaign = document.getElementById('utmCampaign'); 617 | if (utmSource) utmSource.addEventListener('input', autoGenerateQR); 618 | if (utmMedium) utmMedium.addEventListener('input', autoGenerateQR); 619 | if (utmCampaign) utmCampaign.addEventListener('input', autoGenerateQR); 620 | } 621 | 622 | 623 | // Update input fields based on selected data type 624 | function updateFields() { 625 | const container = document.getElementById('inputFields'); 626 | if (!container) return; 627 | 628 | container.innerHTML = ''; 629 | 630 | const typeFields = fields[selectedDataType] || fields.url; 631 | 632 | typeFields.forEach(field => { 633 | const div = document.createElement('div'); 634 | div.className = 'form-group'; 635 | 636 | const label = document.createElement('label'); 637 | label.className = 'block text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2'; 638 | label.textContent = LanguageManager.translate(field.labelKey); 639 | 640 | let input; 641 | if (field.type === 'select') { 642 | input = document.createElement('select'); 643 | field.options.forEach(opt => { 644 | const option = document.createElement('option'); 645 | option.value = opt; 646 | option.textContent = LanguageManager.translate(opt); 647 | input.appendChild(option); 648 | }); 649 | } else { 650 | input = document.createElement('input'); 651 | input.type = field.type; 652 | input.placeholder = LanguageManager.translate(field.placeholderKey); 653 | } 654 | 655 | input.name = field.name; 656 | input.className = 'w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-600 rounded-xl focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all input-bg'; 657 | 658 | // Auto-generate on input change 659 | // Use blur event for validation to avoid auto-validate while typing 660 | input.addEventListener('blur', autoGenerateQR); 661 | input.addEventListener('change', autoGenerateQR); 662 | 663 | div.appendChild(label); 664 | div.appendChild(input); 665 | 666 | // Add error message container 667 | const errorMsg = document.createElement('p'); 668 | errorMsg.className = 'text-xs text-red-500 mt-1 hidden'; 669 | errorMsg.id = `error-${field.name}`; 670 | div.appendChild(errorMsg); 671 | 672 | container.appendChild(div); 673 | }); 674 | 675 | // Show/hide campaign section 676 | const hasCampaign = typeFields.some(field => field.hasCampaign); 677 | const campaignSection = document.getElementById('campaignSection'); 678 | if (campaignSection) { 679 | campaignSection.classList.toggle('hidden', !hasCampaign); 680 | } 681 | } 682 | 683 | // Get data from inputs with validation 684 | async function getData() { 685 | const type = selectedDataType; 686 | const inputs = document.querySelectorAll('#inputFields input, #inputFields select'); 687 | const data = {}; 688 | 689 | // Collect input values 690 | inputs.forEach(input => { 691 | if (input.type === 'file') { 692 | data[input.name] = input.files[0]; 693 | } else { 694 | data[input.name] = input.value.trim(); 695 | } 696 | }); 697 | 698 | // Validate primary input based on type 699 | const validator = validators[type]; 700 | if (validator && data.url) { 701 | const result = validator(data.url); 702 | if (!result.valid) { 703 | const errorMsg = result.messageKey ? LanguageManager.translate(result.messageKey) : 'Invalid input'; 704 | alert(errorMsg); 705 | return null; 706 | } 707 | // Use processed value 708 | data.url = result.processed; 709 | } 710 | 711 | // Validate username fields for social media 712 | if (validator && data.username) { 713 | const result = validator(data.username); 714 | if (!result.valid) { 715 | const errorMsg = result.messageKey ? LanguageManager.translate(result.messageKey) : 'Invalid input'; 716 | alert(errorMsg); 717 | return null; 718 | } 719 | data.username = result.processed; 720 | } 721 | 722 | // Validate phone fields 723 | if (validators[type] && data.phone) { 724 | const result = validators[type](data.phone); 725 | if (!result.valid) { 726 | const errorMsg = result.messageKey ? LanguageManager.translate(result.messageKey) : 'Invalid input'; 727 | alert(errorMsg); 728 | return null; 729 | } 730 | data.phone = result.processed; 731 | } 732 | 733 | // Validate email 734 | if (type === 'email' && data.email) { 735 | const result = validators.email(data.email); 736 | if (!result.valid) { 737 | const errorMsg = result.messageKey ? LanguageManager.translate(result.messageKey) : 'Invalid input'; 738 | alert(errorMsg); 739 | return null; 740 | } 741 | data.email = result.processed; 742 | } 743 | 744 | let qrData = ''; 745 | 746 | switch(type) { 747 | case 'url': 748 | case 'youtube': 749 | case 'linkedin': 750 | case 'facebook': 751 | case 'spotify': 752 | qrData = data.url; 753 | break; 754 | case 'text': 755 | qrData = data.text; 756 | break; 757 | case 'email': 758 | qrData = `mailto:${data.email}`; 759 | break; 760 | case 'phone': 761 | qrData = `tel:${data.phone}`; 762 | break; 763 | case 'sms': 764 | qrData = `sms:${data.phone}?body=${encodeURIComponent(data.message)}`; 765 | break; 766 | case 'wifi': 767 | qrData = `WIFI:T:${data.security};S:${data.ssid};P:${data.password};;`; 768 | break; 769 | case 'whatsapp': 770 | qrData = `https://wa.me/${data.phone.replace(/[^0-9]/g, '')}${data.message ? '?text=' + encodeURIComponent(data.message) : ''}`; 771 | break; 772 | case 'instagram': 773 | case 'tiktok': 774 | case 'telegram': 775 | // Use processed username (already contains full URL from validator) 776 | qrData = data.username; 777 | break; 778 | case 'snapchat': 779 | qrData = `https://www.snapchat.com/add/${data.username}`; 780 | break; 781 | case 'discord': 782 | qrData = `https://discord.gg/${data.invite}`; 783 | break; 784 | case 'x': 785 | qrData = `https://x.com/${data.username}`; 786 | break; 787 | case 'file': 788 | return new Promise((resolve) => { 789 | const reader = new FileReader(); 790 | reader.onload = (e) => resolve(e.target.result); 791 | reader.readAsDataURL(data.file); 792 | }); 793 | case 'address': 794 | qrData = data.address; 795 | break; 796 | } 797 | 798 | // Add campaign tracking if enabled 799 | const typeFields = fields[type] || []; 800 | const hasCampaign = typeFields.some(field => field.hasCampaign); 801 | const enableCampaign = document.getElementById('enableCampaign'); 802 | 803 | if (hasCampaign && enableCampaign?.checked) { 804 | const utm = []; 805 | const source = document.getElementById('utmSource')?.value; 806 | const medium = document.getElementById('utmMedium')?.value; 807 | const campaign = document.getElementById('utmCampaign')?.value; 808 | 809 | if (source) utm.push(`utm_source=${encodeURIComponent(source)}`); 810 | if (medium) utm.push(`utm_medium=${encodeURIComponent(medium)}`); 811 | if (campaign) utm.push(`utm_campaign=${encodeURIComponent(campaign)}`); 812 | 813 | if (utm.length > 0) { 814 | qrData += (qrData.includes('?') ? '&' : '?') + utm.join('&'); 815 | } 816 | } 817 | 818 | return qrData; 819 | } 820 | 821 | // Start app when DOM is ready 822 | if (document.readyState === 'loading') { 823 | document.addEventListener('DOMContentLoaded', init); 824 | } else { 825 | init(); 826 | } 827 | --------------------------------------------------------------------------------