├── .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 | 
6 | 
7 | 
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 |
242 | ${LanguageManager.translate('error_report_button')}
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 = `';
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 |
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 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | 🎨
78 | Màu sắc QR Code:
79 |
80 |
81 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | 📏
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | px
106 |
107 |
108 |
109 |
110 |
111 |
112 |
146 |
147 |
148 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
Live Preview
172 |
173 |
174 |
180 |
181 |
182 |
183 |
184 |
185 |
189 |
193 |
197 |
198 |
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 |
--------------------------------------------------------------------------------