Initialize fork and rebrand app to event_manager
@@ -0,0 +1,21 @@
|
||||
# Root editor config file
|
||||
root = true
|
||||
|
||||
# Common settings
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
# python, js indentation settings
|
||||
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
max_line_length = 99
|
||||
|
||||
# JSON files - mostly doctype schema files
|
||||
[{*.json}]
|
||||
insert_final_newline = false
|
||||
indent_style = space
|
||||
indent_size = 1
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"rules": {
|
||||
"indent": "off",
|
||||
"brace-style": "off",
|
||||
"no-mixed-spaces-and-tabs": "off",
|
||||
"no-useless-escape": "off",
|
||||
"space-unary-ops": ["error", { "words": true }],
|
||||
"linebreak-style": "off",
|
||||
"quotes": ["off"],
|
||||
"semi": "off",
|
||||
"camelcase": "off",
|
||||
"no-unused-vars": "off",
|
||||
"no-console": ["warn"],
|
||||
"no-extra-boolean-cast": ["off"],
|
||||
"no-control-regex": ["off"],
|
||||
},
|
||||
"root": true,
|
||||
"globals": {
|
||||
"buzz": true,
|
||||
"frappe": true,
|
||||
"Vue": true,
|
||||
"SetVueGlobals": true,
|
||||
"__": true,
|
||||
"repl": true,
|
||||
"Class": true,
|
||||
"locals": true,
|
||||
"cint": true,
|
||||
"cstr": true,
|
||||
"cur_frm": true,
|
||||
"cur_dialog": true,
|
||||
"cur_page": true,
|
||||
"cur_list": true,
|
||||
"cur_tree": true,
|
||||
"msg_dialog": true,
|
||||
"is_null": true,
|
||||
"in_list": true,
|
||||
"has_common": true,
|
||||
"posthog": true,
|
||||
"has_words": true,
|
||||
"validate_email": true,
|
||||
"open_web_template_values_editor": true,
|
||||
"validate_name": true,
|
||||
"validate_phone": true,
|
||||
"validate_url": true,
|
||||
"get_number_format": true,
|
||||
"format_number": true,
|
||||
"format_currency": true,
|
||||
"comment_when": true,
|
||||
"open_url_post": true,
|
||||
"toTitle": true,
|
||||
"lstrip": true,
|
||||
"rstrip": true,
|
||||
"strip": true,
|
||||
"strip_html": true,
|
||||
"replace_all": true,
|
||||
"flt": true,
|
||||
"precision": true,
|
||||
"CREATE": true,
|
||||
"AMEND": true,
|
||||
"CANCEL": true,
|
||||
"copy_dict": true,
|
||||
"get_number_format_info": true,
|
||||
"strip_number_groups": true,
|
||||
"print_table": true,
|
||||
"Layout": true,
|
||||
"web_form_settings": true,
|
||||
"$c": true,
|
||||
"$a": true,
|
||||
"$i": true,
|
||||
"$bg": true,
|
||||
"$y": true,
|
||||
"$c_obj": true,
|
||||
"refresh_many": true,
|
||||
"refresh_field": true,
|
||||
"toggle_field": true,
|
||||
"get_field_obj": true,
|
||||
"get_query_params": true,
|
||||
"unhide_field": true,
|
||||
"hide_field": true,
|
||||
"set_field_options": true,
|
||||
"getCookie": true,
|
||||
"getCookies": true,
|
||||
"get_url_arg": true,
|
||||
"md5": true,
|
||||
"$": true,
|
||||
"jQuery": true,
|
||||
"moment": true,
|
||||
"hljs": true,
|
||||
"Awesomplete": true,
|
||||
"Sortable": true,
|
||||
"Showdown": true,
|
||||
"Taggle": true,
|
||||
"Gantt": true,
|
||||
"Slick": true,
|
||||
"Webcam": true,
|
||||
"PhotoSwipe": true,
|
||||
"PhotoSwipeUI_Default": true,
|
||||
"io": true,
|
||||
"JsBarcode": true,
|
||||
"L": true,
|
||||
"Chart": true,
|
||||
"DataTable": true,
|
||||
"Cypress": true,
|
||||
"cy": true,
|
||||
"it": true,
|
||||
"describe": true,
|
||||
"expect": true,
|
||||
"context": true,
|
||||
"before": true,
|
||||
"beforeEach": true,
|
||||
"after": true,
|
||||
"qz": true,
|
||||
"localforage": true,
|
||||
"extend_cscript": true
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,108 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: main-event_manager-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
name: Server
|
||||
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 13000:6379
|
||||
redis-queue:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 11000:6379
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Find tests
|
||||
run: |
|
||||
echo "Finding tests"
|
||||
grep -rn "def test" > /dev/null
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT'
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install MariaDB Client
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt-get install mariadb-client
|
||||
|
||||
- name: Setup
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
- name: Install
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench get-app event_manager $GITHUB_WORKSPACE
|
||||
bench get-app payments
|
||||
bench setup requirements --dev
|
||||
bench new-site --db-root-password root --admin-password admin test_site
|
||||
bench --site test_site install-app event_manager
|
||||
bench build
|
||||
env:
|
||||
CI: 'Yes'
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench --site test_site set-config allow_tests true
|
||||
bench --site test_site run-tests --app event_manager
|
||||
env:
|
||||
TYPE: server
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Linters
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linter:
|
||||
name: 'Frappe Linter'
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
- uses: pre-commit/action@v3.0.0
|
||||
|
||||
- name: Download Semgrep rules
|
||||
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
|
||||
|
||||
- name: Run Semgrep rules
|
||||
run: |
|
||||
pip install semgrep
|
||||
semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness
|
||||
|
||||
deps-vulnerable-check:
|
||||
name: 'Vulnerable Dependency Check'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Install and run pip-audit
|
||||
run: |
|
||||
pip install pip-audit
|
||||
cd ${GITHUB_WORKSPACE}
|
||||
pip-audit --desc on .
|
||||
@@ -0,0 +1,74 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate-pr-title:
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
id: validate
|
||||
uses: amannn/action-semantic-pull-request@v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
with:
|
||||
# Default types from: https://github.com/commitizen/conventional-commit-types
|
||||
requireScope: false
|
||||
wip: false
|
||||
|
||||
- name: Print Failure Documentation
|
||||
if: steps.validate.outcome == 'failure'
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
echo "## ❌ PR Title Validation Failed"
|
||||
echo ""
|
||||
echo "**Current title:** \`$PR_TITLE\`"
|
||||
echo ""
|
||||
echo "Use a conventional commit style so we can keep a clear history. Fix it like this:"
|
||||
echo ""
|
||||
echo "### 📋 Format"
|
||||
echo ""
|
||||
echo "\`type: description\` or \`type(scope): description\`"
|
||||
echo ""
|
||||
echo "### 🏷️ Allowed types"
|
||||
echo ""
|
||||
echo "| Type | Use for |"
|
||||
echo "|----------|--------|"
|
||||
echo "| feat | New feature or user-facing change |"
|
||||
echo "| fix | Bug fix |"
|
||||
echo "| docs | Documentation only |"
|
||||
echo "| style | Formatting, no logic change |"
|
||||
echo "| refactor | Code change (no new feature or bug fix) |"
|
||||
echo "| perf | Performance improvement |"
|
||||
echo "| test | Adding or updating tests |"
|
||||
echo "| build | Build system or dependencies |"
|
||||
echo "| ci | CI configuration |"
|
||||
echo "| chore | Other (maintenance, tooling) |"
|
||||
echo ""
|
||||
echo "### 🎯 Examples"
|
||||
echo ""
|
||||
echo "- \`feat: add user authentication\`"
|
||||
echo "- \`feat(api): add payment endpoint\`"
|
||||
echo "- \`fix: resolve login timeout\`"
|
||||
echo "- \`chore: update Python dependencies\`"
|
||||
echo ""
|
||||
echo "### ❌ Fix these mistakes"
|
||||
echo ""
|
||||
echo "- \`Add user login\` → \`feat: add user authentication\`"
|
||||
echo "- \`Fixed the bug\` → \`fix: resolve checkout error\`"
|
||||
echo "- \`feat:add feature\` → \`feat: add feature\` (space after colon)"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "Edit the PR title and this check will re-run automatically."
|
||||
exit 1
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Dashboard TypeScript
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- "dashboard/**"
|
||||
- ".github/workflows/typecheck.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "dashboard/**"
|
||||
- ".github/workflows/typecheck.yml"
|
||||
|
||||
concurrency:
|
||||
group: typecheck-event_manager-${{ github.event.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "yarn"
|
||||
cache-dependency-path: dashboard/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: dashboard
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
working-directory: dashboard
|
||||
run: yarn typecheck
|
||||
@@ -0,0 +1,169 @@
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ui-tests-event_manager-${{ github.event.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
name: Playwright E2E Tests
|
||||
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 13000:6379
|
||||
redis-queue:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 11000:6379
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 event_manager.test" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT'
|
||||
|
||||
- name: Cache yarn
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install MariaDB Client
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt-get install mariadb-client
|
||||
|
||||
- name: Setup Bench
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
- name: Install event_manager
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench get-app payments
|
||||
bench get-app event_manager $GITHUB_WORKSPACE
|
||||
bench setup requirements --dev
|
||||
bench new-site --db-root-password root --admin-password admin event_manager.test
|
||||
bench --site event_manager.test install-app event_manager
|
||||
bench build
|
||||
env:
|
||||
CI: "Yes"
|
||||
|
||||
- name: Configure Site for UI Tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench --site event_manager.test set-config allow_tests true
|
||||
bench --site event_manager.test set-config host_name "http://event_manager.test:8000"
|
||||
|
||||
- name: Create Test User
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: bench --site event_manager.test add-user testuser@event_manager.test --first-name Test --last-name User --password Test@123 --add-role "System Manager"
|
||||
|
||||
- name: Start Frappe Server
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
# Disable watch and schedule to reduce resource usage
|
||||
sed -i 's/^watch:/# watch:/g' Procfile
|
||||
sed -i 's/^schedule:/# schedule:/g' Procfile
|
||||
|
||||
# Start bench in background
|
||||
bench start &> bench_start.log &
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for Frappe server to start..."
|
||||
timeout 60 bash -c 'until curl -s http://event_manager.test:8000 > /dev/null; do sleep 2; done'
|
||||
echo "Frappe server is ready!"
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
npm install
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright Tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
BASE_URL: http://event_manager.test:8000
|
||||
FRAPPE_USER: testuser@event_manager.test
|
||||
FRAPPE_PASSWORD: Test@123
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Show Bench Logs on Failure
|
||||
if: failure()
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
echo "=== Bench Start Log ==="
|
||||
cat bench_start.log || true
|
||||
echo ""
|
||||
echo "=== Frappe Logs ==="
|
||||
cat logs/*.log || true
|
||||
@@ -0,0 +1,67 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyc
|
||||
*.py~
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
tags
|
||||
MANIFEST
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# IDEs and editors
|
||||
.vscode/
|
||||
.vs/
|
||||
.idea/
|
||||
.kdev4/
|
||||
*.kdev4
|
||||
*.DS_Store
|
||||
*.swp
|
||||
*.comp.js
|
||||
.wnf-lang-status
|
||||
*debug.log
|
||||
|
||||
# Helix Editor
|
||||
.helix/
|
||||
|
||||
# Aider AI Chat
|
||||
.aider*
|
||||
|
||||
buzz/public/dashboard/
|
||||
buzz/public/node_modules
|
||||
buzz/www/dashboard.html
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
/e2e/.auth/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "dashboard/frappe-ui"]
|
||||
path = dashboard/frappe-ui
|
||||
url = https://github.com/frappe/frappe-ui.git
|
||||
@@ -0,0 +1,69 @@
|
||||
exclude: 'node_modules|.git'
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
files: "event_manager.*"
|
||||
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
|
||||
- id: check-merge-conflict
|
||||
- id: check-ast
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
args: ["--select=I", "--fix"]
|
||||
|
||||
- id: ruff
|
||||
name: "Run ruff linter"
|
||||
|
||||
- id: ruff-format
|
||||
name: "Run ruff formatter"
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.7.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [javascript, vue, scss]
|
||||
# Ignore any files that might contain jinja / bundles
|
||||
exclude: |
|
||||
(?x)^(
|
||||
event_manager/public/dist/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
event_manager/templates/includes/.*|
|
||||
event_manager/public/js/lib/.*
|
||||
)$
|
||||
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.44.0
|
||||
hooks:
|
||||
- id: eslint
|
||||
types_or: [javascript]
|
||||
args: ['--quiet']
|
||||
# Ignore any files that might contain jinja / bundles
|
||||
exclude: |
|
||||
(?x)^(
|
||||
event_manager/public/dist/.*|
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
event_manager/templates/includes/.*|
|
||||
event_manager/public/js/lib/.*
|
||||
)$
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
||||
@@ -0,0 +1,249 @@
|
||||
# Pohodex Event Manager App Architecture Notes
|
||||
|
||||
## Overview
|
||||
- Pohodex Event Manager is a Frappe app for event management with a Vue 3 (FrappeUI) dashboard and Frappe Builder/public pages.
|
||||
- Backend is organized into Frappe modules: `Pohodex Event Manager`, `Events`, `Ticketing`, `Proposals`.
|
||||
- Frontend dashboard lives in `dashboard/`, built with Vite and published into `event_manager/public/dashboard` and `event_manager/www/dashboard.html`.
|
||||
|
||||
## Backend Structure
|
||||
- App bootstrap and hooks: `event_manager/hooks.py`
|
||||
- Requires `frappe/payments`.
|
||||
- Scheduler: `event_manager.tasks.unpublish_ticket_types_after_last_date` (daily).
|
||||
- Doc events: assigns `Pohodex Event Manager User` role on user creation; syncs Speaker Profile display name on User update.
|
||||
- App icon entry in Desk apps screen.
|
||||
- Core API: `event_manager/api.py`
|
||||
- Booking, tickets, add-ons, sponsorship, check-in, languages, translations.
|
||||
- Payments integration: `event_manager/payments.py`
|
||||
- Creates `Event Payment` records and generates payment links via the Payments app.
|
||||
- Utilities: `event_manager/utils.py`
|
||||
- App-install guard decorator, custom field helpers, role assignment.
|
||||
- Install/fixtures:
|
||||
- `event_manager/install.py` creates default Event Categories and zoom integration fields if installed.
|
||||
- `event_manager/fixtures` and `event_manager/patches` for setup and migrations.
|
||||
|
||||
## Key DocTypes (Data Model)
|
||||
|
||||
### Events Module
|
||||
- `Pohodex Event Manager Event` (`event_manager/events/doctype/buzz_event`)
|
||||
- Primary entity; holds schedule, category, venue, ticketing/payment settings, sponsorship settings.
|
||||
- Creates default `Sponsorship Tier` and `Event Ticket Type` after insert.
|
||||
- Optional Zoom webinar creation/updates when `zoom_integration` is installed.
|
||||
- `Event Category`, `Event Venue`, `Event Host`
|
||||
- Category slug creation; venue location geojson and map embed cleanup.
|
||||
- `Event Talk`, `Talk Speaker`, `Speaker Profile`, `Event Track`
|
||||
- Talks can be generated from proposals; speaker profiles tied to User records.
|
||||
- `Event Sponsor`, `Sponsorship Tier`, `Sponsorship Deck Item`
|
||||
- Sponsor uniqueness enforced per enquiry.
|
||||
- `Event Check In`
|
||||
- Used for check-in records and attendance reporting.
|
||||
- `Event Payment Gateway`
|
||||
- Child table for multiple payment gateways per event.
|
||||
- `Additional Event Page`
|
||||
- Event-specific extra pages with route validation.
|
||||
|
||||
### Ticketing Module
|
||||
- `Event Booking`
|
||||
- Holds attendees, pricing, tax, UTM parameters, custom fields.
|
||||
- On submit: generates `Event Ticket` documents and applies add-ons/custom fields.
|
||||
- `Event Ticket`
|
||||
- Generates QR code, sends ticket email + print format attachment.
|
||||
- Creates Zoom webinar registration (if enabled).
|
||||
- Supports transfer/cancellation flows.
|
||||
- `Event Ticket Type`
|
||||
- Inventory logic; tracks max tickets and remaining count.
|
||||
- `Ticket Add-on` + `Ticket Add-on Value` + `Attendee Ticket Add-on`
|
||||
- Add-on definitions and per-ticket selections.
|
||||
- `Bulk Ticket Coupon`
|
||||
- Auto-generates code; limits usage via claimed count.
|
||||
- `Ticket Cancellation Request` + `Ticket Cancellation Item`
|
||||
- Cancel booking or specific tickets on acceptance.
|
||||
- `Additional Field`
|
||||
- Generic key/value for ticket/booking custom fields.
|
||||
|
||||
### Proposals Module
|
||||
- `Talk Proposal`
|
||||
- Maps into `Event Talk` and auto-creates users/speaker profiles if needed.
|
||||
- `Sponsorship Enquiry`
|
||||
- Handles approval/payment flow; creates `Event Sponsor` on payment completion.
|
||||
|
||||
### Pohodex Event Manager Module
|
||||
- `Pohodex Event Manager Custom Field`
|
||||
- Event-specific custom fields applied to bookings or tickets.
|
||||
- `Pohodex Event Manager Settings`
|
||||
- Central configuration for transfer/add-on/cancellation windows.
|
||||
|
||||
## Backend Flows
|
||||
|
||||
### Booking + Payment
|
||||
1. Dashboard calls `event_manager.api.get_event_booking_data` to load ticket types, add-ons, custom fields, and payment gateways.
|
||||
2. `event_manager.api.process_booking` creates an `Event Booking` with attendees, add-ons, custom fields, and UTM parameters.
|
||||
3. If total is 0, booking is auto-submitted; otherwise `Event Payment` is created and a payment URL is returned.
|
||||
4. On payment authorization, `Event Booking.on_payment_authorized` marks the payment received and submits the booking.
|
||||
5. Booking submission creates `Event Ticket` records and triggers QR + email flow.
|
||||
|
||||
### Ticket Lifecycle
|
||||
- Ticket creation generates QR code file and email (with print format attachment).
|
||||
- Transfers are handled via `event_manager.api.transfer_ticket` with window checks from `Pohodex Event Manager Settings`.
|
||||
- Add-on preference changes use `event_manager.api.change_add_on_preference` with window checks.
|
||||
- Cancellation requests are created via `event_manager.api.create_cancellation_request` and are accepted/rejected in Desk.
|
||||
|
||||
### Sponsorships
|
||||
- Web form submits `Sponsorship Enquiry`.
|
||||
- Approval moves status to `Payment Pending` and triggers email notification.
|
||||
- Payment link is generated via `event_manager.api.create_sponsorship_payment_link`.
|
||||
- Payment authorization creates `Event Sponsor` and marks enquiry as paid.
|
||||
|
||||
### Check-in
|
||||
- Dashboard scanner validates tickets with `event_manager.api.validate_ticket_for_checkin`.
|
||||
- Successful check-in creates `Event Check In` and returns event/ticket context.
|
||||
|
||||
## API Surface (Whitelisted)
|
||||
- Booking: `get_event_booking_data`, `process_booking`, `get_booking_details`, `create_cancellation_request`.
|
||||
- Ticket actions: `get_ticket_details`, `transfer_ticket`, `change_add_on_preference`.
|
||||
- Sponsorships: `get_sponsorship_details`, `get_user_sponsorship_inquiries`, `create_sponsorship_payment_link`, `withdraw_sponsorship_enquiry`.
|
||||
- Check-in: `validate_ticket_for_checkin`, `checkin_ticket`.
|
||||
- Payments: `get_event_payment_gateways` (plus payment helpers in `event_manager/payments.py`).
|
||||
- User + i18n: `get_user_info`, `get_enabled_languages`, `update_user_language`, `get_translations`.
|
||||
|
||||
## Reports
|
||||
- Events:
|
||||
- `Event Overview` (tickets sold, add-ons sold, sales) in `event_manager/events/report/event_overview`.
|
||||
- `Event Attendance Summary` with dynamic per-day check-ins and chart in `event_manager/events/report/event_attendance_summary`.
|
||||
- Ticketing:
|
||||
- `Event Add-Ons Overview` in `event_manager/ticketing/report/event_add_ons_overview`.
|
||||
- `Detailed Event Registrations` with dynamic custom fields, add-ons, UTM params in `event_manager/ticketing/report/detailed_event_registrations`.
|
||||
|
||||
## Public Pages + Web Forms
|
||||
- Dashboard is served via `event_manager/www/dashboard.html` (built from `dashboard/` output).
|
||||
- Web forms:
|
||||
- `Apply for Sponsorship` and `Propose a Talk` under `event_manager/event_manager/web_form/`.
|
||||
- Frappe Builder assets are under `event_manager/builder_files` and `event_manager/public/builder_files` (currently empty stubs/asset containers).
|
||||
|
||||
## Frontend (Vue Dashboard)
|
||||
|
||||
### Entry + Build
|
||||
- Entry: `dashboard/src/main.js` mounts `App.vue` with router, resources, translation plugin, and socket.
|
||||
- Build: `dashboard/vite.config.js` outputs to `event_manager/public/dashboard` and updates `event_manager/www/dashboard.html`.
|
||||
- Base URL: `/dashboard` (router history uses `createWebHistory("/dashboard")`).
|
||||
|
||||
### Routing
|
||||
- Public-like flows: booking (`/book-tickets/:eventRoute`) and check-in (`/check-in`).
|
||||
- Account area under `/account`:
|
||||
- bookings list/details, tickets list/details, sponsorships list/details.
|
||||
- Guard: `router.beforeEach` checks `event_manager.api.get_user_info` and redirects to `/login` if unauthenticated.
|
||||
|
||||
### Data Access Pattern
|
||||
- Uses `frappe-ui` `createResource` and `createListResource` for API calls.
|
||||
- API endpoints map directly to backend whitelisted methods (see API Surface above).
|
||||
- Session state in `dashboard/src/data/session.js` handles login/logout and cookie-based session user.
|
||||
|
||||
### Key UI Components
|
||||
- Booking flow: `BookingForm.vue`, `PaymentGatewayDialog.vue`, `BookingSummary.vue`.
|
||||
- Ticket management: `TicketDetails.vue`, `TicketTransferDialog.vue`, `AddOnPreferenceDialog.vue`, `CancellationRequestDialog.vue`.
|
||||
- Sponsorship management: `SponsorshipDetails.vue`, `SponsorshipPaymentDialog.vue`, `SponsorLogoUploader.vue`.
|
||||
- Check-in: `CheckInScanner.vue`, `EventSelector.vue`, `QRScanner.vue`.
|
||||
- Shared: `Navbar.vue`, `LanguageSwitcher.vue`, `SuccessMessage.vue`.
|
||||
|
||||
### Notable Composables + Utils
|
||||
- `useTicketValidation` handles check-in validation flow and audio feedback.
|
||||
- `usePaymentSuccess` handles success banners + confetti and URL cleanup.
|
||||
- `useBookingFormStorage` keeps draft booking data in localStorage per event.
|
||||
- `useLanguage` changes user language and reloads translations.
|
||||
|
||||
## Integrations
|
||||
- Payments app required (gateway link generation + payment receipt recording).
|
||||
- Zoom integration optional; `Pohodex Event Manager Event` can create/update webinars and register attendees.
|
||||
|
||||
## Permissions + Roles
|
||||
- Roles: `Pohodex Event Manager User`, `Frontdesk Manager` (fixtures in hooks).
|
||||
- Check-in APIs restricted to `Frontdesk Manager`.
|
||||
- Sponsorship details restricted to enquiry owner or users with permission.
|
||||
|
||||
## Key Paths for Feature Work
|
||||
- Booking logic: `event_manager/api.py`, `event_manager/ticketing/doctype/event_booking`, `dashboard/src/components/BookingForm.vue`.
|
||||
- Ticket lifecycle: `event_manager/ticketing/doctype/event_ticket`, `dashboard/src/pages/TicketDetails.vue`.
|
||||
- Sponsorship lifecycle: `event_manager/proposals/doctype/sponsorship_enquiry`, `dashboard/src/pages/SponsorshipDetails.vue`.
|
||||
- Check-in flow: `event_manager/api.py`, `dashboard/src/pages/CheckInScanner.vue`.
|
||||
- Event configuration: `event_manager/events/doctype/buzz_event` and related event doctypes.
|
||||
|
||||
## Notes / Gotchas
|
||||
- Payment gateway selection is event-scoped via `Event Payment Gateway` child rows.
|
||||
- Event routes are auto-generated when publishing (`Pohodex Event Manager Event`, `Additional Event Page`).
|
||||
- Custom fields are event-scoped (`Pohodex Event Manager Custom Field`) and can apply to booking or ticket.
|
||||
- Ticket type availability is enforced at booking time, plus daily auto-unpublish by scheduler.
|
||||
|
||||
## Data Model Diagram (DocTypes + Relationships)
|
||||
```mermaid
|
||||
erDiagram
|
||||
BUZZ_EVENT ||--o{ EVENT_TICKET_TYPE : has
|
||||
BUZZ_EVENT ||--o{ TICKET_ADD_ON : has
|
||||
BUZZ_EVENT ||--o{ EVENT_BOOKING : has
|
||||
BUZZ_EVENT ||--o{ EVENT_SPONSOR : has
|
||||
BUZZ_EVENT ||--o{ SPONSORSHIP_TIER : has
|
||||
BUZZ_EVENT ||--o{ BUZZ_CUSTOM_FIELD : has
|
||||
BUZZ_EVENT ||--o{ EVENT_CHECK_IN : has
|
||||
BUZZ_EVENT ||--o{ ADDITIONAL_EVENT_PAGE : has
|
||||
EVENT_BOOKING ||--o{ EVENT_BOOKING_ATTENDEE : has
|
||||
EVENT_BOOKING ||--o{ ADDITIONAL_FIELD : has
|
||||
EVENT_BOOKING ||--o{ UTM_PARAMETER : has
|
||||
EVENT_BOOKING ||--o{ EVENT_TICKET : generates
|
||||
EVENT_TICKET ||--o{ TICKET_ADD_ON_VALUE : has
|
||||
EVENT_TICKET ||--o{ ADDITIONAL_FIELD : has
|
||||
EVENT_TICKET_TYPE ||--o{ EVENT_TICKET : sold_as
|
||||
ATTENDEE_TICKET_ADD_ON ||--o{ TICKET_ADD_ON_VALUE : has
|
||||
TICKET_CANCELLATION_REQUEST ||--o{ TICKET_CANCELLATION_ITEM : includes
|
||||
TICKET_CANCELLATION_REQUEST }o--|| EVENT_BOOKING : cancels
|
||||
EVENT_CHECK_IN }o--|| EVENT_TICKET : for
|
||||
SPONSORSHIP_ENQUIRY }o--|| BUZZ_EVENT : for
|
||||
SPONSORSHIP_ENQUIRY ||--o{ EVENT_SPONSOR : creates
|
||||
TALK_PROPOSAL }o--|| BUZZ_EVENT : for
|
||||
TALK_PROPOSAL ||--o{ EVENT_TALK : maps_to
|
||||
```
|
||||
|
||||
## Frontend Page -> API Map
|
||||
- `dashboard/src/pages/BookTickets.vue`
|
||||
- `event_manager.api.get_event_booking_data` to load event booking config.
|
||||
- `event_manager.api.process_booking` via `dashboard/src/components/BookingForm.vue`.
|
||||
- `dashboard/src/pages/BookingDetails.vue`
|
||||
- `event_manager.api.get_booking_details` for booking + ticket data.
|
||||
- `event_manager.api.create_cancellation_request` via `dashboard/src/components/CancellationRequestDialog.vue`.
|
||||
- `event_manager.api.transfer_ticket` via `dashboard/src/components/TicketsSection.vue`.
|
||||
- `dashboard/src/pages/TicketDetails.vue`
|
||||
- `event_manager.api.get_ticket_details`.
|
||||
- `event_manager.api.change_add_on_preference` via `dashboard/src/components/AddOnPreferenceDialog.vue`.
|
||||
- `event_manager.api.transfer_ticket` via `dashboard/src/components/TicketTransferDialog.vue`.
|
||||
- `dashboard/src/pages/SponsorshipsList.vue`
|
||||
- `event_manager.api.get_user_sponsorship_inquiries`.
|
||||
- `dashboard/src/pages/SponsorshipDetails.vue`
|
||||
- `event_manager.api.get_sponsorship_details`.
|
||||
- `event_manager.api.withdraw_sponsorship_enquiry`.
|
||||
- `event_manager.api.get_event_payment_gateways` and `event_manager.api.create_sponsorship_payment_link` via `dashboard/src/components/SponsorshipPaymentDialog.vue`.
|
||||
- `dashboard/src/pages/CheckInScanner.vue`
|
||||
- `event_manager.api.validate_ticket_for_checkin` and `event_manager.api.checkin_ticket` via `dashboard/src/composables/useTicketValidation.js`.
|
||||
- `dashboard/src/data/user.js`
|
||||
- `event_manager.api.get_user_info` for session user details and roles.
|
||||
- `dashboard/src/composables/useLanguage.js`
|
||||
- `event_manager.api.get_enabled_languages`, `event_manager.api.update_user_language`.
|
||||
- `dashboard/src/translation.js`
|
||||
- `event_manager.api.get_translations`.
|
||||
|
||||
## Feature Development Checklist (Common Changes)
|
||||
- Tickets / booking changes
|
||||
- Update `event_manager/api.py` for API shape changes and `Event Booking`/`Event Ticket` doctypes for business logic.
|
||||
- Keep `BookingForm.vue` payload mapping in sync with backend expectations.
|
||||
- Update reports if new fields should be exported (`Detailed Event Registrations`).
|
||||
- Add-on or custom field changes
|
||||
- Check `Pohodex Event Manager Custom Field` logic and `Additional Field` usage in booking/tickets.
|
||||
- Update frontend rendering in `CustomFieldInput.vue` and `BookingForm.vue`.
|
||||
- Payment flow changes
|
||||
- Update `event_manager/payments.py` and any event-scoped gateway selection logic.
|
||||
- Ensure payment redirects still land on `/dashboard/...?...success=true`.
|
||||
- Sponsorship flow changes
|
||||
- Update `Sponsorship Enquiry` for status transitions and `SponsorshipDetails.vue` for UI state.
|
||||
- Verify sponsor creation in `on_payment_authorized`.
|
||||
- Check-in changes
|
||||
- Update `event_manager.api.validate_ticket_for_checkin` and `CheckInScanner.vue`/`useTicketValidation.js`.
|
||||
- Confirm role gating for `Frontdesk Manager` remains consistent.
|
||||
- Event publishing or public pages
|
||||
- Review `Pohodex Event Manager Event` route generation and `Additional Event Page` route uniqueness.
|
||||
- If using Builder, update assets under `event_manager/builder_files` and publish to `event_manager/public/builder_files`.
|
||||
@@ -0,0 +1,368 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
At the start of every session, read `.claude/memory/memory.md` to load project context.
|
||||
After completing significant work (new patterns, architectural decisions, solved problems),
|
||||
update `.claude/memory/memory.md`. Keep it under 300 lines — summarize when it grows.
|
||||
|
||||
---
|
||||
|
||||
## This defines the best practices to write backend code in the Frappe Framework
|
||||
|
||||
* Frappe Framework is a full-stack web application framework that contains all the necessary components for building modern web applications.
|
||||
* It provides background workers using Redis, real-time updates using sockets, and a database layer using MariaDB.
|
||||
* Bench is the official command-line tool for managing Frappe applications.
|
||||
|
||||
## Backend Development
|
||||
|
||||
### JSON & Request Handling
|
||||
|
||||
* Always use built-in functions for parsing JSON:
|
||||
|
||||
* `frappe.parse_json` (handles dicts, lists, and JSON strings safely)
|
||||
|
||||
* Never use `json.loads` directly on request data.
|
||||
|
||||
* For outbound HTTP requests (calling external APIs), use:
|
||||
|
||||
* `frappe.integration.utils.make_get_request`
|
||||
* `frappe.integration.utils.make_post_request`
|
||||
* `frappe.integration.utils.make_put_request`
|
||||
* `frappe.integration.utils.make_patch_request`
|
||||
|
||||
### Datatype Conversion & Utilities
|
||||
|
||||
* For converting datatypes (e.g. str → int, str → float, etc.) use built-in helpers:
|
||||
|
||||
* `frappe.utils.data.cint`
|
||||
* `frappe.utils.data.cstr`
|
||||
* `frappe.utils.data.flt`
|
||||
* `frappe.utils.data.getdate`
|
||||
* `frappe.utils.data.get_datetime`
|
||||
|
||||
* `frappe.utils.data` contains most conversion and formatting helpers you will ever need:
|
||||
|
||||
* date / datetime parsing
|
||||
* currency formatting
|
||||
* number formatting
|
||||
|
||||
* Do NOT create custom utility functions for these conversions.
|
||||
|
||||
* If unsure, ask before implementing.
|
||||
|
||||
### DocType Access Patterns
|
||||
|
||||
* When fetching an existing DocType, prefer:
|
||||
|
||||
* `frappe.get_cached_doc`
|
||||
|
||||
* Use `frappe.get_doc` when:
|
||||
|
||||
* creating a new document
|
||||
|
||||
* To create a new doc go to bench console via bench --site sitename console and use frappe.new_doc("DocType") and then create the doc, don't create the doc via json as the validations doesn't run
|
||||
|
||||
|
||||
### Optimization
|
||||
|
||||
* Don't use get_doc or get_cached_doc inside for loop it creates n+1 db problem use frappe.get_all with all the params required and then loop over that list
|
||||
|
||||
### Database Access
|
||||
|
||||
* Prefer ORM methods:
|
||||
|
||||
* `frappe.get_all`
|
||||
* `frappe.get_list`
|
||||
* `frappe.db.get_value`
|
||||
|
||||
* Avoid raw SQL absolutely.
|
||||
|
||||
|
||||
### Permissions & Security
|
||||
|
||||
* Always respect user permissions.
|
||||
* Use `ignore_permissions=True` only when absolutely required and justified.
|
||||
|
||||
### Background Jobs & Performance
|
||||
|
||||
* For long-running or heavy operations, always use:
|
||||
|
||||
* `frappe.enqueue`
|
||||
|
||||
* Never block request-response cycles with heavy business logic.
|
||||
|
||||
### Error Handling & Logging
|
||||
|
||||
* Use `frappe.throw` or specific exceptions like `frappe.ValidationError` for user-facing errors.
|
||||
* Use `frappe.log_error` for unexpected or system-level exceptions.
|
||||
* Avoid bare `except:` blocks.
|
||||
|
||||
### General Guidelines
|
||||
|
||||
* Prefer framework conventions over custom implementations.
|
||||
* Keep business logic out of controllers where possible.
|
||||
* Write readable, predictable, and maintainable code.
|
||||
|
||||
|
||||
|
||||
## Frontend Development
|
||||
|
||||
1. Always use async/await; avoid callback-based patterns and nested promises.
|
||||
|
||||
2. Use Frappe-provided APIs for server calls: `frappe.call` with `async: true`. Prefer Promise-based usage over callbacks.
|
||||
|
||||
3. Use Frappe's global JS helpers instead of native JS equivalents:
|
||||
* `cstr()` instead of `String()`
|
||||
* `cint()` instead of `parseInt()`
|
||||
* `flt()` instead of `parseFloat()`
|
||||
* `is_null()` instead of manual null/undefined/empty checks
|
||||
* `format_currency()` for currency formatting
|
||||
|
||||
|
||||
## Crawling
|
||||
|
||||
Always use gemini as much as possible for getting the context, to get the help use gemini --help
|
||||
|
||||
For checking if the site works you can use the agent-browser use agent-browser --help to get the context for it
|
||||
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend (Dashboard)
|
||||
```bash
|
||||
# dev server
|
||||
yarn dev # or: cd dashboard && yarn dev
|
||||
|
||||
# build for production
|
||||
yarn build # outputs to event_manager/public/dashboard + event_manager/www/dashboard.html
|
||||
|
||||
# lint/format frontend
|
||||
cd dashboard && yarn lint
|
||||
```
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
Always run bench migrate after doctype schema changes.
|
||||
|
||||
```bash
|
||||
# linting/formatting (via pre-commit)
|
||||
pre-commit run --all-files
|
||||
|
||||
# run ruff directly
|
||||
ruff check event_manager/
|
||||
ruff format event_manager/
|
||||
|
||||
# install app to site
|
||||
bench --site [site-name] install-app event_manager
|
||||
```
|
||||
|
||||
Use bench --help to see how to work with frappe bench, e.g. bench execute, bench console, etc. are very useful
|
||||
|
||||
### Testing
|
||||
|
||||
There are unit tests, run using bench run-tests. Site name is event_manager.localhost, but if not found, ask user for it. The credentials are Administrator/admin.
|
||||
|
||||
* To test in UI, use agent-browser.
|
||||
* For frontend changes use :8080 since yarn dev server is running.
|
||||
* Use in headed mode unless specified
|
||||
|
||||
## Architecture
|
||||
|
||||
**Three-tier stack:**
|
||||
1. **Backend**: Frappe Framework (Python) - DocTypes, API, permissions, scheduler
|
||||
2. **Dashboard**: Vue 3 + FrappeUI + Vite - attendee/sponsor/checkin UI
|
||||
|
||||
**Core entity**: `Pohodex Event Manager Event` DocType drives everything (tickets, sponsors, schedule, payments).
|
||||
|
||||
**Main modules** (inside `event_manager/`):
|
||||
- `events/` - Event, Venue, Category, Talks, Sponsors, Check-ins
|
||||
- `ticketing/` - Bookings, Tickets, Add-ons, Cancellations, Coupons
|
||||
- `proposals/` - Talk Proposals, Sponsorship Enquiries
|
||||
- `event_manager/` - Settings, Custom Fields
|
||||
- `api.py` - whitelisted API methods for dashboard
|
||||
- `payments.py` - integration with frappe/payments app
|
||||
|
||||
**Frontend structure** (inside `dashboard/`):
|
||||
- `src/pages/` - route components (BookTickets, TicketDetails, CheckInScanner, etc)
|
||||
- `src/components/` - BookingForm, dialogs, shared UI
|
||||
- `src/composables/` - reusable logic (useTicketValidation, usePaymentSuccess, etc)
|
||||
- `src/data/` - frappe-ui resources for API calls
|
||||
- Vite builds to `event_manager/public/dashboard/`, router base is `/dashboard`
|
||||
|
||||
**Key flows:**
|
||||
- Booking: load event data → fill form → create booking → generate payment link → on payment auth → submit booking → generate tickets + QR + email
|
||||
- Ticket actions: transfer, cancel, change add-on (window checks from Pohodex Event Manager Settings)
|
||||
- Sponsorship: enquiry → approval → payment link → payment auth → create sponsor record
|
||||
- Check-in: scan QR → validate → create check-in record (requires Frontdesk Manager role)
|
||||
|
||||
**Integrations:**
|
||||
- `frappe/payments` required for payment gateways
|
||||
- `buildwithhussain/zoom_integration` optional for webinar creation/registration
|
||||
|
||||
## Key Paths for Common Tasks
|
||||
|
||||
**Booking changes**: `event_manager/api.py`, `event_manager/ticketing/doctype/event_booking/`, `dashboard/src/components/BookingForm.vue`
|
||||
|
||||
**Ticket lifecycle**: `event_manager/ticketing/doctype/event_ticket/`, `dashboard/src/pages/TicketDetails.vue`
|
||||
|
||||
**Sponsorships**: `event_manager/proposals/doctype/sponsorship_enquiry/`, `dashboard/src/pages/SponsorshipDetails.vue`
|
||||
|
||||
**Check-in**: `event_manager/api.py` (validate_ticket_for_checkin, checkin_ticket), `dashboard/src/pages/CheckInScanner.vue`
|
||||
|
||||
**Event config**: `event_manager/events/doctype/buzz_event/`
|
||||
|
||||
**Reports**: `event_manager/events/report/` and `event_manager/ticketing/report/`
|
||||
|
||||
|
||||
## Joining or creating report
|
||||
"Never write `frappe.db.sql` again"
|
||||
===========================================================
|
||||
|
||||
1. **Ban `frappe.db.sql` in new code**
|
||||
* Add a pre-commit rule or CI step that greps for `\.db\.sql` and fails the build.
|
||||
* Legacy code => wrap in `frappe.db.sql("...", as_dict=1)` and add a `# TODO-QB` comment so the next refactor is trackable.
|
||||
|
||||
2. **Use the typed entry point**
|
||||
```python
|
||||
from frappe.query_builder import DocType, Field
|
||||
from frappe.query_builder.functions import Count, Sum, Coalesce, Date
|
||||
```
|
||||
Never `import pypika` directly; the `frappe.qb` namespace already returns the correct `MariaDB/PostgreSQL` dialect.
|
||||
|
||||
3. **Parameterise, never interpolate**
|
||||
```python
|
||||
# Bad
|
||||
frappe.db.sql(f"... {user_input}") # injection bomb
|
||||
# Good
|
||||
frappe.qb.from_(...).where(table.field == user_input) # auto-escaped
|
||||
```
|
||||
|
||||
4. **Prefer joins over N+1**
|
||||
```python
|
||||
so = DocType("Sales Order")
|
||||
si = DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(so)
|
||||
.left_join(si)
|
||||
.on(so.name == si.sales_order)
|
||||
.select(so.name, si.name)
|
||||
.where(so.customer == customer)
|
||||
)
|
||||
```
|
||||
One round-trip, no loops.
|
||||
|
||||
5. **Sub-queries > raw SQL strings**
|
||||
Need *"latest row per group"*?
|
||||
```python
|
||||
latest = (
|
||||
frappe.qb.from_(si)
|
||||
.select(si.name)
|
||||
.where(si.sales_order == so.name)
|
||||
.orderby(si.creation, order=Order.desc)
|
||||
.limit(1)
|
||||
)
|
||||
query = frappe.qb.from_(so).where(so.name == latest)
|
||||
```
|
||||
Keeps everything composable and dialect-agnostic.
|
||||
|
||||
6. **Use `case` for conditional aggregates**
|
||||
```python
|
||||
from frappe.query_builder.functions import Case
|
||||
paid_amt = Sum(
|
||||
Case()
|
||||
.when(si.status == "Paid", si.grand_total)
|
||||
.else_(0)
|
||||
)
|
||||
```
|
||||
|
||||
7. **Respect Frappe field casing**
|
||||
* SQL column: `grand_total`
|
||||
* Frappe field: `grand_total`
|
||||
* No back-ticks needed; QB adds the correct quotes per DB.
|
||||
|
||||
8. **Use `as_dict=True` or ORM objects**
|
||||
```python
|
||||
rows = query.run(as_dict=True) # list[dict]
|
||||
docs = query.run(as_dict=False) # list[tuple]
|
||||
obj = frappe.get_doc("Doctype", pk) # when you need the full DocType hooks
|
||||
```
|
||||
|
||||
9. **Pagination with `limit_page_length` and `limit_start`**
|
||||
```python
|
||||
query = query.limit(limit_page_length).offset(limit_start)
|
||||
```
|
||||
Same pattern the REST API uses.
|
||||
|
||||
10. **Index-friendly WHERE order**
|
||||
Put indexed columns first (`company`, `customer`, `status`) so MariaDB/PostgreSQL can use composite indexes.
|
||||
|
||||
11. **Avoid `SELECT *` in reports**
|
||||
Explicit list of fields keeps wire-size small and prevents breaking changes when new fields are added.
|
||||
|
||||
13. **Cache heavy aggregations**
|
||||
```python
|
||||
@frappe.whitelist()
|
||||
@redis_cache(ttl=300)
|
||||
def get_dashboard_stats(company):
|
||||
inv = DocType("Sales Invoice")
|
||||
total = frappe.qb.from_(inv).select(Sum(inv.grand_total)).where(inv.company == company).run()
|
||||
return total[0][0] or 0
|
||||
```
|
||||
|
||||
|
||||
Quick migration template
|
||||
----------------------
|
||||
|
||||
Legacy:
|
||||
```python
|
||||
rows = frappe.db.sql("""
|
||||
select name, grand_total
|
||||
from `tabSales Invoice`
|
||||
where customer = %s
|
||||
and docstatus = 1
|
||||
""", customer, as_dict=1)
|
||||
```
|
||||
|
||||
QB equivalent:
|
||||
```python
|
||||
si = DocType("Sales Invoice")
|
||||
rows = (
|
||||
frappe.qb.from_(si)
|
||||
.select(si.name, si.grand_total)
|
||||
.where((si.customer == customer) & (si.docstatus == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
### Report Patterns
|
||||
- Entry: `def execute(filters): return get_columns(), get_data(filters)`
|
||||
- QB imports: `from frappe.query_builder import DocType` + `functions.Sum, Case, Count`
|
||||
- Build lookup maps first, then loop + merge (avoid N+1)
|
||||
- Caching: `@redis_cache(ttl=seconds)` for conversion factors
|
||||
|
||||
### Token-Saving Workflow
|
||||
- Default to `/model haiku` for routine edits, `/model sonnet` for moderate tasks
|
||||
- Use `/model opus` ONLY for architecture, debugging complex issues
|
||||
- Use `/compact` after completing each subtask
|
||||
- Use `gemini -p "prompt"` via stdin to read/summarize files without burning Claude tokens
|
||||
- Scope tasks narrowly: one feature/fix per session
|
||||
- Dump progress to `.claude/memory/scratch.md` before session ends
|
||||
- At session START: read `.claude/memory/scratch.md` — if it has content, resume from there
|
||||
- At session END (or when user says "dump progress"): fill in scratch.md with current task state
|
||||
- After resuming from scratch.md, clear it once the task is complete
|
||||
|
||||
|
||||
### Variable and Function naming convention
|
||||
|
||||
- always use full names for variables don't use abbreviations for ex: use
|
||||
"for row in rows" instead of "for r in rows"
|
||||
proxy_sku = DocType("Proxy SKU") instead of ps = DocType("Proxy SKU")
|
||||
- avoid starting python functions with underscore "_" unless it's a private version behind a whitelisted function (e.g. `get_dial_codes` (whitelisted) -> `_get_dial_codes` (cached logic))
|
||||
- use camelCase in JS and follow the surrounding code style in the project
|
||||
- always put imports at the top of the file, never inside functions
|
||||
|
||||
## Notes
|
||||
|
||||
- Read `ARCHITECTURE.md` for comprehensive details on data model, API surface, flows
|
||||
@@ -1,3 +1,102 @@
|
||||
# event-manager
|
||||
|
||||
Event manager pro Frappe
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="https://github.com/BuildWithHussain/event_manager/actions/workflows/ci.yml"><img src="https://github.com/BuildWithHussain/event_manager/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://github.com/BuildWithHussain/event_manager/actions/workflows/ui-tests.yml"><img src="https://github.com/BuildWithHussain/event_manager/actions/workflows/ui-tests.yml/badge.svg?branch=main" alt="UI Tests"></a>
|
||||
<a href="https://github.com/BuildWithHussain/event_manager/stargazers"><img src="https://img.shields.io/github/stars/BuildWithHussain/event_manager?style=social" alt="GitHub stars"></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3"></a>
|
||||
<a href="https://github.com/BuildWithHussain/event_manager/commits/main"><img src="https://img.shields.io/github/commit-activity/m/BuildWithHussain/event_manager" alt="GitHub commit activity"></a>
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
Open Source, Powerful, and Comprehensive Event Management Platform
|
||||
|
||||
### Learn & Connect
|
||||
[Telegram Public Group](https://t.me/bwh_buzz)
|
||||
### Stack / Architecture
|
||||
|
||||
1. Frappe Framework: The Backend and Admin Interface
|
||||
2. FrappeUI (based on Vue & TailwindCSS): For the frontend dashboard (for attendee, sponsors, etc.)
|
||||
3. Frappe Builder: For the public pages like events list and details page.
|
||||
|
||||
### The Main Entity
|
||||
|
||||
The **Pohodex Event Manager Event** DocType/Form is the primary entity of the system. Once you have created an event, you can setup ticket types, sponsorship tiers, add-ons (like T-Shirts, Meals, etc.), schedule, and much more!
|
||||
|
||||
### Features
|
||||
|
||||
This is not an exhaustive list by any means, just to give you an idea 😃
|
||||
|
||||
#### Dynamic Ticket & Add-on Types
|
||||
|
||||

|
||||
|
||||
#### The Booking Form
|
||||
|
||||
Once you have defined the proper ticket types, add-ons, and publish your event, the booking form will dynamically use it for booking.
|
||||
|
||||

|
||||
|
||||
#### Payments App Integration
|
||||
|
||||
This app depends on Frappe's Payments app for online payments. You can select a Payment Gateway in the event form. BTW GST collection is just a check-box away 😉
|
||||
|
||||
#### The Dashboard
|
||||
|
||||

|
||||
|
||||
#### Ticket Management
|
||||
|
||||
The benefits of having a "self-service" dashboard for attendees is that they can modify their bookings on their own (the deadlines can be configured from the **Pohodex Event Manager Settings**). For example, changing their T-Shirt Size after booking:
|
||||
|
||||

|
||||
|
||||
They can also transfer tickets or request for cancellation.
|
||||
|
||||
#### Sponsorship Management
|
||||
|
||||
Folks can enquire about sponsoring an event and upon approval from the event management team (from desk), they can directly pay from the dashboard too:
|
||||
|
||||

|
||||
|
||||
*As soon as they pay, their logo appears on the event page!*
|
||||
|
||||

|
||||
|
||||
### Installation
|
||||
|
||||
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
|
||||
|
||||
```bash
|
||||
cd $PATH_TO_YOUR_BENCH
|
||||
bench get-app BuildWithHussain/event_manager --branch main
|
||||
bench install-app event_manager
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
|
||||
|
||||
```bash
|
||||
cd apps/event_manager
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Pre-commit is configured to use the following tools for checking and formatting your code:
|
||||
|
||||
- ruff
|
||||
- eslint
|
||||
- prettier
|
||||
- pyupgrade
|
||||
### CI
|
||||
|
||||
This app can use GitHub Actions for CI. The following workflows are configured:
|
||||
|
||||
- CI: Installs this app and runs unit tests on every push to `develop` branch.
|
||||
- Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request.
|
||||
|
||||
|
||||
### License
|
||||
|
||||
agpl-3.0
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -0,0 +1,50 @@
|
||||
# Frappe UI Starter
|
||||
|
||||
This template should help get you started developing custom frontend for Frappe
|
||||
apps with Vue 3 and the Frappe UI package.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
|
||||
the box. It also has basic authentication frontend.
|
||||
|
||||
## Docs
|
||||
|
||||
[Frappe UI Website](https://frappeui.com)
|
||||
|
||||
## Usage
|
||||
|
||||
This template is meant to be cloned inside an existing Frappe App. Assuming your
|
||||
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
|
||||
|
||||
```
|
||||
cd apps/todo
|
||||
npx degit NagariaHussain/doppio_frappeui_starter frontend
|
||||
cd frontend
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
|
||||
|
||||
```
|
||||
"ignore_csrf": 1
|
||||
```
|
||||
|
||||
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
|
||||
|
||||
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
|
||||
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
|
||||
|
||||
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
|
||||
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
|
||||
- [Vue Router](https://next.router.vuejs.org/guide/)
|
||||
- [Frappe UI](https://github.com/frappe/frappe-ui)
|
||||
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
|
||||
- [Vite](https://vitejs.dev/guide/)
|
||||
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const LucideEdit: typeof import("~icons/lucide/edit")["default"]
|
||||
const LucideMic: typeof import("~icons/lucide/mic")["default"]
|
||||
const LucideRadio: typeof import("~icons/lucide/radio")["default"]
|
||||
const LucideSettings: typeof import("~icons/lucide/settings")["default"]
|
||||
const LucideUserPen: typeof import("~icons/lucide/user-pen")["default"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": []
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AddOnPreferenceDialog: typeof import('./src/components/AddOnPreferenceDialog.vue')['default']
|
||||
AttendeeFormControl: typeof import('./src/components/AttendeeFormControl.vue')['default']
|
||||
BackButton: typeof import('./src/components/common/BackButton.vue')['default']
|
||||
BaseCustomEventForm: typeof import('./src/components/BaseCustomEventForm.vue')['default']
|
||||
BillingDetails: typeof import('./src/components/BillingDetails.vue')['default']
|
||||
BookingEventInfo: typeof import('./src/components/BookingEventInfo.vue')['default']
|
||||
BookingFinancialSummary: typeof import('./src/components/BookingFinancialSummary.vue')['default']
|
||||
BookingForm: typeof import('./src/components/BookingForm.vue')['default']
|
||||
BookingHeader: typeof import('./src/components/BookingHeader.vue')['default']
|
||||
BookingSummary: typeof import('./src/components/BookingSummary.vue')['default']
|
||||
BuzzLogo: typeof import('./src/components/common/BuzzLogo.vue')['default']
|
||||
CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default']
|
||||
CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default']
|
||||
CustomFieldInput: typeof import('./src/components/CustomFieldInput.vue')['default']
|
||||
CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default']
|
||||
EventDetailsHeader: typeof import('./src/components/EventDetailsHeader.vue')['default']
|
||||
EventSelector: typeof import('./src/components/EventSelector.vue')['default']
|
||||
EventSponsorForm: typeof import('./src/components/EventSponsorForm.vue')['default']
|
||||
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
|
||||
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
|
||||
LoginRequired: typeof import('./src/components/LoginRequired.vue')['default']
|
||||
Navbar: typeof import('./src/components/Navbar.vue')['default']
|
||||
OfflinePaymentDialog: typeof import('./src/components/OfflinePaymentDialog.vue')['default']
|
||||
PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default']
|
||||
PhoneInput: typeof import('./src/components/PhoneInput.vue')['default']
|
||||
ProfileView: typeof import('./src/components/ProfileView.vue')['default']
|
||||
ProposalEditDialog: typeof import('./src/components/ProposalEditDialog.vue')['default']
|
||||
QRCodeExpandDialog: typeof import('./src/components/QRCodeExpandDialog.vue')['default']
|
||||
QRScanner: typeof import('./src/components/QRScanner.vue')['default']
|
||||
RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SponsorLogoUploader: typeof import('./src/components/SponsorLogoUploader.vue')['default']
|
||||
SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default']
|
||||
SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default']
|
||||
TicketCard: typeof import('./src/components/TicketCard.vue')['default']
|
||||
TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default']
|
||||
TicketsSection: typeof import('./src/components/TicketsSection.vue')['default']
|
||||
TicketTransferDialog: typeof import('./src/components/TicketTransferDialog.vue')['default']
|
||||
TransferTicketDialog: typeof import('./src/components/TransferTicketDialog.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pohodex Event Manager Dashboard</title>
|
||||
</head>
|
||||
<body class="bg-surface-white">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frappe-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --base=/assets/event_manager/dashboard/ && yarn copy-html-entry",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome check --write .",
|
||||
"typecheck": "./typecheck.sh",
|
||||
"copy-html-entry": "cp ../event_manager/public/dashboard/index.html ../event_manager/www/dashboard.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/router": "^13.6.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"feather-icons": "^4.29.2",
|
||||
"frappe-ui": "^0.1.257",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"postcss": "^8.4.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "0.18.6",
|
||||
"vite": "^5.4.10",
|
||||
"vue-tsc": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import LoginDialog from "@/components/LoginDialog.vue";
|
||||
import { FrappeUIProvider, setConfig } from "frappe-ui";
|
||||
import Layout from "./layouts/Layout.vue";
|
||||
|
||||
setConfig("systemTimezone", window.timezone?.system || null);
|
||||
setConfig("localTimezone", window.timezone?.user || null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout>
|
||||
<router-view />
|
||||
</Layout>
|
||||
<LoginDialog />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="dialogOptions">
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-ink-gray-8">
|
||||
Update your add-on preferences for <strong>{{ ticket.attendee_name }}</strong>
|
||||
</p>
|
||||
|
||||
<div v-if="addOnsWithOptions.length === 0" class="text-center py-4">
|
||||
<p class="text-ink-gray-6">No customizable add-ons found for this ticket.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="addon in addOnsWithOptions" :key="addon.id" class="space-y-2">
|
||||
<label class="block text-sm font-medium text-ink-gray-8">
|
||||
{{ __(addon.title) }}
|
||||
</label>
|
||||
<p class="text-xs text-ink-gray-6 mb-2">Current: {{ addon.value }}</p>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="addon.selectOptions"
|
||||
v-model="preferences[addon.id]"
|
||||
:placeholder="`Select ${addon.title.toLowerCase()}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions="{ close }">
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
variant="solid"
|
||||
:loading="savePreferences.loading"
|
||||
:disabled="!hasChanges || addOnsWithOptions.length === 0"
|
||||
@click="handleSave"
|
||||
>
|
||||
Save Preferences
|
||||
</Button>
|
||||
<Button variant="outline" @click="close">Cancel</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dialog, FormControl, createResource, toast } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
ticket: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const preferences = ref({});
|
||||
|
||||
// Filter add-ons that have selectable options
|
||||
const addOnsWithOptions = computed(() => {
|
||||
if (!props.ticket?.add_ons) return [];
|
||||
|
||||
return props.ticket.add_ons
|
||||
.filter((addon) => addon.options && addon.options.length > 0)
|
||||
.map((addon) => ({
|
||||
...addon,
|
||||
selectOptions: addon.options.map((option) => ({
|
||||
label: __(option),
|
||||
value: option,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
// Check if user has made any changes
|
||||
const hasChanges = computed(() => {
|
||||
return addOnsWithOptions.value.some((addon) => {
|
||||
const currentValue = preferences.value[addon.id];
|
||||
return currentValue && currentValue !== addon.value;
|
||||
});
|
||||
});
|
||||
|
||||
const dialogOptions = {
|
||||
title: "Update Add-on Preferences",
|
||||
size: "lg",
|
||||
};
|
||||
|
||||
// Initialize preferences when dialog opens
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue && addOnsWithOptions.value.length > 0) {
|
||||
preferences.value = {};
|
||||
for (const addon of addOnsWithOptions.value) {
|
||||
preferences.value[addon.id] = addon.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const savePreferences = createResource({
|
||||
url: "buzz.api.change_add_on_preference",
|
||||
onSuccess: () => {
|
||||
toast.success("Add-on preferences updated successfully!");
|
||||
emit("success");
|
||||
show.value = false;
|
||||
},
|
||||
onError: (error) => {
|
||||
// Check if this is the specific error about change window closing
|
||||
if (error?.message?.includes("change window has closed")) {
|
||||
toast.error(
|
||||
"Add-on changes are not allowed at this time - the change window has closed as the event is approaching."
|
||||
);
|
||||
} else {
|
||||
toast.error("Failed to update preferences");
|
||||
}
|
||||
console.error("Error updating add-on preferences:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
const changes = addOnsWithOptions.value.filter((addon) => {
|
||||
const newValue = preferences.value[addon.id];
|
||||
return newValue && newValue !== addon.value;
|
||||
});
|
||||
|
||||
if (changes.length === 0) {
|
||||
toast.warning("No changes to save");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save each changed preference
|
||||
for (const addon of changes) {
|
||||
const newValue = preferences.value[addon.id];
|
||||
await savePreferences.submit({
|
||||
add_on_id: addon.id,
|
||||
new_value: newValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!-- AttendeeCard.vue -->
|
||||
<template>
|
||||
<div
|
||||
class="bg-surface-white border border-outline-gray-3 rounded-xl p-4 md:p-6 mb-6 shadow-sm relative"
|
||||
>
|
||||
<!-- Remove Button -->
|
||||
|
||||
<div class="flex justify-between items-start mb-4 border-b pb-2">
|
||||
<h4 class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __("Attendee") }} #{{ index + 1 }}
|
||||
</h4>
|
||||
|
||||
<Tooltip :text="__('Remove Attendee')" :hover-delay="0.5">
|
||||
<Button
|
||||
v-if="showRemove"
|
||||
@click="$emit('remove')"
|
||||
type="button"
|
||||
theme="red"
|
||||
icon="x"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Name, Email and Custom Fields -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 items-end">
|
||||
<FormControl
|
||||
v-model="attendee.first_name"
|
||||
:label="__('First Name')"
|
||||
:placeholder="__('Enter first name')"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="attendee.last_name"
|
||||
:label="__('Last Name')"
|
||||
:placeholder="__('Enter last name')"
|
||||
:required="eventDetails.category === 'Webinars'"
|
||||
type="text"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="attendee.email"
|
||||
:label="__('Email')"
|
||||
:placeholder="__('Enter email address')"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
|
||||
<!-- Ticket Type -->
|
||||
|
||||
<!-- Show selector only if there are multiple ticket types -->
|
||||
<FormControl
|
||||
v-if="
|
||||
availableTicketTypes.length > 1 &&
|
||||
!(eventDetails.category == 'Webinars' && eventDetails.free_webinar)
|
||||
"
|
||||
v-model="attendee.ticket_type"
|
||||
:label="__('Ticket Type')"
|
||||
type="select"
|
||||
:options="
|
||||
availableTicketTypes.map((tt) => ({
|
||||
label: `${__(tt.title)} (${formatPriceOrFree(tt.price, tt.currency)})`,
|
||||
value: String(tt.name),
|
||||
}))
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Custom Fields for Tickets integrated with basic fields -->
|
||||
<template v-if="customFields.length > 0">
|
||||
<CustomFieldInput
|
||||
v-for="field in customFields"
|
||||
:key="field.fieldname"
|
||||
:field="field"
|
||||
:model-value="getCustomFieldValue(field.fieldname)"
|
||||
@update:model-value="updateCustomFieldValue(field.fieldname, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add-ons -->
|
||||
<div v-if="availableAddOns.length > 0">
|
||||
<hr class="my-4" />
|
||||
|
||||
<div v-for="addOn in availableAddOns" :key="addOn.name" class="mb-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:model-value="getAddOnSelected(addOn.name)"
|
||||
@update:model-value="updateAddOnSelection(addOn.name, $event)"
|
||||
:id="`add_on_${addOn.name}_${index}`"
|
||||
:label="__(addOn.title)"
|
||||
/>
|
||||
<div class="text-ink-gray-5 text-sm/4" v-if="addOn.description">
|
||||
<p>
|
||||
{{ __(addOn.description) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="addOn.user_selects_option && getAddOnSelected(addOn.name)"
|
||||
class="mt-2 ml-6"
|
||||
>
|
||||
<FormControl
|
||||
:model-value="getAddOnOption(addOn.name)"
|
||||
@update:model-value="updateAddOnOption(addOn.name, $event)"
|
||||
type="select"
|
||||
:options="
|
||||
addOn.options.map((option) => ({ label: __(option), value: option }))
|
||||
"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getFieldDefaultValue } from "@/composables/useCustomFields";
|
||||
import { formatPriceOrFree } from "@/utils/currency";
|
||||
import { Tooltip } from "frappe-ui";
|
||||
import CustomFieldInput from "./CustomFieldInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
attendee: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
availableTicketTypes: { type: Array, required: true },
|
||||
availableAddOns: { type: Array, required: true },
|
||||
customFields: { type: Array, default: () => [] },
|
||||
showRemove: { type: Boolean, default: false },
|
||||
eventDetails: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["remove"]);
|
||||
|
||||
// Helper methods to safely access add-on properties
|
||||
const ensureAddOnExists = (addOnName) => {
|
||||
if (!props.attendee.add_ons) {
|
||||
props.attendee.add_ons = {};
|
||||
}
|
||||
if (!props.attendee.add_ons[addOnName]) {
|
||||
const addOn = props.availableAddOns.find((a) => a.name === addOnName);
|
||||
props.attendee.add_ons[addOnName] = {
|
||||
selected: false,
|
||||
option: addOn?.options ? addOn.options[0] || null : null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getAddOnSelected = (addOnName) => {
|
||||
ensureAddOnExists(addOnName);
|
||||
return props.attendee.add_ons[addOnName].selected;
|
||||
};
|
||||
|
||||
const getAddOnOption = (addOnName) => {
|
||||
ensureAddOnExists(addOnName);
|
||||
return props.attendee.add_ons[addOnName].option;
|
||||
};
|
||||
|
||||
const updateAddOnSelection = (addOnName, selected) => {
|
||||
ensureAddOnExists(addOnName);
|
||||
props.attendee.add_ons[addOnName].selected = selected;
|
||||
|
||||
// If selecting an add-on and it has options, ensure the first option is selected
|
||||
if (selected) {
|
||||
const addOn = props.availableAddOns.find((a) => a.name === addOnName);
|
||||
if (
|
||||
addOn?.options &&
|
||||
addOn.options.length > 0 &&
|
||||
!props.attendee.add_ons[addOnName].option
|
||||
) {
|
||||
props.attendee.add_ons[addOnName].option = addOn.options[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateAddOnOption = (addOnName, option) => {
|
||||
ensureAddOnExists(addOnName);
|
||||
props.attendee.add_ons[addOnName].option = option;
|
||||
};
|
||||
|
||||
// Custom fields helper methods
|
||||
const ensureCustomFieldsExists = () => {
|
||||
if (!props.attendee.custom_fields) {
|
||||
props.attendee.custom_fields = {};
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomFieldValue = (fieldname) => {
|
||||
ensureCustomFieldsExists();
|
||||
const currentValue = props.attendee.custom_fields[fieldname];
|
||||
|
||||
// Apply default for fields that don't have values yet
|
||||
if (!currentValue && currentValue !== "") {
|
||||
const field = props.customFields.find((f) => f.fieldname === fieldname);
|
||||
if (field) {
|
||||
const defaultValue = getFieldDefaultValue(field);
|
||||
if (defaultValue) {
|
||||
updateCustomFieldValue(fieldname, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentValue || "";
|
||||
};
|
||||
|
||||
const updateCustomFieldValue = (fieldname, value) => {
|
||||
ensureCustomFieldsExists();
|
||||
props.attendee.custom_fields[fieldname] = value;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-8 mx-auto" v-if="formDataResource.loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="submitted" class="text-center">
|
||||
<div class="bg-surface-green-1 border border-outline-green-1 rounded-lg p-8">
|
||||
<LucideCheckCircle class="w-16 h-16 text-ink-green-2 mx-auto mb-4" />
|
||||
<h2 class="text-ink-green-3 font-semibold text-xl mb-2">
|
||||
{{ formData.success_title }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="renderedSuccessMessage"
|
||||
class="prose prose-sm max-w-none text-ink-green-2"
|
||||
v-html="renderedSuccessMessage"
|
||||
></div>
|
||||
<p v-else class="text-ink-green-2">
|
||||
{{ __("Your submission has been received.") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginRequired
|
||||
v-else-if="loginRequired"
|
||||
:message="__('Please log in to submit this form.')"
|
||||
/>
|
||||
|
||||
<div v-else-if="formData?.closed" class="text-center">
|
||||
<div class="bg-surface-amber-1 border border-outline-amber-1 rounded-lg p-8">
|
||||
<LucideAlertCircle class="w-16 h-16 text-ink-amber-3 mx-auto mb-4" />
|
||||
<h2 class="text-ink-amber-3 font-semibold text-xl mb-2">
|
||||
{{ formData.closed_title }}
|
||||
</h2>
|
||||
<p class="text-ink-amber-2">
|
||||
{{ formData.closed_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="formData">
|
||||
<EventDetailsHeader :event-details="formData.event" />
|
||||
|
||||
<form
|
||||
class="bg-surface-white border border-outline-gray-1 rounded-lg p-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<h1 class="text-ink-gray-9 font-bold text-2xl mb-6">
|
||||
{{ formData.form_title }}
|
||||
</h1>
|
||||
|
||||
<div class="space-y-4">
|
||||
<template v-for="field in formData.form_fields" :key="field.fieldname">
|
||||
<div v-if="field.fieldtype === 'Table'" class="space-y-2">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
</label>
|
||||
<div v-if="tableData[field.fieldname]?.length" class="space-y-2">
|
||||
<div
|
||||
v-for="(row, idx) in tableData[field.fieldname]"
|
||||
:key="idx"
|
||||
class="flex items-center justify-between border rounded-md px-3 py-2"
|
||||
>
|
||||
<span class="text-sm text-ink-gray-7">
|
||||
{{ getTableRowSummary(row) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="editTableRow(field, idx)"
|
||||
>
|
||||
{{ __("Edit") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="removeTableRow(field.fieldname, idx)"
|
||||
>
|
||||
{{ __("Remove") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" @click="addTableRow(field)">
|
||||
{{ __("Add {0}", [__(field.label)]) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CustomFieldInput
|
||||
v-else
|
||||
:field="normalizeField(field)"
|
||||
:model-value="formValues[field.fieldname]"
|
||||
@update:model-value="formValues[field.fieldname] = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CustomFieldsSection
|
||||
v-if="formData.custom_fields?.length"
|
||||
:custom-fields="formData.custom_fields"
|
||||
v-model="customFieldValues"
|
||||
:show-title="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full mt-6"
|
||||
:loading="submitResource.loading"
|
||||
type="submit"
|
||||
>
|
||||
{{ __("Submit") }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="text-center">
|
||||
<div class="bg-surface-amber-1 border border-outline-amber-1 rounded-lg p-8">
|
||||
<LucideAlertCircle class="w-16 h-16 text-ink-amber-3 mx-auto mb-4" />
|
||||
<h2 class="text-ink-amber-3 font-semibold text-xl mb-2">
|
||||
{{ __("Not Found") }}
|
||||
</h2>
|
||||
<p class="text-ink-amber-2">
|
||||
{{ loadError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model="tableDialog.open" :options="{ title: tableDialog.title }">
|
||||
<template #body-content>
|
||||
<form @submit.prevent="saveTableRow">
|
||||
<div class="space-y-4">
|
||||
<template
|
||||
v-for="childField in tableDialog.fields"
|
||||
:key="childField.fieldname"
|
||||
>
|
||||
<CustomFieldInput
|
||||
:field="normalizeField(childField)"
|
||||
:model-value="tableDialog.rowData[childField.fieldname]"
|
||||
@update:model-value="
|
||||
tableDialog.rowData[childField.fieldname] = $event
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<Button variant="solid" type="submit" class="mt-4">
|
||||
{{ tableDialog.editIndex !== null ? __("Update") : __("Add") }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CustomFieldInput from "@/components/CustomFieldInput.vue";
|
||||
import CustomFieldsSection from "@/components/CustomFieldsSection.vue";
|
||||
import EventDetailsHeader from "@/components/EventDetailsHeader.vue";
|
||||
import LoginRequired from "@/components/LoginRequired.vue";
|
||||
import { Button, Dialog, Spinner, createResource, toast } from "frappe-ui";
|
||||
import { marked } from "marked";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import LucideAlertCircle from "~icons/lucide/alert-circle";
|
||||
import LucideCheckCircle from "~icons/lucide/check-circle";
|
||||
|
||||
const props = defineProps({
|
||||
eventRoute: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
formRoute: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formData = ref(null);
|
||||
const formValues = reactive({});
|
||||
const customFieldValues = ref({});
|
||||
const submitted = ref(false);
|
||||
const loginRequired = ref(false);
|
||||
const loadError = ref(null);
|
||||
|
||||
const tableData = reactive({});
|
||||
const tableDialog = reactive({
|
||||
open: false,
|
||||
title: "",
|
||||
fieldname: "",
|
||||
fields: [],
|
||||
rowData: {},
|
||||
editIndex: null,
|
||||
});
|
||||
|
||||
const renderedSuccessMessage = computed(() => {
|
||||
const msg = formData.value?.success_message;
|
||||
if (!msg) return "";
|
||||
return marked(msg);
|
||||
});
|
||||
|
||||
function normalizeField(field) {
|
||||
return {
|
||||
fieldname: field.fieldname,
|
||||
fieldtype: field.fieldtype,
|
||||
label: field.label,
|
||||
options: field.options,
|
||||
mandatory: field.reqd || field.mandatory,
|
||||
placeholder: field.placeholder || "",
|
||||
default_value: field.default || field.default_value,
|
||||
link_options: field.link_options,
|
||||
};
|
||||
}
|
||||
|
||||
function getTableRowSummary(row) {
|
||||
const values = Object.values(row).filter((v) => v && typeof v === "string");
|
||||
return values.slice(0, 3).join(" — ") || __("(empty)");
|
||||
}
|
||||
|
||||
function addTableRow(field) {
|
||||
if (!tableData[field.fieldname]) tableData[field.fieldname] = [];
|
||||
|
||||
tableDialog.open = true;
|
||||
tableDialog.title = __("Add {0}", [__(field.label)]);
|
||||
tableDialog.fieldname = field.fieldname;
|
||||
tableDialog.fields = field.child_fields || [];
|
||||
tableDialog.rowData = {};
|
||||
tableDialog.editIndex = null;
|
||||
}
|
||||
|
||||
function editTableRow(field, idx) {
|
||||
tableDialog.open = true;
|
||||
tableDialog.title = __("Edit {0}", [__(field.label)]);
|
||||
tableDialog.fieldname = field.fieldname;
|
||||
tableDialog.fields = field.child_fields || [];
|
||||
tableDialog.rowData = { ...tableData[field.fieldname][idx] };
|
||||
tableDialog.editIndex = idx;
|
||||
}
|
||||
|
||||
function removeTableRow(fieldname, idx) {
|
||||
tableData[fieldname].splice(idx, 1);
|
||||
}
|
||||
|
||||
function saveTableRow() {
|
||||
const fieldname = tableDialog.fieldname;
|
||||
if (!tableData[fieldname]) tableData[fieldname] = [];
|
||||
|
||||
if (tableDialog.editIndex !== null) {
|
||||
tableData[fieldname][tableDialog.editIndex] = { ...tableDialog.rowData };
|
||||
} else {
|
||||
tableData[fieldname].push({ ...tableDialog.rowData });
|
||||
}
|
||||
tableDialog.open = false;
|
||||
}
|
||||
|
||||
const formDataResource = createResource({
|
||||
url: "buzz.api.forms.get_custom_form_data",
|
||||
params: {
|
||||
event_route: props.eventRoute,
|
||||
form_route: props.formRoute,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
formData.value = data;
|
||||
for (const field of data.form_fields || []) {
|
||||
if (field.default) {
|
||||
formValues[field.fieldname] = field.default;
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.exc_type === "AuthenticationError") {
|
||||
loginRequired.value = true;
|
||||
return;
|
||||
}
|
||||
loadError.value = err.messages?.[0] || __("Form not found");
|
||||
},
|
||||
});
|
||||
|
||||
const submitResource = createResource({
|
||||
url: "buzz.api.forms.submit_custom_form",
|
||||
onSuccess: () => {
|
||||
submitted.value = true;
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err.messages?.[0] || __("Failed to submit form");
|
||||
toast.error(msg.replace(/<[^>]*>/g, ""));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
for (const field of formData.value.form_fields || []) {
|
||||
if (field.fieldtype === "Table" && field.reqd && !tableData[field.fieldname]?.length) {
|
||||
toast.error(__("Please add at least one {0}", [__(field.label)]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of formData.value.custom_fields || []) {
|
||||
if (!field.mandatory) continue;
|
||||
const val = customFieldValues.value[field.fieldname];
|
||||
const isEmpty = !val || val === "0" || val === 0;
|
||||
if (isEmpty) {
|
||||
toast.error(__("{0} is required", [__(field.label)]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = { ...formValues };
|
||||
for (const [fieldname, rows] of Object.entries(tableData)) {
|
||||
if (rows.length) {
|
||||
data[fieldname] = rows;
|
||||
}
|
||||
}
|
||||
|
||||
submitResource.submit({
|
||||
event_route: props.eventRoute,
|
||||
form_route: props.formRoute,
|
||||
data,
|
||||
custom_fields_data: customFieldValues.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-surface-white border border-outline-gray-3 rounded-xl p-4 md:p-6 mb-6 shadow-sm"
|
||||
>
|
||||
<h3 class="text-base font-medium text-ink-gray-8 border-b pb-2 mb-4">
|
||||
{{ __("Billing Details") }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:model-value="invoiceRequested"
|
||||
@update:model-value="$emit('update:invoiceRequested', $event)"
|
||||
:label="__('Do you need an invoice?')"
|
||||
/>
|
||||
<template v-if="invoiceRequested">
|
||||
<FormControl
|
||||
:model-value="taxId"
|
||||
@update:model-value="$emit('update:taxId', $event)"
|
||||
type="text"
|
||||
:label="taxIdLabel"
|
||||
:placeholder="__('Enter {0}', [taxIdLabel])"
|
||||
/>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __("Billing Address") }}
|
||||
<span class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
:model-value="billingAddress"
|
||||
@update:model-value="$emit('update:billingAddress', $event)"
|
||||
:placeholder="__('Enter billing address')"
|
||||
:required="true"
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { FormControl, Textarea } from "frappe-ui";
|
||||
|
||||
const props = defineProps({
|
||||
invoiceRequested: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
taxId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
billingAddress: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
taxLabel: {
|
||||
type: String,
|
||||
default: "Tax",
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["update:invoiceRequested", "update:taxId", "update:billingAddress"]);
|
||||
|
||||
const taxIdLabel = computed(() => __(props.taxLabel));
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="bg-surface-cards border border-outline-gray-1 rounded-lg p-6">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-ink-gray-9">{{ event.title }}</h3>
|
||||
|
||||
<Button
|
||||
:link="`/events/${event.route}`"
|
||||
icon-left="external-link"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>{{ __("Visit Event Page") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Start Date & Time -->
|
||||
<div>
|
||||
<div class="flex items-center text-ink-gray-6 mb-1">
|
||||
<LucideCalendarDays class="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span class="text-sm font-medium">{{ __("Start Date") }}</span>
|
||||
</div>
|
||||
<p class="text-ink-gray-9 font-medium">
|
||||
{{ formatEventDateTime(event.start_date, event.start_time) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End Date & Time -->
|
||||
<div v-if="event.end_date">
|
||||
<div class="flex items-center text-ink-gray-6 mb-1">
|
||||
<LucideCalendarDays class="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span class="text-sm font-medium">{{ __("End Date") }}</span>
|
||||
</div>
|
||||
<p class="text-ink-gray-9 font-medium">
|
||||
{{ formatEventDateTime(event.end_date, event.end_time) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Venue -->
|
||||
<div v-if="venue">
|
||||
<div class="flex items-center text-ink-gray-6 mb-1">
|
||||
<LucideMapPin class="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span class="text-sm font-medium">{{ __("Venue") }}</span>
|
||||
</div>
|
||||
<p class="text-ink-gray-9 font-medium">{{ venue.name }}</p>
|
||||
<p v-if="venue.address" class="text-sm text-ink-gray-6 mt-1">
|
||||
{{ venue.address }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event Description -->
|
||||
<div v-if="event.short_description" class="pt-2 border-t border-outline-gray-1">
|
||||
<p class="text-sm text-ink-gray-6">{{ event.short_description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { dayjsLocal } from "frappe-ui";
|
||||
import LucideCalendarDays from "~icons/lucide/calendar-days";
|
||||
import LucideMapPin from "~icons/lucide/map-pin";
|
||||
|
||||
defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => {
|
||||
return (
|
||||
typeof value.title === "string" &&
|
||||
value.start_date &&
|
||||
typeof value.route === "string"
|
||||
);
|
||||
},
|
||||
},
|
||||
venue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to format date and time together (matching TicketDetails.vue)
|
||||
const formatEventDateTime = (date, time) => {
|
||||
if (!date) return "";
|
||||
|
||||
// Create a date object from the date string
|
||||
const dateObj = dayjsLocal(date);
|
||||
|
||||
// If time is provided, combine it with the date
|
||||
if (time) {
|
||||
// Parse the time (format: "HH:mm:ss")
|
||||
const [hours, minutes] = time.split(":");
|
||||
const dateTimeObj = dateObj.hour(Number.parseInt(hours)).minute(Number.parseInt(minutes));
|
||||
return dateTimeObj.format("MMMM DD, YYYY [at] h:mm A");
|
||||
}
|
||||
|
||||
// If no time, just show the date
|
||||
return dateObj.format("MMMM DD, YYYY");
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="bg-surface-cards border border-outline-gray-1 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-ink-gray-9">{{ __("Payment Summary") }}</h3>
|
||||
<Badge
|
||||
v-if="(booking.total_amount || 0) > 0"
|
||||
variant="subtle"
|
||||
:theme="paymentBadge.theme"
|
||||
size="sm"
|
||||
>
|
||||
<template #prefix>
|
||||
<component :is="paymentBadge.icon" class="w-3 h-3" />
|
||||
</template>
|
||||
{{ paymentBadge.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Net Amount (hide when tax-inclusive and no discount) -->
|
||||
<div
|
||||
v-if="!isTaxInclusive || hasDiscount"
|
||||
class="flex justify-between items-center text-ink-gray-7"
|
||||
>
|
||||
<span>{{ __("Subtotal") }}</span>
|
||||
<span class="font-medium">{{
|
||||
formatPrice(booking.net_amount || 0, booking.currency || "INR")
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Coupon Code -->
|
||||
<div
|
||||
v-if="booking.coupon_code"
|
||||
class="flex justify-between items-center text-ink-gray-7"
|
||||
>
|
||||
<span>{{ __("Coupon") }}</span>
|
||||
<span class="font-medium text-green-600">{{ booking.coupon_code }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<div v-if="hasDiscount" class="flex justify-between items-center text-green-600">
|
||||
<span>{{ __("Discount") }}</span>
|
||||
<span class="font-medium"
|
||||
>-{{ formatPrice(booking.discount_amount, booking.currency || "INR") }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Tax Information (exclusive only) -->
|
||||
<div
|
||||
v-if="hasTax && !isTaxInclusive"
|
||||
class="flex justify-between items-center text-ink-gray-7"
|
||||
>
|
||||
<span
|
||||
>{{ __(booking.tax_label || "Tax") }} ({{
|
||||
booking.tax_percentage || 0
|
||||
}}%)</span
|
||||
>
|
||||
<span class="font-medium">{{
|
||||
formatPrice(booking.tax_amount || 0, booking.currency || "INR")
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-outline-gray-1" />
|
||||
|
||||
<!-- Total Amount -->
|
||||
<div class="flex justify-between items-center text-lg font-semibold text-ink-gray-9">
|
||||
<span>{{ isPaid ? __("Total Paid") : __("Total") }}</span>
|
||||
<span :class="isPaid ? 'text-ink-green-2' : 'text-ink-gray-9'">{{
|
||||
formatPrice(booking.total_amount || 0, booking.currency || "INR")
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tax-inclusive note -->
|
||||
<div v-if="hasTax && isTaxInclusive" class="text-sm text-ink-gray-5 text-right mt-3">
|
||||
{{
|
||||
__("Inclusive of {0} {1} ({2}%)", [
|
||||
formatPrice(booking.tax_amount || 0, booking.currency || "INR"),
|
||||
__(booking.tax_label || "Tax"),
|
||||
booking.tax_percentage || 0,
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatPrice } from "@/utils/currency";
|
||||
import { Badge } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideClock from "~icons/lucide/clock";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
const props = defineProps({
|
||||
booking: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => {
|
||||
return typeof value === "object" && value !== null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hasTax = computed(() => {
|
||||
return Boolean(props.booking.tax_amount && props.booking.tax_amount > 0);
|
||||
});
|
||||
|
||||
const hasDiscount = computed(() => {
|
||||
return (props.booking.discount_amount || 0) > 0;
|
||||
});
|
||||
|
||||
const isPaid = computed(() => props.booking.payment_status === "Paid");
|
||||
|
||||
const paymentBadge = computed(() => {
|
||||
const status = props.booking.payment_status;
|
||||
if (status === "Paid") {
|
||||
return { label: __("Paid"), theme: "green", icon: LucideCheck };
|
||||
} else if (status === "Verification Pending") {
|
||||
return {
|
||||
label: __("Verification Pending"),
|
||||
theme: "orange",
|
||||
icon: LucideClock,
|
||||
};
|
||||
}
|
||||
return { label: __(status || "Unpaid"), theme: "red", icon: LucideX };
|
||||
});
|
||||
|
||||
const isTaxInclusive = computed(() => {
|
||||
// Tax-inclusive: total_amount equals net_amount minus discount (tax not added on top)
|
||||
if (!hasTax.value) return false;
|
||||
const expected = (props.booking.net_amount || 0) - (props.booking.discount_amount || 0);
|
||||
return Math.abs(props.booking.total_amount - expected) < 0.01;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<BackButton :to="{ name: 'bookings-list' }" :label="__('Back to Bookings')" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-ink-gray-9 font-semibold text-lg mb-3">
|
||||
{{ __("Booking Details") }}
|
||||
<span class="text-ink-gray-5 font-mono">(#{{ bookingId }})</span>
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BackButton from "./common/BackButton.vue";
|
||||
|
||||
defineProps({
|
||||
bookingId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<!-- BookingSummary.vue -->
|
||||
<template>
|
||||
<div class="bg-surface-gray-1 border border-outline-gray-1 rounded-lg p-4">
|
||||
<h2 class="text-xl font-bold text-ink-gray-9 mb-4">{{ __("Booking Summary") }}</h2>
|
||||
|
||||
<!-- Tickets Section -->
|
||||
<div v-if="Object.keys(summary.tickets).length" class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-ink-gray-8 mb-2">{{ __("Tickets") }}</h3>
|
||||
<div
|
||||
v-for="(ticket, name) in summary.tickets"
|
||||
:key="name"
|
||||
class="flex justify-between items-start text-ink-gray-7 mb-2"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span>{{ __(ticket.title) }}</span>
|
||||
<span
|
||||
v-if="freeTicketType === name && freeTicketCount > 0"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ Math.min(freeTicketCount, ticket.count) }} x
|
||||
<span class="line-through">{{
|
||||
formatPriceOrFree(ticket.price, ticket.currency)
|
||||
}}</span>
|
||||
{{ __("Free")
|
||||
}}{{
|
||||
ticket.count > freeTicketCount
|
||||
? `, ${ticket.count - freeTicketCount} x ${formatPriceOrFree(
|
||||
ticket.price,
|
||||
ticket.currency
|
||||
)}`
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="netAmount > 0" class="text-sm text-ink-gray-5">
|
||||
{{ ticket.count }} x {{ formatPriceOrFree(ticket.price, ticket.currency) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-ink-gray-5">x {{ ticket.count }}</span>
|
||||
</div>
|
||||
<span v-if="freeTicketType === name && freeTicketCount > 0" class="font-medium">
|
||||
{{
|
||||
ticket.count <= freeTicketCount
|
||||
? __("Free")
|
||||
: formatPriceOrFree(
|
||||
(ticket.count - freeTicketCount) * ticket.price,
|
||||
ticket.currency
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="netAmount > 0" class="font-medium">{{
|
||||
formatPriceOrFree(ticket.amount, ticket.currency)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add-ons Section -->
|
||||
<div v-if="Object.keys(summary.add_ons).length" class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-ink-gray-8 mb-2">{{ __("Add-ons") }}</h3>
|
||||
<div
|
||||
v-for="(addOn, name) in summary.add_ons"
|
||||
:key="name"
|
||||
class="flex justify-between items-start text-ink-gray-7 mb-2"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span>{{ __(addOn.title) }}</span>
|
||||
<span v-if="freeAddOnCounts[name] > 0" class="text-sm text-ink-gray-5">
|
||||
{{ Math.min(freeAddOnCounts[name], addOn.count) }} x
|
||||
<span class="line-through">{{
|
||||
formatPriceOrFree(addOn.price, addOn.currency)
|
||||
}}</span>
|
||||
{{ __("Free")
|
||||
}}{{
|
||||
addOn.count > freeAddOnCounts[name]
|
||||
? `, ${addOn.count - freeAddOnCounts[name]} x ${formatPriceOrFree(
|
||||
addOn.price,
|
||||
addOn.currency
|
||||
)}`
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="netAmount > 0" class="text-sm text-ink-gray-5">
|
||||
{{ addOn.count }} x {{ formatPriceOrFree(addOn.price, addOn.currency) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-ink-gray-5">x {{ addOn.count }}</span>
|
||||
</div>
|
||||
<span v-if="freeAddOnCounts[name] > 0" class="font-medium">
|
||||
{{
|
||||
addOn.count <= freeAddOnCounts[name]
|
||||
? __("Free")
|
||||
: formatPriceOrFree(
|
||||
(addOn.count - freeAddOnCounts[name]) * addOn.price,
|
||||
addOn.currency
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="netAmount > 0" class="font-medium">{{
|
||||
formatPriceOrFree(addOn.amount, addOn.currency)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show pricing summary if total > 0 OR coupon made it free -->
|
||||
<template v-if="total > 0 || (couponApplied && netAmount > 0)">
|
||||
<hr class="my-4 border-t border-outline-gray-1" />
|
||||
|
||||
<!-- Subtotal (hide when tax-inclusive and no discount, since it equals total) -->
|
||||
<div
|
||||
v-if="!taxInclusive || (couponApplied && discountAmount > 0)"
|
||||
class="flex justify-between items-center text-ink-gray-7 mb-2"
|
||||
>
|
||||
<span>{{ __("Subtotal") }}</span>
|
||||
<span class="font-medium">{{ formatPriceOrFree(netAmount, totalCurrency) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Discount Section -->
|
||||
<div
|
||||
v-if="couponApplied && discountAmount > 0"
|
||||
class="flex justify-between items-center text-green-600 mb-2"
|
||||
>
|
||||
<span>{{
|
||||
couponType === "Free Tickets" ? __("Free Tickets") : __("Discount")
|
||||
}}</span>
|
||||
<span class="font-medium"
|
||||
>-{{ formatPriceOrFree(discountAmount, totalCurrency) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Tax Section (exclusive only — shown as line item added to total) -->
|
||||
<div
|
||||
v-if="shouldApplyTax && !taxInclusive"
|
||||
class="flex justify-between items-center text-ink-gray-7 mb-2"
|
||||
>
|
||||
<span>{{ __(taxLabel) }} ({{ taxPercentage }}%)</span>
|
||||
<span class="font-medium">{{ formatPriceOrFree(taxAmount, totalCurrency) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Final Total Section -->
|
||||
<hr v-if="shouldApplyTax" class="my-2 border-t border-outline-gray-1" />
|
||||
<div class="flex justify-between items-center text-xl font-bold text-ink-gray-9">
|
||||
<h3>{{ __("Total") }}</h3>
|
||||
<span>{{ formatPriceOrFree(total, totalCurrency) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tax-inclusive note (shown below total) -->
|
||||
<div
|
||||
v-if="shouldApplyTax && taxInclusive"
|
||||
class="text-sm text-ink-gray-5 text-right mt-3"
|
||||
>
|
||||
{{
|
||||
__("Inclusive of {0} {1} ({2}%)", [
|
||||
formatPriceOrFree(taxAmount, totalCurrency),
|
||||
__(taxLabel),
|
||||
taxPercentage,
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Free event message -->
|
||||
<template v-else>
|
||||
<hr class="my-2 border-t border-outline-gray-1" />
|
||||
<div class="text-center pt-2">
|
||||
<div class="text-xl font-bold text-green-600">{{ __("Free Event") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatPriceOrFree } from "@/utils/currency";
|
||||
|
||||
defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
netAmount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
discountAmount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
couponApplied: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
couponType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
freeAddOnCounts: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
freeTicketType: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
freeTicketCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
taxAmount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
taxPercentage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
taxLabel: {
|
||||
type: String,
|
||||
default: "Tax",
|
||||
},
|
||||
taxInclusive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shouldApplyTax: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalCurrency: {
|
||||
type: String,
|
||||
default: "INR",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Request Ticket Cancellation'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-6">
|
||||
<p class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
"Select the tickets you would like to cancel. Please note that cancellation requests are subject to approval and refund policies."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Info about excluded tickets -->
|
||||
<div
|
||||
v-if="cancelledTickets.length > 0 || cancellationRequestedTickets.length > 0"
|
||||
class="p-4 bg-surface-blue-1 border border-outline-blue-1 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-ink-blue-2">
|
||||
<span v-if="cancelledTickets.length > 0">
|
||||
{{ pluralize(cancelledTickets.length, __("ticket")) }}
|
||||
{{ __("already cancelled") }}.
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
cancelledTickets.length > 0 &&
|
||||
cancellationRequestedTickets.length > 0
|
||||
"
|
||||
>
|
||||
<br />
|
||||
</span>
|
||||
<span v-if="cancellationRequestedTickets.length > 0">
|
||||
{{ pluralize(cancellationRequestedTickets.length, __("ticket")) }}
|
||||
{{ __("already have pending cancellation requests") }}.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Select All Option -->
|
||||
<div
|
||||
v-if="availableTickets.length > 0"
|
||||
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
|
||||
:class="{
|
||||
'border-outline-gray-4 bg-surface-gray-2': isAllSelected,
|
||||
}"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="h-4 w-4 text-ink-gray-6 border-outline-gray-1 rounded focus:ring-ink-gray-5"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="font-semibold text-ink-gray-9">
|
||||
{{ __("Select All Available Tickets") }}
|
||||
</h3>
|
||||
<p class="text-sm text-ink-gray-6">
|
||||
{{ __("Cancel all") }}
|
||||
{{ pluralize(availableTickets.length, __("remaining ticket")) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual Ticket Selection -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-medium text-ink-gray-8">
|
||||
{{ __("Or select individual tickets:") }}
|
||||
</h4>
|
||||
<div v-if="availableTickets.length === 0" class="text-center py-8">
|
||||
<p class="text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
"No tickets available for cancellation. All tickets are either already cancelled or have pending cancellation requests."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="ticket in availableTickets"
|
||||
:key="ticket.name"
|
||||
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
|
||||
:class="{
|
||||
'border-outline-gray-4 bg-surface-gray-2':
|
||||
selectedTickets.includes(ticket.name),
|
||||
}"
|
||||
@click="toggleTicketSelection(ticket.name)"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedTickets.includes(ticket.name)"
|
||||
@change="toggleTicketSelection(ticket.name)"
|
||||
class="h-4 w-4 text-ink-gray-6 border-outline-gray-1 rounded focus:ring-ink-gray-5 mt-1"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-ink-gray-9">
|
||||
{{ ticket.attendee_name }}
|
||||
</h3>
|
||||
<p class="text-sm text-ink-gray-6">
|
||||
{{ ticket.attendee_email }}
|
||||
</p>
|
||||
<p class="text-sm text-ink-gray-5">
|
||||
{{ ticket.ticket_type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add-ons if any -->
|
||||
<div
|
||||
v-if="ticket.add_ons && ticket.add_ons.length > 0"
|
||||
class="mt-2 pt-2 border-t border-outline-gray-1"
|
||||
>
|
||||
<p class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __("Add-ons:") }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="addon in ticket.add_ons"
|
||||
:key="addon.name"
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-surface-gray-1 text-ink-gray-7"
|
||||
>
|
||||
{{ addon.title }}: {{ addon.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning if no tickets selected -->
|
||||
<div v-if="selectedTickets.length === 0" class="text-center py-4">
|
||||
<p class="text-ink-red-3 text-sm">
|
||||
{{ __("Please select at least one ticket to cancel.") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div
|
||||
v-if="selectedTickets.length > 0"
|
||||
class="p-4 bg-surface-blue-1 border border-outline-blue-1 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-semibold text-ink-blue-2">
|
||||
{{ __("Cancellation Summary") }}
|
||||
</h4>
|
||||
<p class="text-ink-blue-2">
|
||||
{{ pluralize(selectedTickets.length, __("ticket")) }}
|
||||
{{ __("selected for cancellation") }}
|
||||
<span v-if="isAllSelected" class="font-medium">{{
|
||||
__("(Full booking)")
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-ink-blue-2">{{ __("Request Type") }}</p>
|
||||
<p class="font-medium text-ink-blue-2">
|
||||
{{
|
||||
isAllSelected
|
||||
? __("Full Cancellation")
|
||||
: __("Partial Cancellation")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button variant="ghost" @click="closeDialog" :loading="submitting">
|
||||
{{ __("Cancel") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
@click="submitCancellationRequest"
|
||||
:disabled="selectedTickets.length === 0"
|
||||
:loading="submitting"
|
||||
>
|
||||
{{ __("Submit Cancellation Request") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { pluralize } from "@/utils/pluralize";
|
||||
import { Button, Dialog, createResource, toast } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tickets: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
bookingId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cancellationRequestedTickets: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cancelledTickets: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
// Filter out tickets that are already cancelled or have pending cancellation request
|
||||
const availableTickets = computed(() => {
|
||||
return props.tickets.filter(
|
||||
(ticket) =>
|
||||
!props.cancelledTickets.includes(ticket.name) &&
|
||||
!props.cancellationRequestedTickets.includes(ticket.name)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedTickets = ref([]);
|
||||
const submitting = ref(false);
|
||||
|
||||
const isAllSelected = computed({
|
||||
get: () =>
|
||||
selectedTickets.value.length === availableTickets.value.length &&
|
||||
availableTickets.value.length > 0,
|
||||
set: (val) => {
|
||||
if (val) {
|
||||
selectedTickets.value = availableTickets.value.map((ticket) => ticket.name);
|
||||
} else {
|
||||
selectedTickets.value = [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
isAllSelected.value = !isAllSelected.value;
|
||||
};
|
||||
|
||||
const toggleTicketSelection = (ticketId) => {
|
||||
const index = selectedTickets.value.indexOf(ticketId);
|
||||
if (index > -1) {
|
||||
selectedTickets.value.splice(index, 1);
|
||||
} else {
|
||||
selectedTickets.value.push(ticketId);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
show.value = false;
|
||||
selectedTickets.value = [];
|
||||
};
|
||||
|
||||
const createCancellationRequest = createResource({
|
||||
url: "buzz.api.create_cancellation_request",
|
||||
onSuccess: (data) => {
|
||||
submitting.value = false;
|
||||
const ticketCount = selectedTickets.value.length;
|
||||
const isFullCancellation = isAllSelected.value;
|
||||
|
||||
toast.success(
|
||||
isFullCancellation
|
||||
? __("Full booking cancellation request submitted successfully!")
|
||||
: `${__("Cancellation request submitted for")} ${pluralize(
|
||||
ticketCount,
|
||||
__("ticket")
|
||||
)}!`
|
||||
);
|
||||
emit("success", data);
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
submitting.value = false;
|
||||
toast.error(
|
||||
error?.messages?.[0] || __("Failed to submit cancellation request. Please try again.")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const submitCancellationRequest = () => {
|
||||
if (selectedTickets.value.length === 0) return;
|
||||
|
||||
submitting.value = true;
|
||||
createCancellationRequest.submit({
|
||||
booking_id: props.bookingId,
|
||||
ticket_ids: selectedTickets.value,
|
||||
});
|
||||
};
|
||||
|
||||
// Reset selected tickets when dialog closes
|
||||
watch(show, (newVal) => {
|
||||
if (!newVal) {
|
||||
selectedTickets.value = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div v-if="cancellationRequest" class="mb-6">
|
||||
<div class="bg-surface-blue-1 border border-outline-blue-1 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<LucideInfo class="w-5 h-5 text-ink-blue-2 mr-3" />
|
||||
<div>
|
||||
<h3 class="text-ink-blue-3 font-semibold">
|
||||
{{ __("Cancellation Requested") }}
|
||||
</h3>
|
||||
<p class="text-ink-blue-2">
|
||||
<span v-if="cancellationRequest.cancel_full_booking">
|
||||
{{ __("Full booking cancellation has been requested.") }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
__("Partial cancellation has been requested for selected tickets.")
|
||||
}}
|
||||
</span>
|
||||
{{ __("Request submitted on") }}
|
||||
{{ formatDate(cancellationRequest.creation) }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideInfo from "~icons/lucide/info";
|
||||
|
||||
defineProps({
|
||||
cancellationRequest: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div v-if="isDateField(field.fieldtype)" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<DatePicker
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isDateTimeField(field.fieldtype)" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<DateTimePicker
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.fieldtype === 'Time'" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<TimePicker
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.fieldtype === 'Multi Select'" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<MultiSelect
|
||||
:options="multiSelectOptions"
|
||||
v-model="multiSelectProxy"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Check'"
|
||||
type="checkbox"
|
||||
:model-value="checkboxValue"
|
||||
@update:model-value="$emit('update:modelValue', $event ? 1 : 0)"
|
||||
:label="__(field.label)"
|
||||
/>
|
||||
|
||||
<PhoneInput
|
||||
v-else-if="field.fieldtype === 'Phone'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:label="field.label"
|
||||
:required="field.mandatory"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:label="__(field.label)"
|
||||
type="select"
|
||||
:options="linkFieldOptions"
|
||||
:required="field.mandatory"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
|
||||
<div v-else-if="isTextareaField(field.fieldtype)" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
:required="field.mandatory"
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.fieldtype === 'Rating'" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<Rating
|
||||
:model-value="Math.round((modelValue || 0) * 5)"
|
||||
@update:model-value="$emit('update:modelValue', $event / 5)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.fieldtype === 'Attach Image'" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<div v-if="modelValue" class="flex items-center gap-2">
|
||||
<img :src="modelValue" class="h-16 w-16 rounded object-cover border" />
|
||||
<Button variant="ghost" size="sm" @click="$emit('update:modelValue', '')">
|
||||
{{ __("Remove") }}
|
||||
</Button>
|
||||
</div>
|
||||
<FileUploader
|
||||
v-else
|
||||
@success="(file) => $emit('update:modelValue', file.file_url)"
|
||||
:validateFile="validateImageFile"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button variant="outline" @click="openFileSelector">
|
||||
{{ __("Upload Image") }}
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
|
||||
<div v-else-if="field.fieldtype === 'Attach'" class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(field.label) }}
|
||||
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<div v-if="modelValue" class="flex items-center gap-2">
|
||||
<a
|
||||
:href="modelValue"
|
||||
target="_blank"
|
||||
class="text-sm text-ink-blue-3 underline truncate max-w-xs"
|
||||
>
|
||||
{{ modelValue.split("/").pop() }}
|
||||
</a>
|
||||
<Button variant="ghost" size="sm" @click="$emit('update:modelValue', '')">
|
||||
{{ __("Remove") }}
|
||||
</Button>
|
||||
</div>
|
||||
<FileUploader v-else @success="(file) => $emit('update:modelValue', file.file_url)">
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button variant="outline" @click="openFileSelector">
|
||||
{{ __("Upload File") }}
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:label="__(field.label)"
|
||||
:type="getFormControlType(field.fieldtype, field.options)"
|
||||
:options="getFieldOptions(field)"
|
||||
:required="field.mandatory"
|
||||
:placeholder="getFieldPlaceholder(field)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PhoneInput from "@/components/PhoneInput.vue";
|
||||
import {
|
||||
getFieldOptions,
|
||||
getFieldPlaceholder,
|
||||
getFormControlType,
|
||||
isDateField,
|
||||
isDateTimeField,
|
||||
isTextareaField,
|
||||
} from "@/composables/useCustomFields";
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
DateTimePicker,
|
||||
FileUploader,
|
||||
TimePicker,
|
||||
MultiSelect,
|
||||
Rating,
|
||||
Textarea,
|
||||
} from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const model = defineModel();
|
||||
const multiSelectOptions = computed(() => getFieldOptions(props.field));
|
||||
const checkboxValue = computed(() => model.value === 1 || model.value === "1");
|
||||
|
||||
const multiSelectProxy = computed({
|
||||
get() {
|
||||
if (!model.value) return [];
|
||||
return Array.isArray(model.value) ? model.value : String(model.value).split(",");
|
||||
},
|
||||
set(val) {
|
||||
if (!val || val.length === 0) {
|
||||
model.value = "";
|
||||
} else {
|
||||
const values = val.map((item) => item.value || item);
|
||||
model.value = values.join(",");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const linkFieldOptions = computed(() => {
|
||||
if (!props.field.link_options) return [];
|
||||
return props.field.link_options.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
}));
|
||||
});
|
||||
|
||||
function validateImageFile(file) {
|
||||
const validTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return __("Please upload a valid image file (JPEG, PNG, GIF, WebP, SVG)");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div v-if="customFields.length > 0" class="space-y-4">
|
||||
<h5 v-if="showTitle" class="text-base font-medium text-ink-gray-8 border-b pb-2">
|
||||
{{ __(title) || __("Additional Information") }}
|
||||
</h5>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<CustomFieldInput
|
||||
v-for="field in customFields"
|
||||
:key="field.fieldname"
|
||||
:field="field"
|
||||
:model-value="getFieldValue(field.fieldname)"
|
||||
@update:model-value="updateFieldValue(field.fieldname, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getFieldDefaultValue } from "@/composables/useCustomFields";
|
||||
import CustomFieldInput from "./CustomFieldInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
customFields: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
// Get field value from model
|
||||
const getFieldValue = (fieldname) => {
|
||||
const currentValue = props.modelValue[fieldname];
|
||||
|
||||
// If field already has a value, return it
|
||||
if (currentValue !== undefined && currentValue !== null && currentValue !== "") {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
// Apply default value if available
|
||||
const field = props.customFields.find((f) => f.fieldname === fieldname);
|
||||
if (field) {
|
||||
const defaultValue = getFieldDefaultValue(field);
|
||||
if (defaultValue) {
|
||||
updateFieldValue(fieldname, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
// Update field value in model
|
||||
const updateFieldValue = (fieldname, value) => {
|
||||
const updatedValue = { ...props.modelValue, [fieldname]: value };
|
||||
emit("update:modelValue", updatedValue);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!-- EventDetailsHeader.vue -->
|
||||
<template>
|
||||
<div v-if="eventDetails" class="mb-8">
|
||||
<!-- Banner Image -->
|
||||
<div
|
||||
v-if="eventDetails.banner_image"
|
||||
class="relative w-full h-48 md:h-64 lg:h-80 rounded-lg overflow-hidden mb-6"
|
||||
>
|
||||
<img
|
||||
:src="eventDetails.banner_image"
|
||||
:alt="eventDetails.title"
|
||||
class="w-full h-auto object-cover contrast-100 brightness-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Event Info without banner -->
|
||||
<div v-else class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl lg:text-4xl font-bold text-ink-gray-9 mb-4">
|
||||
{{ __(eventDetails.title) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="bg-surface-gray-1 rounded-lg p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 text-sm">
|
||||
<!-- Date -->
|
||||
<div v-if="eventDetails.start_date" class="flex flex-col items-start gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LucideCalendar class="h-4 w-4 text-ink-gray-6" />
|
||||
<p class="text-ink-gray-6 text-base">{{ __("Date") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-ink-gray-8">
|
||||
{{ formatEventDates(eventDetails.start_date, eventDetails.end_date) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div v-if="eventDetails.start_time" class="flex flex-col items-start gap-3">
|
||||
<div class="flex gap-2 items-center">
|
||||
<LucideClock class="h-4 w-4 text-ink-gray-6" />
|
||||
<p class="text-ink-gray-6 text-base">{{ __("Time") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-ink-gray-8">
|
||||
{{ formatEventTime(eventDetails.start_time, eventDetails.end_time) }}
|
||||
<span v-if="eventDetails.time_zone"
|
||||
>({{ eventDetails.time_zone }})</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Venue -->
|
||||
<div v-if="eventDetails.venue" class="flex flex-col items-start gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LucideMapPin class="h-4 w-4 text-ink-gray-6" />
|
||||
<p class="text-ink-gray-6 text-base">{{ __("Venue") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-ink-gray-8">{{ eventDetails.venue }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="eventDetails.medium === 'Online'"
|
||||
class="flex flex-col items-start gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<LucideMapPin class="h-4 w-4 text-ink-gray-6" />
|
||||
<p class="text-ink-gray-6 text-base">{{ __("Venue") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-ink-gray-8">{{ __("Online") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
v-if="eventDetails.short_description"
|
||||
class="mt-4 pt-4 border-t border-outline-gray-2"
|
||||
>
|
||||
<p class="text-ink-gray-7 leading-relaxed">
|
||||
{{ __(eventDetails.short_description) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { dayjs, dayjsLocal } from "frappe-ui";
|
||||
import LucideCalendar from "~icons/lucide/calendar";
|
||||
import LucideClock from "~icons/lucide/clock";
|
||||
import LucideMapPin from "~icons/lucide/map-pin";
|
||||
|
||||
const props = defineProps({
|
||||
eventDetails: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
// --- UTILITY FUNCTIONS ---
|
||||
const formatEventDates = (startDate, endDate) => {
|
||||
if (!startDate) return "";
|
||||
|
||||
const start = dayjsLocal(startDate);
|
||||
const startFormatted = start.format("ddd, MMM D, YYYY");
|
||||
|
||||
if (!endDate || startDate === endDate) {
|
||||
return startFormatted;
|
||||
}
|
||||
|
||||
const end = dayjsLocal(endDate);
|
||||
const endFormatted = end.format("ddd, MMM D, YYYY");
|
||||
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
};
|
||||
|
||||
const formatEventTime = (startTime, endTime) => {
|
||||
if (!startTime) return "";
|
||||
|
||||
// Create a date object for today with the given time
|
||||
const startDateTime = dayjsLocal()
|
||||
.hour(Number.parseInt(startTime.split(":")[0]))
|
||||
.minute(Number.parseInt(startTime.split(":")[1]));
|
||||
const startFormatted = startDateTime.format("h:mm A");
|
||||
|
||||
if (!endTime) {
|
||||
return startFormatted;
|
||||
}
|
||||
|
||||
const endDateTime = dayjs()
|
||||
.hour(Number.parseInt(endTime.split(":")[0]))
|
||||
.minute(Number.parseInt(endTime.split(":")[1]));
|
||||
const endFormatted = endDateTime.format("h:mm A");
|
||||
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="mb-6 size-full">
|
||||
<!-- Header - only show when there are events -->
|
||||
<h2
|
||||
v-if="eventsResource.data?.length > 0"
|
||||
class="text-lg font-semibold mb-4 text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ __("Select Event") }}
|
||||
</h2>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="eventsResource.loading" class="min-h-[50vh] flex justify-center items-center">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Spinner class="w-6 h-6" />
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ __("Loading events...") }}</p>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{{ __("Please wait while we load the events...") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events List View -->
|
||||
<ListView
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="eventsResource.data || []"
|
||||
row-key="name"
|
||||
:options="{
|
||||
selectable: false,
|
||||
showTooltip: true,
|
||||
onRowClick: handleEventSelect,
|
||||
emptyState: {
|
||||
title: __('No Events Available'),
|
||||
description: __(
|
||||
'There are currently no active events available for check-in. Events may be scheduled for later dates or may need to be published.'
|
||||
),
|
||||
button: {
|
||||
label: __('Refresh Events'),
|
||||
variant: 'solid',
|
||||
onClick: () => eventsResource.fetch(),
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ListView, Spinner, createListResource, dayjsLocal } from "frappe-ui";
|
||||
|
||||
defineProps({
|
||||
selectedEvent: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const columns = [
|
||||
{ label: __("Event"), key: "title", width: 1.5 },
|
||||
{ label: __("Starts At"), key: "starts_at" },
|
||||
{ label: __("Ends At"), key: "ends_at" },
|
||||
];
|
||||
|
||||
const formatTimestamp = (date, time) => {
|
||||
let formattedDate = "";
|
||||
let formattedTime = "";
|
||||
|
||||
if (date || time) {
|
||||
const dateTimeStr = date ? `${date}${time ? "T" + time : "T00:00:00"}` : undefined;
|
||||
|
||||
const parsed = dayjsLocal(dateTimeStr);
|
||||
|
||||
if (parsed.isValid()) {
|
||||
formattedDate = parsed.format("MMM DD, YYYY");
|
||||
formattedTime = parsed.format("h:mm A");
|
||||
}
|
||||
}
|
||||
|
||||
if (!formattedDate && !formattedTime) return "No date specified";
|
||||
if (formattedDate && !formattedTime) return formattedDate;
|
||||
if (!formattedDate && formattedTime) return formattedTime;
|
||||
return `${formattedDate} ${formattedTime}`;
|
||||
};
|
||||
|
||||
const eventsResource = createListResource({
|
||||
doctype: "Buzz Event",
|
||||
fields: ["name", "title", "start_date", "start_time", "end_date", "end_time"],
|
||||
order_by: "start_date desc",
|
||||
filters: {
|
||||
is_published: 1,
|
||||
end_date: [">=", dayjsLocal().format("YYYY-MM-DD")],
|
||||
},
|
||||
auto: true,
|
||||
transform(data) {
|
||||
return data.map((event) => ({
|
||||
...event,
|
||||
starts_at: formatTimestamp(event.start_date, event.start_time),
|
||||
ends_at: formatTimestamp(event.end_date, event.end_time),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const handleEventSelect = (event) => {
|
||||
emit("select", event);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Dropdown :options="languageOptions">
|
||||
<template #default="{ open }">
|
||||
<Button variant="ghost" size="md" :loading="isSwitching">
|
||||
<LucideLanguages class="w-4 h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLanguage } from "@/composables/useLanguage";
|
||||
import { Button, Dropdown } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import LucideLanguages from "~icons/lucide/languages";
|
||||
|
||||
const { availableLanguages, currentLanguage, changeLanguage, isSwitching } = useLanguage();
|
||||
|
||||
const languageOptions = computed(() => {
|
||||
if (!availableLanguages.data || availableLanguages.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return availableLanguages.data.map((lang) => ({
|
||||
label: lang.language_name || lang.name,
|
||||
icon: currentLanguage.value === lang.language_code ? "check" : undefined,
|
||||
onClick: () => changeLanguage(lang.language_code),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<Dialog v-model="is_open" :options="{ size: 'md' }" @after-leave="resetState">
|
||||
<template #body-title>
|
||||
<h3 class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ view_title }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div
|
||||
v-if="login_context?.login_banner"
|
||||
class="rounded-md bg-surface-gray-2 p-3 prose prose-sm max-w-none mb-6"
|
||||
v-html="login_context.login_banner"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="error_message"
|
||||
class="mb-4 rounded-md bg-surface-red-2 p-3 text-sm text-ink-red-3"
|
||||
>
|
||||
{{ error_message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="success_message"
|
||||
class="mb-4 rounded-md bg-surface-green-2 p-3 text-sm text-ink-green-3"
|
||||
>
|
||||
{{ success_message }}
|
||||
</div>
|
||||
|
||||
<form v-if="current_view === 'login'" class="space-y-4" @submit.prevent="handleLogin">
|
||||
<template v-if="!login_context?.disable_user_pass_login">
|
||||
<FormControl
|
||||
type="email"
|
||||
:label="__('Email')"
|
||||
:placeholder="__('Enter your email')"
|
||||
v-model="form.email"
|
||||
required
|
||||
@keydown.enter="focusPassword"
|
||||
/>
|
||||
<FormControl
|
||||
ref="password_input"
|
||||
type="password"
|
||||
:label="__('Password')"
|
||||
:placeholder="__('Enter your password')"
|
||||
v-model="form.password"
|
||||
required
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
|
||||
@click="switchView('forgot-password')"
|
||||
>
|
||||
{{ __("Forgot Password?") }}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:loading="session.login.loading"
|
||||
>
|
||||
{{ __("Login") }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-if="has_social_logins || login_context?.login_with_email_link">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-outline-gray-2" />
|
||||
</div>
|
||||
<span class="relative bg-surface-modal px-2 text-sm text-ink-gray-4">
|
||||
{{ __("or") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SocialLoginButtons :provider_logins="login_context?.provider_logins" />
|
||||
|
||||
<template v-if="login_context?.login_with_email_link">
|
||||
<Button
|
||||
variant="subtle"
|
||||
class="w-full"
|
||||
type="button"
|
||||
@click="switchView('email-link')"
|
||||
>
|
||||
{{ __("Login with Email Link") }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="!login_context?.disable_signup"
|
||||
class="text-center text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __("Don't have an account?") }}
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-ink-gray-7 hover:text-ink-gray-9"
|
||||
@click="switchView('signup')"
|
||||
>
|
||||
{{ __("Sign up") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="current_view === 'signup'"
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleSignup"
|
||||
>
|
||||
<FormControl
|
||||
type="text"
|
||||
:label="__('Full Name')"
|
||||
:placeholder="__('Enter your full name')"
|
||||
v-model="form.full_name"
|
||||
required
|
||||
/>
|
||||
<FormControl
|
||||
type="email"
|
||||
:label="__('Email')"
|
||||
:placeholder="__('Enter your email')"
|
||||
v-model="form.email"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:loading="signup_resource.loading"
|
||||
>
|
||||
{{ __("Sign Up") }}
|
||||
</Button>
|
||||
|
||||
<template v-if="has_social_logins">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-outline-gray-2" />
|
||||
</div>
|
||||
<span class="relative bg-surface-modal px-2 text-sm text-ink-gray-4">
|
||||
{{ __("or") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SocialLoginButtons :provider_logins="login_context?.provider_logins" />
|
||||
</template>
|
||||
|
||||
<div class="text-center text-sm text-ink-gray-5">
|
||||
{{ __("Already have an account?") }}
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-ink-gray-7 hover:text-ink-gray-9"
|
||||
@click="switchView('login')"
|
||||
>
|
||||
{{ __("Login") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="current_view === 'forgot-password'"
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleForgotPassword"
|
||||
>
|
||||
<p class="text-sm text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
"Enter your email address and we'll send you a link to reset your password."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<FormControl
|
||||
type="email"
|
||||
:label="__('Email')"
|
||||
:placeholder="__('Enter your email')"
|
||||
v-model="form.email"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:loading="forgot_password_resource.loading"
|
||||
>
|
||||
{{ __("Reset Password") }}
|
||||
</Button>
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
|
||||
@click="switchView('login')"
|
||||
>
|
||||
{{ __("Back to Login") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
v-else-if="current_view === 'email-link'"
|
||||
class="space-y-4"
|
||||
@submit.prevent="handleEmailLink"
|
||||
>
|
||||
<p class="text-sm text-ink-gray-5">
|
||||
{{ __("We'll send you a one-time login link to your email address.") }}
|
||||
</p>
|
||||
<FormControl
|
||||
type="email"
|
||||
:label="__('Email')"
|
||||
:placeholder="__('Enter your email')"
|
||||
v-model="form.email"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:loading="email_link_resource.loading"
|
||||
>
|
||||
{{ __("Send Login Link") }}
|
||||
</Button>
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
|
||||
@click="switchView('login')"
|
||||
>
|
||||
{{ __("Back to Login") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLoginDialog } from "@/composables/useLoginDialog";
|
||||
import { session } from "@/data/session";
|
||||
import { userResource } from "@/data/user";
|
||||
import { Button, Dialog, FormControl, createResource } from "frappe-ui";
|
||||
import { computed, defineComponent, h, ref, watch } from "vue";
|
||||
|
||||
const { is_open, close, on_success_callback } = useLoginDialog();
|
||||
|
||||
const current_view = ref("login");
|
||||
const error_message = ref("");
|
||||
const success_message = ref("");
|
||||
const password_input = ref(null);
|
||||
|
||||
const form = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
full_name: "",
|
||||
});
|
||||
|
||||
const view_title = computed(() => {
|
||||
const titles = {
|
||||
login: __("Login to Continue"),
|
||||
signup: __("Create Account"),
|
||||
"forgot-password": __("Forgot Password"),
|
||||
"email-link": __("Login with Email Link"),
|
||||
};
|
||||
return titles[current_view.value] || __("Login");
|
||||
});
|
||||
|
||||
const login_context_resource = createResource({
|
||||
url: "buzz.api.auth.get_login_context",
|
||||
params: { redirect_to: window.location.href },
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const login_context = computed(() => login_context_resource.data);
|
||||
|
||||
const has_social_logins = computed(() => login_context.value?.provider_logins?.length > 0);
|
||||
|
||||
const SocialLoginButtons = defineComponent({
|
||||
props: { provider_logins: Array },
|
||||
setup(props) {
|
||||
return () =>
|
||||
(props.provider_logins || []).map((provider) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "subtle",
|
||||
class: "w-full",
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
window.location.href = provider.auth_url;
|
||||
},
|
||||
},
|
||||
{
|
||||
...(provider.icon
|
||||
? {
|
||||
prefix: () =>
|
||||
h("img", {
|
||||
src: provider.icon,
|
||||
class: "h-4 w-4",
|
||||
alt: provider.provider_name,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
default: () => __("Continue with {0}", [provider.provider_name]),
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function focusPassword() {
|
||||
password_input.value?.$el?.querySelector("input")?.focus();
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
current_view.value = view;
|
||||
error_message.value = "";
|
||||
success_message.value = "";
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
current_view.value = "login";
|
||||
error_message.value = "";
|
||||
success_message.value = "";
|
||||
form.value = { email: "", password: "", full_name: "" };
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
error_message.value = "";
|
||||
session.login.submit(
|
||||
{ email: form.value.email, password: form.value.password },
|
||||
{
|
||||
onSuccess() {
|
||||
userResource.reload();
|
||||
session.user =
|
||||
session.login.data?.user || document.cookie.match(/user_id=([^;]+)/)?.[1];
|
||||
close();
|
||||
if (on_success_callback.value) {
|
||||
on_success_callback.value();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
error_message.value = error.messages?.[0] || __("Invalid email or password.");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const signup_resource = createResource({
|
||||
url: "frappe.core.doctype.user.user.sign_up",
|
||||
});
|
||||
|
||||
function handleSignup() {
|
||||
error_message.value = "";
|
||||
signup_resource.submit(
|
||||
{
|
||||
email: form.value.email,
|
||||
full_name: form.value.full_name,
|
||||
redirect_to: window.location.pathname,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (data && data[0] === 1) {
|
||||
success_message.value = __("Please check your email to verify your account.");
|
||||
} else if (data && data[1]) {
|
||||
success_message.value = data[1];
|
||||
} else {
|
||||
success_message.value = __("Please check your email to verify your account.");
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
error_message.value =
|
||||
error.messages?.[0] || __("Something went wrong. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const forgot_password_resource = createResource({
|
||||
url: "frappe.core.doctype.user.user.reset_password",
|
||||
});
|
||||
|
||||
function handleForgotPassword() {
|
||||
error_message.value = "";
|
||||
forgot_password_resource.submit(
|
||||
{ user: form.value.email },
|
||||
{
|
||||
onSuccess() {
|
||||
success_message.value = __("Password reset link has been sent to your email.");
|
||||
},
|
||||
onError(error) {
|
||||
error_message.value =
|
||||
error.messages?.[0] || __("Something went wrong. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const email_link_resource = createResource({
|
||||
url: "frappe.www.login.send_login_link",
|
||||
});
|
||||
|
||||
function handleEmailLink() {
|
||||
error_message.value = "";
|
||||
email_link_resource.submit(
|
||||
{ email: form.value.email },
|
||||
{
|
||||
onSuccess() {
|
||||
success_message.value = __("Login link has been sent to your email.");
|
||||
},
|
||||
onError(error) {
|
||||
error_message.value =
|
||||
error.messages?.[0] || __("Something went wrong. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
watch(is_open, (value) => {
|
||||
if (value) {
|
||||
login_context_resource.fetch({ redirect_to: window.location.href });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div class="text-center max-w-md">
|
||||
<h2 class="text-xl font-semibold text-ink-gray-8 mb-2">
|
||||
{{ __("Login Required") }}
|
||||
</h2>
|
||||
<p class="text-ink-gray-6 mb-6">
|
||||
{{ __(message) }}
|
||||
</p>
|
||||
<Button variant="solid" size="lg" @click="openLogin">{{ __("Log In") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLoginDialog } from "@/composables/useLoginDialog";
|
||||
import { Button } from "frappe-ui";
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: "Please log in to continue.",
|
||||
},
|
||||
});
|
||||
|
||||
const { open: openLogin } = useLoginDialog();
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="border-b">
|
||||
<nav class="flex items-center justify-between gap-4 p-4 max-w-4xl mx-auto">
|
||||
<a href="/">
|
||||
<img
|
||||
class="h-6 contrast-100 brightness-100 invert-[0.8] dark:invert-0"
|
||||
v-if="userResource?.data?.brand_image"
|
||||
:src="userResource.data.brand_image"
|
||||
/>
|
||||
<BuzzLogo v-else class="w-9 h-7 text-ink-gray-9" />
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="md" @click="toggleTheme">
|
||||
<LucideSun v-if="userTheme === 'dark'" class="w-4 h-4" />
|
||||
<LucideMoon v-else class="w-4 h-4" />
|
||||
</Button>
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
v-if="session.isLoggedIn"
|
||||
:loading="session.logout.loading"
|
||||
@click="session.logout.submit"
|
||||
icon-right="log-out"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
>
|
||||
{{ __("Log Out") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
@click="openLoginDialog"
|
||||
icon-right="log-in"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
>
|
||||
{{ __("Log In") }}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { userResource } from "@/data/user";
|
||||
import LucideMoon from "~icons/lucide/moon";
|
||||
import LucideSun from "~icons/lucide/sun";
|
||||
import { session } from "../data/session";
|
||||
import LanguageSwitcher from "./LanguageSwitcher.vue";
|
||||
import BuzzLogo from "./common/BuzzLogo.vue";
|
||||
|
||||
import { useLoginDialog } from "@/composables/useLoginDialog";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const { open: openLoginDialog } = useLoginDialog();
|
||||
const userTheme = useStorage("user-theme", "dark");
|
||||
|
||||
onMounted(() => {
|
||||
document.documentElement.setAttribute("data-theme", userTheme.value);
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = userTheme.value;
|
||||
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", newTheme);
|
||||
userTheme.value = newTheme;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen" :options="{ size: 'md' }">
|
||||
<template #body>
|
||||
<div class="p-4">
|
||||
<!-- Title (shows custom label if set, otherwise default) -->
|
||||
<h3 class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ offlineSettings.label }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Amount -->
|
||||
<div class="text-center p-3 bg-surface-gray-1 rounded">
|
||||
<div class="text-xl font-bold text-ink-gray-9">
|
||||
{{ formatCurrency(amount, currency) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details (HTML Content) -->
|
||||
<div
|
||||
v-if="offlineSettings.payment_details"
|
||||
class="prose-sm [&>:first-child]:mt-0 bg-surface-gray-1 border border-outline-gray-1 rounded p-3 text-ink-gray-9"
|
||||
v-html="offlineSettings.payment_details"
|
||||
></div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection
|
||||
v-if="offlineCustomFields.length > 0"
|
||||
:custom-fields="offlineCustomFields"
|
||||
v-model="customFieldsData"
|
||||
:show-title="false"
|
||||
/>
|
||||
|
||||
<!-- Upload Proof -->
|
||||
<div v-if="offlineSettings.collect_payment_proof">
|
||||
<label class="block text-sm font-medium text-ink-gray-8 mb-2"
|
||||
>{{ __("Proof of Payment") }} *</label
|
||||
>
|
||||
<FileUploader
|
||||
ref="fileUploaderRef"
|
||||
v-model="paymentProof"
|
||||
:file-types="['image/*']"
|
||||
@success="onFileUpload"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading, progress }">
|
||||
<div
|
||||
v-if="paymentProof"
|
||||
class="flex items-center gap-1.5 text-sm text-ink-green-2"
|
||||
>
|
||||
<LucideCheckCircle class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="truncate">{{
|
||||
paymentProof.file_name || paymentProof.name
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto p-1 rounded hover:bg-surface-gray-2 text-ink-gray-5 hover:text-ink-gray-8"
|
||||
:title="__('Replace')"
|
||||
@click="openFileSelector"
|
||||
>
|
||||
<LucideRefreshCw class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
@click="openFileSelector"
|
||||
:loading="uploading"
|
||||
variant="outline"
|
||||
>
|
||||
{{
|
||||
uploading
|
||||
? __("Uploading {0}%", [progress])
|
||||
: __("Upload File")
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1" @click="$emit('cancel')">
|
||||
{{ __("Cancel") }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="flex-1"
|
||||
@click="submitOfflinePayment"
|
||||
:loading="loading"
|
||||
:disabled="isSubmitDisabled"
|
||||
>
|
||||
{{ __("Submit") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dialog, FileUploader, toast } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import LucideCheckCircle from "~icons/lucide/check-circle";
|
||||
import LucideRefreshCw from "~icons/lucide/refresh-cw";
|
||||
import { formatCurrency } from "../utils/currency";
|
||||
import CustomFieldsSection from "./CustomFieldsSection.vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: "INR",
|
||||
},
|
||||
offlineSettings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customFields: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:open", "submit", "cancel"]);
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit("update:open", value),
|
||||
});
|
||||
|
||||
const paymentProof = ref(null);
|
||||
const customFieldsData = ref({});
|
||||
|
||||
// Custom fields are now pre-filtered by method in BookingForm
|
||||
const offlineCustomFields = computed(() => props.customFields);
|
||||
|
||||
// Check if submit should be disabled
|
||||
const isSubmitDisabled = computed(() => {
|
||||
// Check payment proof requirement
|
||||
if (props.offlineSettings.collect_payment_proof && !paymentProof.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check mandatory custom fields
|
||||
for (const field of offlineCustomFields.value) {
|
||||
if (
|
||||
field.mandatory &&
|
||||
(!customFieldsData.value[field.fieldname] ||
|
||||
customFieldsData.value[field.fieldname] === "")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const onFileUpload = (file) => {
|
||||
paymentProof.value = file;
|
||||
};
|
||||
|
||||
const submitOfflinePayment = () => {
|
||||
if (isSubmitDisabled.value) {
|
||||
toast.error(__("Please fill all required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
emit("submit", {
|
||||
payment_proof: paymentProof.value,
|
||||
custom_fields: customFieldsData.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="isOpen"
|
||||
:options="{
|
||||
title: __('Select Payment Method'),
|
||||
size: 'md',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="gateway in paymentGateways"
|
||||
:key="gateway"
|
||||
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
|
||||
:class="{
|
||||
'border-outline-gray-4 bg-surface-gray-2': selectedGateway === gateway,
|
||||
}"
|
||||
@click="selectedGateway = gateway"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="selectedGateway === gateway"
|
||||
@change="selectedGateway = gateway"
|
||||
class="text-ink-gray-6"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="font-semibold text-ink-gray-9">{{ gateway }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button variant="ghost" @click="closeDialog">{{ __("Cancel") }}</Button>
|
||||
<Button variant="solid" :disabled="!selectedGateway" @click="proceedToPayment">
|
||||
{{ __("Proceed to Pay") }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dialog } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
paymentGateways: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:open", "gateway-selected"]);
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (val) => emit("update:open", val),
|
||||
});
|
||||
|
||||
const selectedGateway = ref(null);
|
||||
|
||||
// Reset selection when dialog opens
|
||||
watch(
|
||||
() => props.open,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selectedGateway.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const closeDialog = () => {
|
||||
isOpen.value = false;
|
||||
selectedGateway.value = null;
|
||||
};
|
||||
|
||||
const proceedToPayment = () => {
|
||||
if (!selectedGateway.value) return;
|
||||
emit("gateway-selected", selectedGateway.value);
|
||||
closeDialog();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-ink-gray-5 block">
|
||||
{{ __(label) }}
|
||||
<span v-if="required" class="text-ink-red-4">*</span>
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<div class="w-24 shrink-0">
|
||||
<Combobox
|
||||
:model-value="null"
|
||||
@update:model-value="onDialCodeChange"
|
||||
:options="dialCodeOptions"
|
||||
variant="outline"
|
||||
:placeholder="shortDisplay"
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
type="tel"
|
||||
:model-value="localNumber"
|
||||
@update:model-value="onNumberInput"
|
||||
:placeholder="placeholder || __('Phone number')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Combobox, TextInput, createResource } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
label: { type: String, default: "Phone" },
|
||||
placeholder: { type: String, default: "" },
|
||||
required: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const dialCode = ref("+91");
|
||||
const localNumber = ref("");
|
||||
const dialCodesData = ref([]);
|
||||
|
||||
function getFlagEmoji(countryCode) {
|
||||
if (!countryCode) return "";
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split("")
|
||||
.map((char) => 127397 + char.charCodeAt());
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
const shortDisplay = computed(() => {
|
||||
const entry = dialCodesData.value.find((d) => d.dial_code === dialCode.value);
|
||||
if (entry) return `${getFlagEmoji(entry.code)} ${entry.dial_code}`;
|
||||
return dialCode.value;
|
||||
});
|
||||
|
||||
const dialCodeOptions = computed(() =>
|
||||
dialCodesData.value.map((d) => ({
|
||||
label: `${getFlagEmoji(d.code)} ${d.dial_code}`,
|
||||
value: d.dial_code,
|
||||
}))
|
||||
);
|
||||
|
||||
function parsePhone(value) {
|
||||
if (!value) {
|
||||
localNumber.value = "";
|
||||
return;
|
||||
}
|
||||
const match = value.match(/^(\+\d{1,4})[\s-]?(.*)$/);
|
||||
if (match) {
|
||||
dialCode.value = match[1];
|
||||
localNumber.value = match[2];
|
||||
} else {
|
||||
localNumber.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
parsePhone(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => parsePhone(val)
|
||||
);
|
||||
|
||||
function emitValue() {
|
||||
if (!localNumber.value) {
|
||||
emit("update:modelValue", "");
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", `${dialCode.value} ${localNumber.value}`);
|
||||
}
|
||||
|
||||
function onDialCodeChange(code) {
|
||||
if (code) {
|
||||
dialCode.value = code;
|
||||
emitValue();
|
||||
}
|
||||
}
|
||||
|
||||
function onNumberInput(num) {
|
||||
const digitsOnly = String(num).replace(/\D/g, "");
|
||||
localNumber.value = digitsOnly;
|
||||
emitValue();
|
||||
}
|
||||
|
||||
createResource({
|
||||
url: "buzz.api.forms.get_dial_codes",
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
dialCodesData.value = data;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div v-if="profile" class="flex w-full items-center justify-between mb-3 sm:mb-5">
|
||||
<FileUploader
|
||||
@success="(file) => updateImage(file.file_url)"
|
||||
:validateFile="validateIsImageFile"
|
||||
>
|
||||
<template #default="{ openFileSelector, error: _error }">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="group relative !size-[66px]">
|
||||
<Avatar
|
||||
class="!size-16"
|
||||
:image="profile.user_image"
|
||||
:label="profile.full_name"
|
||||
/>
|
||||
<component
|
||||
:is="profile.user_image ? Dropdown : 'div'"
|
||||
v-bind="
|
||||
profile.user_image
|
||||
? {
|
||||
options: [
|
||||
{
|
||||
icon: 'upload',
|
||||
label: profile.user_image
|
||||
? __('Change image')
|
||||
: __('Upload image'),
|
||||
onClick: openFileSelector,
|
||||
},
|
||||
{
|
||||
icon: 'trash-2',
|
||||
label: __('Remove image'),
|
||||
onClick: () => updateImage(),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { onClick: openFileSelector }
|
||||
"
|
||||
class="!absolute bottom-0 left-0 right-0"
|
||||
>
|
||||
<div
|
||||
class="z-1 absolute bottom-0.5 left-0 right-0.5 flex h-9 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
|
||||
style="
|
||||
-webkit-clip-path: inset(12px 0 0 0);
|
||||
clip-path: inset(12px 0 0 0);
|
||||
"
|
||||
>
|
||||
<LucideCamera class="size-4 cursor-pointer text-white" />
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xl font-semibold text-ink-gray-8">
|
||||
{{ profile.full_name }}
|
||||
</span>
|
||||
<span class="text-base text-ink-gray-7">
|
||||
{{ profile.email }}
|
||||
</span>
|
||||
<ErrorMessage :message="__(_error)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { validateIsImageFile } from "@/utils";
|
||||
import { Avatar, Dropdown, FileUploader, createResource, toast } from "frappe-ui";
|
||||
import { onMounted, ref } from "vue";
|
||||
import LucideCamera from "~icons/lucide/camera";
|
||||
import { userResource } from "../data/user";
|
||||
|
||||
const user = userResource.data || {};
|
||||
|
||||
const profile = ref({});
|
||||
const error = ref("");
|
||||
|
||||
const setUser = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "User",
|
||||
name: user.name,
|
||||
fieldname: {
|
||||
first_name: profile.value.first_name,
|
||||
last_name: profile.value.last_name,
|
||||
user_image: profile.value.user_image,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
error.value = "";
|
||||
toast.success(__("Profile updated successfully"));
|
||||
},
|
||||
onError: (err) => {
|
||||
error.value = err.messages[0] || __("Failed to update profile");
|
||||
},
|
||||
});
|
||||
|
||||
function updateImage(fileUrl = "") {
|
||||
profile.value.user_image = fileUrl;
|
||||
setUser.submit();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
profile.value = { ...userResource.data };
|
||||
});
|
||||
</script>
|
||||