Initialize fork and rebrand app to event_manager
CI / Server (push) Has been cancelled
Linters / Frappe Linter (push) Has been cancelled
Linters / Vulnerable Dependency Check (push) Has been cancelled
UI Tests / Playwright E2E Tests (push) Has been cancelled

This commit is contained in:
2026-05-11 09:56:57 +02:00
parent f82bb803ac
commit 786cbc724f
500 changed files with 41152 additions and 2 deletions
+21
View File
@@ -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
+125
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+108
View File
@@ -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
+63
View File
@@ -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 .
+74
View File
@@ -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
+41
View File
@@ -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
+169
View File
@@ -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
+67
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
[submodule "dashboard/frappe-ui"]
path = dashboard/frappe-ui
url = https://github.com/frappe/frappe-ui.git
+69
View File
@@ -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
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+249
View File
@@ -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`.
+368
View File
@@ -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
+101 -2
View File
@@ -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>
![Pohodex Event Manager Event DocType](.github/images/fe-event-main-form.png)
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
![Dynamic Ticket Types and Add-ons](.github/images/ticket-types-and-add-ons.png)
#### 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.
![Booking Form](.github/images/booking-form.png)
#### 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
![Booking Details Page](.github/images/booking-details-page.png)
#### 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:
![Change Add-on Preference](.github/images/ticket-updates.png)
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:
![Sponsorship Payment](.github/images/sponsorship-payment.png)
*As soon as they pay, their logo appears on the event page!*
![Sponsorship Management](.github/images/sponsorship-management.png)
### 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
+5
View File
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
+50
View File
@@ -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.
![Auth](https://user-images.githubusercontent.com/34810212/236846289-ac31c292-81ea-4456-be65-95773a4049be.png)
![Home](https://user-images.githubusercontent.com/34810212/236846299-fd534e2b-1c06-4f01-a4f2-91a27547cd55.png)
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/)
+14
View File
@@ -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"]
}
+31
View File
@@ -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"
}
}
}
+52
View File
@@ -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']
}
}
+13
View File
@@ -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>
+38
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

+17
View File
@@ -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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+152
View File
@@ -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");
}
Binary file not shown.
Binary file not shown.
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
+233
View File
@@ -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>
+110
View File
@@ -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>
+419
View File
@@ -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>
+67
View File
@@ -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>
+115
View File
@@ -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>
+107
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More