diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 00000000..02be1cdc
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "web-ui",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "api"],
+ "port": 3010
+ }
+ ]
+}
diff --git a/.gitignore b/.gitignore
index 356fb010..aa2cfb2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ note
.DS_Store
.playwright-chromium-installed
.stfolder/
-.env
\ No newline at end of file
+.env
+scheduled_tasks.json
\ No newline at end of file
diff --git a/WEB_UI_GUIDE.md b/WEB_UI_GUIDE.md
new file mode 100644
index 00000000..d829acf4
--- /dev/null
+++ b/WEB_UI_GUIDE.md
@@ -0,0 +1,611 @@
+# Microsoft Rewards Script - Complete Beginner's Guide
+
+A start-to-finish guide to installing and using this tool through its new **Web UI**. No coding experience needed. Every command is something you copy, paste, and press Enter on.
+
+---
+
+## Table of Contents
+
+1. [What this tool actually does](#1-what-this-tool-actually-does)
+2. [Prerequisites and system requirements](#2-prerequisites-and-system-requirements)
+3. [Environment setup (one time only)](#3-environment-setup-one-time-only)
+4. [Starting the Web UI](#4-starting-the-web-ui)
+5. [How to use the Web UI](#5-how-to-use-the-web-ui)
+6. [Optional: turning on scheduling](#6-optional-turning-on-scheduling)
+7. [Troubleshooting](#7-troubleshooting)
+8. [Safety and privacy notes](#8-safety-and-privacy-notes)
+
+---
+
+## 1. What this tool actually does
+
+Think of it as **a robot that uses a web browser for you**.
+
+You normally earn Microsoft Rewards points by searching on Bing, clicking daily
+cards, and reading articles. This tool opens a real browser, signs in as you,
+and does that clicking and searching itself.
+
+There are three pieces. It helps to picture them like a restaurant:
+
+| Piece | Restaurant analogy | What it really is |
+| -------------- | ------------------ | ------------------------------------------------------- |
+| **The bot** | The chef | The part that opens browsers and collects points |
+| **The server** | The waiter | A small program that takes your orders and reports back |
+| **The Web UI** | The menu | The page you click on in your browser |
+
+You will mostly only touch **the menu** (the Web UI). But the waiter has to be
+awake for the menu to work, which is why you will start the server in Step 4 and
+leave it running.
+
+---
+
+## 2. Prerequisites and system requirements
+
+### What your computer needs
+
+- **Windows, macOS, or Linux.** Any of them is fine.
+- **About 2 GB of free disk space.** The tool downloads its own private copy of
+ a browser, which is most of that.
+- **An internet connection.**
+- **A screen.** For your very first sign-in you need to see a browser window, so
+ this doesn't work on a screen-less server for that one step.
+
+### What you need to have ready
+
+- **A Microsoft account** that already has Rewards enabled. Sign in at
+ [rewards.bing.com](https://rewards.bing.com) once in your normal browser and
+ make sure it works before automating it.
+- **Your account's email address.**
+- **Your password** _only if_ your account signs in with a password. If you use
+ the Microsoft Authenticator app with no password, you can skip it.
+- **20 to 30 minutes** for the one-time setup. Most of that is waiting for
+ downloads.
+
+### Software you will install
+
+Just one thing: **Node.js**, version 24 or newer. Step 3 walks you through it.
+
+Node.js is the engine this tool runs on. Nothing about it requires you to write
+code, the same way driving a car doesn't require you to build an engine.
+
+> **A note on comfort level:** you will be typing commands into a black window
+> (the "terminal"). That is normal and expected. You are not programming. You are
+> giving short instructions, like typing a web address into a browser.
+
+---
+
+## 3. Environment setup (one time only)
+
+Do these five steps in order. Each one builds on the last.
+
+### Step 3.1 - Install Node.js
+
+**On Windows:**
+
+1. Go to **[nodejs.org](https://nodejs.org)**.
+2. Download the **LTS** version (the big green button). Make sure the version
+ number starts with **24** or higher.
+3. Open the downloaded file and click **Next** through the installer. The default
+ options are correct, do not change anything.
+4. When it finishes, restart your computer. This matters. Windows needs a restart
+ to notice that Node.js exists.
+
+**On macOS:** download the LTS `.pkg` installer from
+[nodejs.org](https://nodejs.org) and run it.
+
+**On Linux:** use [nodesource](https://github.com/nodesource/distributions) or
+your package manager, and confirm you get version 24 or newer.
+
+**Check that it worked.** Open a terminal:
+
+- **Windows:** press the **Windows key**, type `powershell`, press Enter.
+- **macOS:** press **Cmd+Space**, type `terminal`, press Enter.
+- **Linux:** press **Ctrl+Alt+T**.
+
+Type this and press Enter:
+
+```bash
+node --version
+```
+
+You should see something like `v24.5.0`. If you see "command not found", Node.js
+did not install correctly, or you skipped the restart.
+
+> **If the number is lower than 24**, you have an old Node.js. Install the new
+> one over it using the steps above.
+
+### Step 3.2 - Get the tool onto your computer
+
+**The easy way (no Git needed):**
+
+1. Go to the project's GitHub page.
+2. Click the green **Code** button, then **Download ZIP**.
+3. Right-click the downloaded ZIP and choose **Extract All**.
+4. Move the extracted folder somewhere you'll remember, like your Documents
+ folder.
+
+> **Avoid folders synced by OneDrive, Dropbox, or Google Drive if you can.**
+> They sometimes lock files while the tool is writing to them, which causes
+> confusing errors.
+
+### Step 3.3 - Point your terminal at the folder
+
+Your terminal needs to be "inside" the project folder, the way File Explorer is
+inside a folder when you double-click it.
+
+**The easiest method on Windows:**
+
+1. Open the project folder in File Explorer.
+2. Click the address bar at the top (where the folder path is shown).
+3. Type `powershell` and press Enter.
+
+A terminal opens already pointed at the right place.
+
+**Any platform:** type `cd ` (with a space), then drag the folder from your file
+manager onto the terminal window, then press Enter.
+
+**Check that you're in the right place:**
+
+```bash
+ls
+```
+
+You should see file names including `package.json` and `config.example.json`. If
+you don't, you're in the wrong folder.
+
+> **Keep this terminal open.** Every command from here on gets typed into it.
+
+### Step 3.4 - Install the tool's parts
+
+Type this and press Enter:
+
+```bash
+npm run pre-build
+```
+
+**This takes a while.** Five to fifteen minutes is normal. It is downloading the
+tool's private browser, which is a few hundred megabytes.
+
+You will see a lot of scrolling text. That's fine. Warnings in yellow are
+normal and safe to ignore. Only stop and worry if it ends with the word `error`
+and nothing else happens.
+
+Then run:
+
+```bash
+npm run build
+```
+
+This one is quick, usually under a minute. It converts the project's source
+files into the form the computer actually runs.
+
+> **Remember this command.** Any time you change the project's files, you run
+> `npm run build` again to apply the change.
+
+### Step 3.5 - Create your two settings files
+
+The project ships **example** settings files. You make your own copies and fill
+them in. The examples are templates, like a blank form.
+
+**File 1: `config.json`** - controls how the bot behaves.
+
+Find the file named `config.example.json` in the project folder. **Copy** it, and
+rename the copy to exactly `config.json`.
+
+- Windows: right-click → Copy, right-click → Paste, then rename
+ `config.example - Copy.json` to `config.json`.
+- Make sure the name is exactly `config.json`, with no "example" and no "copy".
+
+You do not need to edit anything inside it. The defaults are fine.
+
+**File 2: `.env`** - holds your account email and password.
+
+Find the file named `env.example`. Copy it and rename the copy to exactly
+`.env` - starting with a dot, and with **no** file extension.
+
+> **Windows tip:** Explorer may refuse to let you name a file starting with a
+> dot. Work around it by naming it `.env.` **with a trailing dot** - Windows
+> strips that automatically and leaves you with `.env`.
+
+Now open `.env` in **Notepad** (right-click → Open with → Notepad). You'll see
+lines like this:
+
+```env
+ACCOUNT_1_EMAIL=email@example.com
+#ACCOUNT_1_PASSWORD=your_password
+```
+
+Change the email to your real one. If your account uses a password, delete the
+`#` at the start of the password line and put your real password in:
+
+```env
+ACCOUNT_1_EMAIL=your.real.email@outlook.com
+ACCOUNT_1_PASSWORD=YourRealPassword
+```
+
+**The `#` means "ignore this line."** Removing it switches the line on.
+
+If your account uses the Authenticator app instead of a password, leave the
+password line alone with its `#`. The tool handles that.
+
+Save the file and close Notepad.
+
+> **If you use two-factor authentication**, there's a line for
+> `ACCOUNT_1_TOTP_SECRET`. Filling it in lets the tool generate your 6-digit
+> codes automatically. To get the value: in your Microsoft security settings,
+> open "Manage how you sign in", add an authenticator app, and when the QR code
+> appears choose **"enter code manually"**. Paste that code as the value.
+
+**Setup is done.** You never have to repeat Steps 3.1 through 3.5.
+
+---
+
+## 4. Starting the Web UI
+
+Two steps: wake up the server, then open the page.
+
+### Step 4.1 - Start the server
+
+In your terminal (still inside the project folder), run:
+
+```bash
+npm run api
+```
+
+You'll see a few lines of startup text and then the terminal will appear to
+**hang**, showing no new output and no prompt.
+
+**That is correct.** The server is now running and listening. It's supposed to
+sit there. The terminal is not frozen.
+
+> **Leave this terminal window open and do not close it.** Closing it turns the
+> server off, and the Web UI stops working. Minimize it instead.
+
+### Step 4.2 - Open the page
+
+Open your normal web browser (Chrome, Edge, Firefox, whatever you use) and go to:
+
+```
+http://127.0.0.1:3010
+```
+
+`127.0.0.1` always means "this computer." You're not visiting the internet, just
+your own machine.
+
+You should see the **Microsoft Rewards Control** dashboard.
+
+Look at the top right. There's a **Server** badge:
+
+| Badge | Meaning |
+| ----------- | ----------------------------------------------------------- |
+| **Online** | Server is awake, nothing running. This is what you want. |
+| **Running** | A run is in progress right now. |
+| **Offline** | The page can't reach the server. Check Step 4.1's terminal. |
+
+If it says Offline, your server terminal is probably closed or errored out.
+
+### Every time after today
+
+Starting the tool is just those two steps:
+
+1. Open a terminal in the project folder, run `npm run api`
+2. Open `http://127.0.0.1:3010`
+
+---
+
+## 5. How to use the Web UI
+
+The page is a single column of sections, top to bottom. Here's each one.
+
+### 5.1 - Add Account
+
+The box at the top. Type an email address and click **Add Account**.
+
+This writes the email into your `.env` file for you, so you don't have to edit
+that file by hand again. The account appears in the list immediately.
+
+> **Important: the UI never asks for your password, on purpose.** Passwords only
+> ever live in your `.env` file on your own disk. If the account you just added
+> needs a password, open `.env` in Notepad and add a matching line. For an
+> account added as `ACCOUNT_2`, the line is `ACCOUNT_2_PASSWORD=yourpassword`.
+
+### 5.2 - Account Overview
+
+Four counters showing the health of your accounts.
+
+| Counter | What it means |
+| ------------------ | --------------------------------------------------------------- |
+| **Total Accounts** | How many accounts the tool knows about |
+| **Logged In** | Has a valid saved sign-in. Ready to run. |
+| **Not Logged In** | Has never signed in. Needs the step below. |
+| **Login Expired** | Was signed in, but the saved sign-in went stale. Sign in again. |
+
+**A brand new account starts as "Not Logged In." That's expected.**
+
+### 5.3 - First sign-in for a new account
+
+Microsoft will not let a robot get through a fresh sign-in cleanly, so you do
+the first one yourself. Once. It gets saved after that.
+
+Open a **second** terminal in the project folder (leave the server one alone)
+and run this, with your own email:
+
+```bash
+npm run manual-login -- --email your.real.email@outlook.com
+```
+
+A browser window opens. Sign in normally, exactly like any other day, including
+any 2FA prompt. When you land on the Rewards page, **wait about five seconds**.
+The window closes by itself and your sign-in is saved.
+
+Back on the dashboard, that account flips to **Logged In** within a few seconds.
+
+> Do this again whenever an account shows **Login Expired**. It's the standard
+> fix for almost every sign-in problem.
+
+### 5.4 - Execution Settings
+
+Four controls that decide _how_ the next run behaves. **Set these before you
+press Run.** Your choices are remembered in your browser for next time.
+
+**Run in Headless Mode**
+
+- **Off** (default): you see the browser windows open and click around.
+- **On**: everything happens invisibly in the background.
+
+Off is better while you're learning, because you can see what's happening. On is
+better once you trust it, so windows don't steal focus while you work.
+
+> Don't use headless for a first sign-in. Use `manual-login` from Section 5.3.
+
+**Run Visual Search**
+
+Off by default. Turn it on to also do Bing Visual Search tasks (the ones where
+you search using a picture instead of words) for extra daily points.
+
+**Run 30-Minute Edge Browsing**
+
+Off by default. Turn it on to complete the Edge browsing reward, where the tool
+browses in the background for half an hour.
+
+> **This makes the run take at least 30 minutes longer.** That's the reward's
+> own requirement, not the tool being slow. It's marked experimental because
+> Microsoft changes how it works from time to time.
+
+**Schedule for Later**
+
+A date and time picker. Leave it **empty** to run right now. Fill it in only if
+you're using the Schedule Run button (see Section 5.7).
+
+### 5.5 - Ready to Execute
+
+Your account list, plus the action buttons.
+
+Each account row shows a **checkbox**, the **email**, how many **runs** it has
+done, when it **last** ran, its **points**, and a **status badge**.
+
+**How to run:**
+
+1. **Tick the checkbox** on each account you want to run. Or click **Select All**
+ to tick everything (it turns into **Deselect All**).
+2. Double-check your toggles in Execution Settings.
+3. Click **Run Selected**.
+
+A small message slides in confirming the start, the Server badge flips to
+**Running**, and log lines start appearing at the bottom of the page.
+
+**What to expect:** a full run takes a while, commonly 20 to 60 minutes per
+account, longer with Edge Browsing on. The tool deliberately pauses between
+actions so it behaves like a person rather than a machine. Slow is intentional.
+
+**The Stop button** is greyed out unless something is actually running. Click it
+to end the run: it asks the bot to shut its browsers down cleanly, and if the bot
+doesn't respond in time the server force-closes it. No orphaned browser windows
+left behind either way.
+
+**The Remove button** takes an account off this list, but it **does not delete it
+from your `.env` file**, so it will reappear on the next refresh. To remove an
+account for good, open `.env` in Notepad and delete its `ACCOUNT_N_EMAIL` line.
+
+### 5.6 - Scheduled Tasks
+
+Lists runs you've queued for a future time. Each entry shows the time, which
+accounts, and a **Cancel** button.
+
+### 5.7 - How to schedule a run
+
+1. Tick the accounts you want.
+2. Set the date and time in **Schedule for Later**. It must be in the future,
+ and it's your computer's local clock.
+3. Click **Schedule Run**.
+
+**Two things have to be true for a scheduled run to actually fire:**
+
+- Scheduling must be switched on. It's **off by default** as a safety measure.
+ See [Section 6](#6-optional-turning-on-scheduling). If it's off, you'll get an
+ error message mentioning `API_ALLOW_SCHEDULE_WRITE`.
+- **Your server terminal must still be running when the time arrives**, and your
+ computer must be awake. Nothing can start a run if the waiter has gone home.
+
+### 5.8 - Live Logs
+
+The black console at the bottom. This is the bot narrating what it's doing, live.
+
+Each line has a timestamp, a level, and a message. The level is the useful part:
+
+| Level | Colour | What it means |
+| --------- | ------ | --------------------------------------------------- |
+| **INFO** | Normal | Routine progress. Ignore these. |
+| **WARN** | Yellow | Something was skipped or retried. Usually harmless. |
+| **ERROR** | Red | Something actually failed. Worth reading. |
+
+**Auto-scroll** (on by default) keeps the newest line in view. Turn it off when
+you want to scroll up and read something without being yanked back down.
+
+**Clear** empties the display. It only clears your view, not the server's
+records, so reloading the page brings recent history back.
+
+The console keeps the most recent 500 lines and replays the last 100 when you
+open the page, so you don't come back to a blank screen.
+
+**When something goes wrong, this is the first place to look.** Scroll to the
+red lines and read the message.
+
+---
+
+## 6. Optional: turning on scheduling
+
+Scheduling is disabled by default so that nothing can queue up runs on your
+machine without you deliberately allowing it. Turning it on is a one-line
+change.
+
+1. Stop the server: click its terminal window and press **Ctrl+C**.
+2. Open your `.env` file in Notepad.
+3. Add this line at the bottom:
+
+```env
+API_ALLOW_SCHEDULE_WRITE=true
+```
+
+4. Save and close.
+5. Start the server again with `npm run api`.
+
+**Schedule Run** now works.
+
+---
+
+## 7. Troubleshooting
+
+### The page says "Offline"
+
+The server isn't running or isn't reachable.
+
+- Check the terminal where you ran `npm run api`. Still open? Any red text?
+- If it's closed, run `npm run api` again.
+- Confirm the address is exactly `http://127.0.0.1:3010`.
+
+### "Port 3010 is already in use"
+
+A server is already running, probably from earlier. Either use the one that's
+already up (just open the page), or close the other terminal window and try
+again.
+
+### An account is stuck on "Not Logged In" or "Login Expired"
+
+Run the manual sign-in from Section 5.3. This fixes the large majority of
+sign-in problems.
+
+```bash
+npm run manual-login -- --email your.real.email@outlook.com
+```
+
+If it keeps expiring, delete the saved sessions and start clean:
+
+```bash
+npm run clear-sessions -- email your.real.email@outlook.com
+```
+
+Then sign in manually again.
+
+### "No accounts configured in .env yet"
+
+The email is in your browser's list but not in the `.env` file. Add it again
+through the **Add Account** box, which writes the file for you.
+
+### Run Selected does nothing
+
+- Did you tick at least one checkbox? Selecting accounts is separate from
+ running them.
+- Is the badge showing **Running**? Only one run happens at a time, so the
+ button is disabled while one is in progress.
+
+### Visual Search or Edge Browsing didn't happen
+
+Confirm the toggle was **on before** you clicked Run Selected, not after. Then
+check the Live Logs for a line like:
+
+```
+[Config] override: CONFIG_WORKER_VISUAL_SEARCH -> .workers.doVisualSearch = true
+```
+
+That line is the tool confirming it received your toggle. If it's there, the
+feature was switched on for that run. If a feature still gets skipped after
+that, the log will say why just below it, usually that the account had no such
+task available that day.
+
+### A command failed with lots of red text
+
+Try, in order:
+
+1. `npm run build` and then retry.
+2. If that fails, `npm run pre-build` followed by `npm run build`.
+3. Check that `config.json` and `.env` both exist and are named exactly right. A
+ missing `config.json` is one of the most common causes.
+
+### Browser windows were left open after a crash
+
+On Windows:
+
+```bash
+npm run kill-chrome-win
+```
+
+---
+
+## 8. Safety and privacy notes
+
+**Your credentials stay on your machine.** Passwords live only in your `.env`
+file on your own disk. The Web UI never asks for them, never displays them, and
+never sends them anywhere. Never share your `.env` file, and never post it in a
+screenshot or a support thread.
+
+**The dashboard has no password on it by default.** It's bound to `127.0.0.1`,
+which means only programs on your own computer can reach it. Anyone else on your
+Wi-Fi cannot. That's a deliberate default.
+
+If you ever change the server to listen on your network (with something like
+`--host 0.0.0.0`), **set a token first**, or anyone on that network can start
+runs on your accounts. Add a line like `API_TOKEN=some-long-random-string` to
+your `.env` before doing that.
+
+**Don't put `.env` or `config.json` into a public GitHub repository.** The
+project already tells Git to ignore both, so this only happens if you go out of
+your way. Just don't.
+
+**About the risk to your account.** Automating Microsoft Rewards is against
+Microsoft's terms of service. Accounts do get suspended or banned for it. This
+tool tries to behave like a person, with realistic delays, but there is no
+guarantee. Use it on an account you can afford to lose, and understand you're
+accepting that risk yourself.
+
+---
+
+## Quick reference
+
+**Daily startup:**
+
+```bash
+npm run api
+```
+
+Then open `http://127.0.0.1:3010`.
+
+**Common commands:**
+
+| Command | What it does |
+| ------------------------------------------------- | -------------------------------------- |
+| `npm run api` | Starts the server for the Web UI |
+| `npm run build` | Applies changes you've made to files |
+| `npm run manual-login -- --email you@example.com` | Signs an account in by hand |
+| `npm run clear-sessions -- list` | Shows saved sign-ins |
+| `npm run clear-sessions -- email you@example.com` | Deletes one account's saved sign-in |
+| `npm run kill-chrome-win` | Closes stuck browser windows (Windows) |
+
+**Key files:**
+
+| File | Holds |
+| ------------- | ------------------------------------------ |
+| `.env` | Your emails, passwords, and server options |
+| `config.json` | How the bot behaves |
+
+**Keyboard:** **Ctrl+C** in the server terminal stops the server.
diff --git a/WEB_UI_GUIDE.vi.md b/WEB_UI_GUIDE.vi.md
new file mode 100644
index 00000000..36e99494
--- /dev/null
+++ b/WEB_UI_GUIDE.vi.md
@@ -0,0 +1,504 @@
+# Microsoft Rewards Script - Hướng dẫn đầy đủ cho người mới bắt đầu
+
+Hướng dẫn từ đầu đến cuối cách cài đặt và sử dụng công cụ này thông qua **Web UI** mới. Không cần biết lập trình. Mọi lệnh đều chỉ cần sao chép, dán và nhấn Enter.
+
+> **Lưu ý:** các nút và tên mục trong giao diện vẫn giữ nguyên tiếng Anh (ví dụ **Run Selected**), vì Web UI chưa được dịch. Lệnh, tên file và tên biến cũng giữ nguyên tiếng Anh.
+
+---
+
+## Mục lục
+
+1. [Công cụ này thực sự làm gì](#1-công-cụ-này-thực-sự-làm-gì)
+2. [Yêu cầu trước khi bắt đầu](#2-yêu-cầu-trước-khi-bắt-đầu)
+3. [Cài đặt môi trường (chỉ làm một lần)](#3-cài-đặt-môi-trường-chỉ-làm-một-lần)
+4. [Khởi động Web UI](#4-khởi-động-web-ui)
+5. [Cách sử dụng Web UI](#5-cách-sử-dụng-web-ui)
+6. [Tùy chọn: bật tính năng lên lịch](#6-tùy-chọn-bật-tính-năng-lên-lịch)
+7. [Xử lý sự cố](#7-xử-lý-sự-cố)
+8. [Lưu ý về an toàn và bảo mật](#8-lưu-ý-về-an-toàn-và-bảo-mật)
+9. [Tra cứu nhanh](#tra-cứu-nhanh)
+
+---
+
+## 1. Công cụ này thực sự làm gì
+
+Hãy hình dung nó như **một con robot dùng trình duyệt web thay cho bạn**.
+
+Bình thường bạn kiếm điểm Microsoft Rewards bằng cách tìm kiếm trên Bing, bấm vào các thẻ nhiệm vụ hằng ngày và đọc bài viết. Công cụ này sẽ mở một trình duyệt thật, đăng nhập bằng tài khoản của bạn, rồi tự bấm và tự tìm kiếm.
+
+Có ba phần. Hình dung chúng như một nhà hàng sẽ dễ hiểu hơn:
+
+| Phần | Ví von nhà hàng | Thực chất là gì |
+| ---------- | --------------- | ----------------------------------------------------- |
+| **Bot** | Đầu bếp | Phần mở trình duyệt và thu thập điểm |
+| **Server** | Người phục vụ | Chương trình nhỏ nhận lệnh của bạn và báo lại kết quả |
+| **Web UI** | Thực đơn | Trang web bạn bấm vào trong trình duyệt |
+
+Bạn gần như chỉ cần đụng tới **thực đơn** (Web UI). Nhưng người phục vụ phải đang làm việc thì thực đơn mới có tác dụng, vì vậy ở bước 4 bạn sẽ khởi động server và để nó chạy suốt.
+
+---
+
+## 2. Yêu cầu trước khi bắt đầu
+
+### Máy tính của bạn cần có
+
+- **Windows, macOS hoặc Linux.** Hệ điều hành nào cũng được.
+- **Khoảng 2 GB dung lượng trống.** Công cụ sẽ tải về một bản trình duyệt riêng của nó, chiếm phần lớn dung lượng này.
+- **Kết nối internet.**
+- **Một màn hình.** Lần đăng nhập đầu tiên bạn cần nhìn thấy cửa sổ trình duyệt, nên bước đó không chạy được trên máy chủ không có màn hình.
+
+### Bạn cần chuẩn bị sẵn
+
+- **Một tài khoản Microsoft** đã bật Rewards. Hãy đăng nhập thử tại [rewards.bing.com](https://rewards.bing.com) một lần bằng trình duyệt thường ngày và chắc chắn nó hoạt động trước khi tự động hóa.
+- **Địa chỉ email của tài khoản.**
+- **Mật khẩu**, _chỉ khi_ tài khoản của bạn đăng nhập bằng mật khẩu. Nếu bạn dùng ứng dụng Microsoft Authenticator và không có mật khẩu thì bỏ qua.
+- **20 đến 30 phút** cho việc cài đặt một lần. Phần lớn thời gian là chờ tải xuống.
+
+### Phần mềm cần cài
+
+Chỉ một thứ duy nhất: **Node.js**, phiên bản 24 trở lên. Bước 3 sẽ hướng dẫn chi tiết.
+
+Node.js là bộ máy chạy công cụ này. Bạn không cần viết bất kỳ dòng code nào, giống như lái xe không đòi hỏi bạn phải tự chế tạo động cơ.
+
+> **Một lời trấn an:** bạn sẽ gõ lệnh vào một cửa sổ màu đen (gọi là "terminal"). Chuyện này hoàn toàn bình thường. Bạn không hề lập trình. Bạn chỉ đang đưa ra vài chỉ dẫn ngắn, giống như gõ địa chỉ trang web vào trình duyệt.
+
+---
+
+## 3. Cài đặt môi trường (chỉ làm một lần)
+
+Làm lần lượt năm bước sau. Mỗi bước dựa trên bước trước.
+
+### Bước 3.1 - Cài Node.js
+
+**Trên Windows:**
+
+1. Vào **[nodejs.org](https://nodejs.org)**.
+2. Tải bản **LTS** (nút màu xanh lớn). Kiểm tra số phiên bản bắt đầu bằng **24** hoặc cao hơn.
+3. Mở file vừa tải và bấm **Next** xuyên suốt trình cài đặt. Các lựa chọn mặc định là đúng, đừng thay đổi gì cả.
+4. Cài xong thì khởi động lại máy. Bước này quan trọng: Windows cần khởi động lại mới nhận ra Node.js.
+
+**Trên macOS:** tải file cài đặt `.pkg` bản LTS từ [nodejs.org](https://nodejs.org) và chạy nó.
+
+**Trên Linux:** dùng [nodesource](https://github.com/nodesource/distributions) hoặc trình quản lý gói của hệ thống, và xác nhận bạn có phiên bản 24 trở lên.
+
+**Kiểm tra đã cài thành công.** Mở terminal:
+
+- **Windows:** nhấn **phím Windows**, gõ `powershell`, nhấn Enter.
+- **macOS:** nhấn **Cmd+Space**, gõ `terminal`, nhấn Enter.
+- **Linux:** nhấn **Ctrl+Alt+T**.
+
+Gõ lệnh này và nhấn Enter:
+
+```bash
+node --version
+```
+
+Bạn sẽ thấy kết quả dạng `v24.5.0`. Nếu thấy "command not found" thì Node.js chưa cài đúng, hoặc bạn đã bỏ qua bước khởi động lại.
+
+> **Nếu số phiên bản nhỏ hơn 24**, nghĩa là máy đang có Node.js cũ. Hãy cài bản mới đè lên theo các bước ở trên.
+
+### Bước 3.2 - Tải công cụ về máy
+
+**Cách đơn giản nhất (không cần Git):**
+
+1. Vào trang GitHub của dự án.
+2. Bấm nút **Code** màu xanh, rồi chọn **Download ZIP**.
+3. Chuột phải vào file ZIP vừa tải, chọn **Extract All**.
+4. Di chuyển thư mục đã giải nén tới nơi bạn dễ nhớ, ví dụ thư mục Documents.
+
+> **Nên tránh các thư mục được đồng bộ bởi OneDrive, Dropbox hoặc Google Drive.** Chúng đôi khi khóa file trong lúc công cụ đang ghi vào, gây ra những lỗi rất khó hiểu.
+
+### Bước 3.3 - Đưa terminal vào đúng thư mục
+
+Terminal cần phải "đứng bên trong" thư mục dự án, giống như cách File Explorer mở một thư mục khi bạn bấm đúp vào nó.
+
+**Cách dễ nhất trên Windows:**
+
+1. Mở thư mục dự án trong File Explorer.
+2. Bấm vào thanh địa chỉ ở trên cùng (nơi hiện đường dẫn thư mục).
+3. Gõ `powershell` và nhấn Enter.
+
+Một terminal sẽ mở ra, đã nằm sẵn đúng vị trí.
+
+**Mọi hệ điều hành:** gõ `cd ` (có dấu cách), rồi kéo thư mục từ trình quản lý file thả vào cửa sổ terminal, sau đó nhấn Enter.
+
+**Kiểm tra bạn đang đứng đúng chỗ:**
+
+```bash
+ls
+```
+
+Bạn phải thấy các tên file, trong đó có `package.json` và `config.example.json`. Nếu không thấy thì bạn đang ở sai thư mục.
+
+> **Giữ nguyên cửa sổ terminal này.** Mọi lệnh từ đây về sau đều gõ vào đó.
+
+### Bước 3.4 - Cài các thành phần của công cụ
+
+Gõ lệnh này và nhấn Enter:
+
+```bash
+npm run pre-build
+```
+
+**Lệnh này chạy khá lâu.** Từ năm đến mười lăm phút là bình thường. Nó đang tải trình duyệt riêng của công cụ, dung lượng vài trăm megabyte.
+
+Bạn sẽ thấy rất nhiều chữ chạy trên màn hình. Điều đó không sao cả. Các cảnh báo màu vàng là bình thường và có thể bỏ qua. Chỉ cần lo khi nó kết thúc bằng chữ `error` và không có gì xảy ra tiếp.
+
+Sau đó chạy:
+
+```bash
+npm run build
+```
+
+Lệnh này nhanh, thường chưa tới một phút. Nó chuyển các file mã nguồn của dự án sang dạng mà máy tính thực sự chạy được.
+
+> **Hãy nhớ lệnh này.** Bất cứ khi nào bạn thay đổi file trong dự án, chạy lại `npm run build` để áp dụng thay đổi.
+
+### Bước 3.5 - Tạo hai file cấu hình
+
+Dự án đi kèm các file cấu hình **mẫu**. Bạn tạo bản sao của riêng mình rồi điền thông tin vào. Các file mẫu giống như một tờ đơn trống.
+
+**File 1: `config.json`** - điều khiển cách bot hoạt động.
+
+Tìm file tên `config.example.json` trong thư mục dự án. **Sao chép** nó, rồi đổi tên bản sao thành đúng `config.json`.
+
+- Windows: chuột phải → Copy, chuột phải → Paste, sau đó đổi tên `config.example - Copy.json` thành `config.json`.
+- Hãy chắc chắn tên file đúng là `config.json`, không còn chữ "example" hay "copy".
+
+Bạn không cần sửa gì bên trong file này. Các giá trị mặc định là đủ dùng.
+
+**File 2: `.env`** - chứa email và mật khẩu tài khoản của bạn.
+
+Tìm file tên `env.example`. Sao chép nó và đổi tên bản sao thành đúng `.env` - bắt đầu bằng một dấu chấm, và **không có** đuôi mở rộng.
+
+> **Mẹo cho Windows:** Explorer có thể không cho bạn đặt tên file bắt đầu bằng dấu chấm. Cách lách: đặt tên là `.env.` **có dấu chấm ở cuối** - Windows sẽ tự bỏ dấu chấm đó và để lại đúng `.env`.
+
+Bây giờ mở `.env` bằng **Notepad** (chuột phải → Open with → Notepad). Bạn sẽ thấy các dòng như thế này:
+
+```env
+ACCOUNT_1_EMAIL=email@example.com
+#ACCOUNT_1_PASSWORD=your_password
+```
+
+Đổi email thành email thật của bạn. Nếu tài khoản dùng mật khẩu, xóa dấu `#` ở đầu dòng mật khẩu và điền mật khẩu thật vào:
+
+```env
+ACCOUNT_1_EMAIL=your.real.email@outlook.com
+ACCOUNT_1_PASSWORD=YourRealPassword
+```
+
+**Dấu `#` nghĩa là "bỏ qua dòng này."** Xóa nó đi là bật dòng đó lên.
+
+Nếu tài khoản của bạn dùng ứng dụng Authenticator thay vì mật khẩu, cứ để nguyên dòng mật khẩu cùng dấu `#`. Công cụ sẽ tự xử lý.
+
+Lưu file và đóng Notepad.
+
+> **Nếu bạn dùng xác thực hai lớp**, có một dòng dành cho `ACCOUNT_1_TOTP_SECRET`. Điền vào đó sẽ giúp công cụ tự sinh mã 6 số cho bạn. Cách lấy giá trị: trong phần cài đặt bảo mật của Microsoft, mở "Manage how you sign in", thêm một ứng dụng authenticator, và khi mã QR hiện ra hãy chọn **"enter code manually"**. Dán đoạn mã đó làm giá trị.
+
+**Cài đặt đã xong.** Bạn không bao giờ phải lặp lại các bước từ 3.1 đến 3.5.
+
+---
+
+## 4. Khởi động Web UI
+
+Hai bước: đánh thức server, rồi mở trang web.
+
+### Bước 4.1 - Khởi động server
+
+Trong terminal của bạn (vẫn đang ở thư mục dự án), chạy:
+
+```bash
+npm run api
+```
+
+Bạn sẽ thấy vài dòng chữ khởi động, sau đó terminal có vẻ như **đứng yên**, không in ra gì thêm và không hiện dấu nhắc lệnh.
+
+**Điều đó là đúng.** Server giờ đang chạy và đang lắng nghe. Nó được thiết kế để nằm yên như vậy. Terminal không hề bị treo.
+
+> **Hãy để cửa sổ terminal này mở, đừng đóng nó.** Đóng lại là tắt server, và Web UI sẽ ngừng hoạt động. Thay vào đó hãy thu nhỏ cửa sổ.
+
+### Bước 4.2 - Mở trang web
+
+Mở trình duyệt bạn vẫn dùng hằng ngày (Chrome, Edge, Firefox, gì cũng được) và vào địa chỉ:
+
+```
+http://127.0.0.1:3010
+```
+
+`127.0.0.1` luôn có nghĩa là "chính máy tính này". Bạn không truy cập internet, chỉ là đang nối vào máy của mình.
+
+Bạn sẽ thấy bảng điều khiển **Microsoft Rewards Control**.
+
+Nhìn lên góc trên bên phải. Ở đó có nhãn **Server**:
+
+| Nhãn | Ý nghĩa |
+| ----------- | -------------------------------------------------------------------------- |
+| **Online** | Server đang chạy, chưa có phiên nào đang chạy. Đây là trạng thái bạn muốn. |
+| **Running** | Đang có một phiên chạy ngay lúc này. |
+| **Offline** | Trang web không kết nối được với server. Xem lại terminal ở bước 4.1. |
+
+Nếu hiện Offline, nhiều khả năng terminal của server đã bị đóng hoặc đã báo lỗi.
+
+### Mỗi lần dùng sau này
+
+Khởi động công cụ chỉ gồm đúng hai bước đó:
+
+1. Mở terminal trong thư mục dự án, chạy `npm run api`
+2. Mở `http://127.0.0.1:3010`
+
+---
+
+## 5. Cách sử dụng Web UI
+
+Trang web là một cột duy nhất gồm các mục, từ trên xuống dưới. Dưới đây là từng mục.
+
+### 5.1 - Add Account (Thêm tài khoản)
+
+Ô ở trên cùng. Gõ địa chỉ email rồi bấm **Add Account**.
+
+Việc này sẽ tự ghi email vào file `.env` cho bạn, nên bạn không phải sửa tay file đó nữa. Tài khoản xuất hiện trong danh sách ngay lập tức.
+
+> **Quan trọng: giao diện cố ý không bao giờ hỏi mật khẩu của bạn.** Mật khẩu chỉ tồn tại trong file `.env` trên chính ổ đĩa của bạn. Nếu tài khoản vừa thêm cần mật khẩu, hãy mở `.env` bằng Notepad và thêm dòng tương ứng. Với tài khoản được thêm ở vị trí `ACCOUNT_2`, dòng cần thêm là `ACCOUNT_2_PASSWORD=yourpassword`.
+
+### 5.2 - Account Overview (Tổng quan tài khoản)
+
+Bốn ô đếm cho thấy tình trạng các tài khoản của bạn.
+
+| Ô đếm | Ý nghĩa |
+| ------------------ | --------------------------------------------------------- |
+| **Total Accounts** | Công cụ biết bao nhiêu tài khoản |
+| **Logged In** | Đã có phiên đăng nhập hợp lệ. Sẵn sàng chạy. |
+| **Not Logged In** | Chưa từng đăng nhập. Cần làm bước bên dưới. |
+| **Login Expired** | Từng đăng nhập nhưng phiên đã hết hạn. Cần đăng nhập lại. |
+
+**Một tài khoản mới tinh sẽ bắt đầu ở trạng thái "Not Logged In". Đó là chuyện bình thường.**
+
+### 5.3 - Đăng nhập lần đầu cho tài khoản mới
+
+Microsoft sẽ không để một con robot vượt qua bước đăng nhập mới một cách trơn tru, nên lần đầu tiên bạn phải tự đăng nhập. Chỉ một lần. Sau đó phiên đăng nhập sẽ được lưu lại.
+
+Mở một terminal **thứ hai** trong thư mục dự án (để yên terminal của server) và chạy lệnh này, thay bằng email của bạn:
+
+```bash
+npm run manual-login -- --email your.real.email@outlook.com
+```
+
+Một cửa sổ trình duyệt sẽ mở ra. Đăng nhập bình thường, y như mọi ngày, kể cả khi được hỏi mã 2FA. Khi bạn vào tới trang Rewards, **hãy đợi khoảng năm giây**. Cửa sổ sẽ tự đóng và phiên đăng nhập của bạn được lưu.
+
+Quay lại bảng điều khiển, tài khoản đó sẽ chuyển sang **Logged In** trong vòng vài giây.
+
+> Hãy làm lại bước này mỗi khi tài khoản hiện **Login Expired**. Đây là cách sửa chuẩn cho gần như mọi vấn đề đăng nhập.
+
+### 5.4 - Execution Settings (Cài đặt thực thi)
+
+Bốn nút điều khiển quyết định phiên chạy tiếp theo sẽ hoạt động _như thế nào_. **Hãy chỉnh chúng trước khi bấm Run.** Lựa chọn của bạn được trình duyệt ghi nhớ cho lần sau.
+
+**Run in Headless Mode (Chạy ở chế độ ẩn)**
+
+- **Tắt** (mặc định): bạn nhìn thấy các cửa sổ trình duyệt mở ra và tự bấm.
+- **Bật**: mọi thứ diễn ra vô hình ở nền.
+
+Để tắt thì tốt hơn khi bạn mới làm quen, vì bạn nhìn thấy chuyện gì đang xảy ra. Bật lên thì tiện hơn khi bạn đã tin tưởng nó, để các cửa sổ không giật mất màn hình lúc bạn đang làm việc khác.
+
+> Đừng dùng chế độ ẩn cho lần đăng nhập đầu tiên. Hãy dùng `manual-login` ở mục 5.3.
+
+**Run Visual Search (Chạy tìm kiếm bằng hình ảnh)**
+
+Mặc định là tắt. Bật lên để làm thêm các nhiệm vụ Bing Visual Search (loại nhiệm vụ tìm kiếm bằng hình ảnh thay vì bằng chữ) nhằm kiếm thêm điểm hằng ngày.
+
+**Run 30-Minute Edge Browsing (Chạy phiên duyệt Edge 30 phút)**
+
+Mặc định là tắt. Bật lên để hoàn thành phần thưởng duyệt Edge, khi đó công cụ sẽ duyệt web ở nền trong nửa giờ.
+
+> **Tính năng này làm phiên chạy kéo dài thêm ít nhất 30 phút.** Đó là yêu cầu của chính phần thưởng, không phải công cụ chạy chậm. Nó được đánh dấu là thử nghiệm vì Microsoft thỉnh thoảng thay đổi cách hoạt động.
+
+**Schedule for Later (Lên lịch để chạy sau)**
+
+Một ô chọn ngày và giờ. Hãy để **trống** nếu muốn chạy ngay. Chỉ điền vào khi bạn định dùng nút Schedule Run (xem mục 5.7).
+
+### 5.5 - Ready to Execute (Sẵn sàng thực thi)
+
+Danh sách tài khoản của bạn, cùng các nút hành động.
+
+Mỗi dòng tài khoản hiển thị một **ô checkbox**, **email**, số lần đã **chạy**, thời điểm chạy **gần nhất**, số **điểm**, và một **nhãn trạng thái**.
+
+**Cách chạy:**
+
+1. **Tích vào ô checkbox** của từng tài khoản bạn muốn chạy. Hoặc bấm **Select All** để tích hết (nút này sẽ đổi thành **Deselect All**).
+2. Kiểm tra lại các công tắc trong Execution Settings.
+3. Bấm **Run Selected**.
+
+Một thông báo nhỏ sẽ trượt ra xác nhận đã bắt đầu, nhãn Server chuyển sang **Running**, và các dòng log bắt đầu hiện ở cuối trang.
+
+**Điều nên biết:** một phiên chạy đầy đủ mất khá lâu, thường từ 20 đến 60 phút cho mỗi tài khoản, lâu hơn nếu bật Edge Browsing. Công cụ cố tình tạm nghỉ giữa các thao tác để hành xử giống người thật hơn là giống máy móc. Chậm là có chủ đích.
+
+**Nút Stop** bị mờ đi trừ khi thực sự có gì đó đang chạy. Bấm vào đó để kết thúc phiên chạy: nó yêu cầu bot tự đóng trình duyệt một cách gọn gàng, và nếu bot không phản hồi kịp thời thì server sẽ buộc đóng. Dù theo cách nào cũng không để sót cửa sổ trình duyệt mồ côi.
+
+**Nút Remove** chỉ gỡ tài khoản khỏi danh sách này, nhưng **không xóa nó khỏi file `.env`**, nên tài khoản sẽ xuất hiện lại ở lần làm mới trang kế tiếp. Muốn xóa hẳn một tài khoản, hãy mở `.env` bằng Notepad và xóa dòng `ACCOUNT_N_EMAIL` của nó.
+
+### 5.6 - Scheduled Tasks (Các tác vụ đã lên lịch)
+
+Liệt kê các phiên chạy bạn đã xếp hàng cho một thời điểm trong tương lai. Mỗi mục hiển thị giờ chạy, những tài khoản nào, và một nút **Cancel**.
+
+### 5.7 - Cách lên lịch một phiên chạy
+
+1. Tích chọn các tài khoản bạn muốn.
+2. Đặt ngày và giờ trong **Schedule for Later**. Thời điểm đó phải ở tương lai, và tính theo đồng hồ của chính máy bạn.
+3. Bấm **Schedule Run**.
+
+**Có hai điều kiện phải đúng thì phiên chạy đã lên lịch mới thực sự khởi động:**
+
+- Tính năng lên lịch phải được bật. Nó **mặc định là tắt** như một biện pháp an toàn. Xem [mục 6](#6-tùy-chọn-bật-tính-năng-lên-lịch). Nếu đang tắt, bạn sẽ nhận thông báo lỗi có nhắc tới `API_ALLOW_SCHEDULE_WRITE`.
+- **Terminal của server vẫn phải đang chạy khi đến giờ**, và máy tính phải đang ở trạng thái thức. Người phục vụ đã về nhà thì không ai bắt đầu phiên chạy được.
+
+### 5.8 - Live Logs (Log trực tiếp)
+
+Bảng console màu đen ở cuối trang. Đây là nơi bot tường thuật những gì nó đang làm, theo thời gian thực.
+
+Mỗi dòng có thời điểm, mức độ và nội dung thông báo. Phần mức độ là phần hữu ích nhất:
+
+| Mức độ | Màu sắc | Ý nghĩa |
+| --------- | ----------- | ---------------------------------------------------- |
+| **INFO** | Bình thường | Tiến trình thông thường. Có thể bỏ qua. |
+| **WARN** | Vàng | Có gì đó bị bỏ qua hoặc được thử lại. Thường vô hại. |
+| **ERROR** | Đỏ | Thực sự có lỗi. Đáng để đọc. |
+
+**Auto-scroll** (mặc định bật) giữ dòng mới nhất luôn nằm trong tầm nhìn. Hãy tắt nó khi bạn muốn cuộn lên đọc lại thứ gì đó mà không bị kéo tuột xuống dưới.
+
+**Clear** xóa trắng phần hiển thị. Nó chỉ xóa những gì bạn đang thấy, không xóa dữ liệu trên server, nên tải lại trang sẽ mang phần lịch sử gần đây trở lại.
+
+Console giữ tối đa 500 dòng gần nhất và phát lại 100 dòng cuối khi bạn mở trang, để bạn không quay lại một màn hình trống trơn.
+
+**Khi có chuyện trục trặc, đây là nơi đầu tiên cần xem.** Cuộn tới các dòng màu đỏ và đọc nội dung.
+
+---
+
+## 6. Tùy chọn: bật tính năng lên lịch
+
+Tính năng lên lịch mặc định bị tắt, để không gì có thể tự xếp hàng phiên chạy trên máy của bạn nếu bạn không chủ động cho phép. Bật nó lên chỉ cần thêm một dòng.
+
+1. Dừng server: bấm vào cửa sổ terminal của nó và nhấn **Ctrl+C**.
+2. Mở file `.env` bằng Notepad.
+3. Thêm dòng này vào cuối file:
+
+```env
+API_ALLOW_SCHEDULE_WRITE=true
+```
+
+4. Lưu và đóng lại.
+5. Khởi động server lần nữa bằng `npm run api`.
+
+Giờ **Schedule Run** đã hoạt động.
+
+---
+
+## 7. Xử lý sự cố
+
+### Trang web báo "Offline"
+
+Server không chạy hoặc không kết nối được.
+
+- Kiểm tra terminal nơi bạn chạy `npm run api`. Còn mở không? Có chữ màu đỏ nào không?
+- Nếu nó đã bị đóng, chạy lại `npm run api`.
+- Xác nhận địa chỉ đúng là `http://127.0.0.1:3010`.
+
+### "Port 3010 is already in use"
+
+Đã có một server đang chạy, nhiều khả năng là từ trước đó. Hoặc dùng luôn cái đang chạy (chỉ cần mở trang web), hoặc đóng cửa sổ terminal kia rồi thử lại.
+
+### Một tài khoản cứ kẹt ở "Not Logged In" hoặc "Login Expired"
+
+Hãy chạy bước đăng nhập thủ công ở mục 5.3. Cách này sửa được phần lớn các vấn đề đăng nhập.
+
+```bash
+npm run manual-login -- --email your.real.email@outlook.com
+```
+
+Nếu nó cứ hết hạn mãi, hãy xóa các phiên đã lưu và bắt đầu sạch sẽ:
+
+```bash
+npm run clear-sessions -- email your.real.email@outlook.com
+```
+
+Rồi đăng nhập thủ công lại lần nữa.
+
+### "No accounts configured in .env yet"
+
+Email có trong danh sách trên trình duyệt của bạn nhưng chưa có trong file `.env`. Hãy thêm lại qua ô **Add Account**, ô đó sẽ ghi file giúp bạn.
+
+### Bấm Run Selected mà không thấy gì
+
+- Bạn đã tích ít nhất một ô checkbox chưa? Chọn tài khoản là việc riêng với chạy chúng.
+- Nhãn trạng thái có đang hiện **Running** không? Mỗi lần chỉ có một phiên chạy, nên nút này bị vô hiệu hóa khi đang có phiên chạy.
+
+### Visual Search hoặc Edge Browsing không diễn ra
+
+Hãy xác nhận công tắc đã **bật trước khi** bạn bấm Run Selected, chứ không phải sau đó. Rồi kiểm tra trong Live Logs xem có dòng như thế này không:
+
+```
+[Config] override: CONFIG_WORKER_VISUAL_SEARCH -> .workers.doVisualSearch = true
+```
+
+Dòng đó là công cụ xác nhận đã nhận được lựa chọn của bạn. Nếu nó có mặt, tính năng đã được bật cho phiên chạy đó. Nếu sau đó tính năng vẫn bị bỏ qua, phần log ngay bên dưới sẽ nói rõ lý do, thường là tài khoản không có nhiệm vụ đó trong ngày hôm ấy.
+
+### Một lệnh báo lỗi với rất nhiều chữ đỏ
+
+Hãy thử lần lượt:
+
+1. `npm run build` rồi thử lại.
+2. Nếu vẫn lỗi, chạy `npm run pre-build` rồi tới `npm run build`.
+3. Kiểm tra cả `config.json` và `.env` đều tồn tại và được đặt tên chính xác. Thiếu `config.json` là một trong những nguyên nhân phổ biến nhất.
+
+### Cửa sổ trình duyệt còn mở sau khi bị lỗi
+
+Trên Windows:
+
+```bash
+npm run kill-chrome-win
+```
+
+---
+
+## 8. Lưu ý về an toàn và bảo mật
+
+**Thông tin đăng nhập của bạn nằm lại trên máy bạn.** Mật khẩu chỉ tồn tại trong file `.env` trên chính ổ đĩa của bạn. Web UI không bao giờ hỏi, không bao giờ hiển thị và không bao giờ gửi chúng đi đâu cả. Đừng bao giờ chia sẻ file `.env`, và đừng đăng nó lên ảnh chụp màn hình hay một chủ đề hỗ trợ kỹ thuật.
+
+**Bảng điều khiển mặc định không có mật khẩu bảo vệ.** Nó được gắn vào `127.0.0.1`, nghĩa là chỉ các chương trình trên chính máy bạn mới truy cập được. Người khác trong cùng mạng Wi-Fi thì không. Đó là một thiết lập mặc định có chủ ý.
+
+Nếu bạn thay đổi để server lắng nghe trên cả mạng (bằng thứ gì đó như `--host 0.0.0.0`), **hãy đặt token trước**, nếu không bất kỳ ai trong mạng đó cũng có thể khởi động phiên chạy trên tài khoản của bạn. Thêm một dòng như `API_TOKEN=some-long-random-string` vào `.env` trước khi làm việc đó.
+
+**Đừng đưa `.env` hoặc `config.json` lên một kho GitHub công khai.** Dự án đã cấu hình để Git bỏ qua cả hai file này, nên chuyện đó chỉ xảy ra nếu bạn cố tình làm. Đừng làm.
+
+**Về rủi ro với tài khoản của bạn.** Tự động hóa Microsoft Rewards là trái với điều khoản dịch vụ của Microsoft. Tài khoản thực sự có thể bị tạm khóa hoặc bị cấm vì việc này. Công cụ cố gắng hành xử giống người thật, với các khoảng nghỉ hợp lý, nhưng không có gì bảo đảm. Hãy dùng nó với một tài khoản mà bạn chấp nhận được nếu mất, và hiểu rằng bạn đang tự nhận lấy rủi ro đó.
+
+---
+
+## Tra cứu nhanh
+
+**Khởi động hằng ngày:**
+
+```bash
+npm run api
+```
+
+Rồi mở `http://127.0.0.1:3010`.
+
+**Các lệnh thường dùng:**
+
+| Lệnh | Tác dụng |
+| ------------------------------------------------- | -------------------------------------------- |
+| `npm run api` | Khởi động server cho Web UI |
+| `npm run build` | Áp dụng các thay đổi bạn đã làm vào file |
+| `npm run manual-login -- --email you@example.com` | Đăng nhập tài khoản bằng tay |
+| `npm run clear-sessions -- list` | Xem các phiên đăng nhập đã lưu |
+| `npm run clear-sessions -- email you@example.com` | Xóa phiên đăng nhập đã lưu của một tài khoản |
+| `npm run kill-chrome-win` | Đóng các cửa sổ trình duyệt bị kẹt (Windows) |
+
+**Các file quan trọng:**
+
+| File | Chứa gì |
+| ------------- | ---------------------------------------------- |
+| `.env` | Email, mật khẩu của bạn và các tùy chọn server |
+| `config.json` | Cách bot hoạt động |
+
+**Phím tắt:** nhấn **Ctrl+C** trong terminal của server để dừng server.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index c1e6c787..ea438f1a 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -25,6 +25,15 @@ export default tseslint.config(
'preserve-caught-error': 'off'
}
},
+ // Browser-side dashboard code
+ {
+ files: ['public/**/*.js'],
+ languageOptions: {
+ globals: {
+ ...globals.browser
+ }
+ }
+ },
// Must come last: disables ESLint rules that conflict with Prettier formatting
prettier
)
diff --git a/package.json b/package.json
index 9e736bc9..43f721cd 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,9 @@
"create-docker": "docker build -t microsoft-rewards-script-docker .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
+ "test": "node --test scripts/api/logParser.test.js tests/*.test.mjs",
"test:log-parser": "node --test scripts/api/logParser.test.js",
+ "test:abort": "node --test tests/abort.test.mjs",
"format": "prettier --write .",
"format:check": "prettier --check .",
"clear-diagnostics": "rimraf diagnostics",
diff --git a/public/README.md b/public/README.md
new file mode 100644
index 00000000..78d62f72
--- /dev/null
+++ b/public/README.md
@@ -0,0 +1,66 @@
+# Web UI for Microsoft Rewards Script
+
+A simplified, accessible web interface for managing Microsoft Rewards accounts.
+
+## Features
+
+- **Add accounts with email only** - no coding knowledge required
+- **Real-time dashboard** - view server status and account statistics
+- **Account queue** - see all accounts ready to execute
+- **Live point balance** - track points for each account
+- **Status tracking** - see which accounts are logged in, expired, or not logged in
+- **Selective execution** - choose which accounts to run with checkboxes
+- **Responsive design** - works on desktop, tablet, and mobile
+
+## Setup
+
+1. Start the API server:
+
+```bash
+npm run api
+```
+
+2. Open your browser to:
+
+```
+http://127.0.0.1:3010
+```
+
+3. Add accounts using the email form
+4. Select accounts and click "Run Selected"
+
+## Configuration
+
+The Web UI connects to the Control API at `http://127.0.0.1:3010` by default.
+
+To change the API URL, edit `public/app.js`:
+
+```javascript
+const API_BASE_URL = 'http://your-server:3010'
+```
+
+## How It Works
+
+- Accounts are stored in browser localStorage for persistence
+- The UI polls the API every 5 seconds for status updates
+- Point balances and run history come from the Control API
+- Account credentials remain in your `.env` file (not exposed to the UI)
+
+## Adding Accounts to .env
+
+After adding an account via the UI, you still need to add credentials to `.env`:
+
+```env
+ACCOUNT_1_EMAIL=email@example.com
+ACCOUNT_1_PASSWORD=your_password
+```
+
+Then rebuild: `npm run build`
+
+## Accessibility
+
+- Keyboard navigable
+- Screen reader compatible
+- ARIA labels on interactive elements
+- High contrast color scheme
+- Focus indicators on all controls
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 00000000..327977cf
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,720 @@
+// Configuration
+const API_BASE_URL = 'http://127.0.0.1:3010'
+const POLL_INTERVAL = 5000 // 5 seconds
+const MAX_LOG_LINES = 500
+
+// State
+let accounts = []
+let selectedAccountIndexes = new Set()
+let scheduledTasks = []
+let logSource = null
+
+// DOM Elements
+const emailInput = document.getElementById('emailInput')
+const addAccountForm = document.getElementById('addAccountForm')
+const accountsList = document.getElementById('accountsList')
+const serverStatusEl = document.getElementById('serverStatus')
+const totalAccountsEl = document.getElementById('totalAccounts')
+const loggedInCountEl = document.getElementById('loggedInCount')
+const notLoggedInCountEl = document.getElementById('notLoggedInCount')
+const expiredCountEl = document.getElementById('expiredCount')
+const selectAllBtn = document.getElementById('selectAllBtn')
+const runSelectedBtn = document.getElementById('runSelectedBtn')
+const scheduleSelectedBtn = document.getElementById('scheduleSelectedBtn')
+const headlessToggle = document.getElementById('headlessToggle')
+const visualSearchToggle = document.getElementById('visualSearchToggle')
+const edgeBrowsingToggle = document.getElementById('edgeBrowsingToggle')
+const stopBtn = document.getElementById('stopBtn')
+const scheduleTimeInput = document.getElementById('scheduleTime')
+const scheduledList = document.getElementById('scheduledList')
+const logsConsole = document.getElementById('logsConsole')
+const autoScrollToggle = document.getElementById('autoScrollToggle')
+const clearLogsBtn = document.getElementById('clearLogsBtn')
+const toast = document.getElementById('toast')
+
+// Initialize
+document.addEventListener('DOMContentLoaded', () => {
+ loadFromLocalStorage()
+ loadScheduledTasks()
+ setupEventListeners()
+ checkServerHealth()
+ startPolling()
+ connectLogStream()
+
+ // Load saved run preferences
+ restoreToggle('headless_mode', headlessToggle)
+ restoreToggle('visual_search', visualSearchToggle)
+ restoreToggle('edge_browsing', edgeBrowsingToggle)
+})
+
+function restoreToggle(key, element) {
+ const saved = localStorage.getItem(key)
+ if (saved !== null) element.checked = saved === 'true'
+}
+
+function setupEventListeners() {
+ addAccountForm.addEventListener('submit', handleAddAccount)
+ selectAllBtn.addEventListener('click', handleSelectAll)
+ runSelectedBtn.addEventListener('click', handleRunSelected)
+ scheduleSelectedBtn.addEventListener('click', handleScheduleSelected)
+ headlessToggle.addEventListener('change', handleHeadlessToggle)
+ visualSearchToggle.addEventListener('change', () =>
+ localStorage.setItem('visual_search', visualSearchToggle.checked)
+ )
+ edgeBrowsingToggle.addEventListener('change', () =>
+ localStorage.setItem('edge_browsing', edgeBrowsingToggle.checked)
+ )
+ stopBtn.addEventListener('click', handleStop)
+ clearLogsBtn.addEventListener('click', handleClearLogs)
+}
+
+// Live log stream (Server-Sent Events)
+function connectLogStream() {
+ if (logSource) logSource.close()
+
+ logsConsole.innerHTML = '
Connecting to log stream...
'
+
+ const source = new EventSource(`${API_BASE_URL}/events?replay=100`)
+ logSource = source
+
+ source.addEventListener('hello', event => {
+ const status = JSON.parse(event.data)
+ updateServerStatus(status.state === 'running' ? 'running' : 'online')
+ logsConsole.innerHTML = ''
+ })
+
+ source.addEventListener('log', event => {
+ appendLogLine(JSON.parse(event.data))
+ })
+
+ source.addEventListener('status', event => {
+ const status = JSON.parse(event.data)
+ updateServerStatus(status.state === 'running' ? 'running' : 'online')
+ })
+
+ source.onerror = () => {
+ updateServerStatus('offline')
+ // EventSource reconnects on its own; surface the gap without spamming.
+ if (!logsConsole.querySelector('.log-empty')) {
+ appendLogLine({ level: 'warn', message: 'Log stream disconnected - reconnecting...' })
+ }
+ }
+
+ window.addEventListener('beforeunload', () => source.close())
+}
+
+function appendLogLine(entry) {
+ const empty = logsConsole.querySelector('.log-empty')
+ if (empty) empty.remove()
+
+ const line = document.createElement('div')
+ line.className = 'log-line'
+ line.dataset.level = entry.level || 'info'
+
+ const time = document.createElement('span')
+ time.className = 'log-time'
+ time.textContent = formatLogTime(entry)
+
+ const level = document.createElement('span')
+ level.className = 'log-level'
+ level.textContent = (entry.level || 'info').toUpperCase()
+
+ const message = document.createElement('span')
+ message.className = 'log-message'
+ message.textContent = entry.title ? `[${entry.title}] ${entry.message ?? ''}` : (entry.message ?? '')
+
+ line.append(time, level, message)
+ logsConsole.append(line)
+
+ while (logsConsole.childElementCount > MAX_LOG_LINES) {
+ logsConsole.firstElementChild.remove()
+ }
+
+ const shouldScroll = autoScrollToggle.checked
+ const nearBottom = logsConsole.scrollHeight - logsConsole.scrollTop - logsConsole.clientHeight < 80
+ if (shouldScroll && nearBottom) {
+ logsConsole.scrollTop = logsConsole.scrollHeight
+ }
+}
+
+function formatLogTime(entry) {
+ const raw = entry.ts || entry.receivedAt
+ if (!raw) return '--:--:--'
+ const date = new Date(raw)
+ if (Number.isNaN(date.getTime())) return '--:--:--'
+ return date.toLocaleTimeString(undefined, { hour12: false })
+}
+
+function handleClearLogs() {
+ logsConsole.innerHTML = ''
+}
+
+// API Functions
+async function checkServerHealth() {
+ try {
+ const response = await fetch(`${API_BASE_URL}/health`)
+ const data = await response.json()
+
+ if (data.ok) {
+ updateServerStatus(data.state === 'running' ? 'running' : 'online')
+ await fetchAccounts()
+ }
+ } catch {
+ updateServerStatus('offline')
+ }
+}
+
+async function fetchAccounts() {
+ try {
+ const response = await fetch(`${API_BASE_URL}/accounts`)
+ const data = await response.json()
+
+ if (data.accounts) {
+ const apiAccounts = new Map()
+ const apiAccountEmails = new Set()
+
+ // Map API accounts by email - session-derived status wins so the
+ // badge reflects live cookies, not empty post-restart run history.
+ for (const acc of data.accounts) {
+ apiAccountEmails.add(acc.email)
+ apiAccounts.set(acc.email, {
+ index: acc.index,
+ email: acc.email,
+ points: acc.lastCollected || 0,
+ status: acc.sessionStatus ? acc.sessionStatus : determineAccountStatus(acc),
+ sessionStatus: acc.sessionStatus ?? null,
+ sessionUpdatedAt: acc.sessionUpdatedAt ?? null,
+ runs: acc.runs || 0,
+ lastRunAt: acc.lastRunAt,
+ lastSuccess: acc.lastSuccess,
+ isConfigured: true // Mark as configured in API
+ })
+ }
+
+ // Merge: update existing localStorage accounts with API data
+ accounts = accounts.map(localAcc => {
+ const apiData = apiAccounts.get(localAcc.email)
+ if (apiData) {
+ // Account exists in API, merge live data
+ return { ...localAcc, ...apiData }
+ }
+ // Account only in localStorage (not yet in .env)
+ return { ...localAcc, isConfigured: false }
+ })
+
+ // Adopt accounts that exist in .env but were never seen by this browser
+ const knownEmails = new Set(accounts.map(acc => acc.email))
+ for (const [email, apiData] of apiAccounts) {
+ if (!knownEmails.has(email)) accounts.push(apiData)
+ }
+
+ saveToLocalStorage()
+ renderAccounts()
+ updateStats()
+ }
+ } catch (error) {
+ console.error('Failed to fetch accounts:', error)
+ }
+}
+
+function determineAccountStatus(account) {
+ if (!account.lastRunAt) return 'not-logged-in'
+ if (account.lastSuccess === true) return 'logged-in'
+ if (account.lastSuccess === false) return 'expired'
+ return 'not-logged-in'
+}
+
+async function startMultipleAccounts(accountIndexes, options = {}) {
+ try {
+ const { headless = false, visualSearch = false, edgeBrowsing = false } = options
+
+ // Only work with accounts that are configured in the API
+ const apiAccounts = accounts.filter(acc => acc.isConfigured)
+
+ if (apiAccounts.length === 0) {
+ showToast('No accounts configured in .env yet. Add credentials and rebuild.', 'warning')
+ return
+ }
+
+ // Filter selected indexes to only include API-backed accounts
+ const validIndexes = accountIndexes.filter(idx => apiAccounts.some(acc => acc.index === idx))
+
+ if (validIndexes.length === 0) {
+ showToast('Selected accounts are not configured in .env yet', 'warning')
+ return
+ }
+
+ const allApiIndexes = apiAccounts.map(acc => acc.index)
+ const excludedIndexes = allApiIndexes.filter(idx => !validIndexes.includes(idx))
+
+ // These map onto config.json paths; both features are off in config by
+ // default, so without the overrides they never run.
+ const body = {
+ env: {
+ CONFIG_HEADLESS: headless ? 'true' : 'false',
+ CONFIG_WORKER_VISUAL_SEARCH: visualSearch ? 'true' : 'false',
+ CONFIG_EXPERIMENTAL_EDGE_BROWSING: edgeBrowsing ? 'true' : 'false'
+ }
+ }
+
+ if (excludedIndexes.length > 0) {
+ body.excludedAccountIndexes = excludedIndexes
+ }
+
+ const response = await fetch(`${API_BASE_URL}/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+
+ const data = await response.json()
+
+ if (response.ok) {
+ showToast(`Started ${validIndexes.length} account(s)${headless ? ' in headless mode' : ''}`, 'success')
+ await checkServerHealth()
+ return true
+ } else {
+ showToast(data.error || 'Failed to start accounts', 'error')
+ return false
+ }
+ } catch {
+ showToast('Failed to connect to server', 'error')
+ return false
+ }
+}
+
+// Event Handlers
+async function handleAddAccount(e) {
+ e.preventDefault()
+
+ const email = emailInput.value.trim()
+
+ if (!email) {
+ showToast('Please enter an email address', 'error')
+ return
+ }
+
+ if (accounts.some(acc => acc.email.toLowerCase() === email.toLowerCase())) {
+ showToast('Account already exists', 'warning')
+ return
+ }
+
+ const submitBtn = addAccountForm.querySelector('button[type="submit"]')
+ submitBtn.disabled = true
+ submitBtn.textContent = 'Adding...'
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/accounts`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email })
+ })
+ const data = await response.json()
+
+ if (response.ok) {
+ accounts.push({
+ index: data.index,
+ email: data.email,
+ points: 0,
+ status: 'not-logged-in',
+ runs: 0,
+ lastRunAt: null,
+ lastSuccess: null,
+ isConfigured: true
+ })
+ saveToLocalStorage()
+ renderAccounts()
+ updateStats()
+ emailInput.value = ''
+ showToast(`${data.email} added and ready to run`, 'success')
+ } else {
+ showToast(data.error || 'Failed to add account', 'error')
+ }
+ } catch {
+ showToast('Failed to connect to server', 'error')
+ }
+
+ submitBtn.disabled = false
+ submitBtn.textContent = 'Add Account'
+}
+
+function handleSelectAll() {
+ if (selectedAccountIndexes.size === accounts.length) {
+ selectedAccountIndexes.clear()
+ selectAllBtn.textContent = 'Select All'
+ } else {
+ accounts.forEach(acc => selectedAccountIndexes.add(acc.index))
+ selectAllBtn.textContent = 'Deselect All'
+ }
+ renderAccounts()
+}
+
+async function handleRunSelected() {
+ if (selectedAccountIndexes.size === 0) {
+ showToast('Please select at least one account', 'warning')
+ return
+ }
+
+ runSelectedBtn.disabled = true
+ runSelectedBtn.textContent = 'Starting...'
+
+ const indexArray = Array.from(selectedAccountIndexes)
+ const success = await startMultipleAccounts(indexArray, {
+ headless: headlessToggle.checked,
+ visualSearch: visualSearchToggle.checked,
+ edgeBrowsing: edgeBrowsingToggle.checked
+ })
+
+ runSelectedBtn.disabled = false
+ runSelectedBtn.textContent = 'Run Selected'
+
+ if (success) {
+ selectedAccountIndexes.clear()
+ selectAllBtn.textContent = 'Select All'
+ renderAccounts()
+ }
+}
+
+async function handleScheduleSelected() {
+ if (selectedAccountIndexes.size === 0) {
+ showToast('Please select at least one account', 'warning')
+ return
+ }
+
+ const scheduleTime = scheduleTimeInput.value
+ if (!scheduleTime) {
+ showToast('Please select a date and time', 'warning')
+ return
+ }
+
+ const scheduledDate = new Date(scheduleTime)
+ const now = new Date()
+
+ if (scheduledDate <= now) {
+ showToast('Schedule time must be in the future', 'warning')
+ return
+ }
+
+ scheduleSelectedBtn.disabled = true
+ scheduleSelectedBtn.textContent = 'Scheduling...'
+
+ const indexArray = Array.from(selectedAccountIndexes)
+ const headless = headlessToggle.checked
+ const visualSearch = visualSearchToggle.checked
+ const edgeBrowsing = edgeBrowsingToggle.checked
+ const accountEmails = accounts.filter(acc => indexArray.includes(acc.index)).map(acc => acc.email)
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/schedule/tasks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ accountIndexes: indexArray,
+ scheduledAt: scheduledDate.toISOString(),
+ headless,
+ visualSearch,
+ edgeBrowsing
+ })
+ })
+
+ const data = await response.json()
+
+ if (response.ok) {
+ const task = {
+ id: data.task.id,
+ accountIndexes: indexArray,
+ accountEmails,
+ scheduledAt: scheduledDate.toISOString(),
+ headless,
+ visualSearch,
+ edgeBrowsing,
+ createdAt: new Date().toISOString()
+ }
+
+ scheduledTasks.push(task)
+ saveScheduledTasks()
+ renderScheduledTasks()
+
+ const timeStr = scheduledDate.toLocaleString()
+ showToast(`Accounts scheduled to run at ${timeStr}`, 'success')
+
+ selectedAccountIndexes.clear()
+ selectAllBtn.textContent = 'Select All'
+ scheduleTimeInput.value = ''
+ renderAccounts()
+ } else {
+ showToast(data.error || 'Failed to schedule task', 'error')
+ }
+ } catch {
+ showToast('Failed to connect to server', 'error')
+ }
+
+ scheduleSelectedBtn.disabled = false
+ scheduleSelectedBtn.textContent = 'Schedule Run'
+}
+
+function handleHeadlessToggle() {
+ localStorage.setItem('headless_mode', headlessToggle.checked)
+}
+
+async function handleStop() {
+ stopBtn.disabled = true
+ stopBtn.textContent = 'Stopping...'
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/stop`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ force: false })
+ })
+
+ if (response.ok) {
+ showToast('Stopping run - closing browsers...', 'warning')
+ } else {
+ const data = await response.json().catch(() => ({}))
+ showToast(data.error || 'Failed to stop the run', 'error')
+ }
+ } catch {
+ showToast('Failed to connect to server', 'error')
+ }
+
+ stopBtn.textContent = 'Stop'
+ // updateServerStatus re-enables it while a run is still active.
+ await checkServerHealth()
+}
+
+function handleAccountCheckbox(index, checked) {
+ if (checked) {
+ selectedAccountIndexes.add(index)
+ } else {
+ selectedAccountIndexes.delete(index)
+ }
+
+ selectAllBtn.textContent = selectedAccountIndexes.size === accounts.length ? 'Deselect All' : 'Select All'
+ renderAccounts()
+}
+
+function handleDeleteAccount(index) {
+ if (!confirm('Remove this account from the queue?')) return
+
+ accounts = accounts.filter(acc => acc.index !== index)
+ selectedAccountIndexes.delete(index)
+ saveToLocalStorage()
+ renderAccounts()
+ updateStats()
+ showToast('Account removed', 'success')
+}
+
+async function handleCancelScheduledTask(taskId) {
+ if (!confirm('Cancel this scheduled task?')) return
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/schedule/tasks/${taskId}`, {
+ method: 'DELETE'
+ })
+
+ if (response.ok) {
+ scheduledTasks = scheduledTasks.filter(task => task.id !== taskId)
+ saveScheduledTasks()
+ renderScheduledTasks()
+ showToast('Scheduled task cancelled', 'success')
+ } else {
+ showToast('Failed to cancel task', 'error')
+ }
+ } catch {
+ // If API doesn't support cancellation, remove locally
+ scheduledTasks = scheduledTasks.filter(task => task.id !== taskId)
+ saveScheduledTasks()
+ renderScheduledTasks()
+ showToast('Scheduled task cancelled', 'success')
+ }
+}
+
+// Rendering Functions
+function renderAccounts() {
+ if (accounts.length === 0) {
+ accountsList.innerHTML = `
+
+
No accounts configured. Add an account above to get started.