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>
|
||||||