diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..73816f1
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..2012ee7
--- /dev/null
+++ b/.eslintrc
@@ -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
+ }
+}
diff --git a/.github/images/booking-details-page.png b/.github/images/booking-details-page.png
new file mode 100644
index 0000000..c4763d0
Binary files /dev/null and b/.github/images/booking-details-page.png differ
diff --git a/.github/images/booking-form.png b/.github/images/booking-form.png
new file mode 100644
index 0000000..86aa6a2
Binary files /dev/null and b/.github/images/booking-form.png differ
diff --git a/.github/images/fe-event-main-form.png b/.github/images/fe-event-main-form.png
new file mode 100644
index 0000000..9f9705d
Binary files /dev/null and b/.github/images/fe-event-main-form.png differ
diff --git a/.github/images/sponsorship-management.png b/.github/images/sponsorship-management.png
new file mode 100644
index 0000000..f21621e
Binary files /dev/null and b/.github/images/sponsorship-management.png differ
diff --git a/.github/images/sponsorship-payment.png b/.github/images/sponsorship-payment.png
new file mode 100644
index 0000000..6afe12e
Binary files /dev/null and b/.github/images/sponsorship-payment.png differ
diff --git a/.github/images/ticket-types-and-add-ons.png b/.github/images/ticket-types-and-add-ons.png
new file mode 100644
index 0000000..6e8c020
Binary files /dev/null and b/.github/images/ticket-types-and-add-ons.png differ
diff --git a/.github/images/ticket-updates.png b/.github/images/ticket-updates.png
new file mode 100644
index 0000000..2524340
Binary files /dev/null and b/.github/images/ticket-updates.png differ
diff --git a/.github/instructions/frappe-ui.instructions.md b/.github/instructions/frappe-ui.instructions.md
new file mode 100644
index 0000000..ed89c5e
--- /dev/null
+++ b/.github/instructions/frappe-ui.instructions.md
@@ -0,0 +1,1550 @@
+---
+applyTo: '**/*.vue'
+---
+
+### General Backend API Calls
+
+```vue
+
+ Reload
+ {{ todos }}
+
+
+
+```
+
+### Document Resource
+
+```vue
+
+```
+
+Example API:
+
+```vue
+todo.doc // doc returned from request
+todo.reload() // reload the doc
+
+// update options
+todo.update({
+ doctype: '',
+ name: ''
+})
+
+todo.get // doc resource
+todos.get.loading // true when data is being fetched
+todos.get.error // error that occurred from making the request
+todos.get.promise // promise object of the request, can be awaited
+
+// resource to set value(s) on the document
+todos.setValue
+todos.setValue.submit({
+ // field value pairs to set
+ status: 'Closed',
+ description: 'Updated description'
+})
+
+// same as setValue but debounced
+todos.setValueDebounced
+// will run once after 500ms
+todos.setValueDebounced.submit({
+ description: 'Updated description'
+})
+
+// resource to delete the document
+todos.delete
+todos.delete.submit()
+
+// if whitelistedMethods is defined
+// you get a resource for each whitelisted method
+todos.sendEmail
+todos.sendEmail.submit
+todos.sendEmail.loading
+```
+
+### List Resource
+
+List Resource is a wrapper on top of [Resource](./Resource.story.md) for working
+with lists. This feature only works with a Frappe Framework backend as of now.
+
+## Usage
+
+A list resource knows how to fetch records of a DocType from a Frappe Framework
+backend so there is no need to specify the url. Instead you only define
+`doctype`, `fields`, `filters`, etc. You also get methods like `next()`,
+`setValue()`, etc.
+
+```vue
+
+
+
+
+ {{ todo.description }}
+
+
{{ todo.status }}
+
+
+ Next Page
+
+
+```
+
+## Options API
+
+You can also define resources if you are using Options API. You need to register
+the `resourcesPlugin` first.
+
+**main.js**
+
+```js
+import { resourcesPlugin } from 'frappe-ui'
+app.use(resourcesPlugin)
+```
+
+In your .vue file, you can declare all your resources under the resources key as
+functions. The resource object will be available on `this.$resources.[name]`. In
+the following example, `this.$resources.todos` is the resource object.
+
+**Component.vue**
+
+```vue
+
+```
+
+## List of Options and API
+
+Here is the list of all options and APIs that are available on a list resource.
+
+### Options
+
+```js
+let todos = uesList({
+ // name of the doctype
+ doctype: 'ToDo',
+
+ // list of fields
+ fields: ['name', 'description', 'status', ...],
+
+ // object of filters to apply
+ filters: {
+ status: 'Open'
+ },
+
+ // the order in which records must be sorted
+ orderBy: 'creation desc',
+
+ // index from which records should be fetched
+ // default value is 0
+ start: 0,
+
+ // number of records to fetch in a single request
+ // default value is 20
+ pageLength: 20,
+
+ // parent doctype when you are fetching records of a child doctype
+ parent: null,
+
+ // set to 1 to enable debugging of list query
+ debug: 0,
+
+ // cache key to cache the resource
+ // can be a string
+ cacheKey: 'todos',
+ // or an array that can be serialized
+ cacheKey: ['todos', 'faris@frappe.io'],
+
+ // default value for url is "frappe.client.get_list"
+ // specify url if you want to use a custom API method
+ url: 'todo_app.api.get_todos',
+
+ // make the first request automatically
+ auto: true,
+
+ // events
+ // error can occur from failed request
+ onError(error) {
+
+ },
+ // on successful response
+ onSuccess(data) {
+
+ },
+ // transform data before setting it
+ transform(data) {
+ for (let d of data) {
+ d.open = false
+ }
+ return data
+ },
+ // other events
+ fetchOne: {
+ onSuccess() {},
+ onError() {}
+ },
+ insert: {
+ onSuccess() {},
+ onError() {}
+ },
+ delete: {
+ onSuccess() {},
+ onError() {}
+ },
+ setValue: {
+ onSuccess() {},
+ onError() {}
+ },
+ runDocMethod: {
+ onSuccess() {},
+ onError() {}
+ },
+})
+```
+
+### API
+
+A list resource is made up of multiple individual resources. In our running
+example, the resource object that fetches the list is at `todos.list`. So all
+the [properties of a resource](./Resource.story.md) are available on this
+object. Similarly, there are resources for `fetchOne`, `setValue`, `insert`,
+`delete`, and `runDocMethod`.
+
+```js
+let todos = useList({...})
+
+todos.data // data returned from request
+todos.originalData // response data before being transformed
+todos.reload() // reload the existing list
+todos.next() // fetch the next page
+todos.hasNextPage // whether there is next page to fetch
+
+// update list options
+todos.update({
+ fields: ['*'],
+ filters: {
+ status: 'Closed'
+ }
+})
+
+todos.data // list resource
+todos.loading // true when data is being fetched
+todos.error // error that occurred from making the request
+todos.promise // promise object of the request, can be awaited
+
+// resource to fetch and update a single record in the list
+todos.fetchOne
+// pass the name of the record to fetch that record and update the list
+todos.fetchOne.submit(name)
+
+// resource to set value(s) for a single record in the list
+todos.setValue
+todos.setValue.submit({
+ // id of the record
+ name: '',
+ // field value pairs to set
+ status: 'Closed',
+ description: 'Updated description'
+})
+
+// resource to insert a new record in the list
+todos.insert
+todos.insert.submit({
+ description: 'New todo'
+})
+
+// resource to delete a single record
+todos.delete
+todos.delete.submit(name)
+
+// resource to run a doc method
+todos.runDocMethod
+todos.runDocMethod.submit({
+ // name of the doc method
+ method: 'send_email',
+ // name of the record
+ name: '',
+ // params to pass to the method
+ email: 'test@example.com'
+})
+```
+
+
+### List View Component
+
+### Story
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.group }} ({{ group.rows.length }})
+
+
+
+
+
+
+
+
+ {{ item }}
+ {{ item }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Props
+
+### Row Key
+
+`row-key` is a unique key which is used to identify each row in the list. It is
+required to be passed in the `row` object.
+
+### Column
+
+1. `label` & `key` is required in column object.
+
+2. `width` is optional and it is used to set column width in list
+
+ 1. If you need a column to be `3` times a default column then add `3`. if
+ width is not mentioned default will be `1`
+ 2. You can also add custom width in px and rem e.g `300px` or `12rem`
+ 3. Combination of both can also be used.
+
+3. `align` is also optional. You can change the alignment of the content in the
+ column by setting it as.
+
+ 1. `start` or `left` (default)
+ 2. `center` or `middle`
+ 3. `end` or `right`
+
+4. You can add more attributes which can be used to render custom column header
+ items.
+
+### Row
+
+1. The row object must contain a unique_key which was mentioned in ListView
+ `row-key`
+2. Then you can add the row fields as key value pairs and each field can be an
+ object or a string (to handle custom rendering)
+
+ ```
+ {
+ // unique_key 'id'
+ id: 1,
+
+ // row fields
+ name: 'John Doe',
+ age: 25,
+ email: 'john@doe.com',
+ }
+ ```
+
+ E.g field value as an object (to handle custom rendering), but make sure it
+ has a `label` attribute which holds the actual value to be shown
+
+ ```
+ row: {
+ name: {
+ label: 'John Doe',
+ image: '/johndoe.jpg',
+ },
+ age: 25,
+ status: {
+ label: 'Active',
+ color: 'green'
+ }
+ }
+ ```
+
+### Grouped Rows
+
+To render grouped rows, you must provide `rows` in the following format:
+
+```
+[
+ {
+ group: 'Group Title 1',
+ collapsed: false,
+ rows: [
+ {id: 1, key1: value1, key2: value2, ...},
+ {id: 2, key1: value1, key2: value2, ...},
+ ]
+ },
+ {
+ group: 'Group Title 2',
+ collapsed: false,
+ rows: [
+ {id: 3, key1: value1, key2: value2, ...},
+ {id: 4, key1: value1, key2: value2, ...},
+ ]
+ },
+]
+```
+
+### Options
+
+1. If you want to route using router-link just add a `getRowRoute` function
+ which returns a route object
+
+ `getRowRoute: (row) => ({ name: 'User', params: { userId: row.id } })`
+
+2. if you need to do some action add a `onRowClick` event handler
+
+ `onRowClick: (row) => console.log(row.label + ' was clicked')`
+
+3. selectable (Boolean) - if true, checkbox will be shown in header and rows, to
+ select/multiselect rows and perform some action on them - default is true
+4. showTooltip (Boolean) - if true, tooltip will be shown on hover of row -
+ default is true
+5. resizeColumn (Boolean) - if true, column can be resized by dragging the
+ resizer on the right side of the column header - default is false
+
+---
+
+### Selection Banner (Will be shown when selectable (default is true) is true)
+
+**Without custom action buttons:**
+
+
+**With custom action buttons:**
+
+
+```
+
+
+
+
+
+
+
+
+```
+
+You can also make your own custom selection banner
+
+
+
+```
+
+ Custom Banner
+
+```
+
+
+### Badge Component
+
+#### Story
+
+```vue
+
+
+
+
+
+ {{ state.label }}
+
+
+
+
+
+
+
+
+
+```
+
+
+### FormControl Component
+
+
+#### Story
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+
+### Toasts
+
+Example:
+
+```vue
+
+```
+
+
+### Dropdown Component
+
+The icon is name of a lucide icon.
+
+#### Story
+
+```vue
+
+
+
+
+
+
+
+
+
+
+
+
+ Custom Trigger
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Dialog Component
+
+
+#### Story
+
+```vue
+
+
+
+
+
+
+ Show Confirmation Dialog
+
+
+
+
+
+ Show Custom Dialog
+
+
+
+ Custom Title with Styling
+
+
+
+
+
+ This dialog uses custom slots for flexible content layout.
+
+
+
+ You can put any content here including forms, lists, or other
+ components.
+
+
+
+
+
+
+ Save Changes
+ Cancel
+
+
+
+
+
+
+
+
+ Small Dialog
+ Large Dialog
+
+
+
+
+
+
+
+
+
+
+
+ Show Modal Dialog
+
+
+
+
+
+ Show Settings Dialog
+
+
+
+ Settings Dialog
+
+
+
+
+
+ This dialog contains interactive elements to test proper layering.
+
+
+
+
+
+
+ Select an option:
+
+
+
+ {{ selectedOption }}
+
+
+
+
+
+
+
+
+
+
Selected value: {{ selectedOption }}
+
+ Interactive components should work properly within dialogs.
+
+
+
+
+
+
+ Save Settings
+ Cancel
+
+
+
+
+
+
+```
diff --git a/.github/screenshots/tax-exclusive.png b/.github/screenshots/tax-exclusive.png
new file mode 100644
index 0000000..b95a1d6
Binary files /dev/null and b/.github/screenshots/tax-exclusive.png differ
diff --git a/.github/screenshots/tax-inclusive.png b/.github/screenshots/tax-inclusive.png
new file mode 100644
index 0000000..0e0dc26
Binary files /dev/null and b/.github/screenshots/tax-inclusive.png differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f970288
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 0000000..8e93d3b
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -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 .
diff --git a/.github/workflows/pr-tittle-check.yml b/.github/workflows/pr-tittle-check.yml
new file mode 100644
index 0000000..c0648fb
--- /dev/null
+++ b/.github/workflows/pr-tittle-check.yml
@@ -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
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
new file mode 100644
index 0000000..c987bfb
--- /dev/null
+++ b/.github/workflows/typecheck.yml
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
new file mode 100644
index 0000000..f1d8085
--- /dev/null
+++ b/.github/workflows/ui-tests.yml
@@ -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
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..89ff80d
--- /dev/null
+++ b/.gitignore
@@ -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/
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..d1d862d
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "dashboard/frappe-ui"]
+ path = dashboard/frappe-ui
+ url = https://github.com/frappe/frappe-ui.git
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..bb6802f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
new file mode 120000
index 0000000..681311e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1 @@
+CLAUDE.md
\ No newline at end of file
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..668b338
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -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`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e07d1b1
--- /dev/null
+++ b/CLAUDE.md
@@ -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
diff --git a/README.md b/README.md
index 41812cb..3c2e944 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,102 @@
-# event-manager
-Event manager pro Frappe
\ No newline at end of file
+
+
+
+
+
+Open Source, Powerful, and Comprehensive Event Management Platform
+
+### Learn & Connect
+[Telegram Public Group](https://t.me/bwh_buzz)
+### Stack / Architecture
+
+1. Frappe Framework: The Backend and Admin Interface
+2. FrappeUI (based on Vue & TailwindCSS): For the frontend dashboard (for attendee, sponsors, etc.)
+3. Frappe Builder: For the public pages like events list and details page.
+
+### The Main Entity
+
+The **Pohodex Event Manager Event** DocType/Form is the primary entity of the system. Once you have created an event, you can setup ticket types, sponsorship tiers, add-ons (like T-Shirts, Meals, etc.), schedule, and much more!
+
+### Features
+
+This is not an exhaustive list by any means, just to give you an idea 😃
+
+#### Dynamic Ticket & Add-on Types
+
+
+
+#### The Booking Form
+
+Once you have defined the proper ticket types, add-ons, and publish your event, the booking form will dynamically use it for booking.
+
+
+
+#### Payments App Integration
+
+This app depends on Frappe's Payments app for online payments. You can select a Payment Gateway in the event form. BTW GST collection is just a check-box away 😉
+
+#### The Dashboard
+
+
+
+#### Ticket Management
+
+The benefits of having a "self-service" dashboard for attendees is that they can modify their bookings on their own (the deadlines can be configured from the **Pohodex Event Manager Settings**). For example, changing their T-Shirt Size after booking:
+
+
+
+They can also transfer tickets or request for cancellation.
+
+#### Sponsorship Management
+
+Folks can enquire about sponsoring an event and upon approval from the event management team (from desk), they can directly pay from the dashboard too:
+
+
+
+*As soon as they pay, their logo appears on the event page!*
+
+
+
+### Installation
+
+You can install this app using the [bench](https://github.com/frappe/bench) CLI:
+
+```bash
+cd $PATH_TO_YOUR_BENCH
+bench get-app BuildWithHussain/event_manager --branch main
+bench install-app event_manager
+```
+
+### Contributing
+
+This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository:
+
+```bash
+cd apps/event_manager
+pre-commit install
+```
+
+Pre-commit is configured to use the following tools for checking and formatting your code:
+
+- ruff
+- eslint
+- prettier
+- pyupgrade
+### CI
+
+This app can use GitHub Actions for CI. The following workflows are configured:
+
+- CI: Installs this app and runs unit tests on every push to `develop` branch.
+- Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request.
+
+
+### License
+
+agpl-3.0
diff --git a/dashboard/.gitignore b/dashboard/.gitignore
new file mode 100644
index 0000000..53f7466
--- /dev/null
+++ b/dashboard/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
\ No newline at end of file
diff --git a/dashboard/README.md b/dashboard/README.md
new file mode 100644
index 0000000..a646151
--- /dev/null
+++ b/dashboard/README.md
@@ -0,0 +1,50 @@
+# Frappe UI Starter
+
+This template should help get you started developing custom frontend for Frappe
+apps with Vue 3 and the Frappe UI package.
+
+
+
+
+
+This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
+the box. It also has basic authentication frontend.
+
+## Docs
+
+[Frappe UI Website](https://frappeui.com)
+
+## Usage
+
+This template is meant to be cloned inside an existing Frappe App. Assuming your
+apps name is `todo`. Clone this template in the root folder of your app using `degit`.
+
+```
+cd apps/todo
+npx degit NagariaHussain/doppio_frappeui_starter frontend
+cd frontend
+yarn
+yarn dev
+```
+
+In a development environment, you need to put the below key-value pair in your `site_config.json` file:
+
+```
+"ignore_csrf": 1
+```
+
+This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
+
+The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
+The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
+
+If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
+To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
+
+## Resources
+
+- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
+- [Vue Router](https://next.router.vuejs.org/guide/)
+- [Frappe UI](https://github.com/frappe/frappe-ui)
+- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
+- [Vite](https://vitejs.dev/guide/)
diff --git a/dashboard/auto-imports.d.ts b/dashboard/auto-imports.d.ts
new file mode 100644
index 0000000..b72c4a7
--- /dev/null
+++ b/dashboard/auto-imports.d.ts
@@ -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"]
+}
diff --git a/dashboard/biome.json b/dashboard/biome.json
new file mode 100644
index 0000000..bde8525
--- /dev/null
+++ b/dashboard/biome.json
@@ -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"
+ }
+ }
+}
diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts
new file mode 100644
index 0000000..308f757
--- /dev/null
+++ b/dashboard/components.d.ts
@@ -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']
+ }
+}
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 0000000..4c430fb
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Pohodex Event Manager Dashboard
+
+
+
+
+
+
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 0000000..c3955d4
--- /dev/null
+++ b/dashboard/package.json
@@ -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"
+ }
+}
diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js
new file mode 100644
index 0000000..7b75c83
--- /dev/null
+++ b/dashboard/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/dashboard/public/favicon.png b/dashboard/public/favicon.png
new file mode 100644
index 0000000..b51db82
Binary files /dev/null and b/dashboard/public/favicon.png differ
diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue
new file mode 100644
index 0000000..6987cad
--- /dev/null
+++ b/dashboard/src/App.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/assets/Inter/Inter-Black.woff b/dashboard/src/assets/Inter/Inter-Black.woff
new file mode 100644
index 0000000..c7737ed
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Black.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Black.woff2 b/dashboard/src/assets/Inter/Inter-Black.woff2
new file mode 100644
index 0000000..b16b995
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Black.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-BlackItalic.woff b/dashboard/src/assets/Inter/Inter-BlackItalic.woff
new file mode 100644
index 0000000..b5f1447
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-BlackItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-BlackItalic.woff2 b/dashboard/src/assets/Inter/Inter-BlackItalic.woff2
new file mode 100644
index 0000000..a3f1b70
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-BlackItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Bold.woff b/dashboard/src/assets/Inter/Inter-Bold.woff
new file mode 100644
index 0000000..e384555
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Bold.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Bold.woff2 b/dashboard/src/assets/Inter/Inter-Bold.woff2
new file mode 100644
index 0000000..835dd49
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Bold.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-BoldItalic.woff b/dashboard/src/assets/Inter/Inter-BoldItalic.woff
new file mode 100644
index 0000000..ffac3f5
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-BoldItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-BoldItalic.woff2 b/dashboard/src/assets/Inter/Inter-BoldItalic.woff2
new file mode 100644
index 0000000..1a41a14
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-BoldItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraBold.woff b/dashboard/src/assets/Inter/Inter-ExtraBold.woff
new file mode 100644
index 0000000..885ac94
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraBold.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraBold.woff2 b/dashboard/src/assets/Inter/Inter-ExtraBold.woff2
new file mode 100644
index 0000000..ae956b1
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraBold.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff b/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff
new file mode 100644
index 0000000..d6cf862
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff2 b/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff2
new file mode 100644
index 0000000..8657899
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraBoldItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraLight.woff b/dashboard/src/assets/Inter/Inter-ExtraLight.woff
new file mode 100644
index 0000000..ff76919
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraLight.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraLight.woff2 b/dashboard/src/assets/Inter/Inter-ExtraLight.woff2
new file mode 100644
index 0000000..694b2df
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraLight.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff b/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff
new file mode 100644
index 0000000..c6ed13a
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff2 b/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff2
new file mode 100644
index 0000000..9a7bd11
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ExtraLightItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Italic.woff b/dashboard/src/assets/Inter/Inter-Italic.woff
new file mode 100644
index 0000000..4fdb59d
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Italic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Italic.woff2 b/dashboard/src/assets/Inter/Inter-Italic.woff2
new file mode 100644
index 0000000..deca637
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Italic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Light.woff b/dashboard/src/assets/Inter/Inter-Light.woff
new file mode 100644
index 0000000..42850ac
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Light.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Light.woff2 b/dashboard/src/assets/Inter/Inter-Light.woff2
new file mode 100644
index 0000000..65a7dad
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Light.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-LightItalic.woff b/dashboard/src/assets/Inter/Inter-LightItalic.woff
new file mode 100644
index 0000000..c4ed9a9
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-LightItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-LightItalic.woff2 b/dashboard/src/assets/Inter/Inter-LightItalic.woff2
new file mode 100644
index 0000000..555fc55
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-LightItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Medium.woff b/dashboard/src/assets/Inter/Inter-Medium.woff
new file mode 100644
index 0000000..495faef
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Medium.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Medium.woff2 b/dashboard/src/assets/Inter/Inter-Medium.woff2
new file mode 100644
index 0000000..871ce4c
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Medium.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-MediumItalic.woff b/dashboard/src/assets/Inter/Inter-MediumItalic.woff
new file mode 100644
index 0000000..389c7a2
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-MediumItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-MediumItalic.woff2 b/dashboard/src/assets/Inter/Inter-MediumItalic.woff2
new file mode 100644
index 0000000..aa80579
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-MediumItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Regular.woff b/dashboard/src/assets/Inter/Inter-Regular.woff
new file mode 100644
index 0000000..fa7715d
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Regular.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Regular.woff2 b/dashboard/src/assets/Inter/Inter-Regular.woff2
new file mode 100644
index 0000000..b52dd0a
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Regular.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-SemiBold.woff b/dashboard/src/assets/Inter/Inter-SemiBold.woff
new file mode 100644
index 0000000..18d7749
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-SemiBold.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-SemiBold.woff2 b/dashboard/src/assets/Inter/Inter-SemiBold.woff2
new file mode 100644
index 0000000..ece5204
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-SemiBold.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff b/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff
new file mode 100644
index 0000000..8ee6439
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff2 b/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff2
new file mode 100644
index 0000000..b32c0ba
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-SemiBoldItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-Thin.woff b/dashboard/src/assets/Inter/Inter-Thin.woff
new file mode 100644
index 0000000..1a22286
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Thin.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-Thin.woff2 b/dashboard/src/assets/Inter/Inter-Thin.woff2
new file mode 100644
index 0000000..c56bc7c
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-Thin.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-ThinItalic.woff b/dashboard/src/assets/Inter/Inter-ThinItalic.woff
new file mode 100644
index 0000000..d8ec837
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ThinItalic.woff differ
diff --git a/dashboard/src/assets/Inter/Inter-ThinItalic.woff2 b/dashboard/src/assets/Inter/Inter-ThinItalic.woff2
new file mode 100644
index 0000000..eca5608
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-ThinItalic.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-italic.var.woff2 b/dashboard/src/assets/Inter/Inter-italic.var.woff2
new file mode 100644
index 0000000..1f5d926
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-italic.var.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter-roman.var.woff2 b/dashboard/src/assets/Inter/Inter-roman.var.woff2
new file mode 100644
index 0000000..05621d8
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter-roman.var.woff2 differ
diff --git a/dashboard/src/assets/Inter/Inter.var.woff2 b/dashboard/src/assets/Inter/Inter.var.woff2
new file mode 100644
index 0000000..46bb515
Binary files /dev/null and b/dashboard/src/assets/Inter/Inter.var.woff2 differ
diff --git a/dashboard/src/assets/Inter/inter.css b/dashboard/src/assets/Inter/inter.css
new file mode 100644
index 0000000..9f8f80f
--- /dev/null
+++ b/dashboard/src/assets/Inter/inter.css
@@ -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");
+}
diff --git a/dashboard/src/assets/audio/beep-fail.wav b/dashboard/src/assets/audio/beep-fail.wav
new file mode 100644
index 0000000..4790122
Binary files /dev/null and b/dashboard/src/assets/audio/beep-fail.wav differ
diff --git a/dashboard/src/assets/audio/beep.wav b/dashboard/src/assets/audio/beep.wav
new file mode 100644
index 0000000..5649b0a
Binary files /dev/null and b/dashboard/src/assets/audio/beep.wav differ
diff --git a/dashboard/src/components/AddOnPreferenceDialog.vue b/dashboard/src/components/AddOnPreferenceDialog.vue
new file mode 100644
index 0000000..482c3fd
--- /dev/null
+++ b/dashboard/src/components/AddOnPreferenceDialog.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+ Update your add-on preferences for {{ ticket.attendee_name }}
+
+
+
+
No customizable add-ons found for this ticket.
+
+
+
+
+
+ {{ __(addon.title) }}
+
+
Current: {{ addon.value }}
+
+
+
+
+
+
+
+
+
+ Save Preferences
+
+ Cancel
+
+
+
+
+
+
diff --git a/dashboard/src/components/AttendeeFormControl.vue b/dashboard/src/components/AttendeeFormControl.vue
new file mode 100644
index 0000000..4a8226e
--- /dev/null
+++ b/dashboard/src/components/AttendeeFormControl.vue
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+ {{ __("Attendee") }} #{{ index + 1 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __(addOn.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/BaseCustomEventForm.vue b/dashboard/src/components/BaseCustomEventForm.vue
new file mode 100644
index 0000000..8d04132
--- /dev/null
+++ b/dashboard/src/components/BaseCustomEventForm.vue
@@ -0,0 +1,322 @@
+
+
+
+
+
+
+
+
+
+
+ {{ formData.success_title }}
+
+
+
+ {{ __("Your submission has been received.") }}
+
+
+
+
+
+
+
+
+
+
+ {{ formData.closed_title }}
+
+
+ {{ formData.closed_message }}
+
+
+
+
+
+
+
+
+
+
+ {{ __("Not Found") }}
+
+
+ {{ loadError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/BillingDetails.vue b/dashboard/src/components/BillingDetails.vue
new file mode 100644
index 0000000..c91bc6c
--- /dev/null
+++ b/dashboard/src/components/BillingDetails.vue
@@ -0,0 +1,67 @@
+
+
+
+ {{ __("Billing Details") }}
+
+
+
+
+
+
+
+ {{ __("Billing Address") }}
+ *
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/BookingEventInfo.vue b/dashboard/src/components/BookingEventInfo.vue
new file mode 100644
index 0000000..45bd58b
--- /dev/null
+++ b/dashboard/src/components/BookingEventInfo.vue
@@ -0,0 +1,99 @@
+
+
+
+
{{ event.title }}
+
+ {{ __("Visit Event Page") }}
+
+
+
+
+
+
+
+
+ {{ __("Start Date") }}
+
+
+ {{ formatEventDateTime(event.start_date, event.start_time) }}
+
+
+
+
+
+
+
+ {{ __("End Date") }}
+
+
+ {{ formatEventDateTime(event.end_date, event.end_time) }}
+
+
+
+
+
+
+
+ {{ __("Venue") }}
+
+
{{ venue.name }}
+
+ {{ venue.address }}
+
+
+
+
+
+
{{ event.short_description }}
+
+
+
+
+
+
diff --git a/dashboard/src/components/BookingFinancialSummary.vue b/dashboard/src/components/BookingFinancialSummary.vue
new file mode 100644
index 0000000..3f0efb7
--- /dev/null
+++ b/dashboard/src/components/BookingFinancialSummary.vue
@@ -0,0 +1,135 @@
+
+
+
+
{{ __("Payment Summary") }}
+
+
+
+
+ {{ paymentBadge.label }}
+
+
+
+
+
+
+ {{ __("Subtotal") }}
+ {{
+ formatPrice(booking.net_amount || 0, booking.currency || "INR")
+ }}
+
+
+
+
+ {{ __("Coupon") }}
+ {{ booking.coupon_code }}
+
+
+
+
+ {{ __("Discount") }}
+ -{{ formatPrice(booking.discount_amount, booking.currency || "INR") }}
+
+
+
+
+ {{ __(booking.tax_label || "Tax") }} ({{
+ booking.tax_percentage || 0
+ }}%)
+ {{
+ formatPrice(booking.tax_amount || 0, booking.currency || "INR")
+ }}
+
+
+
+
+
+
+
+ {{ isPaid ? __("Total Paid") : __("Total") }}
+ {{
+ formatPrice(booking.total_amount || 0, booking.currency || "INR")
+ }}
+
+
+
+
+ {{
+ __("Inclusive of {0} {1} ({2}%)", [
+ formatPrice(booking.tax_amount || 0, booking.currency || "INR"),
+ __(booking.tax_label || "Tax"),
+ booking.tax_percentage || 0,
+ ])
+ }}
+
+
+
+
+
+
diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue
new file mode 100644
index 0000000..d27d91f
--- /dev/null
+++ b/dashboard/src/components/BookingForm.vue
@@ -0,0 +1,1420 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Enter the 6-digit code sent to") }}
+ {{ isPhoneOtp ? guestPhone : guestEmail }}
+
+
+
+
+ {{
+ resendCooldown > 0
+ ? __("Resend code in {0}s", [resendCooldown])
+ : __("Resend code")
+ }}
+
+
+
+
+
+ {{ __("Cancel") }}
+
+ {{ __("Verify & Book") }}
+
+
+
+
+
+
+
+
+
+
+ {{ isWebinar ? __("Registration Confirmed!") : __("Booking Confirmed!") }}
+
+
+
+ {{ __("You have been registered successfully.") }}
+
+
+ {{ __("Your tickets have been sent to") }}
+ {{ guestEmail }}
+
+
+
+
+ {{ __("You will receive an invite at") }}
+ {{ guestEmail }}
+ {{ __("shortly.") }}
+
+
+ {{ __("Check your email for ticket details and QR codes.") }}
+
+
+
+
+ {{ __("Want to manage your bookings?") }}
+
+
+ {{ __("Log in to your account") }}
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/BookingHeader.vue b/dashboard/src/components/BookingHeader.vue
new file mode 100644
index 0000000..a23e8c4
--- /dev/null
+++ b/dashboard/src/components/BookingHeader.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ {{ __("Booking Details") }}
+ (#{{ bookingId }})
+
+
+
+
diff --git a/dashboard/src/components/BookingSummary.vue b/dashboard/src/components/BookingSummary.vue
new file mode 100644
index 0000000..ef439d0
--- /dev/null
+++ b/dashboard/src/components/BookingSummary.vue
@@ -0,0 +1,233 @@
+
+
+
+
{{ __("Booking Summary") }}
+
+
+
+
{{ __("Tickets") }}
+
+
+ {{ __(ticket.title) }}
+
+ {{ Math.min(freeTicketCount, ticket.count) }} x
+ {{
+ formatPriceOrFree(ticket.price, ticket.currency)
+ }}
+ {{ __("Free")
+ }}{{
+ ticket.count > freeTicketCount
+ ? `, ${ticket.count - freeTicketCount} x ${formatPriceOrFree(
+ ticket.price,
+ ticket.currency
+ )}`
+ : ""
+ }}
+
+
+ {{ ticket.count }} x {{ formatPriceOrFree(ticket.price, ticket.currency) }}
+
+ x {{ ticket.count }}
+
+
+ {{
+ ticket.count <= freeTicketCount
+ ? __("Free")
+ : formatPriceOrFree(
+ (ticket.count - freeTicketCount) * ticket.price,
+ ticket.currency
+ )
+ }}
+
+
{{
+ formatPriceOrFree(ticket.amount, ticket.currency)
+ }}
+
+
+
+
+
+
{{ __("Add-ons") }}
+
+
+ {{ __(addOn.title) }}
+
+ {{ Math.min(freeAddOnCounts[name], addOn.count) }} x
+ {{
+ formatPriceOrFree(addOn.price, addOn.currency)
+ }}
+ {{ __("Free")
+ }}{{
+ addOn.count > freeAddOnCounts[name]
+ ? `, ${addOn.count - freeAddOnCounts[name]} x ${formatPriceOrFree(
+ addOn.price,
+ addOn.currency
+ )}`
+ : ""
+ }}
+
+
+ {{ addOn.count }} x {{ formatPriceOrFree(addOn.price, addOn.currency) }}
+
+ x {{ addOn.count }}
+
+
+ {{
+ addOn.count <= freeAddOnCounts[name]
+ ? __("Free")
+ : formatPriceOrFree(
+ (addOn.count - freeAddOnCounts[name]) * addOn.price,
+ addOn.currency
+ )
+ }}
+
+
{{
+ formatPriceOrFree(addOn.amount, addOn.currency)
+ }}
+
+
+
+
+
+
+
+
+
+ {{ __("Subtotal") }}
+ {{ formatPriceOrFree(netAmount, totalCurrency) }}
+
+
+
+
+ {{
+ couponType === "Free Tickets" ? __("Free Tickets") : __("Discount")
+ }}
+ -{{ formatPriceOrFree(discountAmount, totalCurrency) }}
+
+
+
+
+ {{ __(taxLabel) }} ({{ taxPercentage }}%)
+ {{ formatPriceOrFree(taxAmount, totalCurrency) }}
+
+
+
+
+
+
{{ __("Total") }}
+ {{ formatPriceOrFree(total, totalCurrency) }}
+
+
+
+
+ {{
+ __("Inclusive of {0} {1} ({2}%)", [
+ formatPriceOrFree(taxAmount, totalCurrency),
+ __(taxLabel),
+ taxPercentage,
+ ])
+ }}
+
+
+
+
+
+
+
+
{{ __("Free Event") }}
+
+
+
+
+
+
diff --git a/dashboard/src/components/CancellationRequestDialog.vue b/dashboard/src/components/CancellationRequestDialog.vue
new file mode 100644
index 0000000..222d2f3
--- /dev/null
+++ b/dashboard/src/components/CancellationRequestDialog.vue
@@ -0,0 +1,321 @@
+
+
+
+
+
+ {{
+ __(
+ "Select the tickets you would like to cancel. Please note that cancellation requests are subject to approval and refund policies."
+ )
+ }}
+
+
+
+
+
+
+ {{ pluralize(cancelledTickets.length, __("ticket")) }}
+ {{ __("already cancelled") }}.
+
+
+
+
+
+ {{ pluralize(cancellationRequestedTickets.length, __("ticket")) }}
+ {{ __("already have pending cancellation requests") }}.
+
+
+
+
+
+
+
+
+
+
+ {{ __("Select All Available Tickets") }}
+
+
+ {{ __("Cancel all") }}
+ {{ pluralize(availableTickets.length, __("remaining ticket")) }}
+
+
+
+
+
+
+
+
+ {{ __("Or select individual tickets:") }}
+
+
+
+ {{
+ __(
+ "No tickets available for cancellation. All tickets are either already cancelled or have pending cancellation requests."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ ticket.attendee_name }}
+
+
+ {{ ticket.attendee_email }}
+
+
+ {{ ticket.ticket_type }}
+
+
+
+
+
+
+
+ {{ __("Add-ons:") }}
+
+
+
+ {{ addon.title }}: {{ addon.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Please select at least one ticket to cancel.") }}
+
+
+
+
+
+
+
+
+ {{ __("Cancellation Summary") }}
+
+
+ {{ pluralize(selectedTickets.length, __("ticket")) }}
+ {{ __("selected for cancellation") }}
+ {{
+ __("(Full booking)")
+ }}
+
+
+
+
{{ __("Request Type") }}
+
+ {{
+ isAllSelected
+ ? __("Full Cancellation")
+ : __("Partial Cancellation")
+ }}
+
+
+
+
+
+
+
+
+
+
+ {{ __("Cancel") }}
+
+
+ {{ __("Submit Cancellation Request") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/CancellationRequestNotice.vue b/dashboard/src/components/CancellationRequestNotice.vue
new file mode 100644
index 0000000..89ac43c
--- /dev/null
+++ b/dashboard/src/components/CancellationRequestNotice.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ {{ __("Cancellation Requested") }}
+
+
+
+ {{ __("Full booking cancellation has been requested.") }}
+
+
+ {{
+ __("Partial cancellation has been requested for selected tickets.")
+ }}
+
+ {{ __("Request submitted on") }}
+ {{ formatDate(cancellationRequest.creation) }}.
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/CustomFieldInput.vue b/dashboard/src/components/CustomFieldInput.vue
new file mode 100644
index 0000000..fe28773
--- /dev/null
+++ b/dashboard/src/components/CustomFieldInput.vue
@@ -0,0 +1,227 @@
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
+
+ {{ __("Remove") }}
+
+
+
$emit('update:modelValue', file.file_url)"
+ :validateFile="validateImageFile"
+ >
+
+
+ {{ __("Upload Image") }}
+
+
+
+
+
+
+
+ {{ __(field.label) }}
+ *
+
+
+
$emit('update:modelValue', file.file_url)">
+
+
+ {{ __("Upload File") }}
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/CustomFieldsSection.vue b/dashboard/src/components/CustomFieldsSection.vue
new file mode 100644
index 0000000..8cf85d3
--- /dev/null
+++ b/dashboard/src/components/CustomFieldsSection.vue
@@ -0,0 +1,71 @@
+
+
+
+ {{ __(title) || __("Additional Information") }}
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/EventDetailsHeader.vue b/dashboard/src/components/EventDetailsHeader.vue
new file mode 100644
index 0000000..b6e5d28
--- /dev/null
+++ b/dashboard/src/components/EventDetailsHeader.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ __(eventDetails.title) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatEventDates(eventDetails.start_date, eventDetails.end_date) }}
+
+
+
+
+
+
+
+
+
+ {{ formatEventTime(eventDetails.start_time, eventDetails.end_time) }}
+ ({{ eventDetails.time_zone }})
+
+
+
+
+
+
+
+
+
{{ eventDetails.venue }}
+
+
+
+
+
+
+
+
+
+ {{ __(eventDetails.short_description) }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/EventSelector.vue b/dashboard/src/components/EventSelector.vue
new file mode 100644
index 0000000..1d2e815
--- /dev/null
+++ b/dashboard/src/components/EventSelector.vue
@@ -0,0 +1,110 @@
+
+
+
+
+ {{ __("Select Event") }}
+
+
+
+
+
+
+
+
{{ __("Loading events...") }}
+
+ {{ __("Please wait while we load the events...") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/EventSponsorForm.vue b/dashboard/src/components/EventSponsorForm.vue
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/src/components/LanguageSwitcher.vue b/dashboard/src/components/LanguageSwitcher.vue
new file mode 100644
index 0000000..e8f86db
--- /dev/null
+++ b/dashboard/src/components/LanguageSwitcher.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/LoginDialog.vue b/dashboard/src/components/LoginDialog.vue
new file mode 100644
index 0000000..7e818f4
--- /dev/null
+++ b/dashboard/src/components/LoginDialog.vue
@@ -0,0 +1,419 @@
+
+
+
+
+ {{ view_title }}
+
+
+
+
+
+
+ {{ error_message }}
+
+
+
+ {{ success_message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/LoginRequired.vue b/dashboard/src/components/LoginRequired.vue
new file mode 100644
index 0000000..0197695
--- /dev/null
+++ b/dashboard/src/components/LoginRequired.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ __("Login Required") }}
+
+
+ {{ __(message) }}
+
+
{{ __("Log In") }}
+
+
+
+
+
diff --git a/dashboard/src/components/Navbar.vue b/dashboard/src/components/Navbar.vue
new file mode 100644
index 0000000..8d5d617
--- /dev/null
+++ b/dashboard/src/components/Navbar.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Log Out") }}
+
+
+ {{ __("Log In") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/OfflinePaymentDialog.vue b/dashboard/src/components/OfflinePaymentDialog.vue
new file mode 100644
index 0000000..8fc9d3c
--- /dev/null
+++ b/dashboard/src/components/OfflinePaymentDialog.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+ {{ offlineSettings.label }}
+
+
+
+
+
+
+ {{ formatCurrency(amount, currency) }}
+
+
+
+
+
+
+
+
+
+
+
+
{{ __("Proof of Payment") }} *
+
+
+
+
+ {{
+ paymentProof.file_name || paymentProof.name
+ }}
+
+
+
+
+
+ {{
+ uploading
+ ? __("Uploading {0}%", [progress])
+ : __("Upload File")
+ }}
+
+
+
+
+
+
+
+
+ {{ __("Cancel") }}
+
+
+ {{ __("Submit") }}
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/PaymentGatewayDialog.vue b/dashboard/src/components/PaymentGatewayDialog.vue
new file mode 100644
index 0000000..f2d18d1
--- /dev/null
+++ b/dashboard/src/components/PaymentGatewayDialog.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ {{ __("Cancel") }}
+
+ {{ __("Proceed to Pay") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/PhoneInput.vue b/dashboard/src/components/PhoneInput.vue
new file mode 100644
index 0000000..d2de200
--- /dev/null
+++ b/dashboard/src/components/PhoneInput.vue
@@ -0,0 +1,115 @@
+
+
+
+ {{ __(label) }}
+ *
+
+
+
+
+
+
diff --git a/dashboard/src/components/ProfileView.vue b/dashboard/src/components/ProfileView.vue
new file mode 100644
index 0000000..bd7acb1
--- /dev/null
+++ b/dashboard/src/components/ProfileView.vue
@@ -0,0 +1,107 @@
+
+
+
updateImage(file.file_url)"
+ :validateFile="validateIsImageFile"
+ >
+
+
+
+
+
+ {{ profile.full_name }}
+
+
+ {{ profile.email }}
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/ProposalEditDialog.vue b/dashboard/src/components/ProposalEditDialog.vue
new file mode 100644
index 0000000..5a4a03c
--- /dev/null
+++ b/dashboard/src/components/ProposalEditDialog.vue
@@ -0,0 +1,172 @@
+
+
+
+
+ {{ eventTalkId ? __("Edit Talk") : __("Edit Proposal") }}
+
+
+
+
+
+
+
+
+ {{ __("Description") }}
+
+ (editForm.description = val)"
+ editorClass="prose-sm max-w-none py-2 px-3 min-h-[12rem] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
+ />
+
+
+
+
+
+
+
+
+ {{ __("Save Changes") }}
+
+
+ {{ __("Cancel") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/QRCodeExpandDialog.vue b/dashboard/src/components/QRCodeExpandDialog.vue
new file mode 100644
index 0000000..f7dd1a5
--- /dev/null
+++ b/dashboard/src/components/QRCodeExpandDialog.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/QRScanner.vue b/dashboard/src/components/QRScanner.vue
new file mode 100644
index 0000000..86708ce
--- /dev/null
+++ b/dashboard/src/components/QRScanner.vue
@@ -0,0 +1,251 @@
+
+
+
+
+ {{ __("Scan Ticket QR Code") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Start Scanner
+
+
+ {{ __("Stop Scanner") }}
+
+
+
+
+
+
+
+
+ {{ __("Check") }}
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/RestrictionNotices.vue b/dashboard/src/components/RestrictionNotices.vue
new file mode 100644
index 0000000..5979571
--- /dev/null
+++ b/dashboard/src/components/RestrictionNotices.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+ {{
+ __(
+ "Some options are no longer available as the event is approaching:"
+ )
+ }}
+
+
+
+ {{ __("Ticket cancellation requests") }}
+
+ {{ __("Ticket transfers") }}
+ {{ __("Add-on preference changes") }}
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/SponsorLogoUploader.vue b/dashboard/src/components/SponsorLogoUploader.vue
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/src/components/SponsorshipPaymentDialog.vue b/dashboard/src/components/SponsorshipPaymentDialog.vue
new file mode 100644
index 0000000..feb3d0b
--- /dev/null
+++ b/dashboard/src/components/SponsorshipPaymentDialog.vue
@@ -0,0 +1,261 @@
+
+
+
+
+
{{ __("Loading event information...") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tier.title }}
+
+
+
+
+
+ {{ formatCurrency(tier.price, tier.currency) }}
+
+
+
+
+
+
+
+
+
+ {{ __("Select Payment Method") }}
+
+
+
+
+
+
+
+
+
+ {{ __("Selected Tier") }}
+
+
{{ selectedTier.title }}
+
+
+
{{ __("Total Amount") }}
+
+ {{ formatCurrency(selectedTier.price, selectedTier.currency) }}
+
+
+
+
+
+
+
+
{{ __("Error loading sponsorship tiers") }}
+
{{ tiers.error }}
+
+
+
+
+ {{ __("No sponsorship tiers available for this event") }}
+
+
+
+
+
+
+ {{ __("Cancel") }}
+
+ {{ __("Proceed to Pay") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/SuccessMessage.vue b/dashboard/src/components/SuccessMessage.vue
new file mode 100644
index 0000000..61bc469
--- /dev/null
+++ b/dashboard/src/components/SuccessMessage.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+ {{ __("Payment Successful! 🎉") }}
+
+
+ {{
+ isWebinar
+ ? __(
+ "Your registration has been confirmed. You will receive an invite shortly."
+ )
+ : __(
+ "Your booking has been confirmed. Check your email for tickets and details."
+ )
+ }}
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/TicketCard.vue b/dashboard/src/components/TicketCard.vue
new file mode 100644
index 0000000..950821e
--- /dev/null
+++ b/dashboard/src/components/TicketCard.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ticket.attendee_name }}
+
+
{{ __("Email") }}: {{ ticket.attendee_email }}
+
+ {{ __("Ticket Type") }}: {{ ticket.ticket_type }}
+
+
+
+
+
{{ __("Add-ons:") }}
+
+
+
{{ addon.title }}
+
+ {{ addon.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/TicketDetailsModal.vue b/dashboard/src/components/TicketDetailsModal.vue
new file mode 100644
index 0000000..b91e2af
--- /dev/null
+++ b/dashboard/src/components/TicketDetailsModal.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+ {{ validationResult ? __("Valid Ticket") : __("Invalid Ticket") }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Valid Ticket") }}
+
+
{{ __("Ready for check-in") }}
+
+
+
+
+
+
+ {{ __("Attendee") }}
+
+
+ {{ validationResult?.ticket?.attendee_name }}
+
+
+
+
+ {{ __("Email") }}
+
+
+ {{ validationResult?.ticket?.attendee_email }}
+
+
+
+
+ {{ __("Ticket Type") }}
+
+
+ {{ validationResult?.ticket?.ticket_type }}
+
+
+
+
+ {{ __("Ticket ID") }}
+
+
+ {{ validationResult?.ticket?.id }}
+
+
+
+
+
+
+
+ {{ __("Add-ons") }}
+
+
+
+ {{ __(addon.add_on_title || addon.add_on) }} ({{
+ formatPriceOrFree(addon.price, addon.currency)
+ }})
+ {{
+ addon.value
+ }}
+
+
+
+
+
+
+
+ {{ __("Payment Details") }}
+
+
+
+
+
+ {{ __("ID") }}
+
+
{{ validationResult?.payment_details?.name }}
+
+
+
+ {{ __("Amount Paid") }}
+
+
+ {{
+ formatPriceOrFree(
+ validationResult?.payment_details?.amount,
+ validationResult?.payment_details?.currency
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ validationResult?.error || "Invalid Ticket" }}
+
+
+ {{ validationResult?.message }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Check In") }}
+
+
+ {{ __("Cancel") }}
+
+
+
+
+
+
+ {{ __("Close") }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/TicketTransferDialog.vue b/dashboard/src/components/TicketTransferDialog.vue
new file mode 100644
index 0000000..42c0264
--- /dev/null
+++ b/dashboard/src/components/TicketTransferDialog.vue
@@ -0,0 +1,126 @@
+
+
+
+ {{ __("Transfer Ticket") }}
+
+
+
+
+ {{
+ __(
+ "Transfer this ticket to a new attendee. The new attendee will receive the updated ticket information."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Transfer Ticket") }}
+
+ {{ __("Cancel") }}
+
+
+
+
+
+
diff --git a/dashboard/src/components/TicketsSection.vue b/dashboard/src/components/TicketsSection.vue
new file mode 100644
index 0000000..da8510d
--- /dev/null
+++ b/dashboard/src/components/TicketsSection.vue
@@ -0,0 +1,100 @@
+
+
+
+
{{ __("Your Tickets") }}
+
+
+
+ {{
+ cancellationRequest
+ ? __("Request More Cancellations")
+ : __("Request Cancellation")
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/TransferTicketDialog.vue b/dashboard/src/components/TransferTicketDialog.vue
new file mode 100644
index 0000000..47630db
--- /dev/null
+++ b/dashboard/src/components/TransferTicketDialog.vue
@@ -0,0 +1,113 @@
+
+
+
+ Transfer Ticket
+
+
+
+
+ Transfer this ticket to a new attendee. The new attendee will receive the
+ updated ticket information.
+
+
+
+
+
+
+
+
+
+
+ Transfer Ticket
+
+ Cancel
+
+
+
+
+
+
diff --git a/dashboard/src/components/common/BackButton.vue b/dashboard/src/components/common/BackButton.vue
new file mode 100644
index 0000000..24fb005
--- /dev/null
+++ b/dashboard/src/components/common/BackButton.vue
@@ -0,0 +1,20 @@
+
+
+ {{ label }}
+
+
+
+
diff --git a/dashboard/src/components/common/BuzzLogo.vue b/dashboard/src/components/common/BuzzLogo.vue
new file mode 100644
index 0000000..6061243
--- /dev/null
+++ b/dashboard/src/components/common/BuzzLogo.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/composables/useBookingFormStorage.ts b/dashboard/src/composables/useBookingFormStorage.ts
new file mode 100644
index 0000000..774fb7e
--- /dev/null
+++ b/dashboard/src/composables/useBookingFormStorage.ts
@@ -0,0 +1,74 @@
+import { useStorage } from "@vueuse/core"
+
+/**
+ * Composable for managing booking form localStorage data
+ * This allows components to access and clear booking form data stored in localStorage
+ * @param {string} eventRoute - The event route to scope the storage keys
+ */
+export function useBookingFormStorage(eventRoute: string) {
+ if (!eventRoute) {
+ throw new Error("eventRoute is required for useBookingFormStorage")
+ }
+
+ // Scope storage keys to the specific event route
+ const storageKeyPrefix = `event-booking-${eventRoute}`
+ const attendees = useStorage(`${storageKeyPrefix}-attendees`, [], undefined, {
+ deep: true,
+ })
+ const attendeeIdCounter = useStorage(`${storageKeyPrefix}-counter`, 0)
+ const bookingCustomFields = useStorage(
+ `${storageKeyPrefix}-custom-fields`,
+ {},
+ )
+
+ const guestFirstName = useStorage(`${storageKeyPrefix}-guest-first-name`, "")
+ const guestLastName = useStorage(`${storageKeyPrefix}-guest-last-name`, "")
+ const guestEmail = useStorage(`${storageKeyPrefix}-guest-email`, "")
+ const guestPhone = useStorage(`${storageKeyPrefix}-guest-phone`, "")
+
+ const invoiceRequested = useStorage(`${storageKeyPrefix}-invoice-requested`, false)
+ const taxId = useStorage(`${storageKeyPrefix}-tax-id`, "")
+ const billingAddress = useStorage(`${storageKeyPrefix}-billing-address`, "")
+
+ /**
+ * Clear all stored booking form data
+ * This should be called when payment is successful
+ */
+ const clearStoredData = () => {
+ attendees.value = []
+ attendeeIdCounter.value = 0
+ bookingCustomFields.value = {}
+ guestFirstName.value = ""
+ guestLastName.value = ""
+ guestEmail.value = ""
+ guestPhone.value = ""
+ invoiceRequested.value = false
+ taxId.value = ""
+ billingAddress.value = ""
+ }
+
+ /**
+ * Check if there's any stored booking data
+ */
+ const hasStoredData = () => {
+ return (
+ attendees.value.length > 0 ||
+ Object.keys(bookingCustomFields.value).length > 0
+ )
+ }
+
+ return {
+ attendees,
+ attendeeIdCounter,
+ bookingCustomFields,
+ guestFirstName,
+ guestLastName,
+ guestEmail,
+ guestPhone,
+ invoiceRequested,
+ taxId,
+ billingAddress,
+ clearStoredData,
+ hasStoredData,
+ }
+}
diff --git a/dashboard/src/composables/useCustomFields.ts b/dashboard/src/composables/useCustomFields.ts
new file mode 100644
index 0000000..870f060
--- /dev/null
+++ b/dashboard/src/composables/useCustomFields.ts
@@ -0,0 +1,176 @@
+/**
+ * Composable for handling custom field logic
+ * Provides utilities for converting Frappe field types to form control types,
+ * parsing field options, and generating placeholders.
+ */
+
+/**
+ * Interface representing the structure of a Frappe Field
+ */
+export interface FrappeField {
+ fieldname: string
+ fieldtype: string
+ label?: string
+ options?: string | any[]
+ placeholder?: string
+ mandatory?: number | boolean
+ default_value?: string | number | boolean
+}
+
+export interface SelectOption {
+ label: string
+ value: string
+}
+
+/**
+ * Convert Frappe field types to FormControl types
+ * @param {string} fieldtype - Frappe field type
+ * @returns {string} - FormControl type
+ */
+export function getFormControlType(
+ fieldtype: string,
+ options?: string,
+): string {
+ if (fieldtype === "Data" && options === "Email") return "email"
+ if (fieldtype === "Data" && options === "URL") return "url"
+
+ switch (fieldtype) {
+ case "Phone":
+ return "text"
+ case "Email":
+ return "email"
+ case "Select":
+ return "select"
+ case "Number":
+ case "Int":
+ case "Float":
+ return "number"
+ case "Check":
+ return "checkbox"
+ case "Small Text":
+ return "textarea"
+ default:
+ return "text"
+ }
+}
+
+/**
+ * Check if a field type requires a special date/time picker
+ * @param {string} fieldtype - Frappe field type
+ * @returns {boolean}
+ */
+export function isDateField(fieldtype: string): boolean {
+ return fieldtype === "Date"
+}
+
+/**
+ * Check if a field type requires a datetime picker
+ * @param {string} fieldtype - Frappe field type
+ * @returns {boolean}
+ */
+export function isDateTimeField(fieldtype: string): boolean {
+ return fieldtype === "Datetime"
+}
+
+export function isTextareaField(fieldtype: string): boolean {
+ return fieldtype === "Text Editor" || fieldtype === "Small Text"
+}
+
+/**
+ * Get field options for select fields
+ * @param {Object} field - Field definition object
+ * @returns {Array} - Array of { label, value } objects
+ */
+export function getFieldOptions(field: FrappeField): SelectOption[] {
+ const isSelectType =
+ field.fieldtype === "Select" || field.fieldtype === "Multi Select"
+ if (isSelectType && field.options) {
+ let options = []
+
+ if (typeof field.options === "string") {
+ // Split by newlines, trim each option, and filter out empty ones
+ // but preserve an empty first option as a placeholder
+ const allOptions = field.options
+ .split("\n")
+ .map((option) => option.trim())
+ const hasEmptyFirst = allOptions.length > 0 && allOptions[0].length === 0
+ options = allOptions.filter((option) => option.length > 0)
+ if (hasEmptyFirst) {
+ options.unshift("")
+ }
+ } else if (Array.isArray(field.options)) {
+ // If options is already an array
+ options = field.options.filter((option) => {
+ try {
+ return option != null && String(option).trim().length > 0
+ } catch {
+ return false
+ }
+ })
+ }
+
+ const formattedOptions = options.map((option) => {
+ const optionStr = String(option).trim()
+ return {
+ label: optionStr,
+ value: optionStr,
+ }
+ })
+
+ // Debug log for development
+ if (
+ process.env.NODE_ENV === "development" &&
+ formattedOptions.length === 0 &&
+ field.options
+ ) {
+ console.warn(
+ `CustomField "${field.fieldname}" has Select type but no valid options:`,
+ field.options,
+ )
+ }
+
+ return formattedOptions
+ }
+ return []
+}
+
+/**
+ * Get placeholder text for a field
+ * @param {Object} field - Field definition object
+ * @returns {string} - Placeholder text
+ */
+export function getFieldPlaceholder(field: FrappeField): string {
+ // If custom placeholder is provided, use it
+ if (field.placeholder?.trim()) {
+ const placeholder = field.placeholder.trim()
+ return field.mandatory ? `${placeholder} (${__("required")})` : placeholder
+ }
+
+ // If no custom placeholder is provided, return empty string
+ return ""
+}
+
+/**
+ * Get the default value for a field
+ * @param {Object} field - Field definition object
+ * @param {Function} getFieldOptionsFn - Function to get field options
+ * @returns {*} - Default value or empty string
+ */
+export function getFieldDefaultValue(
+ field: FrappeField,
+): string | number | boolean {
+ // For checkbox fields, handle 0/1 values explicitly
+ if (field.fieldtype === "Check") {
+ if (field.default_value === 1 || field.default_value === "1") {
+ return 1
+ }
+ return 0 // Default to unchecked
+ }
+
+ // Check for explicit default value (use != null to allow "0" and 0)
+ if (field.default_value != null && field.default_value !== "") {
+ return field.default_value
+ }
+
+ return ""
+}
diff --git a/dashboard/src/composables/useLanguage.ts b/dashboard/src/composables/useLanguage.ts
new file mode 100644
index 0000000..d9a2626
--- /dev/null
+++ b/dashboard/src/composables/useLanguage.ts
@@ -0,0 +1,41 @@
+import { userResource } from "@/data/user"
+import { createResource } from "frappe-ui"
+import { type ComputedRef, computed } from "vue"
+
+interface LanguageComposable {
+ availableLanguages: any
+ currentLanguage: ComputedRef
+ changeLanguage: (languageCode: string) => void
+ isSwitching: ComputedRef
+}
+
+export function useLanguage(): LanguageComposable {
+ const availableLanguages = createResource({
+ url: "event_manager.api.get_enabled_languages",
+ auto: true,
+ cache: "enabled_languages",
+ })
+
+ const currentLanguage = computed(() => {
+ return userResource.data?.language || "en"
+ })
+
+ const switchLanguage = createResource({
+ url: "event_manager.api.update_user_language",
+ onSuccess() {
+ // Reload the page to apply new translations
+ window.location.reload()
+ },
+ })
+
+ function changeLanguage(languageCode: string) {
+ switchLanguage.submit({ language_code: languageCode })
+ }
+
+ return {
+ availableLanguages,
+ currentLanguage,
+ changeLanguage,
+ isSwitching: computed(() => switchLanguage.loading),
+ }
+}
diff --git a/dashboard/src/composables/useLoginDialog.ts b/dashboard/src/composables/useLoginDialog.ts
new file mode 100644
index 0000000..75ea942
--- /dev/null
+++ b/dashboard/src/composables/useLoginDialog.ts
@@ -0,0 +1,18 @@
+import { ref } from "vue"
+
+const is_open = ref(false)
+const on_success_callback = ref<(() => void) | null>(null)
+
+export function useLoginDialog() {
+ function open(on_success?: () => void) {
+ on_success_callback.value = on_success || null
+ is_open.value = true
+ }
+
+ function close() {
+ is_open.value = false
+ on_success_callback.value = null
+ }
+
+ return { is_open, open, close, on_success_callback }
+}
diff --git a/dashboard/src/composables/usePaymentSuccess.ts b/dashboard/src/composables/usePaymentSuccess.ts
new file mode 100644
index 0000000..43b03cd
--- /dev/null
+++ b/dashboard/src/composables/usePaymentSuccess.ts
@@ -0,0 +1,114 @@
+import { triggerCelebrationConfetti } from "@/utils/confetti"
+import { type Ref, onMounted, ref } from "vue"
+import { useRoute, useRouter } from "vue-router"
+
+interface PaymentSuccessOptions {
+ onSuccess?: () => void
+ messageDuration?: number
+ enableConfetti?: boolean
+ cleanupUrl?: boolean
+}
+
+interface PaymentSuccessReturn {
+ showSuccessMessage: Ref
+ triggerSuccessFlow: () => void
+ checkForSuccess: () => void
+ hideSuccessMessage: () => void
+ showSuccess: () => void
+}
+
+/**
+ * Composable for handling payment success flow
+ * Handles success message display, confetti animation, URL cleanup, and data refresh
+ *
+ * @param {Object} options - Configuration options
+ * @param {Function} options.onSuccess - Callback function to execute on success (e.g., reload data)
+ * @param {number} options.messageDuration - How long to show success message in milliseconds (default: 10000)
+ * @param {boolean} options.enableConfetti - Whether to trigger confetti animation (default: true)
+ * @param {boolean} options.cleanupUrl - Whether to clean up success parameter from URL (default: true)
+ * @returns {Object} - Returns reactive state and helper functions
+ */
+export function usePaymentSuccess(
+ options: PaymentSuccessOptions = {},
+): PaymentSuccessReturn {
+ const {
+ onSuccess,
+ messageDuration = 10000,
+ enableConfetti = true,
+ cleanupUrl = true,
+ } = options
+
+ const route = useRoute()
+ const router = useRouter()
+
+ const showSuccessMessage = ref(false)
+
+ /**
+ * Trigger the complete success flow
+ */
+ const triggerSuccessFlow = () => {
+ // Show success message
+ showSuccessMessage.value = true
+
+ // Trigger confetti animation
+ if (enableConfetti) {
+ triggerCelebrationConfetti()
+ }
+
+ // Execute custom success callback (e.g., reload data)
+ if (onSuccess && typeof onSuccess === "function") {
+ onSuccess()
+ }
+
+ // Clean up the URL by removing the success parameter
+ if (cleanupUrl) {
+ router.replace({
+ name: route.name,
+ params: route.params,
+ })
+ }
+
+ // Hide success message after specified duration
+ if (messageDuration > 0) {
+ setTimeout(() => {
+ showSuccessMessage.value = false
+ }, messageDuration)
+ }
+ }
+
+ /**
+ * Check for success parameter and trigger flow if present
+ */
+ const checkForSuccess = () => {
+ if (route.query.success === "true") {
+ triggerSuccessFlow()
+ }
+ }
+
+ /**
+ * Manually hide the success message
+ */
+ const hideSuccessMessage = () => {
+ showSuccessMessage.value = false
+ }
+
+ /**
+ * Manually show the success message
+ */
+ const showSuccess = () => {
+ triggerSuccessFlow()
+ }
+
+ // Auto-check for success on mount
+ onMounted(() => {
+ checkForSuccess()
+ })
+
+ return {
+ showSuccessMessage,
+ triggerSuccessFlow,
+ checkForSuccess,
+ hideSuccessMessage,
+ showSuccess,
+ }
+}
diff --git a/dashboard/src/composables/useTicketValidation.ts b/dashboard/src/composables/useTicketValidation.ts
new file mode 100644
index 0000000..1609a2c
--- /dev/null
+++ b/dashboard/src/composables/useTicketValidation.ts
@@ -0,0 +1,174 @@
+import beepFailSound from "@/assets/audio/beep-fail.wav"
+import beepSound from "@/assets/audio/beep.wav"
+import type { TicketAddOnValue } from "@/types/Ticketing/TicketAddOnValue"
+import { createResource, toast } from "frappe-ui"
+import { type Ref, ref } from "vue"
+
+interface ValidationTicket {
+ id: string
+ attendee_name: string
+ attendee_email: string
+ event_title: string
+ ticket_type: string
+ venue: string
+ start_date: string
+ start_time: string
+ end_date: string
+ end_time: string
+ is_checked_in: boolean
+ check_in_time: string | null
+ check_in_date?: string | null
+ booking_id: string
+ add_ons: TicketAddOnValue[]
+}
+
+interface ValidationResult {
+ message: string
+ ticket: ValidationTicket
+}
+
+interface TicketValidationState {
+ isProcessingTicket: Ref
+ isCheckingIn: Ref
+ validationResult: Ref
+ showTicketModal: Ref
+ validateTicket: (ticketId: string) => void
+ checkInTicket: () => void
+ clearResults: () => void
+ closeModal: () => void
+}
+
+let ticketValidationState: TicketValidationState | null = null
+
+const isProcessingTicket = ref(false)
+const isCheckingIn = ref(false)
+const validationResult = ref(null)
+const showTicketModal = ref(false)
+
+let lastToastMessage: string | null = null
+let lastToastTime = 0
+const TOAST_DEBOUNCE_MS = 500
+
+const playSuccessSound = (): void => {
+ const audio = new Audio(beepSound)
+ audio.play()
+}
+
+const playErrorSound = (): void => {
+ const audio = new Audio(beepFailSound)
+ audio.play()
+}
+
+const showDebouncedToast = (
+ message: string,
+ type: "error" | "success" = "error",
+): void => {
+ const now = Date.now()
+ if (lastToastMessage === message && now - lastToastTime < TOAST_DEBOUNCE_MS) {
+ return
+ }
+ lastToastMessage = message
+ lastToastTime = now
+
+ if (type === "error") {
+ toast.error(message)
+ } else {
+ toast.success(message)
+ }
+}
+
+// Ticket validation resource
+const validateTicketResource = createResource({
+ url: "event_manager.api.validate_ticket_for_checkin",
+ onSuccess: (data: ValidationResult) => {
+ validationResult.value = data
+ showTicketModal.value = true
+ playSuccessSound()
+ isProcessingTicket.value = false
+ },
+ onError: (error: any) => {
+ validationResult.value = null
+ isProcessingTicket.value = false
+ const errorData = JSON.stringify(error)
+
+ if (errorData.includes("Ticket not found")) {
+ showDebouncedToast("Ticket not found")
+ } else if (
+ errorData.includes(
+ "This ticket is not confirmed and cannot be used for check-in",
+ )
+ ) {
+ showDebouncedToast(
+ "This ticket is not confirmed and cannot be used for check-in",
+ )
+ } else if (errorData.includes("This ticket was already checked in today")) {
+ showDebouncedToast("This ticket was already checked in today.")
+ } else if (errorData.includes("cancelled")) {
+ showDebouncedToast(
+ "This ticket has been cancelled and cannot be checked in",
+ )
+ } else {
+ showDebouncedToast("Error validating ticket")
+ }
+ playErrorSound()
+ },
+})
+
+// Check-in resource
+const checkInResource = createResource({
+ url: "event_manager.api.checkin_ticket",
+ onSuccess: (data: ValidationResult) => {
+ validationResult.value = data
+ showTicketModal.value = false
+ isCheckingIn.value = false
+ },
+ onError: (error: any) => {
+ isCheckingIn.value = false
+ },
+})
+
+export function useTicketValidation(): TicketValidationState {
+ if (ticketValidationState) {
+ return ticketValidationState
+ }
+
+ // Methods
+ const validateTicket = (ticketId: string): void => {
+ isProcessingTicket.value = true
+ validateTicketResource.submit({ ticket_id: ticketId })
+ }
+
+ const checkInTicket = (): void => {
+ if (!validationResult.value?.ticket?.id) return
+
+ isCheckingIn.value = true
+ checkInResource.submit({ ticket_id: validationResult.value.ticket.id })
+ }
+
+ const clearResults = (): void => {
+ validationResult.value = null
+ isProcessingTicket.value = false
+ isCheckingIn.value = false
+ showTicketModal.value = false
+ }
+
+ const closeModal = (): void => {
+ showTicketModal.value = false
+ }
+
+ ticketValidationState = {
+ // State
+ isProcessingTicket,
+ isCheckingIn,
+ validationResult,
+ showTicketModal,
+
+ // Methods
+ validateTicket,
+ checkInTicket,
+ clearResults,
+ closeModal,
+ }
+
+ return ticketValidationState
+}
diff --git a/dashboard/src/data/session.ts b/dashboard/src/data/session.ts
new file mode 100644
index 0000000..e00fcf6
--- /dev/null
+++ b/dashboard/src/data/session.ts
@@ -0,0 +1,46 @@
+import { clearBookingCache } from "@/utils"
+import { createResource } from "frappe-ui"
+import { computed, reactive } from "vue"
+import { userResource } from "./user"
+
+interface LoginParams {
+ email: string
+ password: string
+}
+
+export function sessionUser() {
+ const cookies = new URLSearchParams(document.cookie.split("; ").join("&"))
+ let _sessionUser = cookies.get("user_id")
+ if (_sessionUser === "Guest") {
+ _sessionUser = null
+ }
+ return _sessionUser
+}
+
+export const session = reactive({
+ login: createResource({
+ url: "login",
+ makeParams({ email, password }: LoginParams) {
+ return {
+ usr: email,
+ pwd: password,
+ }
+ },
+ onSuccess() {
+ userResource.reload()
+ session.user = sessionUser()
+ session.login.reset()
+ },
+ }),
+ logout: createResource({
+ url: "logout",
+ onSuccess() {
+ userResource.reset()
+ session.user = sessionUser()
+ clearBookingCache()
+ window.location.reload()
+ },
+ }),
+ user: sessionUser(),
+ isLoggedIn: computed((): boolean => !!session.user),
+})
diff --git a/dashboard/src/data/user.ts b/dashboard/src/data/user.ts
new file mode 100644
index 0000000..3344faf
--- /dev/null
+++ b/dashboard/src/data/user.ts
@@ -0,0 +1,6 @@
+import { createResource } from "frappe-ui"
+
+export const userResource = createResource({
+ url: "event_manager.api.get_user_info",
+ cache: "User",
+})
diff --git a/dashboard/src/global.d.ts b/dashboard/src/global.d.ts
new file mode 100644
index 0000000..fe1757c
--- /dev/null
+++ b/dashboard/src/global.d.ts
@@ -0,0 +1,28 @@
+export {}
+
+declare global {
+ interface Window {
+ timezone?: {
+ system?: string
+ user?: string
+ }
+ site_name?: string
+ }
+
+ function __(str: string, values?: any[]): string
+
+ declare module "*.wav" {
+ const value: string
+ export default value
+ }
+
+ declare module "*.mp3" {
+ const value: string
+ export default value
+ }
+
+ declare module "*.svg" {
+ const value: string
+ export default value
+ }
+}
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
new file mode 100644
index 0000000..a32f4d9
--- /dev/null
+++ b/dashboard/src/index.css
@@ -0,0 +1,7 @@
+@import "./assets/Inter/inter.css";
+@import "frappe-ui/style.css";
+
+/* Fix tabs spacing */
+[role="tablist"] {
+ gap: 1rem;
+}
diff --git a/dashboard/src/layouts/Layout.vue b/dashboard/src/layouts/Layout.vue
new file mode 100644
index 0000000..25d0526
--- /dev/null
+++ b/dashboard/src/layouts/Layout.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts
new file mode 100644
index 0000000..f5b24ac
--- /dev/null
+++ b/dashboard/src/main.ts
@@ -0,0 +1,53 @@
+import { createApp } from "vue"
+
+import App from "./App.vue"
+import router from "./router"
+import { initSocket } from "./socket"
+
+import translationPlugin from "./translation"
+
+import {
+ Alert,
+ Badge,
+ Button,
+ Dialog,
+ ErrorMessage,
+ FormControl,
+ Input,
+ TextInput,
+ frappeRequest,
+ pageMetaPlugin,
+ resourcesPlugin,
+ setConfig,
+} from "frappe-ui"
+
+import "./index.css"
+
+const globalComponents = {
+ Button,
+ TextInput,
+ Input,
+ FormControl,
+ ErrorMessage,
+ Dialog,
+ Alert,
+ Badge,
+}
+
+const app = createApp(App)
+
+setConfig("resourceFetcher", frappeRequest)
+
+app.use(router)
+app.use(translationPlugin)
+app.use(resourcesPlugin)
+app.use(pageMetaPlugin)
+
+const socket = initSocket()
+app.config.globalProperties.$socket = socket
+
+for (const [key, component] of Object.entries(globalComponents)) {
+ app.component(key, component)
+}
+
+app.mount("#app")
diff --git a/dashboard/src/pages/Account.vue b/dashboard/src/pages/Account.vue
new file mode 100644
index 0000000..aa68138
--- /dev/null
+++ b/dashboard/src/pages/Account.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue
new file mode 100644
index 0000000..a5896a2
--- /dev/null
+++ b/dashboard/src/pages/BookTickets.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+ {{ __("Event Not Found") }}
+
+
+ {{
+ __(
+ "The event you are looking for does not exist or may have been removed."
+ )
+ }}
+
+
{{
+ __("Go to Home")
+ }}
+
+
+
+
+
+
+ {{ __("Registrations Closed") }}
+
+
+ {{ __("Registrations for this event are closed.") }}
+
+
{{
+ __("Browse Other Events")
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/BookingDetails.vue b/dashboard/src/pages/BookingDetails.vue
new file mode 100644
index 0000000..9628cab
--- /dev/null
+++ b/dashboard/src/pages/BookingDetails.vue
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Payment Confirmation Pending") }}
+
+
+ {{
+ __(
+ "Your booking is confirmed subject to verifying the offline payment details. You will be notified once payment is verified."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Booking Rejected") }}
+
+
+ {{
+ __(
+ "Your booking has been rejected. Please contact the event organizer for more information."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/BookingsList.vue b/dashboard/src/pages/BookingsList.vue
new file mode 100644
index 0000000..6483693
--- /dev/null
+++ b/dashboard/src/pages/BookingsList.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+ {{ item }}
+
+ {{ item }}
+
+
+
+
+
+
diff --git a/dashboard/src/pages/CheckInScanner.vue b/dashboard/src/pages/CheckInScanner.vue
new file mode 100644
index 0000000..adb589f
--- /dev/null
+++ b/dashboard/src/pages/CheckInScanner.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+ {{ __("Event Check-in Scanner") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Access Denied") }}
+
+
+ {{
+ __(
+ "You don't have the required permissions to access the ticket scanner."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Last Scan Result") }}
+
+
+
+ {{ validationResult.message }}
+
+
+ {{ __("Ticket ID") }}: {{ validationResult.ticket.id }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/CustomFormPage.vue b/dashboard/src/pages/CustomFormPage.vue
new file mode 100644
index 0000000..c3b4c08
--- /dev/null
+++ b/dashboard/src/pages/CustomFormPage.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/dashboard/src/pages/EventProposalForm.vue b/dashboard/src/pages/EventProposalForm.vue
new file mode 100644
index 0000000..3c3f443
--- /dev/null
+++ b/dashboard/src/pages/EventProposalForm.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
+
+
+
+
+
+ {{ form_data.success_title }}
+
+
+
+ {{ __("Your proposal has been received.") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Not Found") }}
+
+
+ {{ load_error }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/ProposalDetails.vue b/dashboard/src/pages/ProposalDetails.vue
new file mode 100644
index 0000000..6f86654
--- /dev/null
+++ b/dashboard/src/pages/ProposalDetails.vue
@@ -0,0 +1,327 @@
+
+
+
+
+
+ {{ isEditingEventTalk ? __("Edit Talk") : __("Edit Proposal") }}
+
+
+
+
+
+
+
+
+
+ {{ proposal.doc.title }}
+ (#{{ proposalId }})
+
+
+
+
+
+
+
+
{{ __("Proposal Accepted") }}
+
+ {{
+ __(
+ "Congratulations! Your talk proposal has been accepted for the event."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
{{ __("Proposal Shortlisted") }}
+
+ {{
+ __(
+ "Your proposal has been shortlisted and is under final consideration."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
{{ __("Review Pending") }}
+
+ {{
+ __(
+ "Your proposal has been submitted and is under review. You can still edit it while it's pending."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
{{ __("Proposal Not Selected") }}
+
+ {{
+ __(
+ "Unfortunately, your proposal was not selected for this event. Thank you for your submission."
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
{{
+ __("Title")
+ }}
+
{{ proposal.doc.title }}
+
+
+
{{
+ __("Event")
+ }}
+
{{ eventTitle }}
+
+
+ {{
+ __("Status")
+ }}
+
+ {{ proposal.doc.status }}
+
+
+
+
{{
+ __("Submitted On")
+ }}
+
{{ formatDate(proposal.doc.creation) }}
+
+
+
{{
+ __("Phone")
+ }}
+
{{ proposal.doc.phone }}
+
+
+
+
+
+
+
{{ __("Speakers") }}
+
+
{{ __("No speakers added") }}
+
+
+
+
+
{{ __("Description") }}
+
+
{{ __("No description provided") }}
+
+
+
+
+
+
{{ __("Error loading proposal details") }}
+
{{ proposal.get.error }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/ProposalsList.vue b/dashboard/src/pages/ProposalsList.vue
new file mode 100644
index 0000000..9b397c0
--- /dev/null
+++ b/dashboard/src/pages/ProposalsList.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+ {{ item }}
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+ {{ __("No proposals yet") }}
+
+
+ {{ __("Your talk proposals will appear here") }}
+
+
+
+
+
+
diff --git a/dashboard/src/pages/RegisterInterest.vue b/dashboard/src/pages/RegisterInterest.vue
new file mode 100644
index 0000000..d5d082d
--- /dev/null
+++ b/dashboard/src/pages/RegisterInterest.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+ {{ __("Thank you for your interest!") }}
+
+
+ {{ __("We have registered your interest and will be in touch soon.") }}
+
+
+
+
+
+
+ {{ campaign.title }}
+
+
+
+
+
+ {{ __("Register") }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+ {{ __("Campaign Not Found") }}
+
+
+ {{ error }}
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/SponsorshipDetails.vue b/dashboard/src/pages/SponsorshipDetails.vue
new file mode 100644
index 0000000..372ed87
--- /dev/null
+++ b/dashboard/src/pages/SponsorshipDetails.vue
@@ -0,0 +1,549 @@
+
+
+
+
+
+
+ Withdraw Inquiry
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Payment Successful! 🎉
+
+ Your sponsorship payment has been confirmed. You'll receive
+ confirmation details via email.
+
+
+
+
+
+
+
+ Sponsorship Inquiry Details
+ (#{{ enquiryId }})
+
+
+
+
+
+
+
Sponsorship Confirmed
+
+
+
+
Sponsor Name
+
{{ sponsorDetails.company_name }}
+
+
+
Confirmed On
+
{{ formatDate(sponsorDetails.creation) }}
+
+
+
Sponsorship Tier
+
{{ sponsorDetails.tier_title }}
+
+
+
+
+
+
+
+
+
+
Inquiry Withdrawn
+
+ This sponsorship inquiry has been withdrawn and is no longer active.
+
+
+
+
+
+
+
+
+
+
+
Awaiting Approval
+
+ Your sponsorship inquiry has been submitted and is pending approval from
+ the event management team. You'll be notified once it's approved and ready
+ for payment.
+
+
+
+
+
+
+
+
+
+
+
+
Payment Pending
+
+ Your sponsorship inquiry has been approved! Complete your payment to
+ confirm your sponsorship.
+
+
+
+
+ Pay Now
+
+
+
+
+
+
+
+
Company Information
+
+
+
Company Name
+
+ {{ enquiryDetails.data.enquiry.company_name }}
+
+
+
+
Company Logo
+
updateLogo(file.file_url)"
+ :validateFile="validateIsImageFile"
+ :uploadArgs="logoUploadArgs"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentLogo ? "Change Logo" : "Upload Logo" }}
+
+
+
+
+
+ Uploading... {{ progress }}%
+
+
+
+
+
+ Updating logo...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sponsorship Details
+
+
+
Event
+
+ {{
+ enquiryDetails.data.event_details.title ||
+ enquiryDetails.data.enquiry.event
+ }}
+
+
+
+
Sponsorship Tier
+
{{ enquiryDetails.data.enquiry.tier_title }}
+
+
+ Status
+
+ {{ enquiryDetails.data.enquiry.status }}
+
+
+
+
Submitted On
+
+ {{ formatDate(enquiryDetails.data.enquiry.creation) }}
+
+
+
+
+
+
+
+
Event Information
+
+
+
Event Date
+
+ {{ formatDate(enquiryDetails.data.event_details.start_date) }}
+
+
+
+
Venue
+
+ {{ enquiryDetails.data.event_details.venue }}
+
+
+
+
+
Event Description
+
+ {{ enquiryDetails.data.event_details.short_description }}
+
+
+
+
+
+
+
+
+
Error loading sponsorship details
+
{{ enquiryDetails.error }}
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/SponsorshipsList.vue b/dashboard/src/pages/SponsorshipsList.vue
new file mode 100644
index 0000000..f6c2567
--- /dev/null
+++ b/dashboard/src/pages/SponsorshipsList.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+ {{ item }}
+
+
+ {{ row.has_sponsor ? __("Sponsored") : __("Inquiry Only") }}
+
+ {{ item }}
+
+
+
+
+
+
+
+
+
+ {{ __("No sponsorship inquiries yet") }}
+
+
+ {{ __("Your sponsorship inquiries will appear here") }}
+
+
+
+
+
+
diff --git a/dashboard/src/pages/TicketDetails.vue b/dashboard/src/pages/TicketDetails.vue
new file mode 100644
index 0000000..d79ff05
--- /dev/null
+++ b/dashboard/src/pages/TicketDetails.vue
@@ -0,0 +1,500 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __("Ticket Details") }}
+ (#{{ ticketId }})
+
+
+
+
+
+
+
+
+
+ {{ __("Download") }}
+
+
+
+
+
+
+ {{ __("Transfer") }}
+
+
+
+
+
+
+
+
+
+ {{
+ __("Add-on preference changes are no longer available")
+ }}
+ - {{ __("The change window has closed as the event is approaching.") }}
+
+
+
+
+
+
+
+
+
+ {{ __("Ticket Information") }}
+
+
+
+
+
{{
+ __("Attendee Name")
+ }}
+
{{ ticketDetails.data.doc.attendee_name }}
+
+
+
+
{{
+ __("Attendee Email")
+ }}
+
{{ ticketDetails.data.doc.attendee_email }}
+
+
+
+
{{
+ __("Event")
+ }}
+
{{ ticketDetails.data.doc.event_title }}
+
+
+
+
{{
+ __("Ticket Type")
+ }}
+
+ {{ ticketDetails.data.doc.ticket_type_title }}
+
+
+
+
+ {{
+ __("Status")
+ }}
+
+ {{ ticketDetails.data.doc.ticket_status }}
+
+
+
+
+
+
+
+
{{ __("QR Code") }}
+
+
+
+
+
+
+
+
+
{{ __("Add-ons") }}
+
+
+
+
+ {{ __("Edit") }}
+
+
+
+
+
+
+
+ {{ addon.title || addon.name }}
+
+
+ {{ addon.value }}
+
+
+
+
+
+
+
+
+
+ {{ __("Event Information") }}
+
+
+
+
+
{{
+ __("Start Date")
+ }}
+
+ {{ ticketDetails.data.doc.formatted_start_date }}
+
+
+
+
+
{{
+ __("End Date")
+ }}
+
+ {{ ticketDetails.data.doc.formatted_end_date }}
+
+
+
+
+
{{
+ __("Venue")
+ }}
+
{{ ticketDetails.data.doc.venue }}
+
+
+
+
{{
+ __("Description")
+ }}
+
{{ ticketDetails.data.doc.description }}
+
+
+
+
+
+
+
+ {{ __("Webinar Access") }}
+
+
+
+
+
+
+
+
+ {{ __("Booking Information") }}
+
+
+
+
+ {{
+ __("Booking ID")
+ }}
+
+ #{{ ticketDetails.data.doc.booking }}
+
+
+
+
+ {{
+ __("Booking Status")
+ }}
+
+ {{ ticketDetails.data.doc.booking_status }}
+
+
+
+
+
{{
+ __("Total Amount")
+ }}
+
+ {{ ticketDetails.data.doc.formatted_amount }}
+
+
+
+
+
{{
+ __("Booked On")
+ }}
+
+ {{ ticketDetails.data.doc.formatted_creation }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/pages/TicketsList.vue b/dashboard/src/pages/TicketsList.vue
new file mode 100644
index 0000000..81b4633
--- /dev/null
+++ b/dashboard/src/pages/TicketsList.vue
@@ -0,0 +1,82 @@
+
+
+
+
{{ __("Loading tickets...") }}
+
+
+
+
+ {{ __("Error loading tickets") }}: {{ tickets.error.message }}
+
+
+
+
+
+ {{ item }}
+
+
+
+
+
+
diff --git a/dashboard/src/router.ts b/dashboard/src/router.ts
new file mode 100644
index 0000000..a782dfc
--- /dev/null
+++ b/dashboard/src/router.ts
@@ -0,0 +1,132 @@
+import { userResource } from "@/data/user"
+import { type RouteRecordRaw, createRouter, createWebHistory } from "vue-router"
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: "/",
+ name: "dashboard",
+ redirect: { name: "bookings-tab" },
+ },
+ {
+ path: "/check-in/:eventName?",
+ name: "check-in",
+ props: true,
+ component: () => import("@/pages/CheckInScanner.vue"),
+ },
+ {
+ path: "/book-tickets/:eventRoute",
+ props: true,
+ name: "event-booking",
+ meta: { isPublic: true },
+ component: () => import("@/pages/BookTickets.vue"),
+ },
+ {
+ path: "/event-proposal",
+ name: "event-proposal",
+ meta: { isPublic: true },
+ component: () => import("@/pages/EventProposalForm.vue"),
+ },
+ {
+ path: "/events/:eventRoute/forms/:formRoute",
+ props: true,
+ name: "custom-form",
+ meta: { isPublic: true },
+ component: () => import("@/pages/CustomFormPage.vue"),
+ },
+ {
+ path: "/register-interest/:campaign",
+ props: true,
+ name: "register-interest",
+ component: () => import("@/pages/RegisterInterest.vue"),
+ },
+ {
+ path: "/bookings",
+ name: "bookings-tab",
+ redirect: "/account/bookings",
+ },
+ {
+ path: "/bookings/:bookingId",
+ redirect: (to) => ({
+ name: "booking-details",
+ params: { bookingId: to.params.bookingId },
+ }),
+ },
+ {
+ path: "/tickets",
+ redirect: "/account/tickets",
+ },
+ {
+ path: "/tickets/:ticketId",
+ redirect: (to) => ({
+ name: "ticket-details",
+ params: { ticketId: to.params.ticketId },
+ }),
+ },
+ {
+ path: "/account",
+ component: () => import("@/pages/Account.vue"),
+ redirect: { name: "bookings-list" },
+ children: [
+ {
+ path: "bookings",
+ name: "bookings-list",
+ component: () => import("@/pages/BookingsList.vue"),
+ },
+ {
+ path: "bookings/:bookingId",
+ props: true,
+ name: "booking-details",
+ component: () => import("@/pages/BookingDetails.vue"),
+ },
+ {
+ path: "tickets",
+ name: "tickets-list",
+ component: () => import("@/pages/TicketsList.vue"),
+ },
+ {
+ path: "tickets/:ticketId",
+ props: true,
+ name: "ticket-details",
+ component: () => import("@/pages/TicketDetails.vue"),
+ },
+ {
+ path: "proposals",
+ name: "proposals-list",
+ component: () => import("@/pages/ProposalsList.vue"),
+ },
+ {
+ path: "proposals/:proposalId",
+ props: true,
+ name: "proposal-details",
+ component: () => import("@/pages/ProposalDetails.vue"),
+ },
+ {
+ path: "sponsorships",
+ name: "sponsorships-list",
+ component: () => import("@/pages/SponsorshipsList.vue"),
+ },
+ {
+ path: "sponsorships/:enquiryId",
+ props: true,
+ name: "sponsorship-details",
+ component: () => import("@/pages/SponsorshipDetails.vue"),
+ },
+ ],
+ },
+]
+
+const router = createRouter({
+ history: createWebHistory("/dashboard"),
+ routes,
+})
+
+router.beforeEach(async (to, from, next) => {
+ try {
+ await userResource.fetch()
+ } catch {
+ // user is not logged in — Layout will show LoginRequired for protected routes
+ }
+ next()
+})
+
+export default router
diff --git a/dashboard/src/socket.ts b/dashboard/src/socket.ts
new file mode 100644
index 0000000..d5084fa
--- /dev/null
+++ b/dashboard/src/socket.ts
@@ -0,0 +1,22 @@
+import { type Socket, io } from "socket.io-client"
+// @ts-ignore
+import { socketio_port } from "../../../../sites/common_site_config.json"
+
+let socket: Socket | null = null
+export function initSocket(): Socket {
+ const host = window.location.hostname
+ const siteName = window.site_name
+ const port = window.location.port ? `:${socketio_port}` : ""
+ const protocol = port ? "http" : "https"
+ const url = `${protocol}://${host}${port}/${siteName}`
+
+ socket = io(url, {
+ withCredentials: true,
+ reconnectionAttempts: 5,
+ })
+ return socket
+}
+
+export function useSocket(): Socket | null {
+ return socket
+}
diff --git a/dashboard/src/translation.js b/dashboard/src/translation.js
new file mode 100644
index 0000000..9b5b7b5
--- /dev/null
+++ b/dashboard/src/translation.js
@@ -0,0 +1,46 @@
+import { createResource } from "frappe-ui";
+
+export default function translationPlugin(app) {
+ app.config.globalProperties.__ = translate;
+ window.__ = translate;
+ if (!window.translatedMessages) fetchTranslations();
+}
+
+function format(message, replace) {
+ return message.replace(/{(\d+)}/g, (match, number) =>
+ typeof replace[number] != "undefined" ? replace[number] : match
+ );
+}
+
+function translate(message, replace, context = null) {
+ const translatedMessages = window.translatedMessages || {};
+ let translatedMessage = "";
+
+ if (context) {
+ const key = `${message}:${context}`;
+ if (translatedMessages[key]) {
+ translatedMessage = translatedMessages[key];
+ }
+ }
+
+ if (!translatedMessage) {
+ translatedMessage = translatedMessages[message] || message;
+ }
+
+ const hasPlaceholders = /{\d+}/.test(message);
+ if (!hasPlaceholders) {
+ return translatedMessage;
+ }
+
+ return format(translatedMessage, replace);
+}
+
+function fetchTranslations(lang) {
+ createResource({
+ url: "event_manager.api.get_translations",
+ auto: true,
+ transform: (data) => {
+ window.translatedMessages = data;
+ },
+ });
+}
diff --git a/dashboard/src/types/Buzz/BuzzCustomField.ts b/dashboard/src/types/Buzz/BuzzCustomField.ts
new file mode 100644
index 0000000..adfeebe
--- /dev/null
+++ b/dashboard/src/types/Buzz/BuzzCustomField.ts
@@ -0,0 +1,42 @@
+export interface BuzzCustomField {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Enabled? : Check */
+ enabled?: 0 | 1
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Label : Data */
+ label: string
+ /** Name : Data */
+ fieldname?: string
+ /** Mandatory? : Check */
+ mandatory?: 0 | 1
+ /** Placeholder : Data */
+ placeholder?: string
+ /** Default Value : Data */
+ default_value?: string
+ /** Applied To : Select */
+ applied_to?: "Booking" | "Ticket" | "Feedback"
+ /** Type : Select */
+ fieldtype:
+ | "Data"
+ | "Phone"
+ | "Email"
+ | "Select"
+ | "Date"
+ | "Number"
+ | "Multi Select"
+ | "Text"
+ /** Options : Small Text */
+ options?: string
+ /** Order : Int */
+ order?: number
+}
diff --git a/dashboard/src/types/Events/AdditionalEventPage.ts b/dashboard/src/types/Events/AdditionalEventPage.ts
new file mode 100644
index 0000000..bd661f6
--- /dev/null
+++ b/dashboard/src/types/Events/AdditionalEventPage.ts
@@ -0,0 +1,22 @@
+export interface AdditionalEventPage {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Route : Data */
+ route?: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Is Published? : Check */
+ is_published?: 0 | 1
+ /** Content : Text Editor */
+ content: string
+}
diff --git a/dashboard/src/types/Events/BuzzEvent.ts b/dashboard/src/types/Events/BuzzEvent.ts
new file mode 100644
index 0000000..8cd758d
--- /dev/null
+++ b/dashboard/src/types/Events/BuzzEvent.ts
@@ -0,0 +1,91 @@
+import type { SponsorshipDeckItem } from "../Proposals/SponsorshipDeckItem"
+import type { EventFeaturedSpeaker } from "./EventFeaturedSpeaker"
+import type { EventPaymentGateway } from "./EventPaymentGateway"
+import type { ScheduleItem } from "./ScheduleItem"
+
+export interface BuzzEvent {
+ name: number
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Category : Link - Event Category */
+ category: string
+ /** Free Webinar? : Check */
+ free_webinar?: 0 | 1
+ /** Medium : Select */
+ medium?: "In Person" | "Online"
+ /** Banner Image : Attach Image */
+ banner_image?: string
+ /** Host : Link - Event Host */
+ host: string
+ /** Venue : Link - Event Venue */
+ venue?: string
+ /** Start Date : Date */
+ start_date: string
+ /** Start Time : Time */
+ start_time?: string
+ /** Time Zone : Autocomplete */
+ time_zone?: any
+ /** End Date : Date */
+ end_date?: string
+ /** End Time : Time */
+ end_time?: string
+ /** Short Description : Small Text */
+ short_description?: string
+ /** About : Text Editor - Description of the event */
+ about?: string
+ /** Schedule : Table - Schedule Item */
+ schedule?: ScheduleItem[]
+ /** Is Published? : Check */
+ is_published?: 0 | 1
+ /** Route : Data - Used by Frappe Builder */
+ route?: string
+ /** Default Ticket Type : Link - Event Ticket Type - Will be selected by default in the booking form */
+ default_ticket_type?: string
+ /** External Registration Page? : Check */
+ external_registration_page?: 0 | 1
+ /** Registration URL : Data */
+ registration_url?: string
+ /** Meta Image : Attach Image */
+ meta_image?: string
+ /** Card Image : Attach Image */
+ card_image?: string
+ /** Featured Speakers : Table - Event Featured Speaker */
+ featured_speakers?: EventFeaturedSpeaker[]
+ /** Payment Gateways : Table - Event Payment Gateway */
+ payment_gateways?: EventPaymentGateway[]
+ /** Apply Tax on Bookings? : Check */
+ apply_tax?: 0 | 1
+ /** Tax Label : Data - Label displayed to customers (e.g., GST, VAT, Sales Tax) */
+ tax_label?: string
+ /** Tax Percentage : Percent - Tax rate to apply on bookings */
+ tax_percentage?: number
+ /** Auto Send Pitch Deck? : Check */
+ auto_send_pitch_deck?: 0 | 1
+ /** Email Template : Link - Email Template - Default template will be used if not set */
+ sponsor_deck_email_template?: string
+ /** Reply To : Data */
+ sponsor_deck_reply_to?: string
+ /** CC : Small Text */
+ sponsor_deck_cc?: string
+ /** Attachments : Table - Sponsorship Deck Item */
+ sponsor_deck_attachments?: SponsorshipDeckItem[]
+ /** Ticket Email Template : Link - Email Template */
+ ticket_email_template?: string
+ /** Feedback Email Template : Link - Email Template - Template uses: {{ feedback_link }}, {{ attendee_name }}, and {{ event_title }}. */
+ feedback_email_template?: string
+ /** Ticket Print Format : Link - Print Format */
+ ticket_print_format?: string
+ /** Allow Editing Talks After Acceptance : Check - When enabled, speakers can edit their talk title and description after acceptance */
+ allow_editing_talks_after_acceptance?: 0 | 1
+ /** Proposal : Link - Event Proposal */
+ proposal?: string
+}
diff --git a/dashboard/src/types/Events/BuzzSettings.ts b/dashboard/src/types/Events/BuzzSettings.ts
new file mode 100644
index 0000000..2e1c7d3
--- /dev/null
+++ b/dashboard/src/types/Events/BuzzSettings.ts
@@ -0,0 +1,34 @@
+export interface BuzzSettings {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Support Email : Data - Will be linked in emails, etc. */
+ support_email?: string
+ /** Allow Transfer Ticket Before Event Start (Days) : Int */
+ allow_transfer_ticket_before_event_start_days?: number
+ /** Allow Add Ons Change Before Event Start (Days) : Int */
+ allow_add_ons_change_before_event_start_days?: number
+ /** Allow Ticket Cancellation Request Before Event Start (Days) : Int */
+ allow_ticket_cancellation_request_before_event_start_days?: number
+ /** Default Ticket Email Template : Link - Email Template - Default template for ticket confirmation emails. Can be overridden per event. */
+ default_ticket_email_template?: string
+ /** Default Feedback Email Template : Link - Email Template - Default template for feedback emails. Can be overridden per event.
+
+Template uses: {{ feedback_link }}, {{ attendee_name }}, and {{ event_title }}. */
+ default_feedback_email_template?: string
+ /** Auto Send Pitch Deck : Check */
+ auto_send_pitch_deck?: 0 | 1
+ /** Default Email Template : Link - Email Template - Default template for sponsorship pitch deck emails. Can be overridden per event. */
+ default_sponsor_deck_email_template?: string
+ /** Default Reply To : Data */
+ default_sponsor_deck_reply_to: string
+ /** Default CC : Small Text */
+ default_sponsor_deck_cc?: string
+}
diff --git a/dashboard/src/types/Events/EventCategory.ts b/dashboard/src/types/Events/EventCategory.ts
new file mode 100644
index 0000000..50fe154
--- /dev/null
+++ b/dashboard/src/types/Events/EventCategory.ts
@@ -0,0 +1,22 @@
+export interface EventCategory {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Enabled : Check */
+ enabled?: 0 | 1
+ /** Slug : Data */
+ slug?: string
+ /** Description : Small Text */
+ description?: string
+ /** Banner Image : Attach Image */
+ banner_image?: string
+ /** Icon SVG : Code */
+ icon_svg?: string
+}
diff --git a/dashboard/src/types/Events/EventCheckIn.ts b/dashboard/src/types/Events/EventCheckIn.ts
new file mode 100644
index 0000000..1846d4f
--- /dev/null
+++ b/dashboard/src/types/Events/EventCheckIn.ts
@@ -0,0 +1,20 @@
+export interface EventCheckIn {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Date : Date */
+ date?: string
+ /** Ticket : Link - Event Ticket */
+ ticket: string
+ /** Amended From : Link - Event Check In */
+ amended_from?: string
+}
diff --git a/dashboard/src/types/Events/EventFeaturedSpeaker.ts b/dashboard/src/types/Events/EventFeaturedSpeaker.ts
new file mode 100644
index 0000000..fb40c36
--- /dev/null
+++ b/dashboard/src/types/Events/EventFeaturedSpeaker.ts
@@ -0,0 +1,14 @@
+export interface EventFeaturedSpeaker {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Speaker : Link - Speaker Profile */
+ speaker: string
+}
diff --git a/dashboard/src/types/Events/EventFeedback.ts b/dashboard/src/types/Events/EventFeedback.ts
new file mode 100644
index 0000000..83631d4
--- /dev/null
+++ b/dashboard/src/types/Events/EventFeedback.ts
@@ -0,0 +1,24 @@
+import type { AdditionalField } from "../Ticketing/AdditionalField"
+
+export interface EventFeedback {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Comment : Small Text */
+ comment?: string
+ /** Ticket : Link - Event Ticket */
+ ticket: string
+ /** Rating : Rating */
+ rating: any
+ /** Additional Fields : Table - Additional Field */
+ additional_fields?: AdditionalField[]
+}
diff --git a/dashboard/src/types/Events/EventHost.ts b/dashboard/src/types/Events/EventHost.ts
new file mode 100644
index 0000000..992fcfc
--- /dev/null
+++ b/dashboard/src/types/Events/EventHost.ts
@@ -0,0 +1,26 @@
+import type { SocialMediaLink } from "./SocialMediaLink"
+
+export interface EventHost {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Logo : Attach Image */
+ logo?: string
+ /** Country : Link - Country */
+ country?: string
+ /** Social Media Links : Table - Social Media Link */
+ social_media_links?: SocialMediaLink[]
+ /** By Line : Data */
+ by_line?: string
+ /** Address : Small Text */
+ address?: string
+ /** About : Text Editor */
+ about?: string
+}
diff --git a/dashboard/src/types/Events/EventPaymentGateway.ts b/dashboard/src/types/Events/EventPaymentGateway.ts
new file mode 100644
index 0000000..5c82730
--- /dev/null
+++ b/dashboard/src/types/Events/EventPaymentGateway.ts
@@ -0,0 +1,14 @@
+export interface EventPaymentGateway {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Payment Gateway : Link - Payment Gateway */
+ payment_gateway: string
+}
diff --git a/dashboard/src/types/Events/EventSponsor.ts b/dashboard/src/types/Events/EventSponsor.ts
new file mode 100644
index 0000000..79c0795
--- /dev/null
+++ b/dashboard/src/types/Events/EventSponsor.ts
@@ -0,0 +1,26 @@
+export interface EventSponsor {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Company Name : Data */
+ company_name: string
+ /** Company Logo : Attach Image */
+ company_logo: string
+ /** Website : Data */
+ website?: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Tier : Link - Sponsorship Tier */
+ tier: string
+ /** Country : Link - Country */
+ country?: string
+ /** Enquiry : Link - Sponsorship Enquiry */
+ enquiry?: string
+}
diff --git a/dashboard/src/types/Events/EventTalk.ts b/dashboard/src/types/Events/EventTalk.ts
new file mode 100644
index 0000000..580c69b
--- /dev/null
+++ b/dashboard/src/types/Events/EventTalk.ts
@@ -0,0 +1,26 @@
+import type { TalkSpeaker } from "./TalkSpeaker"
+
+export interface EventTalk {
+ name: number
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Submitted By : Link - User */
+ submitted_by: string
+ /** Proposal : Link - Talk Proposal */
+ proposal?: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Speakers : Table - Talk Speaker */
+ speakers?: TalkSpeaker[]
+ /** Description : Text Editor */
+ description?: string
+}
diff --git a/dashboard/src/types/Events/EventTrack.ts b/dashboard/src/types/Events/EventTrack.ts
new file mode 100644
index 0000000..af54c49
--- /dev/null
+++ b/dashboard/src/types/Events/EventTrack.ts
@@ -0,0 +1,14 @@
+export interface EventTrack {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+}
diff --git a/dashboard/src/types/Events/EventUserPreferences.ts b/dashboard/src/types/Events/EventUserPreferences.ts
new file mode 100644
index 0000000..62c6077
--- /dev/null
+++ b/dashboard/src/types/Events/EventUserPreferences.ts
@@ -0,0 +1,14 @@
+export interface EventUserPreferences {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** User : Link - User */
+ user: string
+}
diff --git a/dashboard/src/types/Events/EventVenue.ts b/dashboard/src/types/Events/EventVenue.ts
new file mode 100644
index 0000000..22b5a57
--- /dev/null
+++ b/dashboard/src/types/Events/EventVenue.ts
@@ -0,0 +1,24 @@
+export interface EventVenue {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Address : Small Text */
+ address: string
+ /** Type : Select */
+ type?: "Embed Google Maps" | "Open Street Map"
+ /** Google Maps Embed Code : Code - Open a place on Google maps then click on share */
+ google_maps_embed_code?: string
+ /** Longitude : Float */
+ longitude?: number
+ /** Latitude : Float */
+ latitude?: number
+ /** Location : Geolocation */
+ location?: any
+}
diff --git a/dashboard/src/types/Events/ScheduleItem.ts b/dashboard/src/types/Events/ScheduleItem.ts
new file mode 100644
index 0000000..dc5cb6b
--- /dev/null
+++ b/dashboard/src/types/Events/ScheduleItem.ts
@@ -0,0 +1,26 @@
+export interface ScheduleItem {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Date : Date */
+ date: string
+ /** Track : Link - Event Track */
+ track: string
+ /** Type : Select */
+ type: "Talk" | "Break"
+ /** Description : Data - Tea Break, Lunch, etc. */
+ description?: string
+ /** Talk : Link - Event Talk */
+ talk?: string
+ /** Start Time : Time */
+ start_time: string
+ /** End Time : Time */
+ end_time: string
+}
diff --git a/dashboard/src/types/Events/SocialMediaLink.ts b/dashboard/src/types/Events/SocialMediaLink.ts
new file mode 100644
index 0000000..8c0460e
--- /dev/null
+++ b/dashboard/src/types/Events/SocialMediaLink.ts
@@ -0,0 +1,14 @@
+export interface SocialMediaLink {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** URL : Data */
+ url: string
+}
diff --git a/dashboard/src/types/Events/SpeakerProfile.ts b/dashboard/src/types/Events/SpeakerProfile.ts
new file mode 100644
index 0000000..02fc198
--- /dev/null
+++ b/dashboard/src/types/Events/SpeakerProfile.ts
@@ -0,0 +1,26 @@
+import type { SocialMediaLink } from "./SocialMediaLink"
+
+export interface SpeakerProfile {
+ name: number
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** User : Link - User */
+ user: string
+ /** Display Name : Data */
+ display_name?: string
+ /** Display Image : Attach Image */
+ display_image?: string
+ /** Designation : Data */
+ designation?: string
+ /** Company : Data */
+ company?: string
+ /** Social Media Links : Table - Social Media Link */
+ social_media_links?: SocialMediaLink[]
+}
diff --git a/dashboard/src/types/Events/SponsorshipTier.ts b/dashboard/src/types/Events/SponsorshipTier.ts
new file mode 100644
index 0000000..afb1691
--- /dev/null
+++ b/dashboard/src/types/Events/SponsorshipTier.ts
@@ -0,0 +1,20 @@
+export interface SponsorshipTier {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Price : Currency */
+ price?: number
+ /** Currency : Link - Currency */
+ currency?: string
+}
diff --git a/dashboard/src/types/Events/TalkSpeaker.ts b/dashboard/src/types/Events/TalkSpeaker.ts
new file mode 100644
index 0000000..5393f11
--- /dev/null
+++ b/dashboard/src/types/Events/TalkSpeaker.ts
@@ -0,0 +1,18 @@
+import type { SocialMediaLink } from "./SocialMediaLink"
+
+export interface TalkSpeaker {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Speaker : Link - Speaker Profile */
+ speaker: string
+ /** Social Media Links : Table - Social Media Link */
+ social_media_links?: SocialMediaLink[]
+}
diff --git a/dashboard/src/types/Events/UTMParameter.ts b/dashboard/src/types/Events/UTMParameter.ts
new file mode 100644
index 0000000..9e1f91f
--- /dev/null
+++ b/dashboard/src/types/Events/UTMParameter.ts
@@ -0,0 +1,16 @@
+export interface UTMParameter {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** UTM Name : Data */
+ utm_name: string
+ /** Value : Small Text */
+ value: string
+}
diff --git a/dashboard/src/types/Frappe/FrappeError.ts b/dashboard/src/types/Frappe/FrappeError.ts
new file mode 100644
index 0000000..9d2acbe
--- /dev/null
+++ b/dashboard/src/types/Frappe/FrappeError.ts
@@ -0,0 +1,7 @@
+export interface FrappeError {
+ message?: string
+ exc_type?: string
+ exc?: string
+ _server_messages?: string
+ status_code?: number
+}
diff --git a/dashboard/src/types/Proposals/EventProposal.ts b/dashboard/src/types/Proposals/EventProposal.ts
new file mode 100644
index 0000000..8a6dbdf
--- /dev/null
+++ b/dashboard/src/types/Proposals/EventProposal.ts
@@ -0,0 +1,46 @@
+export interface EventProposal {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Event Category : Link - Event Category */
+ category: string
+ /** Free Webinar? : Check */
+ free_webinar?: 0 | 1
+ /** Medium : Select */
+ medium: "Online" | "In Person"
+ /** Status : Select */
+ status?: "Received" | "In Review" | "Approved" | "Event Created" | "Rejected"
+ /** Naming Series : Select */
+ naming_series?: "EPR-.###"
+ /** Start Date : Date */
+ start_date: string
+ /** Start Time : Time */
+ start_time?: string
+ /** End Date : Date */
+ end_date?: string
+ /** End Time : Time */
+ end_time?: string
+ /** Short Description : Small Text */
+ short_description?: string
+ /** About the event : Text Editor */
+ about: string
+ /** Host : Link - Event Host - Required for creating an event */
+ host?: string
+ /** Host Company : Data */
+ host_company?: string
+ /** Additional Notes : Small Text */
+ additional_notes?: string
+ /** Host Company Logo : Attach Image */
+ host_company_logo?: string
+ /** Amended From : Link - Event Proposal */
+ amended_from?: string
+}
diff --git a/dashboard/src/types/Proposals/ProposalSpeaker.ts b/dashboard/src/types/Proposals/ProposalSpeaker.ts
new file mode 100644
index 0000000..2dc7520
--- /dev/null
+++ b/dashboard/src/types/Proposals/ProposalSpeaker.ts
@@ -0,0 +1,18 @@
+export interface ProposalSpeaker {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** First Name : Data */
+ first_name: string
+ /** Last Name : Data */
+ last_name?: string
+ /** Email : Data */
+ email: string
+}
diff --git a/dashboard/src/types/Proposals/SponsorshipDeckItem.ts b/dashboard/src/types/Proposals/SponsorshipDeckItem.ts
new file mode 100644
index 0000000..5b970a5
--- /dev/null
+++ b/dashboard/src/types/Proposals/SponsorshipDeckItem.ts
@@ -0,0 +1,14 @@
+export interface SponsorshipDeckItem {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** File : Attach */
+ file: string
+}
diff --git a/dashboard/src/types/Proposals/SponsorshipEnquiry.ts b/dashboard/src/types/Proposals/SponsorshipEnquiry.ts
new file mode 100644
index 0000000..1a2a74a
--- /dev/null
+++ b/dashboard/src/types/Proposals/SponsorshipEnquiry.ts
@@ -0,0 +1,28 @@
+export interface SponsorshipEnquiry {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Company Name : Data */
+ company_name: string
+ /** Company Logo : Attach Image */
+ company_logo: string
+ /** Website : Data */
+ website?: string
+ /** Status : Select */
+ status?: "Approval Pending" | "Payment Pending" | "Paid" | "Withdrawn"
+ /** Tier : Link - Sponsorship Tier */
+ tier?: string
+ /** Country : Link - Country */
+ country?: string
+ /** Phone : Phone */
+ phone?: string
+}
diff --git a/dashboard/src/types/Proposals/TalkProposal.ts b/dashboard/src/types/Proposals/TalkProposal.ts
new file mode 100644
index 0000000..f5a87de
--- /dev/null
+++ b/dashboard/src/types/Proposals/TalkProposal.ts
@@ -0,0 +1,28 @@
+import type { ProposalSpeaker } from "./ProposalSpeaker"
+
+export interface TalkProposal {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data */
+ title: string
+ /** Submitted By : Link - User */
+ submitted_by?: string
+ /** Status : Select */
+ status?: "Review Pending" | "Shortlisted" | "Accepted" | "Rejected"
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Description : Text Editor */
+ description?: string
+ /** Speakers : Table - Proposal Speaker */
+ speakers?: ProposalSpeaker[]
+ /** Phone : Phone */
+ phone?: string
+}
diff --git a/dashboard/src/types/Ticketing/AdditionalField.ts b/dashboard/src/types/Ticketing/AdditionalField.ts
new file mode 100644
index 0000000..619b8c8
--- /dev/null
+++ b/dashboard/src/types/Ticketing/AdditionalField.ts
@@ -0,0 +1,20 @@
+export interface AdditionalField {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Label : Data */
+ label?: string
+ /** Fieldname : Data */
+ fieldname: string
+ /** Value : Data */
+ value: string
+ /** Fieldtype : Data */
+ fieldtype?: string
+}
diff --git a/dashboard/src/types/Ticketing/AttendeeTicketAdd-on.ts b/dashboard/src/types/Ticketing/AttendeeTicketAdd-on.ts
new file mode 100644
index 0000000..687293f
--- /dev/null
+++ b/dashboard/src/types/Ticketing/AttendeeTicketAdd-on.ts
@@ -0,0 +1,18 @@
+import type { TicketAddOnValue } from "./TicketAddOnValue"
+
+export interface AttendeeTicketAddOn {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Attendee Name : Data */
+ attendee_name?: string
+ /** Add ons : Table - Ticket Add-on Value */
+ add_ons?: TicketAddOnValue[]
+}
diff --git a/dashboard/src/types/Ticketing/BuzzCouponCode.ts b/dashboard/src/types/Ticketing/BuzzCouponCode.ts
new file mode 100644
index 0000000..fa40803
--- /dev/null
+++ b/dashboard/src/types/Ticketing/BuzzCouponCode.ts
@@ -0,0 +1,52 @@
+import type { CouponFreeAddOn } from "./CouponFreeAddOn"
+
+export interface BuzzCouponCode {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Code : Data - Leave empty to auto-generate */
+ code?: string
+ /** Coupon Type : Select */
+ coupon_type: "Free Tickets" | "Discount"
+ /** Applies To : Select */
+ applies_to?: "" | "Event" | "Event Category"
+ /** Is Active : Check */
+ is_active?: 0 | 1
+ /** Event : Link - Pohodex Event Manager Event */
+ event?: string
+ /** Event Category : Link - Event Category */
+ event_category?: string
+ /** Ticket Type : Link - Event Ticket Type */
+ ticket_type?: string
+ /** Number of Free Tickets : Int */
+ number_of_free_tickets?: number
+ /** Discount Type : Select */
+ discount_type?: "Percentage" | "Flat Amount"
+ /** Discount Value : Float */
+ discount_value?: number
+ /** Maximum Discount Amount : Float */
+ maximum_discount_amount?: number
+ /** Minimum Order Value : Float */
+ minimum_order_value?: number
+ /** Valid From : Date - Coupon active from this date (leave empty for immediate) */
+ valid_from?: string
+ /** Valid Till : Date - Coupon expires after this date (leave empty for no expiry) */
+ valid_till?: string
+ /** Max Usage Count : Int - 0 is unlimited */
+ max_usage_count?: number
+ /** Max Usage Per User : Int - 0 = unlimited */
+ max_usage_per_user?: number
+ /** Free Add-ons : Table - Coupon Free Add-on */
+ free_add_ons?: CouponFreeAddOn[]
+ /** Times Used : Int */
+ times_used?: number
+ /** Free Tickets Claimed : Int */
+ free_tickets_claimed?: number
+}
diff --git a/dashboard/src/types/Ticketing/CouponFreeAddOn.ts b/dashboard/src/types/Ticketing/CouponFreeAddOn.ts
new file mode 100644
index 0000000..a0e7791
--- /dev/null
+++ b/dashboard/src/types/Ticketing/CouponFreeAddOn.ts
@@ -0,0 +1,14 @@
+export interface CouponFreeAddOn {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Add-on : Link - Ticket Add-on */
+ add_on: string
+}
diff --git a/dashboard/src/types/Ticketing/EventBooking.ts b/dashboard/src/types/Ticketing/EventBooking.ts
new file mode 100644
index 0000000..2999a9f
--- /dev/null
+++ b/dashboard/src/types/Ticketing/EventBooking.ts
@@ -0,0 +1,46 @@
+import type { UTMParameter } from "../Events/UTMParameter"
+import type { AdditionalField } from "./AdditionalField"
+import type { EventBookingAttendee } from "./EventBookingAttendee"
+
+export interface EventBooking {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** User : Link - User */
+ user: string
+ /** Naming Series : Select */
+ naming_series?: "B.###"
+ /** Attendees : Table - Event Booking Attendee */
+ attendees: EventBookingAttendee[]
+ /** Additional Fields : Table - Additional Field */
+ additional_fields?: AdditionalField[]
+ /** Net Amount : Currency */
+ net_amount?: number
+ /** Tax Percentage : Percent */
+ tax_percentage?: number
+ /** Tax Label : Data */
+ tax_label?: string
+ /** Tax Amount : Currency */
+ tax_amount?: number
+ /** Total Amount : Currency */
+ total_amount?: number
+ /** Currency : Link - Currency */
+ currency: string
+ /** Coupon Code : Link - Pohodex Event Manager Coupon Code */
+ coupon_code?: string
+ /** Discount Amount : Currency */
+ discount_amount?: number
+ /** Amended From : Link - Event Booking */
+ amended_from?: string
+ /** UTM Parameters : Table - UTM Parameter */
+ utm_parameters?: UTMParameter[]
+}
diff --git a/dashboard/src/types/Ticketing/EventBookingAttendee.ts b/dashboard/src/types/Ticketing/EventBookingAttendee.ts
new file mode 100644
index 0000000..7cdfc16
--- /dev/null
+++ b/dashboard/src/types/Ticketing/EventBookingAttendee.ts
@@ -0,0 +1,34 @@
+export interface EventBookingAttendee {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** First Name : Data */
+ first_name: string
+ /** Last Name : Data */
+ last_name?: string
+ /** Full Name : Data */
+ full_name: string
+ /** Ticket Type : Link - Event Ticket Type */
+ ticket_type: string
+ /** Custom Fields : JSON */
+ custom_fields?: any
+ /** Email : Data */
+ email: string
+ /** Amount : Currency */
+ amount?: number
+ /** Currency : Link - Currency */
+ currency: string
+ /** Add Ons : Link - Attendee Ticket Add-on */
+ add_ons?: string
+ /** Number of Add Ons : Int */
+ number_of_add_ons?: number
+ /** Add On Total : Currency */
+ add_on_total?: number
+}
diff --git a/dashboard/src/types/Ticketing/EventPayment.ts b/dashboard/src/types/Ticketing/EventPayment.ts
new file mode 100644
index 0000000..a147532
--- /dev/null
+++ b/dashboard/src/types/Ticketing/EventPayment.ts
@@ -0,0 +1,30 @@
+export interface EventPayment {
+ name: number
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** User : Link - User */
+ user: string
+ /** Reference DocType : Link - DocType */
+ reference_doctype?: string
+ /** Reference Name : Dynamic Link */
+ reference_docname?: string
+ /** Amount : Currency */
+ amount?: number
+ /** Currency : Link - Currency */
+ currency?: string
+ /** Payment Received : Check */
+ payment_received?: 0 | 1
+ /** Payment Gateway : Link - Payment Gateway */
+ payment_gateway?: string
+ /** Payment ID : Data */
+ payment_id?: string
+ /** Order ID : Data */
+ order_id?: string
+}
diff --git a/dashboard/src/types/Ticketing/EventTicket.ts b/dashboard/src/types/Ticketing/EventTicket.ts
new file mode 100644
index 0000000..9157f9e
--- /dev/null
+++ b/dashboard/src/types/Ticketing/EventTicket.ts
@@ -0,0 +1,39 @@
+import type { AdditionalField } from "./AdditionalField"
+import type { TicketAddOnValue } from "./TicketAddOnValue"
+
+export interface EventTicket {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** First Name : Data */
+ first_name: string
+ /** Last Name : Data */
+ last_name?: string
+ /** Attendee Name : Data */
+ attendee_name: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event?: string
+ /** Booking : Link - Event Booking */
+ booking?: string
+ /** Coupon Used : Link - Bulk Ticket Coupon */
+ coupon_used?: string
+ /** Attendee Email : Data */
+ attendee_email: string
+ /** Ticket Type : Link - Event Ticket Type */
+ ticket_type: string
+ /** QR Code : Attach Image */
+ qr_code?: string
+ /** Additional Fields : Table - Additional Field */
+ additional_fields?: AdditionalField[]
+ /** Add Ons : Table - Ticket Add-on Value */
+ add_ons?: TicketAddOnValue[]
+ /** Amended From : Link - Event Ticket */
+ amended_from?: string
+}
diff --git a/dashboard/src/types/Ticketing/EventTicketType.ts b/dashboard/src/types/Ticketing/EventTicketType.ts
new file mode 100644
index 0000000..f45935d
--- /dev/null
+++ b/dashboard/src/types/Ticketing/EventTicketType.ts
@@ -0,0 +1,30 @@
+export interface EventTicketType {
+ name: number
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Title : Data - VIP, Early Bird, etc. */
+ title: string
+ /** Price : Currency */
+ price?: number
+ /** Currency : Link - Currency */
+ currency: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Is Published? : Check */
+ is_published?: 0 | 1
+ /** Auto Unpublish After : Date - For Early Bird, etc. */
+ auto_unpublish_after?: string
+ /** Max Tickets Available : Int - Leave it 0 for no limit */
+ max_tickets_available?: number
+ /** Tickets Sold : Int */
+ tickets_sold?: number
+ /** Remaining Tickets : Int - -1 if no limit defined above */
+ remaining_tickets?: number
+}
diff --git a/dashboard/src/types/Ticketing/TicketAddOn.ts b/dashboard/src/types/Ticketing/TicketAddOn.ts
new file mode 100644
index 0000000..699a750
--- /dev/null
+++ b/dashboard/src/types/Ticketing/TicketAddOn.ts
@@ -0,0 +1,28 @@
+export interface TicketAddOn {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Enabled? : Check */
+ enabled?: 0 | 1
+ /** Title : Data */
+ title: string
+ /** Event : Link - Pohodex Event Manager Event */
+ event: string
+ /** Price : Currency */
+ price?: number
+ /** Description : Small Text */
+ description?: string
+ /** Currency : Link - Currency */
+ currency?: string
+ /** User Selects Option? : Check */
+ user_selects_option?: 0 | 1
+ /** Options : Small Text */
+ options?: string
+}
diff --git a/dashboard/src/types/Ticketing/TicketAddOnValue.ts b/dashboard/src/types/Ticketing/TicketAddOnValue.ts
new file mode 100644
index 0000000..bab365d
--- /dev/null
+++ b/dashboard/src/types/Ticketing/TicketAddOnValue.ts
@@ -0,0 +1,20 @@
+export interface TicketAddOnValue {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Add on : Link - Ticket Add-on */
+ add_on: string
+ /** Value : Autocomplete */
+ value: any
+ /** Price : Currency */
+ price?: number
+ /** Currency : Link - Currency */
+ currency?: string
+}
diff --git a/dashboard/src/types/Ticketing/TicketCancellationItem.ts b/dashboard/src/types/Ticketing/TicketCancellationItem.ts
new file mode 100644
index 0000000..bb09f6a
--- /dev/null
+++ b/dashboard/src/types/Ticketing/TicketCancellationItem.ts
@@ -0,0 +1,14 @@
+export interface TicketCancellationItem {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Ticket : Link - Event Ticket */
+ ticket: string
+}
diff --git a/dashboard/src/types/Ticketing/TicketCancellationRequest.ts b/dashboard/src/types/Ticketing/TicketCancellationRequest.ts
new file mode 100644
index 0000000..5bf2879
--- /dev/null
+++ b/dashboard/src/types/Ticketing/TicketCancellationRequest.ts
@@ -0,0 +1,24 @@
+import type { TicketCancellationItem } from "./TicketCancellationItem"
+
+export interface TicketCancellationRequest {
+ name: string
+ creation: string
+ modified: string
+ owner: string
+ modified_by: string
+ docstatus: 0 | 1 | 2
+ parent?: string
+ parentfield?: string
+ parenttype?: string
+ idx?: number
+ /** Booking : Link - Event Booking */
+ booking: string
+ /** Cancel Full Booking? : Check */
+ cancel_full_booking?: 0 | 1
+ /** Tickets : Table - Ticket Cancellation Item */
+ tickets?: TicketCancellationItem[]
+ /** Status : Select */
+ status?: "In Review" | "Accepted" | "Rejected"
+ /** Amended From : Link - Ticket Cancellation Request */
+ amended_from?: string
+}
diff --git a/dashboard/src/utils/confetti.ts b/dashboard/src/utils/confetti.ts
new file mode 100644
index 0000000..9d6e493
--- /dev/null
+++ b/dashboard/src/utils/confetti.ts
@@ -0,0 +1,79 @@
+import confetti, { type Options as ConfettiOptions } from "canvas-confetti"
+
+/**
+ * Utility function to create a random number within a range
+ * @param {number} min - Minimum value
+ * @param {number} max - Maximum value
+ * @returns {number} Random number between min and max
+ */
+const randomInRange = (min: number, max: number): number =>
+ Math.random() * (max - min) + min
+
+/**
+ * Triggers a celebratory confetti animation
+ * @param {Object} options - Configuration options for the confetti
+ * @param {number} options.particleCount - Base particle count (default: 50)
+ * @param {number} options.startVelocity - Starting velocity of particles (default: 30)
+ * @param {number} options.spread - Spread angle of particles (default: 360)
+ */
+export const triggerCelebrationConfetti = (
+ options: ConfettiOptions = {},
+): void => {
+ const { particleCount = 50, startVelocity = 30, spread = 360 } = options
+ const duration = 3000
+
+ const animationEnd = Date.now() + duration
+
+ const interval = setInterval(() => {
+ const timeLeft = animationEnd - Date.now()
+
+ if (timeLeft <= 0) {
+ clearInterval(interval)
+ return
+ }
+
+ const currentParticleCount = particleCount * (timeLeft / duration)
+
+ // Left side confetti burst
+ confetti({
+ particleCount: currentParticleCount,
+ startVelocity,
+ spread,
+ origin: {
+ x: randomInRange(0.1, 0.3),
+ y: Math.random() - 0.2,
+ },
+ })
+
+ // Right side confetti burst
+ confetti({
+ particleCount: currentParticleCount,
+ startVelocity,
+ spread,
+ origin: {
+ x: randomInRange(0.7, 0.9),
+ y: Math.random() - 0.2,
+ },
+ })
+ }, 250)
+}
+
+/**
+ * Triggers a simple single-burst confetti animation
+ * @param {Object} options - Configuration options
+ */
+export const triggerSimpleConfetti = (options: ConfettiOptions = {}): void => {
+ const {
+ particleCount = 100,
+ startVelocity = 30,
+ spread = 360,
+ origin = { x: 0.5, y: 0.5 },
+ } = options
+
+ confetti({
+ particleCount,
+ startVelocity,
+ spread,
+ origin,
+ })
+}
diff --git a/dashboard/src/utils/currency.ts b/dashboard/src/utils/currency.ts
new file mode 100644
index 0000000..5371ffd
--- /dev/null
+++ b/dashboard/src/utils/currency.ts
@@ -0,0 +1,60 @@
+// Currency formatting utilities using JavaScript Intl API
+
+export function formatCurrency(
+ amount: number,
+ currencyCode = "INR",
+ locale = "en-US",
+) {
+ try {
+ return new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: currencyCode,
+ }).format(amount)
+ } catch (error) {
+ // Fallback if currency code is invalid or not supported
+ console.warn(
+ `Invalid currency code: ${currencyCode}. Falling back to default formatting.`,
+ )
+ return new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: "INR",
+ }).format(amount)
+ }
+}
+
+export function formatPrice(
+ price: number,
+ currencyCode = "INR",
+ locale = "en-US",
+) {
+ return formatCurrency(price, currencyCode, locale)
+}
+
+export function formatPriceOrFree(
+ price: number,
+ currencyCode = "INR",
+ locale = "en-US",
+) {
+ if (price === 0) {
+ return __("Free")
+ }
+ return formatPrice(price, currencyCode, locale)
+}
+
+export function getCurrencySymbol(currencyCode: string, locale = "en-US") {
+ try {
+ const formatter = new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency: currencyCode,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ })
+
+ // Format a small number and extract just the symbol
+ const formatted = formatter.format(0)
+ return formatted.replace(/[\d\s,]/g, "").trim()
+ } catch (error) {
+ console.warn(`Invalid currency code: ${currencyCode}`)
+ return currencyCode
+ }
+}
diff --git a/dashboard/src/utils/index.ts b/dashboard/src/utils/index.ts
new file mode 100644
index 0000000..72df3e5
--- /dev/null
+++ b/dashboard/src/utils/index.ts
@@ -0,0 +1,28 @@
+export function isImage(extension: string | null | undefined): boolean {
+ if (!extension) return false
+ return ["png", "jpg", "jpeg", "gif", "svg", "bmp", "webp"].includes(
+ extension.toLowerCase(),
+ )
+}
+
+export function validateIsImageFile(file: File): string | void {
+ const extn = file.name.split(".").pop()?.toLowerCase()
+ if (!isImage(extn)) {
+ return "Only image files are allowed"
+ }
+}
+
+/**
+ * Clear all booking-related data from localStorage
+ * This removes all keys that start with 'event-booking-'
+ */
+export function clearBookingCache(): void {
+ const keysToRemove: string[] = []
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i)
+ if (key && key.startsWith("event-booking-")) {
+ keysToRemove.push(key)
+ }
+ }
+ keysToRemove.forEach((key) => localStorage.removeItem(key))
+}
diff --git a/dashboard/src/utils/pluralize.ts b/dashboard/src/utils/pluralize.ts
new file mode 100644
index 0000000..5ad350b
--- /dev/null
+++ b/dashboard/src/utils/pluralize.ts
@@ -0,0 +1,36 @@
+/**
+ * Pluralize or singularize a word based on count
+ * @param {number} count - The count to determine singular/plural
+ * @param {string} singular - The singular form of the word
+ * @param {string} plural - The plural form of the word (optional, defaults to singular + 's')
+ * @returns {string} The correctly pluralized word with count
+ */
+export function pluralize(
+ count: number,
+ singular: string,
+ plural?: string | null,
+): string {
+ // If no plural form is provided, default to singular + 's'
+ const pluralForm = plural || `${singular}s`
+
+ // Return singular for count of 1, plural for everything else (including 0)
+ const word = count === 1 ? singular : pluralForm
+
+ return `${count} ${word}`
+}
+
+/**
+ * Get just the pluralized word without the count
+ * @param {number} count - The count to determine singular/plural
+ * @param {string} singular - The singular form of the word
+ * @param {string} plural - The plural form of the word (optional, defaults to singular + 's')
+ * @returns {string} The correctly pluralized word without count
+ */
+export function pluralizeWord(
+ count: number,
+ singular: string,
+ plural?: string | null,
+): string {
+ const pluralForm = plural || `${singular}s`
+ return count === 1 ? singular : pluralForm
+}
diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js
new file mode 100644
index 0000000..081f191
--- /dev/null
+++ b/dashboard/tailwind.config.js
@@ -0,0 +1,14 @@
+import frappeUIPreset from "frappe-ui/tailwind";
+
+export default {
+ presets: [frappeUIPreset],
+ content: [
+ "./index.html",
+ "./src/**/*.{vue,js,ts,jsx,tsx}",
+ "./node_modules/frappe-ui/src/components/**/*.{vue,js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 0000000..2b26851
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES6",
+ "module": "esnext",
+ "strict": true,
+ "jsx": "preserve",
+ "importHelpers": true,
+ "moduleResolution": "bundler",
+ "experimentalDecorators": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "useDefineForClassFields": true,
+ "resolveJsonModule": true,
+ "sourceMap": true,
+ "baseUrl": ".",
+ "types": ["node"],
+ "allowJs": true,
+ "paths": {
+ "@/*": ["src/*"],
+ "tslib": ["/node_modules/tslib/tslib.d.ts"]
+ },
+ "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "src/**/*.vue",
+ "tests/**/*.ts",
+ "tests/**/*.tsx"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/dashboard/typecheck.sh b/dashboard/typecheck.sh
new file mode 100755
index 0000000..867b468
--- /dev/null
+++ b/dashboard/typecheck.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# Typecheck script that filters out errors from node_modules
+# Doing this because frappe-ui has a lot of errors that are not related to our code
+OUTPUT=$(vue-tsc --noEmit 2>&1)
+ERRORS=$(echo "$OUTPUT" | grep -E "^(src/|error TS)" | grep -v "node_modules/")
+
+if [ -n "$ERRORS" ]; then
+ echo "$ERRORS"
+ exit 1
+else
+ echo "Type check completed successfully (node_modules errors ignored)"
+ exit 0
+fi
\ No newline at end of file
diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js
new file mode 100644
index 0000000..a1f9042
--- /dev/null
+++ b/dashboard/vite.config.js
@@ -0,0 +1,82 @@
+import path from "node:path";
+import vue from "@vitejs/plugin-vue";
+import { defineConfig } from "vite";
+
+// Conditionally import frappe-ui plugin
+async function getFrappeUIPlugin(isDev) {
+ if (isDev) {
+ try {
+ const module = await import("./frappe-ui/vite");
+ return module.default;
+ } catch (error) {
+ console.warn("Local frappe-ui not found, falling back to npm package:", error.message);
+ // Fall back to npm package if local import fails
+ const module = await import("frappe-ui/vite");
+ return module.default;
+ }
+ }
+ const module = await import("frappe-ui/vite");
+ return module.default;
+}
+
+// https://vitejs.dev/config/
+export default defineConfig(async ({ command, mode }) => {
+ const isDev = process.env.NODE_ENV !== "production";
+ const frappeui = await getFrappeUIPlugin(isDev);
+
+ const config = {
+ plugins: [
+ frappeui({
+ frappeProxy: {
+ port: 8080,
+ source: "^/(app|login|api|assets|files|private|razorpay_checkout|events)",
+ },
+ jinjaBootData: true,
+ lucideIcons: true,
+ buildConfig: {
+ indexHtmlPath: "../event_manager/www/dashboard.html",
+ emptyOutDir: true,
+ sourcemap: true,
+ outDir: "../event_manager/public/dashboard",
+ chunkSizeWarningLimit: 1500,
+ target: "es2015",
+ },
+ }),
+ vue(),
+ ],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ "tailwind.config.js": path.resolve(__dirname, "tailwind.config.js"),
+ },
+ },
+ optimizeDeps: {
+ include: ["feather-icons", "showdown", "highlight.js/lib/core", "interactjs"],
+ },
+ server: {
+ allowedHosts: true,
+ },
+ };
+
+ // Add local frappe-ui alias only in development if the local frappe-ui exists
+ if (isDev) {
+ try {
+ // Check if the local frappe-ui directory exists
+ const fs = await import("node:fs");
+ const localFrappeUIPath = path.resolve(__dirname, "frappe-ui");
+ if (fs.existsSync(localFrappeUIPath)) {
+ config.resolve.alias["frappe-ui/style.css"] = path.resolve(
+ localFrappeUIPath,
+ "src/style.css"
+ );
+ config.resolve.alias["frappe-ui"] = localFrappeUIPath;
+ } else {
+ console.warn("Local frappe-ui directory not found, using npm package");
+ }
+ } catch (error) {
+ console.warn("Error checking for local frappe-ui, using npm package:", error.message);
+ }
+ }
+
+ return config;
+});
diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock
new file mode 100644
index 0000000..7e09659
--- /dev/null
+++ b/dashboard/yarn.lock
@@ -0,0 +1,3296 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@alloc/quick-lru@^5.2.0":
+ version "5.2.0"
+ resolved "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz"
+ integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
+
+"@antfu/install-pkg@^1.0.0":
+ version "1.1.0"
+ resolved "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz"
+ integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==
+ dependencies:
+ package-manager-detector "^1.3.0"
+ tinyexec "^1.0.1"
+
+"@antfu/utils@^0.7.10":
+ version "0.7.10"
+ resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d"
+ integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==
+
+"@antfu/utils@^8.1.0":
+ version "8.1.1"
+ resolved "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz"
+ integrity sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==
+
+"@babel/helper-string-parser@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
+ integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz"
+ integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
+
+"@babel/helper-validator-identifier@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+ integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
+"@babel/parser@^7.27.2":
+ version "7.27.5"
+ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz"
+ integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==
+ dependencies:
+ "@babel/types" "^7.27.3"
+
+"@babel/parser@^7.28.5":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6"
+ integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==
+ dependencies:
+ "@babel/types" "^7.29.0"
+
+"@babel/types@^7.27.3":
+ version "7.27.6"
+ resolved "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz"
+ integrity sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.27.1"
+
+"@babel/types@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
+ integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.28.5"
+
+"@biomejs/biome@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz"
+ integrity sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==
+ optionalDependencies:
+ "@biomejs/cli-darwin-arm64" "1.9.4"
+ "@biomejs/cli-darwin-x64" "1.9.4"
+ "@biomejs/cli-linux-arm64" "1.9.4"
+ "@biomejs/cli-linux-arm64-musl" "1.9.4"
+ "@biomejs/cli-linux-x64" "1.9.4"
+ "@biomejs/cli-linux-x64-musl" "1.9.4"
+ "@biomejs/cli-win32-arm64" "1.9.4"
+ "@biomejs/cli-win32-x64" "1.9.4"
+
+"@biomejs/cli-darwin-arm64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz"
+ integrity sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==
+
+"@biomejs/cli-darwin-x64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz#eafc2ce3849d385fc02238aad1ca4a73395a64d9"
+ integrity sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==
+
+"@biomejs/cli-linux-arm64-musl@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz#d780c3e01758fc90f3268357e3f19163d1f84fca"
+ integrity sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==
+
+"@biomejs/cli-linux-arm64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz#8ed1dd0e89419a4b66a47f95aefb8c46ae6041c9"
+ integrity sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==
+
+"@biomejs/cli-linux-x64-musl@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz#f36982b966bd671a36671e1de4417963d7db15fb"
+ integrity sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==
+
+"@biomejs/cli-linux-x64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz#a0a7f56680c76b8034ddc149dbf398bdd3a462e8"
+ integrity sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==
+
+"@biomejs/cli-win32-arm64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz#e2ef4e0084e76b7e26f0fc887c5ef1265ea56200"
+ integrity sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==
+
+"@biomejs/cli-win32-x64@1.9.4":
+ version "1.9.4"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz#4c7afa90e3970213599b4095e62f87e5972b2340"
+ integrity sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==
+
+"@esbuild/aix-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
+ integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
+
+"@esbuild/android-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
+ integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
+
+"@esbuild/android-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
+ integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
+
+"@esbuild/android-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
+ integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
+
+"@esbuild/darwin-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz"
+ integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
+
+"@esbuild/darwin-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
+ integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
+
+"@esbuild/freebsd-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
+ integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
+
+"@esbuild/freebsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
+ integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
+
+"@esbuild/linux-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
+ integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
+
+"@esbuild/linux-arm@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
+ integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
+
+"@esbuild/linux-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
+ integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
+
+"@esbuild/linux-loong64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
+ integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
+
+"@esbuild/linux-mips64el@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
+ integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
+
+"@esbuild/linux-ppc64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
+ integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
+
+"@esbuild/linux-riscv64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
+ integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
+
+"@esbuild/linux-s390x@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
+ integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
+
+"@esbuild/linux-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
+ integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
+
+"@esbuild/netbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
+ integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
+
+"@esbuild/openbsd-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
+ integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
+
+"@esbuild/sunos-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
+ integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
+
+"@esbuild/win32-arm64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
+ integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
+
+"@esbuild/win32-ia32@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
+ integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
+
+"@esbuild/win32-x64@0.21.5":
+ version "0.21.5"
+ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
+ integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
+
+"@floating-ui/core@^1.0.0":
+ version "1.6.1"
+ resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.1.tgz"
+ integrity sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==
+ dependencies:
+ "@floating-ui/utils" "^0.2.0"
+
+"@floating-ui/core@^1.7.1":
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz"
+ integrity sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==
+ dependencies:
+ "@floating-ui/utils" "^0.2.9"
+
+"@floating-ui/core@^1.7.3":
+ version "1.7.3"
+ resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz"
+ integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
+ dependencies:
+ "@floating-ui/utils" "^0.2.10"
+
+"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.6.13":
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz"
+ integrity sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==
+ dependencies:
+ "@floating-ui/core" "^1.7.1"
+ "@floating-ui/utils" "^0.2.9"
+
+"@floating-ui/dom@^1.5.4", "@floating-ui/dom@^1.6.1":
+ version "1.6.5"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz"
+ integrity sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==
+ dependencies:
+ "@floating-ui/core" "^1.0.0"
+ "@floating-ui/utils" "^0.2.0"
+
+"@floating-ui/dom@^1.7.0":
+ version "1.7.3"
+ resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz"
+ integrity sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==
+ dependencies:
+ "@floating-ui/core" "^1.7.3"
+ "@floating-ui/utils" "^0.2.10"
+
+"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1":
+ version "0.2.2"
+ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz"
+ integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
+
+"@floating-ui/utils@^0.2.10":
+ version "0.2.10"
+ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz"
+ integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
+
+"@floating-ui/utils@^0.2.9":
+ version "0.2.9"
+ resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz"
+ integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==
+
+"@floating-ui/vue@^1.0.4":
+ version "1.0.6"
+ resolved "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.0.6.tgz"
+ integrity sha512-EdrOljjkpkkqZnrpqUcPoz9NvHxuTjUtSInh6GMv3+Mcy+giY2cE2pHh9rpacRcZ2eMSCxel9jWkWXTjLmY55w==
+ dependencies:
+ "@floating-ui/dom" "^1.6.1"
+ "@floating-ui/utils" "^0.2.1"
+ vue-demi ">=0.13.0"
+
+"@floating-ui/vue@^1.1.6":
+ version "1.1.6"
+ resolved "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.6.tgz"
+ integrity sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+ "@floating-ui/utils" "^0.2.9"
+ vue-demi ">=0.13.0"
+
+"@headlessui/vue@^1.7.14":
+ version "1.7.16"
+ resolved "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.16.tgz"
+ integrity sha512-nKT+nf/q6x198SsyK54mSszaQl/z+QxtASmgMEJtpxSX2Q0OPJX0upS/9daDyiECpeAsvjkoOrm2O/6PyBQ+Qg==
+
+"@iconify/types@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz"
+ integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==
+
+"@iconify/utils@^2.3.0":
+ version "2.3.0"
+ resolved "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz"
+ integrity sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==
+ dependencies:
+ "@antfu/install-pkg" "^1.0.0"
+ "@antfu/utils" "^8.1.0"
+ "@iconify/types" "^2.0.0"
+ debug "^4.4.0"
+ globals "^15.14.0"
+ kolorist "^1.8.0"
+ local-pkg "^1.0.0"
+ mlly "^1.7.4"
+
+"@interactjs/types@1.10.27":
+ version "1.10.27"
+ resolved "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz"
+ integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==
+
+"@internationalized/date@^3.5.0":
+ version "3.8.2"
+ resolved "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz"
+ integrity sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==
+ dependencies:
+ "@swc/helpers" "^0.5.0"
+
+"@internationalized/date@^3.5.2":
+ version "3.5.3"
+ resolved "https://registry.npmjs.org/@internationalized/date/-/date-3.5.3.tgz"
+ integrity sha512-X9bi8NAEHAjD8yzmPYT2pdJsbe+tYSEBAfowtlxJVJdZR3aK8Vg7ZUT1Fm5M47KLzp/M1p1VwAaeSma3RT7biw==
+ dependencies:
+ "@swc/helpers" "^0.5.0"
+
+"@internationalized/number@^3.5.0":
+ version "3.6.3"
+ resolved "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz"
+ integrity sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==
+ dependencies:
+ "@swc/helpers" "^0.5.0"
+
+"@isaacs/cliui@^8.0.2":
+ version "8.0.2"
+ resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
+ integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
+ dependencies:
+ string-width "^5.1.2"
+ string-width-cjs "npm:string-width@^4.2.0"
+ strip-ansi "^7.0.1"
+ strip-ansi-cjs "npm:strip-ansi@^6.0.1"
+ wrap-ansi "^8.1.0"
+ wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
+
+"@jridgewell/gen-mapping@^0.3.2":
+ version "0.3.5"
+ resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz"
+ integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==
+ dependencies:
+ "@jridgewell/set-array" "^1.2.1"
+ "@jridgewell/sourcemap-codec" "^1.4.10"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+ version "3.1.2"
+ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/set-array@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz"
+ integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==
+
+"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
+ version "1.5.0"
+ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz"
+ integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
+
+"@jridgewell/sourcemap-codec@^1.5.5":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@jridgewell/trace-mapping@^0.3.24":
+ version "0.3.25"
+ resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz"
+ integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
+ dependencies:
+ "@jridgewell/resolve-uri" "^3.1.0"
+ "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@juggle/resize-observer@^3.4.0":
+ version "3.4.0"
+ resolved "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz"
+ integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
+
+"@nodelib/fs.scandir@2.1.5":
+ version "2.1.5"
+ resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
+ integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+ dependencies:
+ "@nodelib/fs.stat" "2.0.5"
+ run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+ version "2.0.5"
+ resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
+ integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+ version "1.2.8"
+ resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz"
+ integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+ dependencies:
+ "@nodelib/fs.scandir" "2.1.5"
+ fastq "^1.6.0"
+
+"@pkgjs/parseargs@^0.11.0":
+ version "0.11.0"
+ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
+ integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
+
+"@popperjs/core@^2.11.2":
+ version "2.11.2"
+ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz"
+ integrity sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==
+
+"@popperjs/core@^2.9.0":
+ version "2.11.7"
+ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz"
+ integrity sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==
+
+"@remirror/core-constants@3.0.0":
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz"
+ integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
+
+"@rollup/pluginutils@^5.1.3", "@rollup/pluginutils@^5.1.4":
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
+ integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ estree-walker "^2.0.2"
+ picomatch "^4.0.2"
+
+"@rollup/rollup-android-arm-eabi@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.4.tgz#c460b54c50d42f27f8254c435a4f3b3e01910bc8"
+ integrity sha512-jfUJrFct/hTA0XDM5p/htWKoNNTbDLY0KRwEt6pyOA6k2fmk0WVwl65PdUdJZgzGEHWx+49LilkcSaumQRyNQw==
+
+"@rollup/rollup-android-arm64@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.4.tgz#96e01f3a04675d8d5973ab8d3fd6bc3be21fa5e1"
+ integrity sha512-j4nrEO6nHU1nZUuCfRKoCcvh7PIywQPUCBa2UsootTHvTHIoIu2BzueInGJhhvQO/2FTRdNYpf63xsgEqH9IhA==
+
+"@rollup/rollup-darwin-arm64@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.4.tgz"
+ integrity sha512-GmU/QgGtBTeraKyldC7cDVVvAJEOr3dFLKneez/n7BvX57UdhOqDsVwzU7UOnYA7AAOt+Xb26lk79PldDHgMIQ==
+
+"@rollup/rollup-darwin-x64@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.4.tgz#f30e4ee6929e048190cf10e0daa8e8ae035b6e46"
+ integrity sha512-N6oDBiZCBKlwYcsEPXGDE4g9RoxZLK6vT98M8111cW7VsVJFpNEqvJeIPfsCzbf0XEakPslh72X0gnlMi4Ddgg==
+
+"@rollup/rollup-freebsd-arm64@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.24.4.tgz#c54b2373ec5bcf71f08c4519c7ae80a0b6c8e03b"
+ integrity sha512-py5oNShCCjCyjWXCZNrRGRpjWsF0ic8f4ieBNra5buQz0O/U6mMXCpC1LvrHuhJsNPgRt36tSYMidGzZiJF6mw==
+
+"@rollup/rollup-freebsd-x64@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.24.4.tgz#3bc53aa29d5a34c28ba8e00def76aa612368458e"
+ integrity sha512-L7VVVW9FCnTTp4i7KrmHeDsDvjB4++KOBENYtNYAiYl96jeBThFfhP6HVxL74v4SiZEVDH/1ILscR5U9S4ms4g==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.4.tgz#c85aedd1710c9e267ee86b6d1ce355ecf7d9e8d9"
+ integrity sha512-10ICosOwYChROdQoQo589N5idQIisxjaFE/PAnX2i0Zr84mY0k9zul1ArH0rnJ/fpgiqfu13TFZR5A5YJLOYZA==
+
+"@rollup/rollup-linux-arm-musleabihf@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.4.tgz#e77313408bf13995aecde281aec0cceb08747e42"
+ integrity sha512-ySAfWs69LYC7QhRDZNKqNhz2UKN8LDfbKSMAEtoEI0jitwfAG2iZwVqGACJT+kfYvvz3/JgsLlcBP+WWoKCLcw==
+
+"@rollup/rollup-linux-arm64-gnu@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.4.tgz#633f632397b3662108cfaa1abca2a80b85f51102"
+ integrity sha512-uHYJ0HNOI6pGEeZ/5mgm5arNVTI0nLlmrbdph+pGXpC9tFHFDQmDMOEqkmUObRfosJqpU8RliYoGz06qSdtcjg==
+
+"@rollup/rollup-linux-arm64-musl@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.4.tgz#63edd72b29c4cced93e16113a68e1be9fef88907"
+ integrity sha512-38yiWLemQf7aLHDgTg85fh3hW9stJ0Muk7+s6tIkSUOMmi4Xbv5pH/5Bofnsb6spIwD5FJiR+jg71f0CH5OzoA==
+
+"@rollup/rollup-linux-powerpc64le-gnu@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.4.tgz#a9418a4173df80848c0d47df0426a0bf183c4e75"
+ integrity sha512-q73XUPnkwt9ZNF2xRS4fvneSuaHw2BXuV5rI4cw0fWYVIWIBeDZX7c7FWhFQPNTnE24172K30I+dViWRVD9TwA==
+
+"@rollup/rollup-linux-riscv64-gnu@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.4.tgz#bc9c195db036a27e5e3339b02f51526b4ce1e988"
+ integrity sha512-Aie/TbmQi6UXokJqDZdmTJuZBCU3QBDA8oTKRGtd4ABi/nHgXICulfg1KI6n9/koDsiDbvHAiQO3YAUNa/7BCw==
+
+"@rollup/rollup-linux-s390x-gnu@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.4.tgz#1651fdf8144ae89326c01da5d52c60be63e71a82"
+ integrity sha512-P8MPErVO/y8ohWSP9JY7lLQ8+YMHfTI4bAdtCi3pC2hTeqFJco2jYspzOzTUB8hwUWIIu1xwOrJE11nP+0JFAQ==
+
+"@rollup/rollup-linux-x64-gnu@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.4.tgz#e473de5e4acb95fcf930a35cbb7d3e8080e57a6f"
+ integrity sha512-K03TljaaoPK5FOyNMZAAEmhlyO49LaE4qCsr0lYHUKyb6QacTNF9pnfPpXnFlFD3TXuFbFbz7tJ51FujUXkXYA==
+
+"@rollup/rollup-linux-x64-musl@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.4.tgz#0af12dd2578c29af4037f0c834b4321429dd5b01"
+ integrity sha512-VJYl4xSl/wqG2D5xTYncVWW+26ICV4wubwN9Gs5NrqhJtayikwCXzPL8GDsLnaLU3WwhQ8W02IinYSFJfyo34Q==
+
+"@rollup/rollup-win32-arm64-msvc@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.4.tgz#e48e78cdd45313b977c1390f4bfde7ab79be8871"
+ integrity sha512-ku2GvtPwQfCqoPFIJCqZ8o7bJcj+Y54cZSr43hHca6jLwAiCbZdBUOrqE6y29QFajNAzzpIOwsckaTFmN6/8TA==
+
+"@rollup/rollup-win32-ia32-msvc@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.4.tgz#a3fc8536d243fe161c796acb93eba43c250f311c"
+ integrity sha512-V3nCe+eTt/W6UYNr/wGvO1fLpHUrnlirlypZfKCT1fG6hWfqhPgQV/K/mRBXBpxc0eKLIF18pIOFVPh0mqHjlg==
+
+"@rollup/rollup-win32-x64-msvc@4.24.4":
+ version "4.24.4"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.4.tgz#e2a9d1fd56524103a6cc8a54404d9d3ebc73c454"
+ integrity sha512-LTw1Dfd0mBIEqUVCxbvTE/LLo+9ZxVC9k99v1v4ahg9Aak6FpqOfNu5kRkeTAn0wphoC4JU7No1/rL+bBCEwhg==
+
+"@socket.io/component-emitter@~3.1.0":
+ version "3.1.0"
+ resolved "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz"
+ integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
+
+"@swc/helpers@^0.5.0":
+ version "0.5.11"
+ resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.11.tgz"
+ integrity sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==
+ dependencies:
+ tslib "^2.4.0"
+
+"@tailwindcss/forms@^0.5.3":
+ version "0.5.3"
+ resolved "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.3.tgz"
+ integrity sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==
+ dependencies:
+ mini-svg-data-uri "^1.2.3"
+
+"@tailwindcss/line-clamp@^0.4.4":
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
+ integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
+
+"@tailwindcss/typography@^0.5.16":
+ version "0.5.16"
+ resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz"
+ integrity sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==
+ dependencies:
+ lodash.castarray "^4.4.0"
+ lodash.isplainobject "^4.0.6"
+ lodash.merge "^4.6.2"
+ postcss-selector-parser "6.0.10"
+
+"@tanstack/virtual-core@3.13.10":
+ version "3.13.10"
+ resolved "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.10.tgz"
+ integrity sha512-sPEDhXREou5HyZYqSWIqdU580rsF6FGeN7vpzijmP3KTiOGjOMZASz4Y6+QKjiFQwhWrR58OP8izYaNGVxvViA==
+
+"@tanstack/virtual-core@3.5.0":
+ version "3.5.0"
+ resolved "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.0.tgz"
+ integrity sha512-KnPRCkQTyqhanNC0K63GBG3wA8I+D1fQuVnAvcBF8f13akOKeQp1gSbu6f77zCxhEk727iV5oQnbHLYzHrECLg==
+
+"@tanstack/vue-virtual@^3.1.3":
+ version "3.5.0"
+ resolved "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.5.0.tgz"
+ integrity sha512-wvRQ8sFxn/NDr3WvI5XabhFovZ5MBmpEck2GHpTxYunmV63Ovpl30lRu6W5BPQo35a1GqDZ+Pvzlz6WDWRNqqw==
+ dependencies:
+ "@tanstack/virtual-core" "3.5.0"
+
+"@tanstack/vue-virtual@^3.12.0":
+ version "3.13.10"
+ resolved "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.10.tgz"
+ integrity sha512-1UZmUiMNyKxQ1JFPtO3rfRmK7IuLYwfj/foPC7FVWj6yHand4ry5joFh8LQ1Ckm7Dfe/08cv6LKZNc4WYj7hxQ==
+ dependencies:
+ "@tanstack/virtual-core" "3.13.10"
+
+"@tiptap/core@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/core/-/core-2.26.1.tgz"
+ integrity sha512-fymyd/XZvYiHjBoLt1gxs024xP/LY26d43R1vluYq7AHBL/7DE3ywzy+1GEsGyAv5Je2L0KBhNIR/izbq3Kaqg==
+
+"@tiptap/extension-blockquote@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.26.1.tgz"
+ integrity sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==
+
+"@tiptap/extension-bold@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.26.1.tgz"
+ integrity sha512-zCce9PRuTNhadFir71luLo99HERDpGJ0EEflGm7RN8I1SnNi9gD5ooK42BOIQtejGCJqg3hTPZiYDJC2hXvckQ==
+
+"@tiptap/extension-bubble-menu@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz"
+ integrity sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==
+ dependencies:
+ tippy.js "^6.3.7"
+
+"@tiptap/extension-bullet-list@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.26.1.tgz"
+ integrity sha512-HHakuV4ckYCDOnBbne088FvCEP4YICw+wgPBz/V2dfpiFYQ4WzT0LPK9s7OFMCN+ROraoug+1ryN1Z1KdIgujQ==
+
+"@tiptap/extension-code-block-lowlight@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz"
+ integrity sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==
+
+"@tiptap/extension-code-block@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.26.1.tgz"
+ integrity sha512-/TDDOwONl0qEUc4+B6V9NnWtSjz95eg7/8uCb8Y8iRbGvI9vT4/znRKofFxstvKmW4URu/H74/g0ywV57h0B+A==
+
+"@tiptap/extension-code@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.26.1.tgz"
+ integrity sha512-GU9deB1A/Tr4FMPu71CvlcjGKwRhGYz60wQ8m4aM+ELZcVIcZRa1ebR8bExRIEWnvRztQuyRiCQzw2N0xQJ1QQ==
+
+"@tiptap/extension-color@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.26.1.tgz"
+ integrity sha512-lsPw3qpQNes1rHpxBtsV9XniN1dEjYd2nVTpQHGE4XLNwfE5+ejm6ySs8qVLM7+EXWcjANLLh4UA3zqkX6t6HA==
+
+"@tiptap/extension-document@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.26.1.tgz"
+ integrity sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==
+
+"@tiptap/extension-dropcursor@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.26.1.tgz"
+ integrity sha512-JkDQU2ZYFOuT5mNYb8OiWGwD1HcjbtmX8tLNugQbToECmz9WvVPqJmn7V/q8VGpP81iEECz/IsyRmuf2kSD4uA==
+
+"@tiptap/extension-floating-menu@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz"
+ integrity sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==
+ dependencies:
+ tippy.js "^6.3.7"
+
+"@tiptap/extension-gapcursor@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.26.1.tgz"
+ integrity sha512-KOiMZc3PwJS3hR0nSq5d0TJi2jkNZkLZElcT6pCEnhRHzPH6dRMu9GM5Jj798ZRUy0T9UFcKJalFZaDxnmRnpg==
+
+"@tiptap/extension-hard-break@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz"
+ integrity sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==
+
+"@tiptap/extension-heading@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.26.1.tgz"
+ integrity sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==
+
+"@tiptap/extension-highlight@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.26.1.tgz"
+ integrity sha512-9eW2UqDqeAKSDIiL6SqcPSDCQAdU5qQmRMsJlShOM7Fu1aU71b1ewhUP9YioUCanciR99tqNsk/n3LAe0w5XdA==
+
+"@tiptap/extension-history@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.26.1.tgz"
+ integrity sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==
+
+"@tiptap/extension-horizontal-rule@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.26.1.tgz"
+ integrity sha512-mT6baqOhs/NakgrAeDeed194E/ZJFGL692H0C7f1N7WDRaWxUu2oR0LrnRqSH5OyPjELkzu6nQnNy0+0tFGHHg==
+
+"@tiptap/extension-image@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.26.1.tgz"
+ integrity sha512-96+MaYBJebQlR/ik5W72GLUfXdEoxFs+6jsoERxbM5qEdhb7TEnodBFtWZOwgDO27kFd6rSNZuW9r5KJNtljEg==
+
+"@tiptap/extension-italic@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.26.1.tgz"
+ integrity sha512-pOs6oU4LyGO89IrYE4jbE8ZYsPwMMIiKkYfXcfeD9NtpGNBnjeVXXF5I9ndY2ANrCAgC8k58C3/powDRf0T2yA==
+
+"@tiptap/extension-link@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.26.1.tgz"
+ integrity sha512-7yfum5Jymkue/uOSTQPt2SmkZIdZx7t3QhZLqBU7R9ettkdSCBgEGok6N+scJM1R1Zes+maSckLm0JZw5BKYNA==
+ dependencies:
+ linkifyjs "^4.2.0"
+
+"@tiptap/extension-list-item@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.26.1.tgz"
+ integrity sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==
+
+"@tiptap/extension-mention@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.26.1.tgz"
+ integrity sha512-sBrlJ9nWjFx7oWCtt0hV192FgCBXva1zwImWbgXTCGPAjv0d5EoPymIfRgoeanAmuQjOHoKzzZnJ6bELTZhkGw==
+
+"@tiptap/extension-ordered-list@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.26.1.tgz"
+ integrity sha512-UHKNRxq6TBnXMGFSq91knD6QaHsyyOwLOsXMzupmKM5Su0s+CRXEjfav3qKlbb9e4m7D7S/a0aPm8nC9KIXNhQ==
+
+"@tiptap/extension-paragraph@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.26.1.tgz"
+ integrity sha512-UezvM9VDRAVJlX1tykgHWSD1g3MKfVMWWZ+Tg+PE4+kizOwoYkRWznVPgCAxjmyHajxpCKRXgqTZkOxjJ9Kjzg==
+
+"@tiptap/extension-placeholder@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz"
+ integrity sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==
+
+"@tiptap/extension-strike@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.26.1.tgz"
+ integrity sha512-CkoRH+pAi6MgdCh7K0cVZl4N2uR4pZdabXAnFSoLZRSg6imLvEUmWHfSi1dl3Z7JOvd3a4yZ4NxerQn5MWbJ7g==
+
+"@tiptap/extension-table-cell@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.26.1.tgz"
+ integrity sha512-0P5zY+WGFnULggJkX6+CevmFoBmVv1aUiBBXfcFuLG2mnUsS3QALQTowFtz/0/VbtbjzcOSStaGDHRJxPbk9XQ==
+
+"@tiptap/extension-table-header@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.26.1.tgz"
+ integrity sha512-SAwTW9H+sjVYjoeU5z8pVDMHn3r3FCi+zp2KAxsEsmujcd7qrQdY0cAjQtWjckCq6H3sQkbICa+xlCCd7C8ZAQ==
+
+"@tiptap/extension-table-row@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.26.1.tgz"
+ integrity sha512-c4oLrUfj1EVVDpbfKX36v7nnaeI4NxML2KRTQXocvcY65VCe0bPQh8ujpPgPcnKEzdWYdIuAX9RbEAkiYWe8Ww==
+
+"@tiptap/extension-table@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.26.1.tgz"
+ integrity sha512-LQ63CK53qx2ZsbLTB4mUX0YCoGC0GbYQ82jS3kD+K7M/mb9MCkefvDk6rA8rXF8TjfGnv6o/Fseoot8uhH3qfg==
+
+"@tiptap/extension-task-item@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.26.1.tgz"
+ integrity sha512-b7JNeOsBqEd1p2oQ5N6Msz9fr2o73WR1WsYDC0WhECg07Goud2gQEkwWkQaLsvfcwuS746eMJK/nrT2pVEngYA==
+
+"@tiptap/extension-task-list@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.26.1.tgz"
+ integrity sha512-xR4LMpMPZ6bpkZNmFvIojmNGtdGKNlKFbpvyIOgs4qhlWskbFQQVevglHjV1R8xJLic5c+byJQaAmQdQudqGng==
+
+"@tiptap/extension-text-align@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz"
+ integrity sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==
+
+"@tiptap/extension-text-style@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.26.1.tgz"
+ integrity sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==
+
+"@tiptap/extension-text@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.26.1.tgz"
+ integrity sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==
+
+"@tiptap/extension-typography@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.26.1.tgz"
+ integrity sha512-1zwKWfy7Tjutert1Vn/unN+98E0JFr5C2jx1xuesAEf4X405cQMb/zNMI44ON3xBG+aXZoTRlJuXNoYodeVSAg==
+
+"@tiptap/pm@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-2.26.1.tgz"
+ integrity sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==
+ dependencies:
+ prosemirror-changeset "^2.3.0"
+ prosemirror-collab "^1.3.1"
+ prosemirror-commands "^1.6.2"
+ prosemirror-dropcursor "^1.8.1"
+ prosemirror-gapcursor "^1.3.2"
+ prosemirror-history "^1.4.1"
+ prosemirror-inputrules "^1.4.0"
+ prosemirror-keymap "^1.2.2"
+ prosemirror-markdown "^1.13.1"
+ prosemirror-menu "^1.2.4"
+ prosemirror-model "^1.23.0"
+ prosemirror-schema-basic "^1.2.3"
+ prosemirror-schema-list "^1.4.1"
+ prosemirror-state "^1.4.3"
+ prosemirror-tables "^1.6.4"
+ prosemirror-trailing-node "^3.0.0"
+ prosemirror-transform "^1.10.2"
+ prosemirror-view "^1.37.0"
+
+"@tiptap/starter-kit@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.26.1.tgz"
+ integrity sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==
+ dependencies:
+ "@tiptap/core" "^2.26.1"
+ "@tiptap/extension-blockquote" "^2.26.1"
+ "@tiptap/extension-bold" "^2.26.1"
+ "@tiptap/extension-bullet-list" "^2.26.1"
+ "@tiptap/extension-code" "^2.26.1"
+ "@tiptap/extension-code-block" "^2.26.1"
+ "@tiptap/extension-document" "^2.26.1"
+ "@tiptap/extension-dropcursor" "^2.26.1"
+ "@tiptap/extension-gapcursor" "^2.26.1"
+ "@tiptap/extension-hard-break" "^2.26.1"
+ "@tiptap/extension-heading" "^2.26.1"
+ "@tiptap/extension-history" "^2.26.1"
+ "@tiptap/extension-horizontal-rule" "^2.26.1"
+ "@tiptap/extension-italic" "^2.26.1"
+ "@tiptap/extension-list-item" "^2.26.1"
+ "@tiptap/extension-ordered-list" "^2.26.1"
+ "@tiptap/extension-paragraph" "^2.26.1"
+ "@tiptap/extension-strike" "^2.26.1"
+ "@tiptap/extension-text" "^2.26.1"
+ "@tiptap/extension-text-style" "^2.26.1"
+ "@tiptap/pm" "^2.26.1"
+
+"@tiptap/suggestion@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.26.1.tgz"
+ integrity sha512-iNWJdQN7h01keNoVwyCsdI7ZX11YkrexZjCnutWK17Dd72s3NYVTmQXu7saftwddT4nDdlczNxAFosrt0zMhcg==
+
+"@tiptap/vue-3@^2.26.1":
+ version "2.26.1"
+ resolved "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-2.26.1.tgz"
+ integrity sha512-GC0UP+v3KEb0nhgjIHYmWIn5ziTaRqSy8TESXOjG5aljJ8BdP+A0pbcpumB3u0QU+BLUANZqUV2r3l+V18AKYg==
+ dependencies:
+ "@tiptap/extension-bubble-menu" "^2.26.1"
+ "@tiptap/extension-floating-menu" "^2.26.1"
+
+"@types/canvas-confetti@^1.9.0":
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz#d1077752e046413c9881fbb2ba34a70ebe3c1773"
+ integrity sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==
+
+"@types/estree@1.0.6":
+ version "1.0.6"
+ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz"
+ integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==
+
+"@types/estree@^1.0.0":
+ version "1.0.8"
+ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+
+"@types/hast@^3.0.0":
+ version "3.0.4"
+ resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
+"@types/linkify-it@^5":
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz"
+ integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
+
+"@types/markdown-it@^14.0.0":
+ version "14.1.2"
+ resolved "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz"
+ integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
+ dependencies:
+ "@types/linkify-it" "^5"
+ "@types/mdurl" "^2"
+
+"@types/mdurl@^2":
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz"
+ integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
+
+"@types/node@^25.2.0":
+ version "25.2.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.0.tgz#015b7d228470c1dcbfc17fe9c63039d216b4d782"
+ integrity sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==
+ dependencies:
+ undici-types "~7.16.0"
+
+"@types/trusted-types@^2.0.7":
+ version "2.0.7"
+ resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"
+ integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
+
+"@types/unist@*":
+ version "3.0.3"
+ resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"
+ integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
+
+"@types/web-bluetooth@^0.0.20":
+ version "0.0.20"
+ resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz"
+ integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
+
+"@types/web-bluetooth@^0.0.21":
+ version "0.0.21"
+ resolved "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz"
+ integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
+
+"@vexip-ui/hooks@^2.8.0":
+ version "2.9.3"
+ resolved "https://registry.npmjs.org/@vexip-ui/hooks/-/hooks-2.9.3.tgz"
+ integrity sha512-DrGlwSa0P0KQ98RU0MrQ4+KcItZDaejAJISv3iT6T6/E2ly4z7c2dzuNzn5Wk7y4FYnkXDfrf2UFNv7EDw8GJg==
+ dependencies:
+ "@floating-ui/dom" "^1.7.0"
+ "@juggle/resize-observer" "^3.4.0"
+ "@vexip-ui/utils" "2.16.4"
+
+"@vexip-ui/utils@2.16.4", "@vexip-ui/utils@^2.16.1":
+ version "2.16.4"
+ resolved "https://registry.npmjs.org/@vexip-ui/utils/-/utils-2.16.4.tgz"
+ integrity sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==
+
+"@vitejs/plugin-vue@^5.1.4":
+ version "5.1.4"
+ resolved "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz"
+ integrity sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==
+
+"@volar/language-core@2.4.27":
+ version "2.4.27"
+ resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.27.tgz#c66d44cd22a914384d238bbcd0f621ecc57e3618"
+ integrity sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==
+ dependencies:
+ "@volar/source-map" "2.4.27"
+
+"@volar/source-map@2.4.27":
+ version "2.4.27"
+ resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.27.tgz#8ce6f16e207987078fd866e2faf65c35c4d15987"
+ integrity sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==
+
+"@volar/typescript@2.4.27":
+ version "2.4.27"
+ resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.27.tgz#8950318a33d5dfcdc4b0e5bbe5a38c1b8383eae6"
+ integrity sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==
+ dependencies:
+ "@volar/language-core" "2.4.27"
+ path-browserify "^1.0.1"
+ vscode-uri "^3.0.8"
+
+"@vue/compiler-core@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.16.tgz"
+ integrity sha512-AOQS2eaQOaaZQoL1u+2rCJIKDruNXVBZSiUD3chnUrsoX5ZTQMaCvXlWNIfxBJuU15r1o7+mpo5223KVtIhAgQ==
+ dependencies:
+ "@babel/parser" "^7.27.2"
+ "@vue/shared" "3.5.16"
+ entities "^4.5.0"
+ estree-walker "^2.0.2"
+ source-map-js "^1.2.1"
+
+"@vue/compiler-core@3.5.27":
+ version "3.5.27"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz#ce4402428e26095586eb889c41f6e172eb3960bd"
+ integrity sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==
+ dependencies:
+ "@babel/parser" "^7.28.5"
+ "@vue/shared" "3.5.27"
+ entities "^7.0.0"
+ estree-walker "^2.0.2"
+ source-map-js "^1.2.1"
+
+"@vue/compiler-dom@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz"
+ integrity sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==
+ dependencies:
+ "@vue/compiler-core" "3.5.16"
+ "@vue/shared" "3.5.16"
+
+"@vue/compiler-dom@^3.5.0":
+ version "3.5.27"
+ resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz#32b2bc87f0a652c253986796ace0ed6213093af8"
+ integrity sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==
+ dependencies:
+ "@vue/compiler-core" "3.5.27"
+ "@vue/shared" "3.5.27"
+
+"@vue/compiler-sfc@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.16.tgz"
+ integrity sha512-rQR6VSFNpiinDy/DVUE0vHoIDUF++6p910cgcZoaAUm3POxgNOOdS/xgoll3rNdKYTYPnnbARDCZOyZ+QSe6Pw==
+ dependencies:
+ "@babel/parser" "^7.27.2"
+ "@vue/compiler-core" "3.5.16"
+ "@vue/compiler-dom" "3.5.16"
+ "@vue/compiler-ssr" "3.5.16"
+ "@vue/shared" "3.5.16"
+ estree-walker "^2.0.2"
+ magic-string "^0.30.17"
+ postcss "^8.5.3"
+ source-map-js "^1.2.1"
+
+"@vue/compiler-ssr@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.16.tgz"
+ integrity sha512-d2V7kfxbdsjrDSGlJE7my1ZzCXViEcqN6w14DOsDrUCHEA6vbnVCpRFfrc4ryCP/lCKzX2eS1YtnLE/BuC9f/A==
+ dependencies:
+ "@vue/compiler-dom" "3.5.16"
+ "@vue/shared" "3.5.16"
+
+"@vue/devtools-api@^6.6.4":
+ version "6.6.4"
+ resolved "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
+ integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
+
+"@vue/language-core@3.2.4":
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.4.tgz#03bb7a67ab8639fabb2cc4e49360fc742e99816a"
+ integrity sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==
+ dependencies:
+ "@volar/language-core" "2.4.27"
+ "@vue/compiler-dom" "^3.5.0"
+ "@vue/shared" "^3.5.0"
+ alien-signals "^3.0.0"
+ muggle-string "^0.4.1"
+ path-browserify "^1.0.1"
+ picomatch "^4.0.2"
+
+"@vue/reactivity@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.16.tgz"
+ integrity sha512-FG5Q5ee/kxhIm1p2bykPpPwqiUBV3kFySsHEQha5BJvjXdZTUfmya7wP7zC39dFuZAcf/PD5S4Lni55vGLMhvA==
+ dependencies:
+ "@vue/shared" "3.5.16"
+
+"@vue/runtime-core@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.16.tgz"
+ integrity sha512-bw5Ykq6+JFHYxrQa7Tjr+VSzw7Dj4ldR/udyBZbq73fCdJmyy5MPIFR9IX/M5Qs+TtTjuyUTCnmK3lWWwpAcFQ==
+ dependencies:
+ "@vue/reactivity" "3.5.16"
+ "@vue/shared" "3.5.16"
+
+"@vue/runtime-dom@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.16.tgz"
+ integrity sha512-T1qqYJsG2xMGhImRUV9y/RseB9d0eCYZQ4CWca9ztCuiPj/XWNNN+lkNBuzVbia5z4/cgxdL28NoQCvC0Xcfww==
+ dependencies:
+ "@vue/reactivity" "3.5.16"
+ "@vue/runtime-core" "3.5.16"
+ "@vue/shared" "3.5.16"
+ csstype "^3.1.3"
+
+"@vue/server-renderer@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.16.tgz"
+ integrity sha512-BrX0qLiv/WugguGsnQUJiYOE0Fe5mZTwi6b7X/ybGB0vfrPH9z0gD/Y6WOR1sGCgX4gc25L1RYS5eYQKDMoNIg==
+ dependencies:
+ "@vue/compiler-ssr" "3.5.16"
+ "@vue/shared" "3.5.16"
+
+"@vue/shared@3.5.16":
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.5.16.tgz"
+ integrity sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==
+
+"@vue/shared@3.5.27", "@vue/shared@^3.5.0":
+ version "3.5.27"
+ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.27.tgz#33a63143d8fb9ca1b3efbc7ecf9bd0ab05f7e06e"
+ integrity sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==
+
+"@vueuse/core@^10.4.1", "@vueuse/core@^10.5.0":
+ version "10.11.1"
+ resolved "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz"
+ integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
+ dependencies:
+ "@types/web-bluetooth" "^0.0.20"
+ "@vueuse/metadata" "10.11.1"
+ "@vueuse/shared" "10.11.1"
+ vue-demi ">=0.14.8"
+
+"@vueuse/core@^12.5.0":
+ version "12.8.2"
+ resolved "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz"
+ integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==
+ dependencies:
+ "@types/web-bluetooth" "^0.0.21"
+ "@vueuse/metadata" "12.8.2"
+ "@vueuse/shared" "12.8.2"
+ vue "^3.5.13"
+
+"@vueuse/core@^13.6.0":
+ version "13.6.0"
+ resolved "https://registry.npmjs.org/@vueuse/core/-/core-13.6.0.tgz"
+ integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==
+ dependencies:
+ "@types/web-bluetooth" "^0.0.21"
+ "@vueuse/metadata" "13.6.0"
+ "@vueuse/shared" "13.6.0"
+
+"@vueuse/metadata@10.11.1":
+ version "10.11.1"
+ resolved "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz"
+ integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
+
+"@vueuse/metadata@12.8.2":
+ version "12.8.2"
+ resolved "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz"
+ integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
+
+"@vueuse/metadata@13.6.0":
+ version "13.6.0"
+ resolved "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz"
+ integrity sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==
+
+"@vueuse/router@^13.6.0":
+ version "13.9.0"
+ resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-13.9.0.tgz#44235e6732a30b53d1c8e2ef13ce783fdd189ca6"
+ integrity sha512-7AYay8Pv/0fC4D0eygbIyZuLyVs+9D7dsnO5D8aqat9qcOz91v/XFWR667WE1+p+OkU0ib+FjQUdnTVBNoIw8g==
+ dependencies:
+ "@vueuse/shared" "13.9.0"
+
+"@vueuse/shared@10.11.1", "@vueuse/shared@^10.5.0":
+ version "10.11.1"
+ resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz"
+ integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==
+ dependencies:
+ vue-demi ">=0.14.8"
+
+"@vueuse/shared@12.8.2", "@vueuse/shared@^12.5.0":
+ version "12.8.2"
+ resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz"
+ integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==
+ dependencies:
+ vue "^3.5.13"
+
+"@vueuse/shared@13.6.0":
+ version "13.6.0"
+ resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz"
+ integrity sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==
+
+"@vueuse/shared@13.9.0":
+ version "13.9.0"
+ resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923"
+ integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==
+
+acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0:
+ version "8.15.0"
+ resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
+ integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
+
+alien-signals@^3.0.0:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.1.2.tgz#26e623e3ed81e401df1a7c503f726e2288a4fa02"
+ integrity sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==
+
+ansi-regex@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz"
+ integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-regex@^6.0.1:
+ version "6.1.0"
+ resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz"
+ integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
+
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+ version "4.3.0"
+ resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
+ integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+ dependencies:
+ color-convert "^2.0.1"
+
+ansi-styles@^6.1.0:
+ version "6.2.1"
+ resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz"
+ integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
+
+any-promise@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz"
+ integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
+
+anymatch@~3.1.2:
+ version "3.1.2"
+ resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz"
+ integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
+arg@^5.0.2:
+ version "5.0.2"
+ resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz"
+ integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
+
+argparse@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
+ integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
+aria-hidden@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz"
+ integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==
+ dependencies:
+ tslib "^2.0.0"
+
+aria-hidden@^1.2.4:
+ version "1.2.6"
+ resolved "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz"
+ integrity sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==
+ dependencies:
+ tslib "^2.0.0"
+
+autoprefixer@^10.4.2:
+ version "10.4.2"
+ resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.2.tgz"
+ integrity sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ==
+ dependencies:
+ browserslist "^4.19.1"
+ caniuse-lite "^1.0.30001297"
+ fraction.js "^4.1.2"
+ normalize-range "^0.1.2"
+ picocolors "^1.0.0"
+ postcss-value-parser "^4.2.0"
+
+balanced-match@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
+ integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-js@^1.3.1:
+ version "1.5.1"
+ resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
+binary-extensions@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
+ integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
+bl@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz"
+ integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+ dependencies:
+ buffer "^5.5.0"
+ inherits "^2.0.4"
+ readable-stream "^3.4.0"
+
+brace-expansion@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz"
+ integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+ dependencies:
+ balanced-match "^1.0.0"
+
+braces@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
+ integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+ dependencies:
+ fill-range "^7.1.1"
+
+braces@~3.0.2:
+ version "3.0.2"
+ resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
+ integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+ dependencies:
+ fill-range "^7.0.1"
+
+browserslist@^4.19.1:
+ version "4.19.1"
+ resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz"
+ integrity sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==
+ dependencies:
+ caniuse-lite "^1.0.30001286"
+ electron-to-chromium "^1.4.17"
+ escalade "^3.1.1"
+ node-releases "^2.0.1"
+ picocolors "^1.0.0"
+
+buffer@^5.5.0:
+ version "5.7.1"
+ resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz"
+ integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+ dependencies:
+ base64-js "^1.3.1"
+ ieee754 "^1.1.13"
+
+camelcase-css@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
+ integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
+
+caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001297:
+ version "1.0.30001300"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz"
+ integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA==
+
+canvas-confetti@^1.9.3:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/canvas-confetti/-/canvas-confetti-1.9.3.tgz#ef4c857420ad8045ab4abe8547261c8cdf229845"
+ integrity sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==
+
+chalk@^4.1.0:
+ version "4.1.2"
+ resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
+ integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+ dependencies:
+ ansi-styles "^4.1.0"
+ supports-color "^7.1.0"
+
+chokidar@^3.6.0:
+ version "3.6.0"
+ resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
+ integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
+ dependencies:
+ anymatch "~3.1.2"
+ braces "~3.0.2"
+ glob-parent "~5.1.2"
+ is-binary-path "~2.1.0"
+ is-glob "~4.0.1"
+ normalize-path "~3.0.0"
+ readdirp "~3.6.0"
+ optionalDependencies:
+ fsevents "~2.3.2"
+
+classnames@^2.2.5:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz"
+ integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
+cli-cursor@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz"
+ integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+ dependencies:
+ restore-cursor "^3.1.0"
+
+cli-spinners@^2.5.0:
+ version "2.9.2"
+ resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz"
+ integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==
+
+clone@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz"
+ integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
+
+color-convert@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
+ integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+ dependencies:
+ color-name "~1.1.4"
+
+color-name@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+commander@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz"
+ integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
+
+confbox@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz"
+ integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
+
+confbox@^0.2.1, confbox@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz"
+ integrity sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==
+
+core-js@^3.1.3:
+ version "3.20.3"
+ resolved "https://registry.npmjs.org/core-js/-/core-js-3.20.3.tgz"
+ integrity sha512-vVl8j8ph6tRS3B8qir40H7yw7voy17xL0piAjlbBUsH7WIfzoedL/ZOr1OV9FyZQLWXsayOJyV4tnRyXR85/ag==
+
+crelt@^1.0.0:
+ version "1.0.5"
+ resolved "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz"
+ integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==
+
+cross-spawn@^7.0.0:
+ version "7.0.3"
+ resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
+ integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+ dependencies:
+ path-key "^3.1.0"
+ shebang-command "^2.0.0"
+ which "^2.0.1"
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+csstype@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"
+ integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+
+dayjs@^1.11.13:
+ version "1.11.13"
+ resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz"
+ integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
+
+debug@^4.4.0, debug@^4.4.1:
+ version "4.4.1"
+ resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz"
+ integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
+ dependencies:
+ ms "^2.1.3"
+
+debug@~4.3.1, debug@~4.3.2:
+ version "4.3.4"
+ resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
+ integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+ dependencies:
+ ms "2.1.2"
+
+defaults@^1.0.3:
+ version "1.0.4"
+ resolved "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz"
+ integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==
+ dependencies:
+ clone "^1.0.2"
+
+defu@^6.1.4:
+ version "6.1.4"
+ resolved "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz"
+ integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
+
+dequal@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz"
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+
+devlop@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz"
+ integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
+ dependencies:
+ dequal "^2.0.0"
+
+didyoumean@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
+ integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
+
+dlv@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz"
+ integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
+
+dompurify@^3.2.6:
+ version "3.2.6"
+ resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz"
+ integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
+ optionalDependencies:
+ "@types/trusted-types" "^2.0.7"
+
+eastasianwidth@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
+ integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+
+echarts@^5.6.0:
+ version "5.6.0"
+ resolved "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz"
+ integrity sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==
+ dependencies:
+ tslib "2.3.0"
+ zrender "5.6.1"
+
+electron-to-chromium@^1.4.17:
+ version "1.4.47"
+ resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.47.tgz"
+ integrity sha512-ZHc8i3/cgeCRK/vC7W2htAG6JqUmOUgDNn/f9yY9J8UjfLjwzwOVEt4MWmgJAdvmxyrsR5KIFA/6+kUHGY0eUA==
+
+emoji-regex@^8.0.0:
+ version "8.0.0"
+ resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
+ integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+emoji-regex@^9.2.2:
+ version "9.2.2"
+ resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
+ integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==
+
+engine.io-client@~6.4.0:
+ version "6.4.0"
+ resolved "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.4.0.tgz"
+ integrity sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.1"
+ engine.io-parser "~5.0.3"
+ ws "~8.11.0"
+ xmlhttprequest-ssl "~2.0.0"
+
+engine.io-client@~6.6.1:
+ version "6.6.3"
+ resolved "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz"
+ integrity sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.1"
+ engine.io-parser "~5.2.1"
+ ws "~8.17.1"
+ xmlhttprequest-ssl "~2.1.1"
+
+engine.io-parser@~5.0.3:
+ version "5.0.6"
+ resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz"
+ integrity sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==
+
+engine.io-parser@~5.2.1:
+ version "5.2.3"
+ resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz"
+ integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==
+
+entities@^4.4.0, entities@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
+entities@^7.0.0:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
+ integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
+
+esbuild@^0.21.3:
+ version "0.21.5"
+ resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz"
+ integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
+ optionalDependencies:
+ "@esbuild/aix-ppc64" "0.21.5"
+ "@esbuild/android-arm" "0.21.5"
+ "@esbuild/android-arm64" "0.21.5"
+ "@esbuild/android-x64" "0.21.5"
+ "@esbuild/darwin-arm64" "0.21.5"
+ "@esbuild/darwin-x64" "0.21.5"
+ "@esbuild/freebsd-arm64" "0.21.5"
+ "@esbuild/freebsd-x64" "0.21.5"
+ "@esbuild/linux-arm" "0.21.5"
+ "@esbuild/linux-arm64" "0.21.5"
+ "@esbuild/linux-ia32" "0.21.5"
+ "@esbuild/linux-loong64" "0.21.5"
+ "@esbuild/linux-mips64el" "0.21.5"
+ "@esbuild/linux-ppc64" "0.21.5"
+ "@esbuild/linux-riscv64" "0.21.5"
+ "@esbuild/linux-s390x" "0.21.5"
+ "@esbuild/linux-x64" "0.21.5"
+ "@esbuild/netbsd-x64" "0.21.5"
+ "@esbuild/openbsd-x64" "0.21.5"
+ "@esbuild/sunos-x64" "0.21.5"
+ "@esbuild/win32-arm64" "0.21.5"
+ "@esbuild/win32-ia32" "0.21.5"
+ "@esbuild/win32-x64" "0.21.5"
+
+escalade@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz"
+ integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+
+escape-string-regexp@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
+ integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+escape-string-regexp@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"
+ integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
+
+estree-walker@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz"
+ integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
+estree-walker@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz"
+ integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
+ dependencies:
+ "@types/estree" "^1.0.0"
+
+exsolve@^1.0.1:
+ version "1.0.5"
+ resolved "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz"
+ integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==
+
+exsolve@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz"
+ integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==
+
+fast-deep-equal@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
+ integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-glob@^3.3.2, fast-glob@^3.3.3:
+ version "3.3.3"
+ resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz"
+ integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
+ dependencies:
+ "@nodelib/fs.stat" "^2.0.2"
+ "@nodelib/fs.walk" "^1.2.3"
+ glob-parent "^5.1.2"
+ merge2 "^1.3.0"
+ micromatch "^4.0.8"
+
+fastq@^1.6.0:
+ version "1.13.0"
+ resolved "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz"
+ integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+ dependencies:
+ reusify "^1.0.4"
+
+fdir@^6.4.4:
+ version "6.4.6"
+ resolved "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz"
+ integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==
+
+feather-icons@^4.28.0:
+ version "4.28.0"
+ resolved "https://registry.npmjs.org/feather-icons/-/feather-icons-4.28.0.tgz"
+ integrity sha512-gRdqKESXRBUZn6Nl0VBq2wPHKRJgZz7yblrrc2lYsS6odkNFDnA4bqvrlEVRUPjE1tFax+0TdbJKZ31ziJuzjg==
+ dependencies:
+ classnames "^2.2.5"
+ core-js "^3.1.3"
+
+feather-icons@^4.29.2:
+ version "4.29.2"
+ resolved "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz"
+ integrity sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==
+ dependencies:
+ classnames "^2.2.5"
+ core-js "^3.1.3"
+
+fill-range@^7.0.1:
+ version "7.0.1"
+ resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz"
+ integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+ dependencies:
+ to-regex-range "^5.0.1"
+
+fill-range@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
+ integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+ dependencies:
+ to-regex-range "^5.0.1"
+
+foreground-child@^3.1.0:
+ version "3.3.0"
+ resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz"
+ integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==
+ dependencies:
+ cross-spawn "^7.0.0"
+ signal-exit "^4.0.1"
+
+fraction.js@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz"
+ integrity sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA==
+
+frappe-ui@^0.1.257:
+ version "0.1.257"
+ resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.257.tgz#a8f307c9ee406eedf9eeb259809c82e8591936d5"
+ integrity sha512-PGFu7sedjM/AvsnClqpnPxZJQyIO8qeTI7aVXDlSKwNoPlA8M5A5bRaFLsejEXf0W4E3wfhMBW/aavHJkTAhQQ==
+ dependencies:
+ "@floating-ui/vue" "^1.1.6"
+ "@headlessui/vue" "^1.7.14"
+ "@popperjs/core" "^2.11.2"
+ "@tailwindcss/forms" "^0.5.3"
+ "@tailwindcss/line-clamp" "^0.4.4"
+ "@tailwindcss/typography" "^0.5.16"
+ "@tiptap/core" "^2.26.1"
+ "@tiptap/extension-code" "^2.26.1"
+ "@tiptap/extension-code-block" "^2.26.1"
+ "@tiptap/extension-code-block-lowlight" "^2.26.1"
+ "@tiptap/extension-color" "^2.26.1"
+ "@tiptap/extension-heading" "^2.26.1"
+ "@tiptap/extension-highlight" "^2.26.1"
+ "@tiptap/extension-image" "^2.26.1"
+ "@tiptap/extension-link" "^2.26.1"
+ "@tiptap/extension-mention" "^2.26.1"
+ "@tiptap/extension-placeholder" "^2.26.1"
+ "@tiptap/extension-table" "^2.26.1"
+ "@tiptap/extension-table-cell" "^2.26.1"
+ "@tiptap/extension-table-header" "^2.26.1"
+ "@tiptap/extension-table-row" "^2.26.1"
+ "@tiptap/extension-task-item" "^2.26.1"
+ "@tiptap/extension-task-list" "^2.26.1"
+ "@tiptap/extension-text-align" "^2.26.1"
+ "@tiptap/extension-text-style" "^2.26.1"
+ "@tiptap/extension-typography" "^2.26.1"
+ "@tiptap/pm" "^2.26.1"
+ "@tiptap/starter-kit" "^2.26.1"
+ "@tiptap/suggestion" "^2.26.1"
+ "@tiptap/vue-3" "^2.26.1"
+ "@vueuse/core" "^10.4.1"
+ dayjs "^1.11.13"
+ dompurify "^3.2.6"
+ echarts "^5.6.0"
+ feather-icons "^4.28.0"
+ grid-layout-plus "^1.1.0"
+ highlight.js "^11.11.1"
+ idb-keyval "^6.2.0"
+ lowlight "^3.3.0"
+ lucide-static "^0.535.0"
+ marked "^15.0.12"
+ ora "5.4.1"
+ prettier "^3.3.2"
+ radix-vue "^1.5.3"
+ reka-ui "^2.5.0"
+ socket.io-client "^4.5.1"
+ tippy.js "^6.3.7"
+ typescript "^5.0.2"
+ unplugin-auto-import "^19.3.0"
+ unplugin-icons "^22.1.0"
+ unplugin-vue-components "^28.4.1"
+
+fsevents@~2.3.2:
+ version "2.3.2"
+ resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+fsevents@~2.3.3:
+ version "2.3.3"
+ resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+glob-parent@^5.1.2, glob-parent@~5.1.2:
+ version "5.1.2"
+ resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
+ integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+ dependencies:
+ is-glob "^4.0.1"
+
+glob-parent@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz"
+ integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+ dependencies:
+ is-glob "^4.0.3"
+
+glob@^10.3.10:
+ version "10.4.5"
+ resolved "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz"
+ integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==
+ dependencies:
+ foreground-child "^3.1.0"
+ jackspeak "^3.1.2"
+ minimatch "^9.0.4"
+ minipass "^7.1.2"
+ package-json-from-dist "^1.0.0"
+ path-scurry "^1.11.1"
+
+globals@^15.14.0:
+ version "15.15.0"
+ resolved "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz"
+ integrity sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==
+
+grid-layout-plus@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.npmjs.org/grid-layout-plus/-/grid-layout-plus-1.1.0.tgz"
+ integrity sha512-Q5uj0U5nx6xfHg8G1CDRJAEg+/40RVJl5jjRImcRwC78BxoJrEkTneT1pyxYMlbZ8fpGPT6QdHJQkD4+W6gt5A==
+ dependencies:
+ "@vexip-ui/hooks" "^2.8.0"
+ "@vexip-ui/utils" "^2.16.1"
+ interactjs "^1.10.27"
+
+has-flag@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
+ integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+hasown@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz"
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+ dependencies:
+ function-bind "^1.1.2"
+
+highlight.js@^11.11.1, highlight.js@~11.11.0:
+ version "11.11.1"
+ resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz"
+ integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
+
+html5-qrcode@^2.3.8:
+ version "2.3.8"
+ resolved "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz"
+ integrity sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==
+
+idb-keyval@^6.2.0:
+ version "6.2.0"
+ resolved "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz"
+ integrity sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==
+ dependencies:
+ safari-14-idb-fix "^3.0.0"
+
+ieee754@^1.1.13:
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
+ integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
+inherits@^2.0.3, inherits@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
+ integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+interactjs@^1.10.27:
+ version "1.10.27"
+ resolved "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz"
+ integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==
+ dependencies:
+ "@interactjs/types" "1.10.27"
+
+is-binary-path@~2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz"
+ integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+ dependencies:
+ binary-extensions "^2.0.0"
+
+is-core-module@^2.13.0:
+ version "2.15.1"
+ resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz"
+ integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==
+ dependencies:
+ hasown "^2.0.2"
+
+is-core-module@^2.16.0:
+ version "2.16.1"
+ resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz"
+ integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
+ dependencies:
+ hasown "^2.0.2"
+
+is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
+ integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
+
+is-fullwidth-code-point@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
+ integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
+ version "4.0.3"
+ resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
+ integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-interactive@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz"
+ integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==
+
+is-number@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz"
+ integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-unicode-supported@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz"
+ integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
+
+isexe@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
+ integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
+
+jackspeak@^3.1.2:
+ version "3.4.3"
+ resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz"
+ integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==
+ dependencies:
+ "@isaacs/cliui" "^8.0.2"
+ optionalDependencies:
+ "@pkgjs/parseargs" "^0.11.0"
+
+jiti@^1.21.6:
+ version "1.21.7"
+ resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz"
+ integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
+
+js-tokens@^9.0.1:
+ version "9.0.1"
+ resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz"
+ integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==
+
+kolorist@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz"
+ integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
+
+lilconfig@^3.0.0:
+ version "3.1.2"
+ resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz"
+ integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==
+
+lilconfig@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz"
+ integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==
+
+lines-and-columns@^1.1.6:
+ version "1.2.4"
+ resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
+ integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
+linkifyjs@^4.2.0:
+ version "4.3.2"
+ resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz"
+ integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
+
+local-pkg@^0.5.1:
+ version "0.5.1"
+ resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.1.tgz#69658638d2a95287534d4c2fff757980100dbb6d"
+ integrity sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==
+ dependencies:
+ mlly "^1.7.3"
+ pkg-types "^1.2.1"
+
+local-pkg@^1.0.0, local-pkg@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz"
+ integrity sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==
+ dependencies:
+ mlly "^1.7.4"
+ pkg-types "^2.0.1"
+ quansync "^0.2.8"
+
+lodash.castarray@^4.4.0:
+ version "4.4.0"
+ resolved "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz"
+ integrity sha1-wCUTUV4wna3dTCTGDP3c9ZdtkRU=
+
+lodash.isplainobject@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz"
+ integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
+lodash.merge@^4.6.2:
+ version "4.6.2"
+ resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
+ integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
+log-symbols@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz"
+ integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
+ dependencies:
+ chalk "^4.1.0"
+ is-unicode-supported "^0.1.0"
+
+lowlight@^3.3.0:
+ version "3.3.0"
+ resolved "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz"
+ integrity sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ devlop "^1.0.0"
+ highlight.js "~11.11.0"
+
+lru-cache@^10.2.0:
+ version "10.4.3"
+ resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
+ integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
+
+lucide-static@^0.535.0:
+ version "0.535.0"
+ resolved "https://registry.npmjs.org/lucide-static/-/lucide-static-0.535.0.tgz"
+ integrity sha512-wlYTSPpeyMjLjQ5jgSAENQwVfURVf2XHV5TDp8YPCJBEyWz+FJGuGB5LYBgOFvWIDOMW+AIoiA8sNd8My/nxlw==
+
+magic-string@^0.30.14:
+ version "0.30.21"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+ integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.5"
+
+magic-string@^0.30.17:
+ version "0.30.17"
+ resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz"
+ integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.0"
+
+markdown-it@^14.0.0:
+ version "14.1.0"
+ resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
+marked@^15.0.12:
+ version "15.0.12"
+ resolved "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz"
+ integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==
+
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
+merge2@^1.3.0:
+ version "1.4.1"
+ resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
+ integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz"
+ integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
+ dependencies:
+ braces "^3.0.3"
+ picomatch "^2.3.1"
+
+mimic-fn@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
+ integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+mini-svg-data-uri@^1.2.3:
+ version "1.4.3"
+ resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.3.tgz"
+ integrity sha512-gSfqpMRC8IxghvMcxzzmMnWpXAChSA+vy4cia33RgerMS8Fex95akUyQZPbxJJmeBGiGmK7n/1OpUX8ksRjIdA==
+
+minimatch@^9.0.4, minimatch@^9.0.5:
+ version "9.0.5"
+ resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"
+ integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
+ dependencies:
+ brace-expansion "^2.0.1"
+
+"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2:
+ version "7.1.2"
+ resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz"
+ integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
+
+mlly@^1.7.3:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e"
+ integrity sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==
+ dependencies:
+ acorn "^8.15.0"
+ pathe "^2.0.3"
+ pkg-types "^1.3.1"
+ ufo "^1.6.1"
+
+mlly@^1.7.4:
+ version "1.7.4"
+ resolved "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz"
+ integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
+ dependencies:
+ acorn "^8.14.0"
+ pathe "^2.0.1"
+ pkg-types "^1.3.0"
+ ufo "^1.5.4"
+
+ms@2.1.2:
+ version "2.1.2"
+ resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
+ integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+muggle-string@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328"
+ integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==
+
+mz@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz"
+ integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
+ dependencies:
+ any-promise "^1.0.0"
+ object-assign "^4.0.1"
+ thenify-all "^1.0.0"
+
+nanoid@^3.1.30:
+ version "3.2.0"
+ resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz"
+ integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
+
+nanoid@^3.3.11:
+ version "3.3.11"
+ resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz"
+ integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+
+nanoid@^3.3.7:
+ version "3.3.7"
+ resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
+ integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
+
+nanoid@^5.0.6:
+ version "5.0.7"
+ resolved "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz"
+ integrity sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==
+
+node-releases@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz"
+ integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
+ integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+normalize-range@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz"
+ integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
+
+object-assign@^4.0.1:
+ version "4.1.1"
+ resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
+ integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-hash@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz"
+ integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
+
+ohash@^2.0.11:
+ version "2.0.11"
+ resolved "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz"
+ integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==
+
+onetime@^5.1.0:
+ version "5.1.2"
+ resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz"
+ integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+ dependencies:
+ mimic-fn "^2.1.0"
+
+ora@5.4.1:
+ version "5.4.1"
+ resolved "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz"
+ integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==
+ dependencies:
+ bl "^4.1.0"
+ chalk "^4.1.0"
+ cli-cursor "^3.1.0"
+ cli-spinners "^2.5.0"
+ is-interactive "^1.0.0"
+ is-unicode-supported "^0.1.0"
+ log-symbols "^4.1.0"
+ strip-ansi "^6.0.0"
+ wcwidth "^1.0.1"
+
+orderedmap@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.0.tgz"
+ integrity sha512-/pIFexOm6S70EPdznemIz3BQZoJ4VTFrhqzu0ACBqBgeLsLxq8e6Jim63ImIfwW/zAD1AlXpRMlOv3aghmo4dA==
+
+package-json-from-dist@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz"
+ integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
+
+package-manager-detector@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz"
+ integrity sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==
+
+path-browserify@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
+ integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
+
+path-key@^3.1.0:
+ version "3.1.1"
+ resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
+ integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
+path-parse@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-scurry@^1.11.1:
+ version "1.11.1"
+ resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz"
+ integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==
+ dependencies:
+ lru-cache "^10.2.0"
+ minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
+
+pathe@^2.0.1, pathe@^2.0.2, pathe@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
+ integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
+
+picocolors@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
+ integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+
+picocolors@^1.1.0, picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
+ integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+picomatch@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz"
+ integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
+
+pify@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
+ integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
+
+pirates@^4.0.1:
+ version "4.0.6"
+ resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz"
+ integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
+
+pkg-types@^1.2.1, pkg-types@^1.3.0, pkg-types@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz"
+ integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
+ dependencies:
+ confbox "^0.1.8"
+ mlly "^1.7.4"
+ pathe "^2.0.1"
+
+pkg-types@^2.0.1:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz"
+ integrity sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==
+ dependencies:
+ confbox "^0.2.1"
+ exsolve "^1.0.1"
+ pathe "^2.0.3"
+
+pkg-types@^2.1.0:
+ version "2.2.0"
+ resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz"
+ integrity sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==
+ dependencies:
+ confbox "^0.2.2"
+ exsolve "^1.0.7"
+ pathe "^2.0.3"
+
+postcss-import@^15.1.0:
+ version "15.1.0"
+ resolved "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz"
+ integrity sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==
+ dependencies:
+ postcss-value-parser "^4.0.0"
+ read-cache "^1.0.0"
+ resolve "^1.1.7"
+
+postcss-js@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
+ integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==
+ dependencies:
+ camelcase-css "^2.0.1"
+
+postcss-load-config@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz"
+ integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==
+ dependencies:
+ lilconfig "^3.0.0"
+ yaml "^2.3.4"
+
+postcss-nested@^6.2.0:
+ version "6.2.0"
+ resolved "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz"
+ integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==
+ dependencies:
+ postcss-selector-parser "^6.1.1"
+
+postcss-selector-parser@6.0.10:
+ version "6.0.10"
+ resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz"
+ integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
+ version "6.1.2"
+ resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz"
+ integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
+ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
+postcss@^8.4.43, postcss@^8.4.47:
+ version "8.4.47"
+ resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz"
+ integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==
+ dependencies:
+ nanoid "^3.3.7"
+ picocolors "^1.1.0"
+ source-map-js "^1.2.1"
+
+postcss@^8.4.5:
+ version "8.4.5"
+ resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz"
+ integrity sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==
+ dependencies:
+ nanoid "^3.1.30"
+ picocolors "^1.0.0"
+ source-map-js "^1.0.1"
+
+postcss@^8.5.3:
+ version "8.5.5"
+ resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz"
+ integrity sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+prettier@^3.3.2:
+ version "3.3.3"
+ resolved "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz"
+ integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==
+
+prosemirror-changeset@^2.3.0:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz"
+ integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==
+ dependencies:
+ prosemirror-transform "^1.0.0"
+
+prosemirror-collab@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz"
+ integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+
+prosemirror-commands@^1.0.0:
+ version "1.5.1"
+ resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.5.1.tgz"
+ integrity sha512-ga1ga/RkbzxfAvb6iEXYmrEpekn5NCwTb8w1dr/gmhSoaGcQ0VPuCzOn5qDEpC45ql2oDkKoKQbRxLJwKLpMTQ==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-commands@^1.6.2:
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz"
+ integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.10.2"
+
+prosemirror-dropcursor@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz"
+ integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+ prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.3.2:
+ version "1.3.2"
+ resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz"
+ integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==
+ dependencies:
+ prosemirror-keymap "^1.0.0"
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.3.0.tgz"
+ integrity sha512-qo/9Wn4B/Bq89/YD+eNWFbAytu6dmIM85EhID+fz9Jcl9+DfGEo8TTSrRhP15+fFEoaPqpHSxlvSzSEbmlxlUA==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-history@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz"
+ integrity sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.31.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-inputrules@^1.4.0:
+ version "1.5.0"
+ resolved "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz"
+ integrity sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@^1.0.0:
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.1.tgz"
+ integrity sha512-kVK6WGC+83LZwuSJnuCb9PsADQnFZllt94qPP3Rx/vLcOUV65+IbBeH2nS5cFggPyEVJhGkGrgYFRrG250WhHQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-keymap@^1.2.2:
+ version "1.2.3"
+ resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz"
+ integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-markdown@^1.13.1:
+ version "1.13.2"
+ resolved "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz"
+ integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
+ dependencies:
+ "@types/markdown-it" "^14.0.0"
+ markdown-it "^14.0.0"
+ prosemirror-model "^1.25.0"
+
+prosemirror-menu@^1.2.4:
+ version "1.2.5"
+ resolved "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz"
+ integrity sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==
+ dependencies:
+ crelt "^1.0.0"
+ prosemirror-commands "^1.0.0"
+ prosemirror-history "^1.0.0"
+ prosemirror-state "^1.0.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.16.0:
+ version "1.19.0"
+ resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.19.0.tgz"
+ integrity sha512-/CvFGJnwc41EJSfDkQLly1cAJJJmBpZwwUJtwZPTjY2RqZJfM8HVbCreOY/jti8wTRbVyjagcylyGoeJH/g/3w==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-model@^1.20.0:
+ version "1.25.1"
+ resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz"
+ integrity sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-model@^1.21.0, prosemirror-model@^1.23.0, prosemirror-model@^1.25.0:
+ version "1.25.3"
+ resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz"
+ integrity sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-schema-basic@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz"
+ integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==
+ dependencies:
+ prosemirror-model "^1.25.0"
+
+prosemirror-schema-list@^1.4.1:
+ version "1.5.1"
+ resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz"
+ integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.7.3"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2:
+ version "1.4.2"
+ resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.2.tgz"
+ integrity sha512-puuzLD2mz/oTdfgd8msFbe0A42j5eNudKAAPDB0+QJRw8cO1ygjLmhLrg9RvDpf87Dkd6D4t93qdef00KKNacQ==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-state@^1.4.3:
+ version "1.4.3"
+ resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz"
+ integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-tables@^1.6.4:
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz"
+ integrity sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==
+ dependencies:
+ prosemirror-keymap "^1.2.2"
+ prosemirror-model "^1.25.0"
+ prosemirror-state "^1.4.3"
+ prosemirror-transform "^1.10.3"
+ prosemirror-view "^1.39.1"
+
+prosemirror-trailing-node@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz"
+ integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==
+ dependencies:
+ "@remirror/core-constants" "3.0.0"
+ escape-string-regexp "^4.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0:
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.7.1.tgz"
+ integrity sha512-VteoifAfpt46z0yEt6Fc73A5OID9t/y2QIeR5MgxEwTuitadEunD/V0c9jQW8ziT8pbFM54uTzRLJ/nLuQjMxg==
+ dependencies:
+ prosemirror-model "^1.0.0"
+
+prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.3, prosemirror-transform@^1.7.3:
+ version "1.10.4"
+ resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz"
+ integrity sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==
+ dependencies:
+ prosemirror-model "^1.21.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0:
+ version "1.30.2"
+ resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.30.2.tgz"
+ integrity sha512-nTNzZvalQf9kHeEyO407LiV6DoOs/pXsid88UqW9Vvybo4ozJW2PJhkfZUxCUF1hR/9vJLdhxX84wuw9P9HsXA==
+ dependencies:
+ prosemirror-model "^1.16.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
+prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.39.1:
+ version "1.40.1"
+ resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.1.tgz"
+ integrity sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
+quansync@^0.2.8:
+ version "0.2.10"
+ resolved "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz"
+ integrity sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==
+
+queue-microtask@^1.2.2:
+ version "1.2.3"
+ resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
+ integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+radix-vue@^1.5.3:
+ version "1.7.4"
+ resolved "https://registry.npmjs.org/radix-vue/-/radix-vue-1.7.4.tgz"
+ integrity sha512-Z75iJVb2rNL6347pWwqDIP02ktTowiDKVsScrLRv9y7uj8tMsEcD4pC0oTogVScRP5voZd3rov6zKE20MQTj+A==
+ dependencies:
+ "@floating-ui/dom" "^1.5.4"
+ "@floating-ui/vue" "^1.0.4"
+ "@internationalized/date" "^3.5.2"
+ "@tanstack/vue-virtual" "^3.1.3"
+ "@vueuse/core" "^10.5.0"
+ "@vueuse/shared" "^10.5.0"
+ aria-hidden "^1.2.3"
+ defu "^6.1.4"
+ fast-deep-equal "^3.1.3"
+ nanoid "^5.0.6"
+
+read-cache@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz"
+ integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
+ dependencies:
+ pify "^2.3.0"
+
+readable-stream@^3.4.0:
+ version "3.6.2"
+ resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz"
+ integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
+ dependencies:
+ inherits "^2.0.3"
+ string_decoder "^1.1.1"
+ util-deprecate "^1.0.1"
+
+readdirp@~3.6.0:
+ version "3.6.0"
+ resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
+ integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+ dependencies:
+ picomatch "^2.2.1"
+
+reka-ui@^2.5.0:
+ version "2.5.0"
+ resolved "https://registry.yarnpkg.com/reka-ui/-/reka-ui-2.5.0.tgz#3797f3cc5296ebb3352a8fa79f0eb506a9d27570"
+ integrity sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==
+ dependencies:
+ "@floating-ui/dom" "^1.6.13"
+ "@floating-ui/vue" "^1.1.6"
+ "@internationalized/date" "^3.5.0"
+ "@internationalized/number" "^3.5.0"
+ "@tanstack/vue-virtual" "^3.12.0"
+ "@vueuse/core" "^12.5.0"
+ "@vueuse/shared" "^12.5.0"
+ aria-hidden "^1.2.4"
+ defu "^6.1.4"
+ ohash "^2.0.11"
+
+resolve@^1.1.7:
+ version "1.22.8"
+ resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz"
+ integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
+ dependencies:
+ is-core-module "^2.13.0"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
+resolve@^1.22.8:
+ version "1.22.10"
+ resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz"
+ integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==
+ dependencies:
+ is-core-module "^2.16.0"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
+restore-cursor@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz"
+ integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+ dependencies:
+ onetime "^5.1.0"
+ signal-exit "^3.0.2"
+
+reusify@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
+ integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
+rollup@^4.20.0:
+ version "4.24.4"
+ resolved "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz"
+ integrity sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA==
+ dependencies:
+ "@types/estree" "1.0.6"
+ optionalDependencies:
+ "@rollup/rollup-android-arm-eabi" "4.24.4"
+ "@rollup/rollup-android-arm64" "4.24.4"
+ "@rollup/rollup-darwin-arm64" "4.24.4"
+ "@rollup/rollup-darwin-x64" "4.24.4"
+ "@rollup/rollup-freebsd-arm64" "4.24.4"
+ "@rollup/rollup-freebsd-x64" "4.24.4"
+ "@rollup/rollup-linux-arm-gnueabihf" "4.24.4"
+ "@rollup/rollup-linux-arm-musleabihf" "4.24.4"
+ "@rollup/rollup-linux-arm64-gnu" "4.24.4"
+ "@rollup/rollup-linux-arm64-musl" "4.24.4"
+ "@rollup/rollup-linux-powerpc64le-gnu" "4.24.4"
+ "@rollup/rollup-linux-riscv64-gnu" "4.24.4"
+ "@rollup/rollup-linux-s390x-gnu" "4.24.4"
+ "@rollup/rollup-linux-x64-gnu" "4.24.4"
+ "@rollup/rollup-linux-x64-musl" "4.24.4"
+ "@rollup/rollup-win32-arm64-msvc" "4.24.4"
+ "@rollup/rollup-win32-ia32-msvc" "4.24.4"
+ "@rollup/rollup-win32-x64-msvc" "4.24.4"
+ fsevents "~2.3.2"
+
+rope-sequence@^1.3.0:
+ version "1.3.3"
+ resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.3.tgz"
+ integrity sha512-85aZYCxweiD5J8yTEbw+E6A27zSnLPNDL0WfPdw3YYodq7WjnTKo0q4dtyQ2gz23iPT8Q9CUyJtAaUNcTxRf5Q==
+
+run-parallel@^1.1.9:
+ version "1.2.0"
+ resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
+ integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+ dependencies:
+ queue-microtask "^1.2.2"
+
+safari-14-idb-fix@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz"
+ integrity sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==
+
+safe-buffer@~5.2.0:
+ version "5.2.1"
+ resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
+ integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+scule@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz"
+ integrity sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==
+
+shebang-command@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
+ integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+ dependencies:
+ shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
+ integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+signal-exit@^3.0.2:
+ version "3.0.7"
+ resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
+ integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
+signal-exit@^4.0.1:
+ version "4.1.0"
+ resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"
+ integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+
+socket.io-client@^4.5.1:
+ version "4.6.1"
+ resolved "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.6.1.tgz"
+ integrity sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.2"
+ engine.io-client "~6.4.0"
+ socket.io-parser "~4.2.1"
+
+socket.io-client@^4.7.2:
+ version "4.8.1"
+ resolved "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz"
+ integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.2"
+ engine.io-client "~6.6.1"
+ socket.io-parser "~4.2.4"
+
+socket.io-parser@~4.2.1:
+ version "4.2.2"
+ resolved "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz"
+ integrity sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.1"
+
+socket.io-parser@~4.2.4:
+ version "4.2.4"
+ resolved "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz"
+ integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.1"
+
+source-map-js@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.1.tgz"
+ integrity sha512-4+TN2b3tqOCd/kaGRJ/sTYA0tR0mdXx26ipdolxcwtJVqEnqNYvlCAt1q3ypy4QMlYus+Zh34RNtYLoq2oQ4IA==
+
+source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.1.0:
+ version "4.2.3"
+ resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^5.0.1, string-width@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz"
+ integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
+ dependencies:
+ eastasianwidth "^0.2.0"
+ emoji-regex "^9.2.2"
+ strip-ansi "^7.0.1"
+
+string_decoder@^1.1.1:
+ version "1.3.0"
+ resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
+ integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+ dependencies:
+ safe-buffer "~5.2.0"
+
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ version "6.0.1"
+ resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^7.0.1:
+ version "7.1.0"
+ resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz"
+ integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
+ dependencies:
+ ansi-regex "^6.0.1"
+
+strip-literal@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.1.tgz#26906e65f606d49f748454a08084e94190c2e5ad"
+ integrity sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==
+ dependencies:
+ js-tokens "^9.0.1"
+
+strip-literal@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz"
+ integrity sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==
+ dependencies:
+ js-tokens "^9.0.1"
+
+sucrase@^3.35.0:
+ version "3.35.0"
+ resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz"
+ integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.2"
+ commander "^4.0.0"
+ glob "^10.3.10"
+ lines-and-columns "^1.1.6"
+ mz "^2.7.0"
+ pirates "^4.0.1"
+ ts-interface-checker "^0.1.9"
+
+supports-color@^7.1.0:
+ version "7.2.0"
+ resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
+ integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+ dependencies:
+ has-flag "^4.0.0"
+
+supports-preserve-symlinks-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
+ integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+tailwindcss@^3.4.15:
+ version "3.4.17"
+ resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz"
+ integrity sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==
+ dependencies:
+ "@alloc/quick-lru" "^5.2.0"
+ arg "^5.0.2"
+ chokidar "^3.6.0"
+ didyoumean "^1.2.2"
+ dlv "^1.1.3"
+ fast-glob "^3.3.2"
+ glob-parent "^6.0.2"
+ is-glob "^4.0.3"
+ jiti "^1.21.6"
+ lilconfig "^3.1.3"
+ micromatch "^4.0.8"
+ normalize-path "^3.0.0"
+ object-hash "^3.0.0"
+ picocolors "^1.1.1"
+ postcss "^8.4.47"
+ postcss-import "^15.1.0"
+ postcss-js "^4.0.1"
+ postcss-load-config "^4.0.2"
+ postcss-nested "^6.2.0"
+ postcss-selector-parser "^6.1.2"
+ resolve "^1.22.8"
+ sucrase "^3.35.0"
+
+thenify-all@^1.0.0:
+ version "1.6.0"
+ resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz"
+ integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
+ dependencies:
+ thenify ">= 3.1.0 < 4"
+
+"thenify@>= 3.1.0 < 4":
+ version "3.3.1"
+ resolved "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz"
+ integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
+ dependencies:
+ any-promise "^1.0.0"
+
+tinyexec@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz"
+ integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==
+
+tinyglobby@^0.2.12, tinyglobby@^0.2.14:
+ version "0.2.14"
+ resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz"
+ integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==
+ dependencies:
+ fdir "^6.4.4"
+ picomatch "^4.0.2"
+
+tippy.js@^6.3.7:
+ version "6.3.7"
+ resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz"
+ integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
+ dependencies:
+ "@popperjs/core" "^2.9.0"
+
+to-regex-range@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
+ integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+ dependencies:
+ is-number "^7.0.0"
+
+ts-interface-checker@^0.1.9:
+ version "0.1.13"
+ resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
+ integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
+
+tslib@2.3.0:
+ version "2.3.0"
+ resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz"
+ integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
+
+tslib@^2.0.0, tslib@^2.4.0:
+ version "2.6.2"
+ resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
+ integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+
+typescript@^5.0.2:
+ version "5.8.3"
+ resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
+ integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
+
+typescript@^5.9.3:
+ version "5.9.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
+ integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
+
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
+ufo@^1.5.4, ufo@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz"
+ integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
+
+undici-types@~7.16.0:
+ version "7.16.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
+ integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
+
+unimport@^3.13.4:
+ version "3.14.6"
+ resolved "https://registry.yarnpkg.com/unimport/-/unimport-3.14.6.tgz#f01170aa2fb94c4f97b22c0ac2822ef7e8e0726d"
+ integrity sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==
+ dependencies:
+ "@rollup/pluginutils" "^5.1.4"
+ acorn "^8.14.0"
+ escape-string-regexp "^5.0.0"
+ estree-walker "^3.0.3"
+ fast-glob "^3.3.3"
+ local-pkg "^1.0.0"
+ magic-string "^0.30.17"
+ mlly "^1.7.4"
+ pathe "^2.0.1"
+ picomatch "^4.0.2"
+ pkg-types "^1.3.0"
+ scule "^1.3.0"
+ strip-literal "^2.1.1"
+ unplugin "^1.16.1"
+
+unimport@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.npmjs.org/unimport/-/unimport-4.2.0.tgz"
+ integrity sha512-mYVtA0nmzrysnYnyb3ALMbByJ+Maosee2+WyE0puXl+Xm2bUwPorPaaeZt0ETfuroPOtG8jj1g/qeFZ6buFnag==
+ dependencies:
+ acorn "^8.14.1"
+ escape-string-regexp "^5.0.0"
+ estree-walker "^3.0.3"
+ local-pkg "^1.1.1"
+ magic-string "^0.30.17"
+ mlly "^1.7.4"
+ pathe "^2.0.3"
+ picomatch "^4.0.2"
+ pkg-types "^2.1.0"
+ scule "^1.3.0"
+ strip-literal "^3.0.0"
+ tinyglobby "^0.2.12"
+ unplugin "^2.2.2"
+ unplugin-utils "^0.2.4"
+
+unplugin-auto-import@0.18.6:
+ version "0.18.6"
+ resolved "https://registry.yarnpkg.com/unplugin-auto-import/-/unplugin-auto-import-0.18.6.tgz#6848bef1742bc457f42cf67a13e824f69debc889"
+ integrity sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==
+ dependencies:
+ "@antfu/utils" "^0.7.10"
+ "@rollup/pluginutils" "^5.1.3"
+ fast-glob "^3.3.2"
+ local-pkg "^0.5.1"
+ magic-string "^0.30.14"
+ minimatch "^9.0.5"
+ unimport "^3.13.4"
+ unplugin "^1.16.0"
+
+unplugin-auto-import@^19.3.0:
+ version "19.3.0"
+ resolved "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-19.3.0.tgz"
+ integrity sha512-iIi0u4Gq2uGkAOGqlPJOAMI8vocvjh1clGTfSK4SOrJKrt+tirrixo/FjgBwXQNNdS7ofcr7OxzmOb/RjWxeEQ==
+ dependencies:
+ local-pkg "^1.1.1"
+ magic-string "^0.30.17"
+ picomatch "^4.0.2"
+ unimport "^4.2.0"
+ unplugin "^2.3.4"
+ unplugin-utils "^0.2.4"
+
+unplugin-icons@^22.1.0:
+ version "22.1.0"
+ resolved "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-22.1.0.tgz"
+ integrity sha512-ect2ZNtk1Zgwb0NVHd0C1IDW/MV+Jk/xaq4t8o6rYdVS3+L660ZdD5kTSQZvsgdwCvquRw+/wYn75hsweRjoIA==
+ dependencies:
+ "@antfu/install-pkg" "^1.0.0"
+ "@iconify/utils" "^2.3.0"
+ debug "^4.4.0"
+ local-pkg "^1.0.0"
+ unplugin "^2.2.0"
+
+unplugin-utils@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.4.tgz"
+ integrity sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==
+ dependencies:
+ pathe "^2.0.2"
+ picomatch "^4.0.2"
+
+unplugin-vue-components@^28.4.1:
+ version "28.7.0"
+ resolved "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-28.7.0.tgz"
+ integrity sha512-3SuWAHlTjOiZckqRBGXRdN/k6IMmKyt2Ch5/+DKwYaT321H0ItdZDvW4r8/YkEKQpN9TN3F/SZ0W342gQROC3Q==
+ dependencies:
+ chokidar "^3.6.0"
+ debug "^4.4.1"
+ local-pkg "^1.1.1"
+ magic-string "^0.30.17"
+ mlly "^1.7.4"
+ tinyglobby "^0.2.14"
+ unplugin "^2.3.4"
+ unplugin-utils "^0.2.4"
+
+unplugin@^1.16.0, unplugin@^1.16.1:
+ version "1.16.1"
+ resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.16.1.tgz#a844d2e3c3b14a4ac2945c42be80409321b61199"
+ integrity sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==
+ dependencies:
+ acorn "^8.14.0"
+ webpack-virtual-modules "^0.6.2"
+
+unplugin@^2.2.0, unplugin@^2.2.2, unplugin@^2.3.4:
+ version "2.3.5"
+ resolved "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz"
+ integrity sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==
+ dependencies:
+ acorn "^8.14.1"
+ picomatch "^4.0.2"
+ webpack-virtual-modules "^0.6.2"
+
+util-deprecate@^1.0.1, util-deprecate@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
+ integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+vite@^5.4.10:
+ version "5.4.10"
+ resolved "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz"
+ integrity sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==
+ dependencies:
+ esbuild "^0.21.3"
+ postcss "^8.4.43"
+ rollup "^4.20.0"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
+vscode-uri@^3.0.8:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c"
+ integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==
+
+vue-demi@>=0.13.0:
+ version "0.14.7"
+ resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz"
+ integrity sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==
+
+vue-demi@>=0.14.8:
+ version "0.14.10"
+ resolved "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz"
+ integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
+
+vue-router@^4.5.0:
+ version "4.5.1"
+ resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz"
+ integrity sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==
+ dependencies:
+ "@vue/devtools-api" "^6.6.4"
+
+vue-tsc@^3.2.4:
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.4.tgz#a8cebd4b44e6804a99f4d88a8161a4bfb293c3b4"
+ integrity sha512-xj3YCvSLNDKt1iF9OcImWHhmYcihVu9p4b9s4PGR/qp6yhW+tZJaypGxHScRyOrdnHvaOeF+YkZOdKwbgGvp5g==
+ dependencies:
+ "@volar/typescript" "2.4.27"
+ "@vue/language-core" "3.2.4"
+
+vue@^3.5.13:
+ version "3.5.16"
+ resolved "https://registry.npmjs.org/vue/-/vue-3.5.16.tgz"
+ integrity sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==
+ dependencies:
+ "@vue/compiler-dom" "3.5.16"
+ "@vue/compiler-sfc" "3.5.16"
+ "@vue/runtime-dom" "3.5.16"
+ "@vue/server-renderer" "3.5.16"
+ "@vue/shared" "3.5.16"
+
+w3c-keyname@^2.2.0:
+ version "2.2.6"
+ resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz"
+ integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==
+
+wcwidth@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz"
+ integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==
+ dependencies:
+ defaults "^1.0.3"
+
+webpack-virtual-modules@^0.6.2:
+ version "0.6.2"
+ resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
+ integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
+
+which@^2.0.1:
+ version "2.0.2"
+ resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
+ integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+ dependencies:
+ isexe "^2.0.0"
+
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
+wrap-ansi@^8.1.0:
+ version "8.1.0"
+ resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
+ integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
+ dependencies:
+ ansi-styles "^6.1.0"
+ string-width "^5.0.1"
+ strip-ansi "^7.0.1"
+
+ws@~8.11.0:
+ version "8.11.0"
+ resolved "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz"
+ integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
+
+ws@~8.17.1:
+ version "8.17.1"
+ resolved "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz"
+ integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
+
+xmlhttprequest-ssl@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz"
+ integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
+
+xmlhttprequest-ssl@~2.1.1:
+ version "2.1.2"
+ resolved "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz"
+ integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==
+
+yaml@^2.3.4:
+ version "2.6.0"
+ resolved "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz"
+ integrity sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==
+
+zrender@5.6.1:
+ version "5.6.1"
+ resolved "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz"
+ integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==
+ dependencies:
+ tslib "2.3.0"
diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts
new file mode 100644
index 0000000..c236396
--- /dev/null
+++ b/e2e/helpers/auth.ts
@@ -0,0 +1,80 @@
+import { APIRequestContext, BrowserContext, Page } from "@playwright/test";
+
+const STORAGE_STATE_PATH = "e2e/.auth/user.json";
+
+/**
+ * Login via Frappe API (faster than UI login).
+ * Sets cookies on the request context for subsequent API calls.
+ */
+export async function loginViaAPI(
+ request: APIRequestContext,
+ email = "Administrator",
+ password = "admin",
+): Promise {
+ const response = await request.post("/api/method/login", {
+ form: {
+ usr: email,
+ pwd: password,
+ },
+ });
+
+ if (!response.ok()) {
+ throw new Error(`Login failed: ${response.status()} ${await response.text()}`);
+ }
+}
+
+/**
+ * Login via UI (for testing the login flow itself).
+ */
+export async function loginViaUI(
+ page: Page,
+ email = "Administrator",
+ password = "admin",
+): Promise {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ await page.fill('input[data-fieldname="email"]', email);
+ await page.fill('input[data-fieldname="password"]', password);
+ await page.click('button[type="submit"]');
+
+ // Wait for redirect to desk/app
+ await page.waitForURL(/\/(app|desk)/, { timeout: 30000 });
+}
+
+/**
+ * Logout the current user.
+ */
+export async function logout(page: Page): Promise {
+ await page.goto("/api/method/logout");
+ await page.waitForLoadState("networkidle");
+}
+
+/**
+ * Save authentication state for reuse across tests.
+ */
+export async function saveAuthState(context: BrowserContext): Promise {
+ await context.storageState({ path: STORAGE_STATE_PATH });
+}
+
+/**
+ * Get the storage state path for authenticated sessions.
+ */
+export function getStorageStatePath() {
+ return STORAGE_STATE_PATH;
+}
+
+/**
+ * Check if user is logged in by verifying session.
+ */
+export async function isLoggedIn(request: APIRequestContext): Promise {
+ try {
+ const response = await request.get("/api/method/frappe.auth.get_logged_user");
+ if (!response.ok()) return false;
+
+ const data = (await response.json()) as { message?: string };
+ return Boolean(data.message && data.message !== "Guest");
+ } catch {
+ return false;
+ }
+}
diff --git a/e2e/helpers/frappe.ts b/e2e/helpers/frappe.ts
new file mode 100644
index 0000000..5338df3
--- /dev/null
+++ b/e2e/helpers/frappe.ts
@@ -0,0 +1,223 @@
+import { APIRequestContext } from "@playwright/test";
+import * as fs from "fs";
+
+/**
+ * Frappe API response wrapper.
+ */
+export interface FrappeResponse {
+ message?: T;
+ exc?: string;
+ exc_type?: string;
+ _server_messages?: string;
+}
+
+// Path to CSRF token file saved by auth.setup.ts
+const CSRF_FILE = "e2e/.auth/csrf.json";
+
+// Cache for CSRF token (read from file once)
+let csrfTokenCache: string | null = null;
+
+/**
+ * Get CSRF token from the file saved during auth setup.
+ * The token is extracted from window.frappe.csrf_token after login.
+ */
+function getCsrfToken(): string {
+ // Return cached token if available
+ if (csrfTokenCache !== null) {
+ return csrfTokenCache;
+ }
+
+ // Read token from file
+ try {
+ if (fs.existsSync(CSRF_FILE)) {
+ const data = JSON.parse(fs.readFileSync(CSRF_FILE, "utf-8"));
+ csrfTokenCache = data.csrf_token || "";
+ return csrfTokenCache;
+ }
+ } catch (error) {
+ console.warn("Failed to read CSRF token file:", error);
+ }
+
+ csrfTokenCache = "";
+ return "";
+}
+
+/**
+ * Create a new document via Frappe REST API.
+ */
+export async function createDoc>(
+ request: APIRequestContext,
+ doctype: string,
+ doc: Record,
+): Promise {
+ const csrfToken = getCsrfToken();
+
+ const response = await request.post(`/api/resource/${doctype}`, {
+ data: doc,
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
+ },
+ });
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to create ${doctype}: ${error}`);
+ }
+
+ const result = await response.json();
+ return result.data as T;
+}
+
+/**
+ * Get a document by name via Frappe REST API.
+ */
+export async function getDoc>(
+ request: APIRequestContext,
+ doctype: string,
+ name: string,
+): Promise {
+ const response = await request.get(
+ `/api/resource/${doctype}/${encodeURIComponent(name)}`,
+ );
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to get ${doctype}/${name}: ${error}`);
+ }
+
+ const result = await response.json();
+ return result.data as T;
+}
+
+/**
+ * Update a document via Frappe REST API.
+ */
+export async function updateDoc>(
+ request: APIRequestContext,
+ doctype: string,
+ name: string,
+ updates: Record,
+): Promise {
+ const csrfToken = getCsrfToken();
+
+ const response = await request.put(`/api/resource/${doctype}/${encodeURIComponent(name)}`, {
+ data: updates,
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
+ },
+ });
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to update ${doctype}/${name}: ${error}`);
+ }
+
+ const result = await response.json();
+ return result.data as T;
+}
+
+/**
+ * Delete a document via Frappe REST API.
+ */
+export async function deleteDoc(
+ request: APIRequestContext,
+ doctype: string,
+ name: string,
+): Promise {
+ const csrfToken = getCsrfToken();
+
+ const response = await request.delete(`/api/resource/${doctype}/${encodeURIComponent(name)}`, {
+ headers: {
+ ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
+ },
+ });
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to delete ${doctype}/${name}: ${error}`);
+ }
+}
+
+/**
+ * Call a Frappe whitelisted method.
+ */
+export async function callMethod(
+ request: APIRequestContext,
+ method: string,
+ args: Record = {},
+): Promise {
+ const csrfToken = getCsrfToken();
+
+ const response = await request.post(`/api/method/${method}`, {
+ data: args,
+ headers: {
+ "Content-Type": "application/json",
+ ...(csrfToken ? { "X-Frappe-CSRF-Token": csrfToken } : {}),
+ },
+ });
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to call ${method}: ${error}`);
+ }
+
+ const result: FrappeResponse = await response.json();
+ return result.message as T;
+}
+
+/**
+ * Get a list of documents via Frappe REST API.
+ */
+export async function getList>(
+ request: APIRequestContext,
+ doctype: string,
+ options: {
+ fields?: string[];
+ filters?: Record;
+ limit?: number;
+ orderBy?: string;
+ } = {},
+): Promise {
+ const params = new URLSearchParams();
+
+ if (options.fields) {
+ params.set("fields", JSON.stringify(options.fields));
+ }
+ if (options.filters) {
+ params.set("filters", JSON.stringify(options.filters));
+ }
+ if (options.limit) {
+ params.set("limit_page_length", options.limit.toString());
+ }
+ if (options.orderBy) {
+ params.set("order_by", options.orderBy);
+ }
+
+ const response = await request.get(`/api/resource/${doctype}?${params.toString()}`);
+
+ if (!response.ok()) {
+ const error = await response.text();
+ throw new Error(`Failed to get list of ${doctype}: ${error}`);
+ }
+
+ const result = await response.json();
+ return result.data as T[];
+}
+
+/**
+ * Check if a document exists.
+ */
+export async function docExists(
+ request: APIRequestContext,
+ doctype: string,
+ name: string,
+): Promise {
+ try {
+ await getDoc(request, doctype, name);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts
new file mode 100644
index 0000000..d94023b
--- /dev/null
+++ b/e2e/helpers/index.ts
@@ -0,0 +1,2 @@
+export * from "./auth";
+export * from "./frappe";
diff --git a/e2e/pages/booking.page.ts b/e2e/pages/booking.page.ts
new file mode 100644
index 0000000..3f3dbba
--- /dev/null
+++ b/e2e/pages/booking.page.ts
@@ -0,0 +1,120 @@
+import { expect, Locator, Page } from "@playwright/test";
+
+export class BookingPage {
+ private page: Page;
+ attendeeNameInput: Locator;
+ attendeeEmailInput: Locator;
+ ticketTypeSelect: Locator;
+ addOnCheckboxes: Locator;
+ private bookButton: Locator;
+ private addAttendeeButton: Locator;
+ private bookingForm: Locator;
+ private summarySection: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+
+ // Form elements
+ this.attendeeNameInput = page
+ .locator('input[placeholder*="name" i], input[name*="name" i]')
+ .first();
+ this.attendeeEmailInput = page
+ .locator('input[type="email"], input[placeholder*="email" i]')
+ .first();
+ this.ticketTypeSelect = page.locator('select, [data-testid="ticket-type"]').first();
+
+ // Add-ons
+ this.addOnCheckboxes = page.locator('input[type="checkbox"]');
+
+ // Buttons
+ this.bookButton = page.locator('button[type="submit"]');
+ this.addAttendeeButton = page.locator('button:has-text("Add Another Attendee")');
+
+ // Sections
+ this.bookingForm = page.locator("form");
+ this.summarySection = page.locator('[class*="summary" i]').first();
+ }
+
+ // Navigate to the booking page for a specific event.
+ async goto(eventRoute: string): Promise {
+ await this.page.goto(`/dashboard/book-tickets/${eventRoute}`);
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ // Wait for the booking form to fully load.
+ async waitForFormLoad(): Promise {
+ await expect(this.bookingForm).toBeVisible({ timeout: 15000 });
+ }
+
+ // Fill in attendee details.
+ async fillAttendeeDetails(name: string, email: string): Promise {
+ await this.attendeeNameInput.fill(name);
+ await this.attendeeEmailInput.fill(email);
+ }
+
+ // Select a ticket type by its visible text.
+ async selectTicketType(ticketTitle: string): Promise {
+ const ticketOption = this.page.locator(`text=${ticketTitle}`).first();
+ if (await ticketOption.isVisible()) {
+ await ticketOption.click();
+ }
+ }
+
+ /**
+ * Toggle an add-on by its title.
+ */
+ async toggleAddOn(addOnTitle: string): Promise {
+ const addOnLabel = this.page.locator(`label:has-text("${addOnTitle}")`).first();
+ if (await addOnLabel.isVisible()) {
+ await addOnLabel.click();
+ }
+ }
+
+ // Add another attendee to the booking.
+ async addAnotherAttendee(): Promise {
+ await this.addAttendeeButton.click();
+ }
+
+ // Submit the booking form.
+ async submit(): Promise {
+ await this.bookButton.click();
+ }
+
+ // Get the booking button text.
+ async getBookButtonText(): Promise {
+ return this.bookButton.textContent();
+ }
+
+ // Assert that the booking form is visible.
+ async expectFormVisible(): Promise {
+ await expect(this.bookingForm).toBeVisible();
+ }
+
+ // Assert that ticket types are displayed.
+ async expectTicketTypesVisible(): Promise {
+ // Check for any ticket-related content
+ const ticketContent = this.page
+ .locator('[class*="ticket"], select, input[type="radio"]')
+ .first();
+ await expect(ticketContent).toBeVisible({ timeout: 10000 });
+ }
+
+ // Assert that add-ons are displayed.
+ async expectAddOnsVisible(): Promise {
+ const addOnCount = await this.addOnCheckboxes.count();
+ expect(addOnCount).toBeGreaterThan(0);
+ }
+
+ // Assert that the book button is visible with expected text.
+ async expectBookButtonVisible(): Promise {
+ await expect(this.bookButton).toBeVisible();
+ const text = await this.bookButton.textContent();
+ expect(text?.match(/Book|Pay|Register/i)).toBeTruthy();
+ }
+
+ // Get the count of attendee forms on the page.
+ async getAttendeeCount(): Promise {
+ const attendeeForms = this.page.locator('[class*="attendee"], [class*="Attendee"]');
+ return await attendeeForms.count();
+ }
+}
diff --git a/e2e/pages/custom-form.page.ts b/e2e/pages/custom-form.page.ts
new file mode 100644
index 0000000..217110f
--- /dev/null
+++ b/e2e/pages/custom-form.page.ts
@@ -0,0 +1,96 @@
+import { expect, Locator, Page } from "@playwright/test";
+
+export class CustomFormPage {
+ private page: Page;
+ private form: Locator;
+ private submitButton: Locator;
+ private successBanner: Locator;
+ private closedBanner: Locator;
+ private errorBanner: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.form = page.locator("form");
+ this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ });
+ this.successBanner = page.locator(".bg-surface-green-1");
+ this.closedBanner = page.locator(".bg-surface-orange-1");
+ this.errorBanner = page.locator(".bg-surface-amber-1");
+ }
+
+ async goto(eventRoute: string, formRoute: string): Promise {
+ await this.page.goto(`/dashboard/events/${eventRoute}/forms/${formRoute}`);
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ async waitForFormLoad(): Promise {
+ await expect(this.form).toBeVisible({ timeout: 15000 });
+ }
+
+ getInputByLabel(label: string): Locator {
+ return this.page
+ .locator(`label:has-text("${label}")`)
+ .locator("..")
+ .locator("input, textarea, select")
+ .first();
+ }
+
+ async submit(): Promise {
+ await this.submitButton.click();
+ }
+
+ async expectFormVisible(): Promise {
+ await expect(this.form).toBeVisible();
+ }
+
+ async expectFormTitle(title: string): Promise {
+ await expect(this.page.locator(`h1:has-text("${title}")`)).toBeVisible();
+ }
+
+ async expectFieldVisible(label: string): Promise {
+ await expect(this.page.locator(`label:has-text("${label}")`)).toBeVisible();
+ }
+
+ async expectSubmitButtonVisible(): Promise {
+ await expect(this.submitButton).toBeVisible();
+ const text = await this.submitButton.textContent();
+ expect(text?.match(/Submit/i)).toBeTruthy();
+ }
+
+ async submitAndExpectResponse(): Promise<{ succeeded: boolean; status: number }> {
+ const responsePromise = this.page.waitForResponse(
+ (resp) => resp.url().includes("submit_custom_form"),
+ { timeout: 20000 },
+ );
+
+ await this.submitButton.click();
+
+ const response = await responsePromise;
+ const status = response.status();
+ const succeeded = status === 200;
+
+ if (succeeded) {
+ await expect(this.successBanner).toBeVisible({ timeout: 15000 });
+ } else {
+ await expect(this.form).toBeVisible();
+ }
+
+ return { succeeded, status };
+ }
+
+ async expectSuccess(): Promise {
+ await expect(this.successBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ async expectClosed(): Promise {
+ await expect(this.closedBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ async expectNotFound(): Promise {
+ await expect(this.errorBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ async getFieldLabels(): Promise {
+ const labels = this.page.locator("form label");
+ return labels.allTextContents();
+ }
+}
diff --git a/e2e/pages/event-proposal.page.ts b/e2e/pages/event-proposal.page.ts
new file mode 100644
index 0000000..ec6b3de
--- /dev/null
+++ b/e2e/pages/event-proposal.page.ts
@@ -0,0 +1,81 @@
+import { expect, Locator, Page } from "@playwright/test";
+
+export class EventProposalPage {
+ private page: Page;
+ private form: Locator;
+ private submitButton: Locator;
+ private successBanner: Locator;
+ private notFoundBanner: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.form = page.locator("form");
+ this.submitButton = page.locator('button[type="submit"]').filter({ hasText: /^Submit$/ });
+ this.successBanner = page.locator(".bg-surface-green-1");
+ this.notFoundBanner = page.locator(".bg-surface-amber-1");
+ }
+
+ async goto(): Promise {
+ await this.page.goto("/dashboard/event-proposal");
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ async waitForFormLoad(): Promise {
+ await expect(this.form).toBeVisible({ timeout: 15000 });
+ }
+
+ getInputByLabel(label: string): Locator {
+ return this.page
+ .locator(`label:has-text("${label}")`)
+ .locator("..")
+ .locator("input, textarea, select")
+ .first();
+ }
+
+ async expectFormVisible(): Promise {
+ await expect(this.form).toBeVisible();
+ }
+
+ async expectBannerTitle(title: string): Promise {
+ await expect(this.page.locator(`h1:has-text("${title}")`)).toBeVisible();
+ }
+
+ async expectFieldVisible(label: string): Promise {
+ await expect(this.page.locator(`label:has-text("${label}")`)).toBeVisible();
+ }
+
+ async expectSubmitButtonVisible(): Promise {
+ await expect(this.submitButton).toBeVisible();
+ }
+
+ async expectSuccess(): Promise {
+ await expect(this.successBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ async expectNotFound(): Promise {
+ await expect(this.notFoundBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ async submit(): Promise {
+ await this.submitButton.click();
+ }
+
+ async submitAndExpectResponse(): Promise<{ succeeded: boolean; status: number }> {
+ const responsePromise = this.page.waitForResponse(
+ (resp) => resp.url().includes("submit_event_proposal"),
+ { timeout: 20000 },
+ );
+
+ await this.submitButton.click();
+
+ const response = await responsePromise;
+ const status = response.status();
+ const succeeded = status === 200;
+
+ if (succeeded) {
+ await expect(this.successBanner).toBeVisible({ timeout: 15000 });
+ }
+
+ return { succeeded, status };
+ }
+}
diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts
new file mode 100644
index 0000000..4f2d96d
--- /dev/null
+++ b/e2e/pages/index.ts
@@ -0,0 +1,4 @@
+export { LoginPage } from "./login.page";
+export { BookingPage } from "./booking.page";
+export { CustomFormPage } from "./custom-form.page";
+export { EventProposalPage } from "./event-proposal.page";
diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts
new file mode 100644
index 0000000..5af61b5
--- /dev/null
+++ b/e2e/pages/login.page.ts
@@ -0,0 +1,69 @@
+import { expect, Locator, Page } from "@playwright/test";
+
+/**
+ * Page Object for the Frappe login page.
+ */
+export class LoginPage {
+ private page: Page;
+ private emailInput: Locator;
+ private passwordInput: Locator;
+ private submitButton: Locator;
+ private errorMessage: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+
+ // Frappe login page selectors
+ this.emailInput = page.locator("#login_email");
+ this.passwordInput = page.locator("#login_password");
+ this.submitButton = page.locator("button.btn-login");
+ this.errorMessage = page.locator(".msgprint, .alert-danger").first();
+ }
+
+ /**
+ * Navigate to the login page.
+ */
+ async goto(): Promise {
+ await this.page.goto("/login");
+ await this.page.waitForLoadState("networkidle");
+ }
+
+ /**
+ * Fill in the login form with credentials.
+ */
+ async fillCredentials(email: string, password: string): Promise {
+ await this.emailInput.fill(email);
+ await this.passwordInput.fill(password);
+ }
+
+ /**
+ * Submit the login form.
+ */
+ async submit(): Promise {
+ await this.submitButton.click();
+ }
+
+ /**
+ * Perform a complete login.
+ */
+ async login(email = "Administrator", password = "admin"): Promise {
+ await this.goto();
+ await this.fillCredentials(email, password);
+ await this.submit();
+ await this.page.waitForURL(/\/(app|desk|event_manager)/, { timeout: 30000 });
+ }
+
+ /**
+ * Assert that login failed with an error.
+ */
+ async expectLoginError(): Promise {
+ await expect(this.errorMessage).toBeVisible();
+ }
+
+ /**
+ * Assert that we're on the login page.
+ */
+ async expectToBeOnLoginPage(): Promise {
+ await expect(this.page).toHaveURL(/.*login.*/);
+ }
+}
diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts
new file mode 100644
index 0000000..305f0db
--- /dev/null
+++ b/e2e/tests/auth.setup.ts
@@ -0,0 +1,54 @@
+import { test as setup, expect } from "@playwright/test";
+import * as fs from "fs";
+import * as path from "path";
+
+const authFile = "e2e/.auth/user.json";
+const csrfFile = "e2e/.auth/csrf.json";
+
+setup("authenticate", async ({ page }) => {
+ const authDir = path.dirname(authFile);
+ if (!fs.existsSync(authDir)) {
+ fs.mkdirSync(authDir, { recursive: true });
+ }
+
+ // Login via Frappe API using page.request
+ const loginResponse = await page.request.post("/api/method/login", {
+ form: {
+ usr: process.env.FRAPPE_USER || "Administrator",
+ pwd: process.env.FRAPPE_PASSWORD || "admin",
+ },
+ });
+
+ expect(loginResponse.ok()).toBeTruthy();
+
+ // Verify login succeeded by checking current user
+ const userResponse = await page.request.get("/api/method/frappe.auth.get_logged_user");
+ expect(userResponse.ok()).toBeTruthy();
+
+ const userData = (await userResponse.json()) as { message?: string };
+ expect(userData.message).not.toBe("Guest");
+
+ console.log(`✅ Authenticated as: ${userData.message}`);
+
+ // Navigate to app to load frappe context and get CSRF token
+ await page.goto("/app");
+ await page.waitForLoadState("networkidle");
+
+ // Wait for frappe to initialize and extract CSRF token
+ const csrfToken = await page.evaluate(() => {
+ return (window as Window & { frappe?: { csrf_token?: string } }).frappe
+ ?.csrf_token;
+ });
+
+ if (csrfToken) {
+ // Save CSRF token to file for API helpers to use
+ fs.writeFileSync(csrfFile, JSON.stringify({ csrf_token: csrfToken }));
+ console.log(`🔐 Saved CSRF token to ${csrfFile}`);
+ } else {
+ console.warn("⚠️ Could not extract CSRF token from page");
+ }
+
+ // Save authentication state
+ await page.context().storageState({ path: authFile });
+ console.log(`💾 Saved auth state to ${authFile}`);
+});
diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts
new file mode 100644
index 0000000..73e7998
--- /dev/null
+++ b/e2e/tests/auth.spec.ts
@@ -0,0 +1,59 @@
+import { test, expect } from "@playwright/test";
+import { LoginPage } from "../pages";
+import { isLoggedIn } from "../helpers";
+
+/**
+ * Tests for authentication that already have auth state from setup.
+ */
+test.describe("Authentication - Pre-authenticated", () => {
+ test("should access event_manager when authenticated", async ({ page }) => {
+ // Already authenticated via setup project
+ await page.goto("/dashboard/");
+ await page.waitForLoadState("networkidle");
+
+ // Should not be redirected to login
+ await expect(page).not.toHaveURL(/.*login.*/);
+ });
+
+ test("should verify session via API", async ({ request }) => {
+ // Already authenticated via setup project
+ const loggedIn = await isLoggedIn(request);
+ expect(loggedIn).toBe(true);
+ });
+});
+
+/**
+ * Tests for authentication that need fresh (unauthenticated) state.
+ * Uses storageState reset to clear any auth cookies.
+ */
+test.describe("Authentication - Fresh state", () => {
+ // Reset storage state to test without authentication
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test("should login via UI", async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.login(process.env.FRAPPE_USER || "Administrator", process.env.FRAPPE_PASSWORD || "admin");
+
+ // Should be redirected away from login
+ await expect(page).not.toHaveURL(/.*login.*/);
+ });
+
+ test("should show error for invalid credentials", async ({ page }) => {
+ const loginPage = new LoginPage(page);
+
+ await loginPage.goto();
+ await loginPage.fillCredentials("invalid@example.com", "wrongpassword");
+ await loginPage.submit();
+
+ // Should stay on login page
+ await loginPage.expectToBeOnLoginPage();
+ });
+
+ test("should show login button when not authenticated", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.waitForLoadState("networkidle");
+
+ await expect(page.getByRole("button", { name: "Log In" }).first()).toBeVisible();
+ });
+});
diff --git a/e2e/tests/custom-forms.setup.ts b/e2e/tests/custom-forms.setup.ts
new file mode 100644
index 0000000..148e8f5
--- /dev/null
+++ b/e2e/tests/custom-forms.setup.ts
@@ -0,0 +1,66 @@
+import { test as setup, expect } from "@playwright/test";
+import { createDoc, docExists, getDoc, getList, updateDoc } from "../helpers/frappe";
+
+interface NamedDoc {
+ name: string;
+}
+
+const testEventRoute = "custom-forms-e2e";
+const testCategoryName = "E2E Test Category";
+const testHostName = "E2E Test Host";
+
+setup("setup custom forms on test event", async ({ request }) => {
+ let eventName: string;
+
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: ["=", testEventRoute] },
+ });
+
+ if (events.length > 0) {
+ eventName = events[0].name;
+ } else {
+ if (!(await docExists(request, "Event Category", testCategoryName))) {
+ await createDoc(request, "Event Category", {
+ name: testCategoryName,
+ enabled: 1,
+ slug: "e2e-test-category",
+ });
+ }
+ if (!(await docExists(request, "Event Host", testHostName))) {
+ await createDoc(request, "Event Host", { name: testHostName });
+ }
+
+ const futureDate = new Date();
+ futureDate.setMonth(futureDate.getMonth() + 1);
+ const startDate = futureDate.toISOString().split("T")[0];
+
+ const event = await createDoc(request, "Pohodex Event Manager Event", {
+ title: "E2E Custom Forms Event",
+ category: testCategoryName,
+ host: testHostName,
+ start_date: startDate,
+ route: testEventRoute,
+ is_published: 1,
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ medium: "In Person",
+ });
+ eventName = event.name;
+ }
+
+ await updateDoc(request, "Pohodex Event Manager Event", eventName, {
+ custom_forms: [
+ { doctype: "Pohodex Event Manager Event Form", form_doctype: "Event Feedback", route: "feedback", publish: 1 },
+ { doctype: "Pohodex Event Manager Event Form", form_doctype: "Talk Proposal", route: "propose-talk", publish: 1 },
+ { doctype: "Pohodex Event Manager Event Form", form_doctype: "Sponsorship Enquiry", route: "enquire-sponsorship", publish: 1 },
+ ],
+ });
+
+ const updated = await getDoc<{ custom_forms: Array<{ route: string; publish: number }> }>(
+ request, "Pohodex Event Manager Event", eventName,
+ );
+ const publishedForms = (updated.custom_forms || []).filter((f) => f.publish);
+ expect(publishedForms.length).toBe(3);
+
+ console.log(`Custom forms enabled on event: ${eventName} (${publishedForms.length} forms: ${publishedForms.map((f) => f.route).join(", ")})`);
+});
diff --git a/e2e/tests/custom-forms.spec.ts b/e2e/tests/custom-forms.spec.ts
new file mode 100644
index 0000000..77dd7c4
--- /dev/null
+++ b/e2e/tests/custom-forms.spec.ts
@@ -0,0 +1,341 @@
+import { test, expect } from "@playwright/test";
+import { CustomFormPage } from "../pages";
+import { callMethod } from "../helpers/frappe";
+
+const testEventRoute = "custom-forms-e2e";
+
+test.describe("Event Feedback Form", () => {
+ test("should display feedback form with title", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+ await formPage.expectFormVisible();
+ await formPage.expectFormTitle("Event Feedback");
+ });
+
+ test("should display submit button", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should display feedback text area", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+
+ const textarea = page.locator("textarea").first();
+ await expect(textarea).toBeVisible();
+ });
+
+ test("should fill feedback form fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+
+ const feedbackText = "Great event, learned a lot about testing!";
+ const textarea = page.locator("textarea").first();
+ await textarea.fill(feedbackText);
+ await expect(textarea).toHaveValue(feedbackText);
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await phoneInput.fill("9876543210");
+ await expect(phoneInput).toHaveValue("9876543210");
+ }
+
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should interact with rating stars if present", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+
+ const stars = page.locator(".cursor-pointer svg");
+ if (await stars.first().isVisible({ timeout: 1000 }).catch(() => false)) {
+ const starCount = await stars.count();
+ expect(starCount).toBe(5);
+ await stars.nth(3).click();
+ }
+ });
+
+ test("should submit feedback and get a response", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "feedback");
+ await formPage.waitForFormLoad();
+
+ const textarea = page.locator("textarea").first();
+ await textarea.fill("E2E test feedback - event was excellent!");
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await phoneInput.fill("9876543210");
+ }
+
+ const stars = page.locator(".cursor-pointer svg");
+ if (await stars.first().isVisible({ timeout: 1000 }).catch(() => false)) {
+ await stars.nth(4).click();
+ }
+
+ const { succeeded, status } = await formPage.submitAndExpectResponse();
+ console.log(`Feedback submission: status=${status}, succeeded=${succeeded}`);
+ });
+});
+
+test.describe("Talk Proposal Form", () => {
+ test("should display talk proposal form with title", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+ await formPage.expectFormVisible();
+ await formPage.expectFormTitle("Talk Proposal");
+ });
+
+ test("should display required fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ await formPage.expectFieldVisible("Title");
+
+ const requiredIndicator = page.locator('label:has-text("Title") .text-ink-red-4');
+ const hasRequired = await requiredIndicator.isVisible({ timeout: 1000 }).catch(() => false);
+ if (hasRequired) {
+ await expect(requiredIndicator).toBeVisible();
+ }
+ });
+
+ test("should display submit button", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should display description textarea", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ const textarea = page.locator("textarea").first();
+ const hasTextarea = await textarea.isVisible({ timeout: 2000 }).catch(() => false);
+ expect(hasTextarea).toBeTruthy();
+ });
+
+ test("should display phone input if present", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await expect(phoneInput).toBeVisible();
+ }
+ });
+
+ test("should fill talk proposal form fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ const titleInput = formPage.getInputByLabel("Title");
+ await titleInput.fill("Introduction to E2E Testing");
+ await expect(titleInput).toHaveValue("Introduction to E2E Testing");
+
+ const textarea = page.locator("textarea").first();
+ if (await textarea.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await textarea.fill("A comprehensive talk about writing E2E tests with Playwright.");
+ await expect(textarea).toHaveValue("A comprehensive talk about writing E2E tests with Playwright.");
+ }
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await phoneInput.fill("1234567890");
+ await expect(phoneInput).toHaveValue("1234567890");
+ }
+
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should display speakers table with add button if present", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ const speakersLabel = page.locator('label:has-text("Speakers")');
+ if (await speakersLabel.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await expect(speakersLabel).toBeVisible();
+
+ const addButton = page.locator('button:has-text("Add Speakers")');
+ await expect(addButton).toBeVisible();
+ }
+ });
+
+ test("should submit talk proposal and get a response", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "propose-talk");
+ await formPage.waitForFormLoad();
+
+ const titleInput = formPage.getInputByLabel("Title");
+ await titleInput.fill("E2E Test Talk: Automated Testing Best Practices");
+
+ const textarea = page.locator("textarea").first();
+ if (await textarea.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await textarea.fill("This talk covers best practices for writing robust E2E tests.");
+ }
+
+ const addSpeakersButton = page.locator('button:has-text("Add Speakers")');
+ if (await addSpeakersButton.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await addSpeakersButton.click();
+
+ const dialog = page.locator('[role="dialog"]');
+ await expect(dialog).toBeVisible({ timeout: 5000 });
+
+ const firstNameInput = dialog.locator('label:has-text("First Name")').locator("..").locator("input").first();
+ await firstNameInput.fill("E2E Speaker");
+
+ const emailInput = dialog.locator('label:has-text("Email")').locator("..").locator("input").first();
+ await emailInput.fill("e2e-speaker@test.com");
+
+ const addButton = dialog.locator('button[type="submit"]');
+ await addButton.click();
+ }
+
+ const { succeeded, status } = await formPage.submitAndExpectResponse();
+ console.log(`Talk proposal submission: status=${status}, succeeded=${succeeded}`);
+ });
+});
+
+test.describe("Sponsorship Enquiry Form", () => {
+ test("should display sponsorship form with title", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+ await formPage.expectFormVisible();
+ await formPage.expectFormTitle("Sponsorship Enquiry");
+ });
+
+ test("should display required fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+
+ await formPage.expectFieldVisible("Company Name");
+ await formPage.expectFieldVisible("Company Logo");
+ });
+
+ test("should display submit button", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should display optional fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+
+ const websiteLabel = page.locator('label:has-text("Website")');
+ if (await websiteLabel.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await expect(websiteLabel).toBeVisible();
+ }
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await expect(phoneInput).toBeVisible();
+ }
+ });
+
+ test("should display upload image button for company logo", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+
+ const uploadButton = page.locator('button:has-text("Upload Image")');
+ await expect(uploadButton).toBeVisible();
+ });
+
+ test("should fill sponsorship enquiry form fields", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+
+ const companyNameInput = formPage.getInputByLabel("Company Name");
+ await companyNameInput.fill("Test Corp Ltd");
+ await expect(companyNameInput).toHaveValue("Test Corp Ltd");
+
+ const websiteInput = formPage.getInputByLabel("Website");
+ if (await websiteInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await websiteInput.fill("https://testcorp.example.com");
+ await expect(websiteInput).toHaveValue("https://testcorp.example.com");
+ }
+
+ const phoneInput = page.locator('input[placeholder="Phone number"]');
+ if (await phoneInput.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await phoneInput.fill("5551234567");
+ await expect(phoneInput).toHaveValue("5551234567");
+ }
+
+ await formPage.expectSubmitButtonVisible();
+ });
+
+ test("should stay on form when submitting without required company logo", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+
+ await formPage.goto(testEventRoute, "enquire-sponsorship");
+ await formPage.waitForFormLoad();
+
+ const companyNameInput = formPage.getInputByLabel("Company Name");
+ await companyNameInput.fill("Test Corp Without Logo");
+
+ await formPage.submit();
+
+ await formPage.expectFormVisible();
+ });
+});
+
+test.describe("Custom Form Edge Cases", () => {
+ test("should show not found for nonexistent event", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+ await formPage.goto("nonexistent-event-route-xyz", "feedback");
+ await formPage.expectNotFound();
+ });
+
+ test("should return error for invalid form route via API", async ({ request }) => {
+ const result = await callMethod(request, "event_manager.api.forms.get_custom_form_data", {
+ event_route: testEventRoute,
+ form_route: "invalid-route",
+ }).catch((err: Error) => err);
+
+ expect(result).toBeInstanceOf(Error);
+ });
+
+ test("should show not found for valid event but invalid form route", async ({ page }) => {
+ const formPage = new CustomFormPage(page);
+ await formPage.goto(testEventRoute, "nonexistent-form-route");
+ await formPage.expectNotFound();
+ });
+});
diff --git a/e2e/tests/event-booking.spec.ts b/e2e/tests/event-booking.spec.ts
new file mode 100644
index 0000000..4054ce4
--- /dev/null
+++ b/e2e/tests/event-booking.spec.ts
@@ -0,0 +1,91 @@
+import { test, expect } from "@playwright/test";
+import { BookingPage } from "../pages";
+
+// Verifies that the booking form displays correctly with ticket types, add-ons, and booking button.
+test.describe("Event Registration Page", () => {
+ const testEventRoute = "test-event-e2e";
+
+ test("should display event booking page", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+ await bookingPage.expectFormVisible();
+
+ console.log("Event booking page loaded successfully");
+ });
+
+ test("should display add-ons section", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+
+ const addOnCount = await bookingPage.addOnCheckboxes.count();
+ console.log(`Found ${addOnCount} add-on related elements`);
+
+ if (addOnCount > 0) {
+ await bookingPage.expectAddOnsVisible();
+ console.log("Add-ons section is displayed");
+ } else {
+ console.log("No add-on elements found");
+ }
+ });
+
+ test("should display booking button", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+ await bookingPage.expectBookButtonVisible();
+
+ const buttonText = await bookingPage.getBookButtonText();
+ console.log(`Booking button found with text: "${buttonText}"`);
+ });
+
+ test("should display attendee form fields", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+
+ const hasNameInput = await bookingPage.attendeeNameInput.isVisible().catch(() => false);
+ const hasEmailInput = await bookingPage.attendeeEmailInput.isVisible().catch(() => false);
+
+ expect(hasNameInput && hasEmailInput).toBeTruthy();
+ console.log("Attendee form fields are displayed");
+ });
+
+ test("should fill booking form with attendee details and select add-ons", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+
+ // Fill in attendee details
+ const testName = "John Doe";
+ const testEmail = "john.doe@example.com";
+
+ await bookingPage.fillAttendeeDetails(testName, testEmail);
+
+ // Verify the values were entered
+ await expect(bookingPage.attendeeNameInput).toHaveValue(testName);
+ await expect(bookingPage.attendeeEmailInput).toHaveValue(testEmail);
+ console.log("Attendee details filled successfully");
+
+ // Select add-ons if available
+ const addOnCount = await bookingPage.addOnCheckboxes.count();
+ if (addOnCount > 0) {
+ // Click the first add-on checkbox
+ await bookingPage.addOnCheckboxes.first().click();
+
+ // Verify it's checked
+ await expect(bookingPage.addOnCheckboxes.first()).toBeChecked();
+ console.log("Add-on selected successfully");
+ }
+
+ // Verify booking button is still visible and ready
+ await bookingPage.expectBookButtonVisible();
+ console.log("Form is ready to submit");
+ });
+});
diff --git a/e2e/tests/event-proposal.setup.ts b/e2e/tests/event-proposal.setup.ts
new file mode 100644
index 0000000..411e6ca
--- /dev/null
+++ b/e2e/tests/event-proposal.setup.ts
@@ -0,0 +1,22 @@
+import { test as setup } from "@playwright/test";
+import { createDoc, docExists, updateDoc } from "../helpers/frappe";
+
+const testCategoryName = "E2E Test Category";
+
+setup("setup event proposal form", async ({ request }) => {
+ if (!(await docExists(request, "Event Category", testCategoryName))) {
+ await createDoc(request, "Event Category", {
+ name: testCategoryName,
+ enabled: 1,
+ slug: "e2e-test-category",
+ });
+ console.log(`Created Event Category: ${testCategoryName}`);
+ }
+
+ await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
+ accept_event_proposals: 1,
+ allow_guest_event_proposals: 1,
+ });
+
+ console.log("Event proposal form enabled in Pohodex Event Manager Settings");
+});
diff --git a/e2e/tests/event-proposal.spec.ts b/e2e/tests/event-proposal.spec.ts
new file mode 100644
index 0000000..56aa90a
--- /dev/null
+++ b/e2e/tests/event-proposal.spec.ts
@@ -0,0 +1,154 @@
+import { test, expect } from "@playwright/test";
+import { EventProposalPage } from "../pages";
+import { callMethod, updateDoc } from "../helpers/frappe";
+
+test.describe("Event Proposal Form - Rendering", () => {
+ test("should display the proposal form", async ({ page }) => {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+ await proposalPage.expectFormVisible();
+ });
+
+ test("should display the banner title", async ({ page }) => {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+ await proposalPage.expectBannerTitle("Propose an Event");
+ });
+
+ test("should display the title field", async ({ page }) => {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+ await proposalPage.expectFieldVisible("Title");
+ });
+
+ test("should display the submit button", async ({ page }) => {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+ await proposalPage.expectSubmitButtonVisible();
+ });
+});
+
+test.describe("Event Proposal Form - Submission", () => {
+ test("should fill and submit a proposal", async ({ page }) => {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+
+ const titleInput = proposalPage.getInputByLabel("Title");
+ await titleInput.fill("E2E Test: Automated Testing with Playwright");
+
+ const categorySelect = page.locator("select").first();
+ await categorySelect.waitFor({ state: "visible", timeout: 5000 });
+ await categorySelect.selectOption({ label: "E2E Test Category" });
+
+ const aboutTextarea = page
+ .locator('label:has-text("About the event")')
+ .locator("..")
+ .locator("textarea");
+ await aboutTextarea.fill("An E2E test proposal about automated testing practices.");
+
+ const { succeeded, status } = await proposalPage.submitAndExpectResponse();
+ console.log(`Proposal submission: status=${status}, succeeded=${succeeded}`);
+ });
+
+ test("should show success banner after submission", async ({ page }) => {
+ await page.route(/submit_event_proposal/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ message: null }),
+ }),
+ );
+
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.waitForFormLoad();
+
+ const titleInput = proposalPage.getInputByLabel("Title");
+ await titleInput.fill("E2E Test: Another Proposal for Success Banner");
+
+ const categorySelect = page.locator("select").first();
+ await categorySelect.waitFor({ state: "visible", timeout: 5000 });
+ await categorySelect.selectOption({ label: "E2E Test Category" });
+
+ const aboutTextarea = page
+ .locator('label:has-text("About the event")')
+ .locator("..")
+ .locator("textarea");
+ await aboutTextarea.fill("An E2E test proposal about building better events.");
+
+ await proposalPage.submitAndExpectResponse();
+ await proposalPage.expectSuccess();
+ });
+});
+
+test.describe("Event Proposal Form - Access Control", () => {
+ test("should show not found when proposals are disabled", async ({ page, request }) => {
+ await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
+ accept_event_proposals: 0,
+ });
+
+ try {
+ const proposalPage = new EventProposalPage(page);
+ await proposalPage.goto();
+ await proposalPage.expectNotFound();
+ } finally {
+ await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
+ accept_event_proposals: 1,
+ });
+ }
+ });
+
+ test("should return error via API when proposals are disabled", async ({ request }) => {
+ await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
+ accept_event_proposals: 0,
+ });
+
+ try {
+ const result = await callMethod(
+ request,
+ "event_manager.api.forms.get_event_proposal_form_data",
+ ).catch((err: Error) => err);
+
+ expect(result).toBeInstanceOf(Error);
+ } finally {
+ await updateDoc(request, "Pohodex Event Manager Settings", "Pohodex Event Manager Settings", {
+ accept_event_proposals: 1,
+ });
+ }
+ });
+});
+
+test.describe("Event Proposal Form - API", () => {
+ test("should return form data with expected shape", async ({ request }) => {
+ const data = await callMethod<{
+ form_fields: Array<{ fieldname: string }>;
+ banner_title: string;
+ form_title: string;
+ success_title: string;
+ }>(request, "event_manager.api.forms.get_event_proposal_form_data");
+
+ expect(data.form_fields).toBeInstanceOf(Array);
+ expect(data.form_fields.length).toBeGreaterThan(0);
+ expect(data.banner_title).toBeTruthy();
+ expect(data.form_title).toBeTruthy();
+ expect(data.success_title).toBeTruthy();
+ });
+
+ test("should not expose excluded fields in form data", async ({ request }) => {
+ const data = await callMethod<{
+ form_fields: Array<{ fieldname: string }>;
+ }>(request, "event_manager.api.forms.get_event_proposal_form_data");
+
+ const fieldnames = data.form_fields.map((f) => f.fieldname);
+ const excludedFields = ["status", "submitted_by", "host", "naming_series", "amended_from"];
+
+ for (const excluded of excludedFields) {
+ expect(fieldnames).not.toContain(excluded);
+ }
+ });
+});
diff --git a/e2e/tests/event.setup.ts b/e2e/tests/event.setup.ts
new file mode 100644
index 0000000..4e61aa1
--- /dev/null
+++ b/e2e/tests/event.setup.ts
@@ -0,0 +1,113 @@
+import { test as setup, expect } from "@playwright/test";
+import { createDoc, deleteDoc, docExists, getList } from "../helpers/frappe";
+
+interface NamedDoc {
+ name: string;
+}
+
+// Setup: Creates Event Category, Event Host, Pohodex Event Manager Event, Event Ticket Type, and Ticket Add-on.
+setup("create test event for booking", async ({ request }) => {
+ const testEventTitle = "E2E Test Event";
+ const testEventRoute = "test-event-e2e";
+ const testCategoryName = "E2E Test Category";
+ const testHostName = "E2E Test Host";
+
+ // Clean up any existing test data first
+ try {
+ // Find the event by title
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { title: ["=", testEventTitle] },
+ });
+ const existingEvent = events[0]; // getList returns array
+
+ if (existingEvent) {
+ // Delete sponsorship tiers first
+ const tiers = await getList(request, "Sponsorship Tier", {
+ filters: { event: ["=", existingEvent.name] },
+ });
+ for (const tier of tiers) {
+ await deleteDoc(request, "Sponsorship Tier", tier.name).catch(() => {});
+ }
+
+ // Delete ticket types
+ const ticketTypes = await getList(request, "Event Ticket Type", {
+ filters: { event: ["=", existingEvent.name] },
+ });
+ for (const tt of ticketTypes) {
+ await deleteDoc(request, "Event Ticket Type", tt.name).catch(() => {});
+ }
+
+ // Delete add-ons
+ const addOns = await getList(request, "Ticket Add-on", {
+ filters: { event: ["=", existingEvent.name] },
+ });
+ for (const ao of addOns) {
+ await deleteDoc(request, "Ticket Add-on", ao.name).catch(() => {});
+ }
+
+ // Now delete the event
+ await deleteDoc(request, "Pohodex Event Manager Event", existingEvent.name).catch(() => {});
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.log("Cleanup: Some test data may not have existed", message);
+ }
+
+ // Create Event Category if it doesn't exist
+ if (!(await docExists(request, "Event Category", testCategoryName))) {
+ await createDoc(request, "Event Category", {
+ name: testCategoryName,
+ enabled: 1,
+ slug: "e2e-test-category",
+ });
+ console.log(`Created Event Category: ${testCategoryName}`);
+ }
+
+ // Create Event Host if it doesn't exist
+ if (!(await docExists(request, "Event Host", testHostName))) {
+ await createDoc(request, "Event Host", {
+ name: testHostName,
+ });
+ console.log(`Created Event Host: ${testHostName}`);
+ }
+
+ // Create Pohodex Event Manager Event
+ const futureDate = new Date();
+ futureDate.setMonth(futureDate.getMonth() + 1);
+ const startDate = futureDate.toISOString().split("T")[0];
+
+ const event = await createDoc(request, "Pohodex Event Manager Event", {
+ title: testEventTitle,
+ category: testCategoryName,
+ host: testHostName,
+ start_date: startDate,
+ route: testEventRoute,
+ is_published: 1,
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ medium: "In Person",
+ });
+ console.log(`Created Pohodex Event Manager Event: ${event.name} (route: ${testEventRoute})`);
+
+ // Create Event Ticket Type
+ const ticketType = await createDoc(request, "Event Ticket Type", {
+ event: event.name,
+ title: "Standard Ticket",
+ price: 500,
+ currency: "INR",
+ is_published: 1,
+ });
+ console.log(`Created Event Ticket Type: ${ticketType.name}`);
+
+ // Create Ticket Add-on
+ const addOn = await createDoc(request, "Ticket Add-on", {
+ event: event.name,
+ title: "Event T-Shirt",
+ price: 200,
+ currency: "INR",
+ enabled: 1,
+ });
+ console.log(`Created Ticket Add-on: ${addOn.name}`);
+
+ console.log(`Test event setup complete! Route: /dashboard/book-tickets/${testEventRoute}`);
+});
diff --git a/e2e/tests/guest-booking.spec.ts b/e2e/tests/guest-booking.spec.ts
new file mode 100644
index 0000000..16bc822
--- /dev/null
+++ b/e2e/tests/guest-booking.spec.ts
@@ -0,0 +1,102 @@
+import { test, expect } from "@playwright/test";
+import { BookingPage } from "../pages";
+
+// Unique suffix per run to avoid rate limits
+const uid = Date.now();
+
+test.describe("Guest Booking UX", () => {
+ test("guest details auto-fill to Attendee 1", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto("guest-no-otp-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter your first name"]').fill("Test");
+ await page.locator('input[placeholder="Enter your last name"]').fill("Guest");
+ await page.locator('input[placeholder="Enter your email"]').fill("test@example.com");
+ await page.locator('input[placeholder="Enter your email"]').blur();
+
+ await expect(page.locator('input[placeholder="Enter first name"]').first()).toHaveValue("Test");
+ await expect(page.locator('input[placeholder="Enter last name"]').first()).toHaveValue("Guest");
+ await expect(page.locator('input[placeholder="Enter email address"]').first()).toHaveValue("test@example.com");
+ });
+});
+
+test.describe("Guest Booking", () => {
+ test("guest booking without OTP", async ({ page }) => {
+ const email = `guest-no-otp-${uid}@test.com`;
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto("guest-no-otp-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter your first name"]').fill("Test");
+ await page.locator('input[placeholder="Enter your last name"]').fill("Guest");
+ await page.locator('input[placeholder="Enter your email"]').fill(email);
+ await page.locator('input[placeholder="Enter your email"]').blur();
+
+ await bookingPage.submit();
+
+ await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
+ });
+
+ test("guest booking with Email OTP", async ({ page }) => {
+ const email = `guest-email-otp-${uid}@test.com`;
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto("guest-email-otp-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter your first name"]').fill("Test");
+ await page.locator('input[placeholder="Enter your last name"]').fill("Guest Email");
+ await page.locator('input[placeholder="Enter your email"]').fill(email);
+ await page.locator('input[placeholder="Enter your email"]').blur();
+
+ const otpResponsePromise = page.waitForResponse(
+ (resp) => resp.url().includes("send_guest_booking_otp") && resp.status() === 200,
+ );
+
+ await bookingPage.submit();
+
+ const otpResponse = await otpResponsePromise;
+ const otpData = (await otpResponse.json()) as { message?: { otp?: string } };
+ const otp = otpData.message?.otp;
+ expect(otp).toBeTruthy();
+
+ await expect(page.getByText("Verify Your Email")).toBeVisible({ timeout: 10000 });
+
+ await page.locator('input[placeholder="123456"]').fill(otp!);
+ await page.getByRole("button", { name: "Verify & Book" }).click();
+
+ await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
+ });
+
+ test("guest booking with Phone OTP", async ({ page }) => {
+ const email = `guest-phone-otp-${uid}@test.com`;
+ const phone = `9${uid.toString().slice(-9)}`;
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto("guest-phone-otp-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter your first name"]').fill("Test");
+ await page.locator('input[placeholder="Enter your last name"]').fill("Guest Phone");
+ await page.locator('input[placeholder="Enter your email"]').fill(email);
+ await page.locator('input[placeholder="Enter your email"]').blur(); // triggers auto-fill
+ await page.locator('input[placeholder="Enter your phone number"]').fill(phone);
+
+ const otpResponsePromise = page.waitForResponse(
+ (resp) => resp.url().includes("send_guest_booking_otp") && resp.status() === 200,
+ );
+
+ await bookingPage.submit();
+
+ const otpResponse = await otpResponsePromise;
+ const otpData = (await otpResponse.json()) as { message?: { otp?: string } };
+ const otp = otpData.message?.otp;
+ expect(otp).toBeTruthy();
+
+ await expect(page.getByText("Verify Your Phone")).toBeVisible({ timeout: 10000 });
+
+ await page.locator('input[placeholder="123456"]').fill(otp!);
+ await page.getByRole("button", { name: "Verify & Book" }).click();
+
+ await expect(page.getByText("Booking Confirmed!")).toBeVisible({ timeout: 30000 });
+ });
+});
diff --git a/e2e/tests/guest-event.setup.ts b/e2e/tests/guest-event.setup.ts
new file mode 100644
index 0000000..feb9bb4
--- /dev/null
+++ b/e2e/tests/guest-event.setup.ts
@@ -0,0 +1,144 @@
+import { test as setup } from "@playwright/test";
+import { callMethod, createDoc, docExists, getList } from "../helpers/frappe";
+
+interface NamedDoc {
+ name: string;
+}
+
+const testCategoryName = "E2E Test Category";
+const testHostName = "E2E Test Host";
+
+const guestEvents = [
+ {
+ title: "E2E Guest No OTP",
+ route: "guest-no-otp-e2e",
+ guest_verification_method: "None",
+ },
+ {
+ title: "E2E Guest Email OTP",
+ route: "guest-email-otp-e2e",
+ guest_verification_method: "Email OTP",
+ },
+ {
+ title: "E2E Guest Phone OTP",
+ route: "guest-phone-otp-e2e",
+ guest_verification_method: "Phone OTP",
+ },
+];
+
+/**
+ * Cancel and delete a document. Submittable docs (Event Booking, Event Ticket)
+ * must be cancelled (docstatus=2) before they can be deleted.
+ */
+async function forceCleanup(
+ request: Parameters[0],
+ doctype: string,
+ name: string,
+): Promise {
+ try {
+ await callMethod(request, "frappe.client.cancel", { doctype, name });
+ } catch {
+ // Not submittable or already cancelled — ignore
+ }
+ await callMethod(request, "frappe.client.delete", { doctype, name });
+}
+
+setup("create guest booking test events", async ({ request }) => {
+ // Clean up existing guest test events by route (unique constraint).
+ // Must delete in order: tickets → bookings → ticket types → events
+ // because Frappe blocks deletion of documents with linked records.
+ for (const evt of guestEvents) {
+ try {
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: evt.route },
+ });
+ if (!events.length) continue;
+
+ for (const existing of events) {
+ // Delete all linked docs in dependency order.
+ // Tickets and bookings are submittable (cancel before delete).
+ const linkedDoctypes = [
+ { doctype: "Event Ticket", submittable: true },
+ { doctype: "Event Booking", submittable: true },
+ { doctype: "Sponsorship Tier", submittable: false },
+ { doctype: "Event Ticket Type", submittable: false },
+ { doctype: "Ticket Add-on", submittable: false },
+ ];
+
+ for (const { doctype, submittable } of linkedDoctypes) {
+ const docs = await getList(request, doctype, {
+ filters: { event: existing.name },
+ }).catch(() => [] as NamedDoc[]);
+
+ for (const doc of docs) {
+ if (submittable) {
+ await forceCleanup(request, doctype, doc.name).catch(() => {});
+ } else {
+ await callMethod(request, "frappe.client.delete", {
+ doctype,
+ name: doc.name,
+ }).catch(() => {});
+ }
+ }
+ }
+
+ // Delete the event itself
+ await callMethod(request, "frappe.client.delete", {
+ doctype: "Pohodex Event Manager Event",
+ name: existing.name,
+ }).catch((e) => console.log(`Cleanup event ${existing.name}: ${e}`));
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.log(`Cleanup: ${evt.title} - ${message}`);
+ }
+ }
+
+ // Ensure category and host exist (shared with event.setup.ts)
+ if (!(await docExists(request, "Event Category", testCategoryName))) {
+ await createDoc(request, "Event Category", {
+ name: testCategoryName,
+ enabled: 1,
+ slug: "e2e-test-category",
+ });
+ }
+
+ if (!(await docExists(request, "Event Host", testHostName))) {
+ await createDoc(request, "Event Host", {
+ name: testHostName,
+ });
+ }
+
+ const futureDate = new Date();
+ futureDate.setMonth(futureDate.getMonth() + 1);
+ const startDate = futureDate.toISOString().split("T")[0];
+
+ // Create each guest test event with a free ticket type
+ for (const evt of guestEvents) {
+ const event = await createDoc(request, "Pohodex Event Manager Event", {
+ title: evt.title,
+ category: testCategoryName,
+ host: testHostName,
+ start_date: startDate,
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ route: evt.route,
+ is_published: 1,
+ medium: "In Person",
+ allow_guest_booking: 1,
+ guest_verification_method: evt.guest_verification_method,
+ });
+
+ await createDoc(request, "Event Ticket Type", {
+ event: event.name,
+ title: "Free Ticket",
+ price: 0,
+ currency: "INR",
+ is_published: 1,
+ });
+
+ console.log(`Created: ${evt.title} (route: ${evt.route}, method: ${evt.guest_verification_method})`);
+ }
+
+ console.log("Guest event setup complete!");
+});
diff --git a/e2e/tests/login-modal.spec.ts b/e2e/tests/login-modal.spec.ts
new file mode 100644
index 0000000..b5593b9
--- /dev/null
+++ b/e2e/tests/login-modal.spec.ts
@@ -0,0 +1,35 @@
+import { test, expect } from "@playwright/test";
+
+const FRAPPE_USER = process.env.FRAPPE_USER || "Administrator";
+const FRAPPE_PASSWORD = process.env.FRAPPE_PASSWORD || "admin";
+
+test.describe("Login Modal", () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test("email/password login via modal", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.waitForLoadState("networkidle");
+ await page.getByRole("button", { name: "Log In" }).first().click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(page.getByText("Login to Continue")).toBeVisible();
+ await page.getByLabel("Email").fill(FRAPPE_USER);
+ await page.getByLabel("Password").fill(FRAPPE_PASSWORD);
+ await page.getByRole("button", { name: "Login", exact: true }).click();
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 10000 });
+ await expect(page.getByRole("button", { name: "Log In" })).not.toBeVisible();
+ });
+
+ test("shows error for invalid credentials", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.waitForLoadState("networkidle");
+
+ await page.getByRole("button", { name: "Log In" }).first().click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+
+ await page.getByLabel("Email").fill("wrong@example.com");
+ await page.getByLabel("Password").fill("wrongpassword");
+ await page.getByRole("button", { name: "Login", exact: true }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(page.locator(".bg-surface-red-2")).toBeVisible();
+ });
+});
diff --git a/e2e/tests/offline-payment.setup.ts b/e2e/tests/offline-payment.setup.ts
new file mode 100644
index 0000000..a4e46a4
--- /dev/null
+++ b/e2e/tests/offline-payment.setup.ts
@@ -0,0 +1,127 @@
+import { test as setup } from "@playwright/test";
+import { callMethod, createDoc, docExists, getList } from "../helpers/frappe";
+
+interface NamedDoc {
+ name: string;
+}
+
+const testCategoryName = "E2E Test Category";
+const testHostName = "E2E Test Host";
+
+const offlinePaymentEvent = {
+ title: "E2E Offline Payment",
+ route: "offline-payment-e2e",
+};
+
+async function forceCleanup(
+ request: Parameters[0],
+ doctype: string,
+ name: string,
+): Promise {
+ try {
+ await callMethod(request, "frappe.client.cancel", { doctype, name });
+ } catch {
+ // Not submittable or already cancelled
+ }
+ await callMethod(request, "frappe.client.delete", { doctype, name });
+}
+
+setup("create offline payment test event", async ({ request }) => {
+ // Clean up existing test event - retry if needed
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: offlinePaymentEvent.route },
+ });
+
+ if (events.length === 0) break;
+
+ for (const existing of events) {
+ const linkedDoctypes = [
+ { doctype: "Event Ticket", submittable: true },
+ { doctype: "Event Booking", submittable: true },
+ { doctype: "Sponsorship Tier", submittable: false },
+ { doctype: "Event Ticket Type", submittable: false },
+ { doctype: "Ticket Add-on", submittable: false },
+ { doctype: "Offline Payment Method", submittable: false },
+ ];
+
+ for (const { doctype, submittable } of linkedDoctypes) {
+ const docs = await getList(request, doctype, {
+ filters: { event: existing.name },
+ }).catch(() => [] as NamedDoc[]);
+
+ for (const doc of docs) {
+ if (submittable) {
+ await forceCleanup(request, doctype, doc.name).catch(() => {});
+ } else {
+ await callMethod(request, "frappe.client.delete", {
+ doctype,
+ name: doc.name,
+ }).catch(() => {});
+ }
+ }
+ }
+
+ await callMethod(request, "frappe.client.delete", {
+ doctype: "Pohodex Event Manager Event",
+ name: existing.name,
+ }).catch((e) => console.log(`Failed to delete event: ${e}`));
+ }
+ } catch (error) {
+ console.log(`Cleanup attempt ${attempt + 1}: ${error}`);
+ }
+ }
+
+ // Ensure category and host exist
+ if (!(await docExists(request, "Event Category", testCategoryName))) {
+ await createDoc(request, "Event Category", {
+ name: testCategoryName,
+ enabled: 1,
+ slug: "e2e-test-category",
+ });
+ }
+
+ if (!(await docExists(request, "Event Host", testHostName))) {
+ await createDoc(request, "Event Host", {
+ name: testHostName,
+ });
+ }
+
+ const futureDate = new Date();
+ futureDate.setMonth(futureDate.getMonth() + 1);
+ const startDate = futureDate.toISOString().split("T")[0];
+
+ // Create event
+ const event = await createDoc(request, "Pohodex Event Manager Event", {
+ title: offlinePaymentEvent.title,
+ category: testCategoryName,
+ host: testHostName,
+ start_date: startDate,
+ start_time: "09:00:00",
+ end_time: "17:00:00",
+ route: offlinePaymentEvent.route,
+ is_published: 1,
+ medium: "In Person",
+ });
+
+ // Create offline payment method
+ await createDoc(request, "Offline Payment Method", {
+ event: event.name,
+ title: "Bank Transfer",
+ enabled: 1,
+ description: "Transfer to Account: 123456789
",
+ });
+
+ // Create paid ticket type
+ await createDoc(request, "Event Ticket Type", {
+ event: event.name,
+ title: "Standard Ticket",
+ price: 500,
+ currency: "INR",
+ is_published: 1,
+ });
+
+ console.log(`Created: ${offlinePaymentEvent.title} (route: ${offlinePaymentEvent.route})`);
+ console.log("Offline payment event setup complete!");
+});
diff --git a/e2e/tests/offline-payment.spec.ts b/e2e/tests/offline-payment.spec.ts
new file mode 100644
index 0000000..7e553ae
--- /dev/null
+++ b/e2e/tests/offline-payment.spec.ts
@@ -0,0 +1,86 @@
+import { test, expect } from "@playwright/test";
+import { BookingPage } from "../pages";
+
+const uid = Date.now();
+
+test.describe("Offline Payment Flow", () => {
+ test("complete booking with offline payment", async ({ page }) => {
+ const email = `offline-${uid}@test.com`;
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto("offline-payment-e2e");
+ await bookingPage.waitForFormLoad();
+
+ // Fill attendee details
+ await page.locator('input[placeholder="Enter first name"]').first().fill("Test");
+ await page.locator('input[placeholder="Enter last name"]').first().fill("User");
+ await page.locator('input[placeholder="Enter email address"]').first().fill(email);
+
+ // Submit booking form
+ await bookingPage.submit();
+
+ // Wait for offline payment dialog
+ await expect(page.getByText("Bank Transfer")).toBeVisible({ timeout: 10000 });
+ await expect(page.getByText("Transfer to Account: 123456789")).toBeVisible();
+
+ // Submit offline payment (no file upload required in test setup)
+ const submitButton = page.getByRole("button", { name: "Submit" });
+ await submitButton.click();
+
+ // Verify booking created with verification pending status
+ await expect(page.getByText("Payment Confirmation Pending")).toBeVisible({ timeout: 30000 });
+ });
+
+ test("offline payment dialog shows amount", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto("offline-payment-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter first name"]').first().fill("Amount");
+ await page.locator('input[placeholder="Enter last name"]').first().fill("Test");
+ await page.locator('input[placeholder="Enter email address"]').first().fill(`amount-${uid}@test.com`);
+
+ await bookingPage.submit();
+
+ // Wait for dialog and verify amount is displayed in dialog
+ const dialog = page.getByRole("dialog");
+ await expect(dialog.getByText("₹500.00", { exact: true })).toBeVisible({ timeout: 10000 });
+ });
+
+ test("can cancel offline payment dialog", async ({ page }) => {
+ const bookingPage = new BookingPage(page);
+
+ await bookingPage.goto("offline-payment-e2e");
+ await bookingPage.waitForFormLoad();
+
+ await page.locator('input[placeholder="Enter first name"]').first().fill("Cancel");
+ await page.locator('input[placeholder="Enter last name"]').first().fill("Test");
+ await page.locator('input[placeholder="Enter email address"]').first().fill(`cancel-${uid}@test.com`);
+
+ await bookingPage.submit();
+
+ await expect(page.getByText("Bank Transfer")).toBeVisible({ timeout: 10000 });
+
+ // Click cancel
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ // Dialog should close
+ await expect(page.getByText("Bank Transfer")).not.toBeVisible({ timeout: 5000 });
+ });
+});
+
+test.describe("Booking Details - Offline Payment", () => {
+ test("shows verification pending status", async ({ page }) => {
+ // This test assumes a booking already exists
+ // Navigate to bookings list
+ await page.goto("/dashboard/bookings");
+ await page.waitForLoadState("networkidle");
+
+ // Look for verification pending badge
+ const pendingBadge = page.locator('text=/Verification Pending|Approval Pending/i').first();
+ if (await pendingBadge.isVisible({ timeout: 5000 })) {
+ await expect(pendingBadge).toBeVisible();
+ }
+ });
+});
diff --git a/e2e/tests/tax-inclusive.spec.ts b/e2e/tests/tax-inclusive.spec.ts
new file mode 100644
index 0000000..ef01e5f
--- /dev/null
+++ b/e2e/tests/tax-inclusive.spec.ts
@@ -0,0 +1,139 @@
+import { test, expect } from "@playwright/test";
+import { BookingPage } from "../pages";
+import { updateDoc, getList } from "../helpers/frappe";
+
+interface NamedDoc {
+ name: string;
+}
+
+test.describe("Tax Inclusive Pricing", () => {
+ const testEventRoute = "test-event-e2e";
+
+ test("should show tax as inclusive with correct amounts", async ({ page, request }) => {
+ // Find the test event
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: ["=", testEventRoute] },
+ });
+ expect(events.length).toBeGreaterThan(0);
+ const eventName = events[0].name;
+
+ // Enable tax-inclusive pricing on the event
+ await updateDoc(request, "Pohodex Event Manager Event", eventName, {
+ apply_tax: 1,
+ tax_inclusive: 1,
+ tax_label: "GST",
+ tax_percentage: 18,
+ });
+
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+
+ // Fill attendee details to trigger summary
+ await bookingPage.fillAttendeeDetails("Tax Test User", "taxtest@example.com");
+
+ // Wait for the booking summary to render
+ const summarySection = page.locator("text=Booking Summary");
+ await expect(summarySection).toBeVisible({ timeout: 10000 });
+
+ // Subtotal should be hidden for tax-inclusive (no discount case)
+ const subtotalLabel = page.locator("span:text-is('Subtotal')");
+ await expect(subtotalLabel).not.toBeVisible();
+
+ // Tax should NOT appear as a separate line item
+ const taxLineItem = page.locator("text=/GST.*18.*%/").first();
+ // The "Inclusive of" note contains GST 18%, but there should be no standalone tax row
+ const separateTaxRow = page.locator(".flex.justify-between:has(span:text-is('GST (18%)'))");
+ await expect(separateTaxRow).not.toBeVisible();
+
+ // Verify the "Inclusive of" note appears below the total
+ const inclusiveNote = page.locator("text=/Inclusive of/");
+ await expect(inclusiveNote).toBeVisible({ timeout: 5000 });
+
+ // Verify the note contains GST and 18%
+ const noteText = await inclusiveNote.textContent();
+ expect(noteText).toContain("GST");
+ expect(noteText).toContain("18%");
+
+ // Verify the total shows the ticket price (unchanged by tax)
+ const totalHeading = page.locator("h3:has-text('Total')");
+ const totalContainer = totalHeading.locator("..");
+ const totalText = await totalContainer.textContent();
+ const totalMatch = totalText?.match(/[\d,]+/);
+ expect(totalMatch).toBeTruthy();
+ expect(totalMatch![0]).toBe("500");
+
+ console.log(`Tax inclusive test passed: Total=500, note="${noteText?.trim()}"`);
+ });
+
+ test("should show tax as exclusive with increased total", async ({ page, request }) => {
+ // Find the test event
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: ["=", testEventRoute] },
+ });
+ expect(events.length).toBeGreaterThan(0);
+ const eventName = events[0].name;
+
+ // Enable tax-exclusive pricing on the event
+ await updateDoc(request, "Pohodex Event Manager Event", eventName, {
+ apply_tax: 1,
+ tax_inclusive: 0,
+ tax_label: "GST",
+ tax_percentage: 18,
+ });
+
+ const bookingPage = new BookingPage(page);
+ await bookingPage.goto(testEventRoute);
+ await bookingPage.waitForFormLoad();
+
+ // Fill attendee details to trigger summary
+ await bookingPage.fillAttendeeDetails("Tax Test User", "taxtest@example.com");
+
+ // Wait for the booking summary to render
+ const summarySection = page.locator("text=Booking Summary");
+ await expect(summarySection).toBeVisible({ timeout: 10000 });
+
+ // Verify tax label does NOT show "Incl."
+ const taxLineInclusive = page.locator("text=/GST.*18.*%.*Incl/");
+ await expect(taxLineInclusive).not.toBeVisible();
+
+ // Verify tax line exists without "Incl."
+ const taxLine = page.locator("text=/GST.*18.*%/");
+ await expect(taxLine).toBeVisible({ timeout: 5000 });
+
+ // For exclusive tax: total should be greater than subtotal
+ const totalHeading = page.locator("h3:has-text('Total')");
+ const totalContainer = totalHeading.locator("..");
+ const totalText = await totalContainer.textContent();
+
+ const subtotalContainer = page.locator("span:text-is('Subtotal')").locator("..");
+ const subtotalText = await subtotalContainer.textContent();
+
+ const subtotalMatch = subtotalText?.match(/[\d,]+/);
+ const totalMatch = totalText?.match(/[\d,]+/);
+
+ expect(subtotalMatch).toBeTruthy();
+ expect(totalMatch).toBeTruthy();
+
+ const subtotalNum = parseInt(subtotalMatch![0].replace(/,/g, ""));
+ const totalNum = parseInt(totalMatch![0].replace(/,/g, ""));
+
+ // Total should be greater than subtotal for exclusive tax
+ expect(totalNum).toBeGreaterThan(subtotalNum);
+
+ console.log(`Tax exclusive test passed: Subtotal=${subtotalNum}, Total=${totalNum}`);
+ });
+
+ // Clean up: disable tax after tests
+ test.afterAll(async ({ request }) => {
+ const events = await getList(request, "Pohodex Event Manager Event", {
+ filters: { route: ["=", testEventRoute] },
+ });
+ if (events.length > 0) {
+ await updateDoc(request, "Pohodex Event Manager Event", events[0].name, {
+ apply_tax: 0,
+ tax_inclusive: 0,
+ });
+ }
+ });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000..e9b45a5
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": false,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@helpers/*": ["helpers/*"],
+ "@pages/*": ["pages/*"]
+ }
+ },
+ "include": [
+ "**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/entities.png b/entities.png
new file mode 100644
index 0000000..95dbcb1
Binary files /dev/null and b/entities.png differ
diff --git a/event_manager/__init__.py b/event_manager/__init__.py
new file mode 100644
index 0000000..1fe27c6
--- /dev/null
+++ b/event_manager/__init__.py
@@ -0,0 +1,9 @@
+__version__ = "0.0.1"
+
+import os
+
+if os.environ.get("CI"):
+ import frappe
+ from frappe.tests.utils import toggle_test_mode
+
+ toggle_test_mode(True)
diff --git a/event_manager/api/__init__.py b/event_manager/api/__init__.py
new file mode 100644
index 0000000..6dfe513
--- /dev/null
+++ b/event_manager/api/__init__.py
@@ -0,0 +1,1258 @@
+import os
+from base64 import b32encode
+
+import frappe
+import pyotp
+from frappe import _
+from frappe.auth import LoginAttemptTracker
+from frappe.core.doctype.sms_settings.sms_settings import send_sms
+from frappe.rate_limiter import rate_limit
+from frappe.translate import get_all_translations
+from frappe.utils import (
+ days_diff,
+ format_date,
+ format_time,
+ get_datetime,
+ get_datetime_in_timezone,
+ get_system_timezone,
+ now_datetime,
+ today,
+ validate_email_address,
+)
+
+from event_manager.payments import (
+ get_payment_gateways_for_event,
+ get_payment_link_for_booking,
+ get_payment_link_for_sponsorship,
+)
+from event_manager.utils import is_app_installed
+
+OFFLINE_PAYMENT_METHOD = "Offline"
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+@rate_limit(key="identifier", limit=5, seconds=3600)
+def send_guest_booking_otp(event: int, identifier: str) -> dict:
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", event)
+
+ if not event_doc.allow_guest_booking:
+ frappe.throw(_("Guest booking is not enabled for this event"))
+
+ if event_doc.guest_verification_method == "None":
+ frappe.throw(_("OTP verification is not enabled for this event"))
+
+ channel = "phone" if event_doc.guest_verification_method == "Phone OTP" else "email"
+
+ identifier = identifier.strip()
+ if not identifier:
+ frappe.throw(_("Email or phone is required"))
+
+ if channel == "email":
+ identifier = identifier.lower()
+ validate_email_address(identifier, throw=True)
+
+ otp_secret = b32encode(os.urandom(10)).decode("utf-8")
+ otp_code = pyotp.HOTP(otp_secret).at(0)
+ cache_key = f"guest_booking_otp:{channel}:{identifier}"
+
+ if frappe.in_test:
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+ return {"otp": otp_code}
+
+ try:
+ if channel == "email":
+ frappe.sendmail(
+ recipients=[identifier],
+ subject=_("Your Booking Verification Code"),
+ message=_(
+ "Your verification code is: {0} This code expires in 10 minutes."
+ ).format(otp_code),
+ now=True,
+ )
+ else:
+ send_sms(
+ receiver_list=[identifier],
+ msg=_("Your booking verification code is: {0}. It expires in 10 minutes.").format(otp_code),
+ )
+ except Exception:
+ frappe.throw(_("Failed to send verification code. Please try again."))
+
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+
+
+def verify_guest_otp(channel: str, identifier: str, otp: str):
+ cache_key = f"guest_booking_otp:{channel}:{identifier}"
+ tracker = LoginAttemptTracker(
+ key=f"guest_otp:{channel}:{identifier}",
+ max_consecutive_login_attempts=5,
+ lock_interval=600,
+ )
+
+ if not tracker.is_user_allowed():
+ frappe.throw(_("Too many failed attempts. Please try again later."))
+
+ otp_secret = frappe.cache.get_value(cache_key)
+ if not otp_secret:
+ frappe.throw(_("Verification code expired. Please request a new one."))
+
+ if not pyotp.HOTP(otp_secret).verify(otp.strip(), 0):
+ tracker.add_failure_attempt()
+ frappe.throw(_("Invalid verification code"))
+
+ frappe.cache.delete_value(cache_key)
+ tracker.add_success_attempt()
+
+
+def get_or_create_guest_user(email: str, full_name: str) -> str:
+ email = email.lower().strip()
+
+ validate_email_address(email, throw=True)
+ if frappe.db.exists("User", email):
+ return email
+
+ name_parts = full_name.strip().split(" ", 1)
+ first_name = name_parts[0] if name_parts else "Guest"
+ last_name = name_parts[1] if len(name_parts) > 1 else ""
+
+ user = frappe.get_doc(
+ {
+ "doctype": "User",
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "enabled": 1,
+ "user_type": "Website User",
+ "send_welcome_email": 0,
+ }
+ )
+ user.insert(ignore_permissions=True)
+
+ return email
+
+
+@frappe.whitelist()
+def get_event_payment_gateways(event: str) -> list[str]:
+ return get_payment_gateways_for_event(event)
+
+
+def are_registrations_closed(event_doc) -> bool:
+ if not event_doc.registrations_close_at:
+ return False
+
+ event_tz = event_doc.time_zone or get_system_timezone()
+ now_in_event_tz = get_datetime_in_timezone(event_tz).replace(tzinfo=None)
+
+ return now_in_event_tz > get_datetime(event_doc.registrations_close_at)
+
+
+def is_ticket_transfer_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", event_id)
+ settings = frappe.get_single("Pohodex Event Manager Settings")
+ transfer_cutoff_days = settings.get("allow_transfer_ticket_before_event_start_days", 7)
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= transfer_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking ticket transfer eligibility: {e!s}")
+ return False
+
+
+def is_add_on_change_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", event_id)
+ settings = frappe.get_cached_doc("Pohodex Event Manager Settings")
+ add_on_change_cutoff_days = settings.get("allow_add_ons_change_before_event_start_days", 7)
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= add_on_change_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking add-on change eligibility: {e!s}")
+ return False
+
+
+@frappe.whitelist()
+def can_transfer_ticket(event_id: str | int) -> dict:
+ return {"can_transfer": is_ticket_transfer_allowed(event_id), "event_id": event_id}
+
+
+@frappe.whitelist()
+def can_change_add_ons(event_id: str | int) -> dict:
+ return {"can_change_add_ons": is_add_on_change_allowed(event_id), "event_id": event_id}
+
+
+def is_cancellation_request_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", event_id)
+ settings = frappe.get_cached_doc("Pohodex Event Manager Settings")
+ cancellation_cutoff_days = settings.get(
+ "allow_ticket_cancellation_request_before_event_start_days", 7
+ )
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= cancellation_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking cancellation request eligibility: {e!s}")
+ return False
+
+
+@frappe.whitelist()
+def can_request_cancellation(event_id: str | int) -> dict:
+ return {"can_request_cancellation": is_cancellation_request_allowed(event_id), "event_id": event_id}
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_event_booking_data(event_route: str) -> dict:
+ data = frappe._dict()
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", {"route": event_route})
+
+ if not event_doc.is_published:
+ frappe.throw(_("Event not found"), frappe.DoesNotExistError)
+
+ data.registrations_closed = are_registrations_closed(event_doc)
+
+ is_guest = frappe.session.user == "Guest"
+ if is_guest:
+ data.event_details = {
+ "name": event_doc.name,
+ "title": event_doc.title,
+ "route": event_doc.route,
+ "start_date": event_doc.start_date,
+ "end_date": event_doc.end_date,
+ "start_time": event_doc.start_time,
+ "end_time": event_doc.end_time,
+ "time_zone": event_doc.time_zone,
+ "venue": event_doc.venue,
+ "medium": event_doc.medium,
+ "category": event_doc.category,
+ "banner_image": event_doc.banner_image,
+ "short_description": event_doc.short_description,
+ "free_webinar": event_doc.free_webinar,
+ "send_ticket_email": event_doc.send_ticket_email,
+ "allow_guest_booking": event_doc.allow_guest_booking,
+ "guest_verification_method": event_doc.guest_verification_method,
+ "default_ticket_type": event_doc.default_ticket_type,
+ }
+ else:
+ data.event_details = event_doc
+
+ available_ticket_types = []
+ published_ticket_types = frappe.db.get_all(
+ "Event Ticket Type", filters={"is_published": True, "event": event_doc.name}, pluck="name"
+ )
+ for ticket_type in published_ticket_types:
+ tt = frappe.get_cached_doc("Event Ticket Type", ticket_type)
+ if tt.are_tickets_available(1):
+ available_ticket_types.append(tt)
+ data.available_ticket_types = available_ticket_types
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on", filters={"event": event_doc.name, "enabled": 1}, fields=["*"], order_by="title"
+ )
+
+ for add_on in add_ons:
+ if add_on.user_selects_option:
+ add_on.options = add_on.options.split("\n")
+
+ data.available_add_ons = add_ons
+
+ data.tax_settings = {
+ "apply_tax": event_doc.apply_tax,
+ "tax_inclusive": event_doc.tax_inclusive,
+ "tax_label": event_doc.tax_label or "Tax",
+ "tax_percentage": event_doc.tax_percentage or 0,
+ }
+
+ custom_fields = frappe.db.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event_doc.name, "enabled": 1},
+ fields=["*"],
+ order_by="order",
+ )
+ data.custom_fields = custom_fields
+
+ payment_gateways = get_payment_gateways_for_event(event_doc.name)
+
+ offline_methods_raw = frappe.get_all(
+ "Offline Payment Method",
+ filters={"event": event_doc.name, "enabled": 1},
+ fields=["name", "title", "description", "collect_payment_proof"],
+ order_by="creation",
+ )
+
+ offline_methods = []
+ for method in offline_methods_raw:
+ method_custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={
+ "event": event_doc.name,
+ "enabled": 1,
+ "applied_to": "Offline Payment Form",
+ "offline_payment_method": method.name,
+ },
+ fields=["*"],
+ order_by="order",
+ )
+ offline_methods.append(
+ {
+ "name": method.name,
+ "title": method.title,
+ "description": method.description,
+ "collect_payment_proof": method.collect_payment_proof,
+ "custom_fields": method_custom_fields,
+ }
+ )
+ payment_gateways.append(method.title)
+
+ data.payment_gateways = payment_gateways
+ data.offline_payment_enabled = len(offline_methods) > 0
+ data.offline_methods = offline_methods
+
+ return data
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def process_booking(
+ attendees: list[dict],
+ event: str,
+ coupon_code: str | None = None,
+ booking_custom_fields: dict | None = None,
+ payment_gateway: str | None = None,
+ utm_parameters: list[dict] | None = None,
+ guest_email: str | None = None,
+ guest_full_name: str | None = None,
+ otp: str | None = None,
+ guest_phone: str | None = None,
+ payment_proof: str | None = None,
+ is_offline: bool = False,
+ offline_payment_method: str | None = None,
+ invoice_requested: bool = False,
+ tax_id: str | None = None,
+ billing_address: str | None = None,
+) -> dict:
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", event)
+ if not event_doc.is_published:
+ frappe.throw(_("Event is not live"))
+
+ if are_registrations_closed(event_doc):
+ frappe.throw(_("Registrations for this event are closed"))
+
+ is_guest = frappe.session.user == "Guest"
+
+ if is_guest:
+ if not event_doc.allow_guest_booking:
+ frappe.throw(_("Please log in to access this feature"), frappe.AuthenticationError)
+
+ if not guest_email:
+ frappe.throw(_("Email is required for guest booking"))
+
+ validate_email_address(guest_email, throw=True)
+ email = guest_email.lower().strip()
+
+ if event_doc.guest_verification_method == "Email OTP":
+ if not otp:
+ frappe.throw(_("Verification code is required"))
+ verify_guest_otp("email", email, otp)
+
+ elif event_doc.guest_verification_method == "Phone OTP":
+ if not otp:
+ frappe.throw(_("Verification code is required"))
+ if not guest_phone:
+ frappe.throw(_("Phone number is required"))
+ verify_guest_otp("phone", guest_phone.strip(), otp)
+
+ first_name = (attendees[0].get("first_name") or "").strip()
+ last_name = (attendees[0].get("last_name") or "").strip()
+ full_name = (guest_full_name or "").strip() or f"{first_name} {last_name}".strip()
+ if not full_name:
+ frappe.throw(_("Full name is required for guest booking"))
+ booking_user = get_or_create_guest_user(guest_email, full_name)
+ else:
+ booking_user = frappe.session.user
+
+ booking = frappe.new_doc("Event Booking")
+ booking.event = event
+ booking.coupon_code = coupon_code
+ booking.user = booking_user
+
+ if event_doc.apply_tax and invoice_requested:
+ booking.invoice_requested = 1
+ booking.tax_id = tax_id
+ booking.billing_address = billing_address
+
+ if utm_parameters:
+ for utm_param in utm_parameters:
+ booking.append(
+ "utm_parameters",
+ {
+ "utm_name": utm_param.get("utm_name"),
+ "value": utm_param.get("value"),
+ },
+ )
+
+ if booking_custom_fields:
+ booking_custom_field_defs = frappe.db.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event, "enabled": 1, "applied_to": "Booking"},
+ fields=["fieldname", "label", "fieldtype"],
+ )
+ custom_field_map = {cf["fieldname"]: cf for cf in booking_custom_field_defs}
+
+ for field_name, field_value in booking_custom_fields.items():
+ if field_value and field_name in custom_field_map:
+ field_def = custom_field_map[field_name]
+ booking.append(
+ "additional_fields",
+ {
+ "fieldname": field_name,
+ "value": str(field_value),
+ "label": field_def["label"],
+ "fieldtype": field_def["fieldtype"],
+ },
+ )
+ if event_doc.category == "Webinars":
+ for attendee in attendees:
+ if not (attendee.get("last_name") or "").strip():
+ frappe.throw(_("Last name is required for all attendees in webinar events"))
+
+ for attendee in attendees:
+ first_name = (attendee.get("first_name") or "").strip()
+ last_name = (attendee.get("last_name") or "").strip()
+
+ if not first_name and attendee.get("full_name"):
+ name_parts = attendee["full_name"].strip().split(" ", 1)
+ first_name = name_parts[0]
+ last_name = last_name or (name_parts[1] if len(name_parts) > 1 else "")
+
+ attendee_full_name = f"{first_name} {last_name}".strip()
+
+ add_ons = attendee.get("add_ons", None)
+ if add_ons:
+ add_ons = create_add_on_doc(
+ attendee_name=attendee_full_name,
+ add_ons=add_ons,
+ )
+
+ custom_fields = attendee.get("custom_fields", {})
+ attendee_row = {
+ "first_name": first_name,
+ "last_name": last_name,
+ "email": attendee.get("email"),
+ "ticket_type": attendee.get("ticket_type"),
+ "add_ons": add_ons.name if add_ons else None,
+ "custom_fields": custom_fields if custom_fields else None,
+ }
+
+ booking.append("attendees", attendee_row)
+
+ booking.insert(ignore_permissions=True)
+ frappe.db.commit()
+
+ if booking.total_amount == 0:
+ booking.flags.ignore_permissions = True
+ booking.submit()
+ return {"booking_name": booking.name}
+
+ if is_offline:
+ method_filters = {"event": event, "enabled": 1}
+ if offline_payment_method:
+ method_filters["name"] = offline_payment_method
+ method_doc = frappe.db.get_value(
+ "Offline Payment Method", method_filters, ["name", "title"], as_dict=True
+ )
+ if not method_doc:
+ frappe.throw(_("Offline payment is not enabled for this event"))
+
+ booking.payment_method = OFFLINE_PAYMENT_METHOD
+ booking.offline_payment_method = method_doc.title
+
+ booking.status = "Approval Pending"
+ booking.payment_status = "Verification Pending"
+ booking.flags.ignore_permissions = True
+ booking.save()
+
+ if payment_proof:
+ try:
+ file_doc = frappe.get_doc(
+ {
+ "doctype": "File",
+ "file_url": payment_proof,
+ "attached_to_doctype": "Event Booking",
+ "attached_to_name": booking.name,
+ "is_private": 1,
+ }
+ )
+ file_doc.insert(ignore_permissions=True)
+ except Exception as e:
+ frappe.log_error(f"Failed to attach payment proof: {e}")
+
+ return {"booking_name": booking.name, "offline_payment": True}
+
+ return {
+ "payment_link": get_payment_link_for_booking(
+ booking.name,
+ redirect_to=f"/dashboard/bookings/{booking.name}?success=true",
+ payment_gateway=payment_gateway,
+ )
+ }
+
+
+def create_add_on_doc(attendee_name: str, add_ons: list[dict]):
+ for add_on in add_ons:
+ add_on["currency"] = frappe.db.get_value("Ticket Add-on", add_on["add_on"], "currency")
+
+ return frappe.get_doc(
+ {"doctype": "Attendee Ticket Add-on", "add_ons": add_ons, "attendee_name": attendee_name}
+ ).insert(ignore_permissions=True)
+
+
+@frappe.whitelist()
+def transfer_ticket(ticket_id: str, new_first_name: str, new_last_name: str, new_email: str):
+ if not frappe.db.exists("Event Ticket", ticket_id):
+ frappe.throw(frappe._("Ticket not found."))
+
+ ticket = frappe.get_doc("Event Ticket", ticket_id)
+ booking_user = frappe.db.get_value("Event Booking", ticket.booking, "user")
+
+ if (
+ ticket.attendee_email != frappe.session.user
+ and booking_user != frappe.session.user
+ and not frappe.has_permission("Event Ticket", "write", ticket)
+ ):
+ frappe.throw(frappe._("Not permitted to transfer this ticket."))
+
+ if not is_ticket_transfer_allowed(ticket.event):
+ frappe.throw(frappe._("Ticket transfer is not allowed at this time. The transfer window has closed."))
+
+ old_name = ticket.attendee_name
+ old_email = ticket.attendee_email
+ new_name = f"{new_first_name} {new_last_name}".strip()
+
+ ticket.first_name = new_first_name
+ ticket.last_name = new_last_name
+ ticket.attendee_email = new_email
+ ticket.save(ignore_permissions=True)
+
+ send_ticket_transfer_emails(ticket.name, old_name, old_email, new_name, new_email)
+
+
+def send_ticket_transfer_emails(ticket_id: str, old_name: str, old_email: str, new_name: str, new_email: str):
+ try:
+ ticket = frappe.get_doc("Event Ticket", ticket_id)
+ event = frappe.get_doc("Pohodex Event Manager Event", ticket.event)
+ booking = frappe.get_doc("Event Booking", ticket.booking)
+
+ old_attendee_subject = f"Your ticket for {event.title} has been transferred"
+ old_attendee_message = f"""
+ Dear {old_name},
+
+ This is to inform you that your ticket for {event.title} has been transferred to {new_name} ({new_email}).
+
+ Event Details:
+
+ Event: {event.title}
+ Date: {format_date(event.start_date)}
+ Ticket Type: {ticket.ticket_type}
+ Booking ID: {booking.name}
+
+
+ If you have any questions about this transfer, please contact us.
+
+ Best regards,
+ {event.title} Team
+ """
+
+ frappe.sendmail(
+ recipients=[old_email], subject=old_attendee_subject, message=old_attendee_message, delayed=False
+ )
+
+ new_attendee_subject = f"Welcome! Your ticket for {event.title}"
+ new_attendee_message = f"""
+ Dear {new_name},
+
+ Great news! A ticket for {event.title} has been transferred to you.
+
+ Event Details:
+
+ Event: {event.title}
+ Date: {format_date(event.start_date)}
+ Time: {format_time(event.start_time) if event.start_time else "TBA"}
+ Venue: {event.venue or "TBA"}
+ Ticket Type: {ticket.ticket_type}
+ Booking ID: {booking.name}
+
+
+ Your Ticket Details:
+
+ Ticket ID: {ticket.name}
+ Attendee Name: {new_name}
+ Email: {new_email}
+
+
+ Please save this email for your records. You may need to present this ticket information at the event entrance.
+
+ We look forward to seeing you at the event!
+
+ Best regards,
+ {event.title} Team
+ """
+
+ frappe.sendmail(
+ recipients=[new_email], subject=new_attendee_subject, message=new_attendee_message, delayed=False
+ )
+
+ except Exception as e:
+ frappe.log_error(f"Failed to send ticket transfer emails for ticket {ticket_id}: {e!s}")
+
+
+@frappe.whitelist()
+def get_booking_details(booking_id: str) -> dict:
+ details = frappe._dict()
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+ details.doc = booking_doc
+
+ tickets = frappe.db.get_all(
+ "Event Ticket",
+ filters={"booking": booking_id},
+ fields=[
+ "name",
+ "attendee_name",
+ "attendee_email",
+ "ticket_type.title as ticket_type",
+ "qr_code",
+ "event",
+ "docstatus",
+ ],
+ )
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ("in", [ticket.name for ticket in tickets])},
+ fields=[
+ "parent",
+ "name",
+ "add_on",
+ "value",
+ "add_on.title as add_on_title",
+ "add_on.user_selects_option as user_selects_option",
+ ],
+ )
+
+ event_add_ons = frappe.db.get_all(
+ "Ticket Add-on",
+ filters={"event": booking_doc.event, "user_selects_option": True},
+ fields=["name", "title", "user_selects_option", "options"],
+ )
+
+ add_on_options_map = {}
+ for event_add_on in event_add_ons:
+ if event_add_on.user_selects_option:
+ add_on_options_map[event_add_on.name] = (
+ event_add_on.options.split("\n") if event_add_on.options else []
+ )
+
+ for ticket in tickets:
+ ticket.add_ons = []
+ for add_on in add_ons:
+ if add_on.parent == ticket.name:
+ add_on_data = {
+ "id": add_on.name,
+ "name": add_on.add_on,
+ "title": add_on.add_on_title,
+ "value": add_on.value,
+ "user_selects_option": add_on.user_selects_option,
+ "options": add_on_options_map.get(add_on.add_on, []),
+ }
+ ticket.add_ons.append(add_on_data)
+ ticket.add_ons = sorted(ticket.add_ons, key=lambda x: x["title"])
+
+ details.tickets = tickets
+ details.event = frappe.get_cached_doc("Pohodex Event Manager Event", booking_doc.event)
+
+ if details.event.venue:
+ details.venue = frappe.get_cached_doc("Event Venue", details.event.venue)
+
+ details.can_transfer_ticket = can_transfer_ticket(details.event.name)
+ details.can_change_add_ons = can_change_add_ons(details.event.name)
+ details.can_request_cancellation = can_request_cancellation(details.event.name)
+
+ existing_cancellation = frappe.db.get_value(
+ "Ticket Cancellation Request",
+ {"booking": booking_id},
+ ["name", "cancel_full_booking", "creation", "status", "docstatus"],
+ as_dict=True,
+ )
+ details.cancellation_request = existing_cancellation
+
+ details.cancellation_requested_tickets = []
+
+ if existing_cancellation and existing_cancellation.docstatus == 0:
+ if existing_cancellation.cancel_full_booking:
+ details.cancellation_requested_tickets = [ticket.name for ticket in tickets]
+ else:
+ requested_tickets = frappe.db.get_all(
+ "Ticket Cancellation Item", filters={"parent": existing_cancellation.name}, fields=["ticket"]
+ )
+ details.cancellation_requested_tickets = [item.ticket for item in requested_tickets]
+
+ details.cancelled_tickets = [ticket.name for ticket in tickets if ticket.docstatus == 2]
+
+ return details
+
+
+@frappe.whitelist()
+def change_add_on_preference(add_on_id: str, new_value: str):
+ if not frappe.db.exists("Ticket Add-on Value", add_on_id):
+ frappe.throw(frappe._("Add-on value not found."))
+
+ add_on_value = frappe.get_cached_doc("Ticket Add-on Value", add_on_id)
+ ticket = frappe.get_cached_doc("Event Ticket", add_on_value.parent)
+
+ if not is_add_on_change_allowed(ticket.event):
+ frappe.throw(
+ frappe._(
+ "Add-on changes are not allowed at this time. The change window has closed as the event is approaching."
+ )
+ )
+
+ frappe.db.set_value(
+ "Ticket Add-on Value",
+ add_on_id,
+ "value",
+ new_value,
+ )
+
+
+@frappe.whitelist()
+def get_sponsorship_details(enquiry_id: str) -> dict:
+ enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
+
+ if enquiry.owner != frappe.session.user and not frappe.has_permission(
+ "Sponsorship Enquiry", "read", enquiry
+ ):
+ frappe.throw(frappe._("Not permitted to view this sponsorship enquiry"))
+
+ tier_title = ""
+ if enquiry.tier:
+ tier_title = frappe.db.get_value("Sponsorship Tier", enquiry.tier, "title") or enquiry.tier
+
+ event_details = {}
+ if enquiry.event:
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", enquiry.event)
+ event_details = {
+ "title": event.title,
+ "short_description": getattr(event, "short_description", ""),
+ "about": getattr(event, "about", ""),
+ "start_date": event.start_date,
+ "end_date": getattr(event, "end_date", ""),
+ "venue": getattr(event, "venue", ""),
+ "route": getattr(event, "route", ""),
+ }
+
+ sponsor_details = None
+ sponsors = frappe.db.get_all(
+ "Event Sponsor",
+ filters={"enquiry": enquiry_id},
+ fields=["name", "company_name", "company_logo", "creation", "event", "tier"],
+ limit=1,
+ )
+
+ if sponsors:
+ sponsor_details = sponsors[0]
+ if sponsor_details.get("tier"):
+ sponsor_tier_title = frappe.db.get_value("Sponsorship Tier", sponsor_details["tier"], "title")
+ sponsor_details["tier_title"] = sponsor_tier_title or sponsor_details["tier"]
+
+ return {
+ "enquiry": {
+ "name": enquiry.name,
+ "company_name": enquiry.company_name,
+ "company_logo": enquiry.company_logo,
+ "event": enquiry.event,
+ "tier": enquiry.tier,
+ "tier_title": tier_title,
+ "status": enquiry.status,
+ "creation": enquiry.creation,
+ "owner": enquiry.owner,
+ },
+ "event_details": event_details,
+ "sponsor_details": sponsor_details,
+ "has_sponsor": bool(sponsor_details),
+ }
+
+
+@frappe.whitelist()
+def get_user_sponsorship_inquiries() -> list:
+ inquiries = frappe.db.get_all(
+ "Sponsorship Enquiry",
+ filters={"owner": frappe.session.user},
+ fields=["name", "company_name", "event", "tier", "status", "creation"],
+ order_by="creation desc",
+ )
+
+ for inquiry in inquiries:
+ if inquiry.event:
+ event_title = frappe.db.get_value("Pohodex Event Manager Event", inquiry.event, "title")
+ inquiry["event_title"] = event_title
+
+ if inquiry.tier:
+ tier_title = frappe.db.get_value("Sponsorship Tier", inquiry.tier, "title")
+ inquiry["tier_title"] = tier_title or inquiry.tier
+ else:
+ inquiry["tier_title"] = ""
+
+ inquiry_names = [inquiry.name for inquiry in inquiries]
+ if inquiry_names:
+ sponsors = frappe.db.get_all(
+ "Event Sponsor",
+ filters={"enquiry": ["in", inquiry_names]},
+ fields=["enquiry"],
+ )
+ sponsored_inquiries = {sponsor.enquiry for sponsor in sponsors}
+
+ for inquiry in inquiries:
+ inquiry["has_sponsor"] = inquiry.name in sponsored_inquiries
+ else:
+ for inquiry in inquiries:
+ inquiry["has_sponsor"] = False
+
+ return inquiries
+
+
+@frappe.whitelist()
+def create_sponsorship_payment_link(enquiry_id: str, tier_id: str, payment_gateway: str | None = None) -> str:
+ enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
+ if enquiry.owner != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to create payment for this enquiry"))
+
+ redirect_url = f"/dashboard/account/sponsorships/{enquiry_id}?success=true"
+ return get_payment_link_for_sponsorship(
+ enquiry_id, tier_id, redirect_url, payment_gateway=payment_gateway
+ )
+
+
+@frappe.whitelist()
+def withdraw_sponsorship_enquiry(enquiry_id: str):
+ enquiry = frappe.get_cached_doc("Sponsorship Enquiry", enquiry_id)
+ if enquiry.owner != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to withdraw this enquiry"))
+
+ if enquiry.status == "Paid":
+ frappe.throw(frappe._("Cannot withdraw a paid sponsorship enquiry"))
+
+ if enquiry.status == "Withdrawn":
+ frappe.throw(frappe._("This sponsorship enquiry has already been withdrawn"))
+
+ enquiry.status = "Withdrawn"
+ enquiry.save(ignore_permissions=True)
+
+
+@frappe.whitelist()
+def get_ticket_details(ticket_id: str) -> dict:
+ details = frappe._dict()
+ ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
+
+ if frappe.session.user != "Administrator":
+ if ticket_doc.attendee_email != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to view this ticket"))
+
+ details.doc = ticket_doc
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ticket_id},
+ fields=[
+ "name",
+ "add_on",
+ "add_on.title as add_on_title",
+ "value",
+ "price",
+ "currency",
+ "add_on.user_selects_option as user_selects_option",
+ ],
+ )
+
+ event_add_ons = frappe.db.get_all(
+ "Ticket Add-on",
+ filters={"event": ticket_doc.event, "user_selects_option": True},
+ fields=["name", "title", "user_selects_option", "options"],
+ )
+
+ add_on_options_map = {}
+ for event_add_on in event_add_ons:
+ if event_add_on.user_selects_option:
+ add_on_options_map[event_add_on.name] = (
+ event_add_on.options.split("\n") if event_add_on.options else []
+ )
+
+ enhanced_add_ons = []
+ for add_on in add_ons:
+ add_on_data = {
+ "id": add_on.name,
+ "name": add_on.add_on,
+ "title": add_on.add_on_title,
+ "value": add_on.value,
+ "price": add_on.price,
+ "currency": add_on.currency,
+ "user_selects_option": add_on.user_selects_option,
+ "options": add_on_options_map.get(add_on.add_on, []),
+ }
+ enhanced_add_ons.append(add_on_data)
+
+ details.add_ons = enhanced_add_ons
+ details.event = frappe.get_cached_doc("Pohodex Event Manager Event", ticket_doc.event)
+
+ booking_doc = None
+ if ticket_doc.booking:
+ booking_doc = frappe.get_cached_doc("Event Booking", ticket_doc.booking)
+ if booking_doc.owner == frappe.session.user:
+ details.booking = booking_doc
+ else:
+ details.booking = None
+ else:
+ details.booking = None
+
+ details.ticket_type = frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type)
+ details.can_transfer_ticket = (
+ can_transfer_ticket(details.event.name) if details.event else {"can_transfer": False}
+ )
+ details.can_change_add_ons = (
+ can_change_add_ons(details.event.name) if details.event else {"can_change_add_ons": False}
+ )
+ details.can_request_cancellation = (
+ can_request_cancellation(details.event.name) if details.event else {"can_request_cancellation": False}
+ )
+
+ details.zoom_join_url = None
+ if hasattr(ticket_doc, "zoom_webinar_registration") and ticket_doc.zoom_webinar_registration:
+ zoom_registration = frappe.db.get_value(
+ "Zoom Webinar Registration",
+ ticket_doc.zoom_webinar_registration,
+ ["join_url", "webinar"],
+ as_dict=True,
+ )
+ if zoom_registration:
+ details.zoom_join_url = zoom_registration.join_url
+ details.zoom_webinar = zoom_registration.webinar
+
+ return details
+
+
+@frappe.whitelist()
+def create_cancellation_request(booking_id: str, ticket_ids: list | None = None) -> dict:
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+
+ if booking_doc.user != frappe.session.user and not frappe.has_permission(
+ "Event Booking", "write", booking_doc
+ ):
+ frappe.throw(frappe._("Not permitted to request cancellation for this booking."))
+
+ if not is_cancellation_request_allowed(booking_doc.event):
+ frappe.throw("Cancellation requests are no longer allowed for this event.")
+
+ existing_request = frappe.db.exists(
+ "Ticket Cancellation Request", {"booking": booking_id, "docstatus": 0}
+ )
+ if existing_request:
+ frappe.throw("A cancellation request already exists for this booking.")
+
+ all_tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking_id}, fields=["name"])
+ cancel_full_booking = not ticket_ids or len(ticket_ids) == len(all_tickets)
+
+ cancellation_request = frappe.new_doc("Ticket Cancellation Request")
+ cancellation_request.booking = booking_id
+ cancellation_request.cancel_full_booking = cancel_full_booking
+
+ if not cancel_full_booking and ticket_ids:
+ for ticket_id in ticket_ids:
+ ticket_booking = frappe.db.get_value("Event Ticket", ticket_id, "booking")
+ if ticket_booking != booking_id:
+ frappe.throw(f"Ticket {ticket_id} does not belong to booking {booking_id}")
+
+ cancellation_request.append("tickets", {"ticket": ticket_id})
+
+ cancellation_request.insert(ignore_permissions=True)
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_user_info() -> dict:
+ if frappe.session.user == "Guest":
+ return {"is_logged_in": False}
+
+ user = frappe.get_cached_doc("User", frappe.session.user)
+
+ return {
+ "name": user.name,
+ "is_logged_in": True,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "full_name": user.full_name,
+ "email": user.email,
+ "user_image": user.user_image,
+ "roles": user.roles,
+ "brand_image": frappe.get_single_value("Website Settings", "banner_image"),
+ "language": user.language,
+ }
+
+
+@frappe.whitelist()
+def validate_ticket_for_checkin(ticket_id: str) -> dict:
+ frappe.only_for("Frontdesk Manager", True)
+ if not frappe.db.exists("Event Ticket", ticket_id):
+ frappe.throw(_("Ticket not found"))
+
+ ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
+
+ if ticket_doc.docstatus == 2:
+ frappe.throw(_("This ticket has been cancelled and cannot be checked in"))
+
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", ticket_doc.event)
+ ticket_type_doc = (
+ frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type) if ticket_doc.ticket_type else None
+ )
+
+ checkin_date = frappe.utils.today()
+ existing_checkin = frappe.db.exists("Event Check In", {"ticket": ticket_id, "date": checkin_date})
+
+ if existing_checkin:
+ checkin_doc = frappe.get_doc("Event Check In", existing_checkin)
+ formatted_checkin_time = (
+ format_date(checkin_doc.creation) + " at " + format_time(checkin_doc.creation)
+ )
+
+ frappe.throw(_("This ticket was already checked in today ({0}).").format(formatted_checkin_time))
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ticket_id},
+ fields=[
+ "add_on",
+ "add_on.title as add_on_title",
+ "add_on.user_selects_option as add_on_selects_option",
+ "value",
+ "price",
+ "currency",
+ ],
+ )
+
+ return {
+ "message": _("Valid ticket ready for check-in"),
+ "ticket": {
+ "id": ticket_doc.name,
+ "attendee_name": ticket_doc.attendee_name,
+ "attendee_email": ticket_doc.attendee_email,
+ "event_title": event_doc.title,
+ "ticket_type": (ticket_type_doc.title if ticket_type_doc else ticket_doc.ticket_type),
+ "venue": event_doc.venue,
+ "start_date": event_doc.start_date,
+ "start_time": event_doc.start_time,
+ "end_date": event_doc.end_date,
+ "end_time": event_doc.end_time,
+ "is_checked_in": False,
+ "check_in_time": None,
+ "booking_id": ticket_doc.booking,
+ "add_ons": add_ons,
+ },
+ "payment_details": get_payment_details_for_ticket(ticket_id),
+ }
+
+
+def get_payment_details_for_ticket(ticket_id: str) -> dict | None:
+ booking_id = frappe.get_cached_value("Event Ticket", ticket_id, "booking")
+ if not booking_id:
+ return None
+
+ payments = frappe.db.get_all(
+ "Event Payment",
+ filters={
+ "reference_doctype": "Event Booking",
+ "reference_docname": booking_id,
+ "payment_received": 1,
+ },
+ fields=["name", "amount", "currency"],
+ limit=1,
+ )
+
+ if payments:
+ return payments[0]
+
+
+@frappe.whitelist()
+def checkin_ticket(ticket_id: str) -> dict:
+ frappe.only_for("Frontdesk Manager", True)
+
+ checkin_date = frappe.utils.today()
+ validation_result = validate_ticket_for_checkin(ticket_id)
+
+ checkin_doc = frappe.new_doc("Event Check In")
+ checkin_doc.ticket = ticket_id
+ checkin_doc.date = checkin_date
+ checkin_doc.insert(ignore_permissions=True)
+ checkin_doc.submit()
+
+ return {
+ "message": _("Successfully checked in {attendee_name} for {checkin_date}").format(
+ attendee_name=validation_result["ticket"]["attendee_name"],
+ checkin_date=frappe.format(checkin_date, {"fieldtype": "Date"}),
+ ),
+ "ticket": {
+ **validation_result["ticket"],
+ "is_checked_in": True,
+ "check_in_time": checkin_doc.creation,
+ "check_in_date": checkin_date,
+ },
+ }
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_enabled_languages():
+ languages = frappe.get_all(
+ "Language",
+ filters={"enabled": 1},
+ fields=["name", "language_name", "language_code"],
+ order_by="language_name",
+ )
+ return languages
+
+
+@frappe.whitelist()
+def update_user_language(language_code: str):
+ if not frappe.db.exists("Language", {"language_code": language_code}):
+ frappe.throw(_("Invalid language"))
+
+ frappe.db.set_value("User", frappe.session.user, "language", language_code)
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_translations():
+ if frappe.session.user != "Guest":
+ language = frappe.db.get_value("User", frappe.session.user, "language")
+ else:
+ language = frappe.db.get_single_value("System Settings", "language")
+
+ return get_all_translations(language)
+
+
+def has_app_permission():
+ return True
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def validate_coupon(coupon_code: str, event: str, user_email: str | None = None) -> dict:
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", event)
+ if frappe.session.user == "Guest" and not event_doc.allow_guest_booking:
+ frappe.throw(_("Please log in to access this feature"), frappe.AuthenticationError)
+
+ if not frappe.db.exists("Pohodex Event Manager Coupon Code", coupon_code):
+ return {"valid": False, "error": _("Invalid coupon code")}
+
+ coupon = frappe.get_doc("Pohodex Event Manager Coupon Code", coupon_code)
+
+ is_valid, error = coupon.is_valid_for_event(event)
+ if not is_valid:
+ return {"valid": False, "error": error}
+
+ is_available, error = coupon.is_usage_available()
+ if not is_available:
+ return {"valid": False, "error": error}
+
+ if frappe.session.user == "Guest":
+ check_user = user_email.lower().strip() if user_email else None
+ else:
+ check_user = frappe.session.user
+ is_limited, error = coupon.is_user_limit_reached(user=check_user)
+ if is_limited:
+ return {"valid": False, "error": error}
+
+ if coupon.coupon_type == "Discount":
+ return {
+ "valid": True,
+ "coupon_type": "Discount",
+ "discount_type": coupon.discount_type,
+ "discount_value": coupon.discount_value,
+ "max_discount_amount": coupon.maximum_discount_amount or 0,
+ "min_order_value": coupon.minimum_order_value or 0,
+ }
+
+ remaining = coupon.number_of_free_tickets - coupon.free_tickets_claimed
+ if remaining <= 0:
+ return {"valid": False, "error": _("All free tickets have been claimed")}
+
+ return {
+ "valid": True,
+ "coupon_type": "Free Tickets",
+ "ticket_type": coupon.ticket_type,
+ "remaining_tickets": remaining,
+ "free_add_ons": [a.add_on for a in coupon.free_add_ons],
+ }
+
+
+@frappe.whitelist()
+def get_campaign_details(campaign: str):
+ if not frappe.db.exists("Pohodex Event Manager Campaign", campaign):
+ frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
+
+ campaign_doc = frappe.get_cached_doc("Pohodex Event Manager Campaign", campaign)
+
+ if not campaign_doc.enabled:
+ frappe.throw(_("This campaign is not active"))
+
+ return {
+ "title": campaign_doc.title,
+ "description": campaign_doc.description,
+ "event": campaign_doc.event,
+ }
+
+
+@frappe.whitelist()
+def register_campaign_interest(campaign: str):
+ if frappe.session.user == "Guest":
+ frappe.throw(_("Please login to register your interest"))
+
+ if not is_app_installed("crm"):
+ frappe.throw(_("CRM integration is not available"))
+
+ if not frappe.db.exists("Pohodex Event Manager Campaign", campaign):
+ frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
+
+ campaign_doc = frappe.get_cached_doc("Pohodex Event Manager Campaign", campaign)
+
+ user = frappe.get_cached_doc("User", frappe.session.user)
+ first_name = user.first_name or user.full_name or frappe.session.user.split("@")[0]
+
+ existing_lead = frappe.db.exists(
+ "CRM Lead",
+ {"email": frappe.session.user, "buzz_campaign": campaign},
+ )
+ if existing_lead:
+ frappe.throw(_("You have already registered for this campaign"))
+
+ ticket = None
+ if campaign_doc.event:
+ ticket = frappe.db.get_value(
+ "Event Ticket",
+ {
+ "attendee_email": frappe.session.user,
+ "event": campaign_doc.event,
+ "docstatus": 1,
+ },
+ "name",
+ )
+
+ lead = frappe.get_doc(
+ {
+ "doctype": "CRM Lead",
+ "first_name": first_name,
+ "email": frappe.session.user,
+ "status": "New",
+ "buzz_campaign": campaign,
+ "event_ticket": ticket,
+ }
+ )
+ lead.insert(ignore_permissions=True)
diff --git a/event_manager/api/auth.py b/event_manager/api/auth.py
new file mode 100644
index 0000000..85e6958
--- /dev/null
+++ b/event_manager/api/auth.py
@@ -0,0 +1,38 @@
+import frappe
+from frappe.utils import cint, md_to_html
+from frappe.utils.oauth import get_oauth2_authorize_url, get_oauth_keys
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_login_context(redirect_to: str | None = None):
+ context = {
+ "disable_signup": frappe.get_website_settings("disable_signup"),
+ "disable_user_pass_login": frappe.get_system_settings("disable_user_pass_login"),
+ "login_with_email_link": frappe.get_system_settings("login_with_email_link"),
+ "login_banner": md_to_html(raw_banner)
+ if (raw_banner := frappe.db.get_single_value("Pohodex Event Manager Settings", "login_banner"))
+ else None,
+ "provider_logins": [],
+ }
+
+ if not redirect_to:
+ redirect_to = frappe.utils.get_url("/dashboard")
+
+ social_login_keys = frappe.get_all(
+ "Social Login Key",
+ filters={"enable_social_login": 1},
+ fields=["name", "provider_name", "icon", "client_id", "base_url"],
+ )
+
+ for provider in social_login_keys:
+ if provider.client_id and provider.base_url and get_oauth_keys(provider.name):
+ context["provider_logins"].append(
+ {
+ "name": provider.name,
+ "provider_name": provider.provider_name,
+ "icon": provider.icon or "",
+ "auth_url": get_oauth2_authorize_url(provider.name, redirect_to),
+ }
+ )
+
+ return context
diff --git a/event_manager/api/forms.py b/event_manager/api/forms.py
new file mode 100644
index 0000000..150d77f
--- /dev/null
+++ b/event_manager/api/forms.py
@@ -0,0 +1,344 @@
+from functools import lru_cache
+
+import frappe
+from frappe import _
+from frappe.geo.country_info import get_all as get_all_countries
+from frappe.model import DEFAULT_FIELDS, display_fieldtypes
+from frappe.utils import get_datetime, now_datetime, today
+from frappe.utils.data import cstr, sbool
+
+LAYOUT_FIELDTYPES = set(display_fieldtypes)
+
+EVENT_PROPOSAL_EXCLUDE_FIELDS = DEFAULT_FIELDS | {
+ "naming_series",
+ "amended_from",
+ "host",
+ "host_company",
+ "host_company_logo",
+ "additional_notes",
+ "status",
+ "submitted_by",
+}
+
+STANDARD_EXCLUDE_FIELDS = DEFAULT_FIELDS | {
+ "additional_fields",
+ "event",
+ "section_break_additional",
+ "submitted_by",
+ "status",
+}
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_dial_codes() -> list:
+ return _get_dial_codes()
+
+
+@lru_cache(maxsize=1)
+def _get_dial_codes() -> list:
+ data = get_all_countries()
+ codes = []
+ seen = set()
+ for country in sorted(data):
+ info = data[country]
+ isd = info.get("isd", "")
+ code = (info.get("code") or "").upper()
+ if isd and isd not in seen:
+ codes.append({"country": country, "code": code, "dial_code": isd})
+ seen.add(isd)
+ return codes
+
+
+def get_form_fields(doctype: str, exclude_fields: set) -> list:
+ meta = frappe.get_meta(doctype)
+ fields = []
+ for df in meta.fields:
+ if df.fieldname in exclude_fields:
+ continue
+ if df.fieldtype in LAYOUT_FIELDTYPES:
+ continue
+ if df.hidden:
+ continue
+ if df.read_only:
+ continue
+ default_value = df.default
+ if default_value:
+ if default_value == "Today":
+ default_value = today()
+ elif default_value == "Now":
+ default_value = cstr(now_datetime())
+ elif default_value.startswith("eval:") or default_value.startswith("%"):
+ default_value = None
+
+ field_data = {
+ "fieldname": df.fieldname,
+ "fieldtype": df.fieldtype,
+ "label": df.label or df.fieldname,
+ "options": df.options,
+ "reqd": df.reqd,
+ "default": default_value,
+ "description": df.description,
+ }
+ if df.fieldtype == "Link" and df.options:
+ link_values = frappe.get_all(
+ df.options,
+ fields=["name"],
+ limit_page_length=0,
+ order_by="name asc",
+ )
+ field_data["link_options"] = [d.name for d in link_values]
+ if df.fieldtype == "Table" and df.options:
+ child_meta = frappe.get_meta(df.options)
+ child_fields = []
+ for child_df in child_meta.fields:
+ if child_df.fieldtype in LAYOUT_FIELDTYPES:
+ continue
+ if child_df.hidden:
+ continue
+ child_fields.append(
+ {
+ "fieldname": child_df.fieldname,
+ "fieldtype": child_df.fieldtype,
+ "label": child_df.label or child_df.fieldname,
+ "options": child_df.options,
+ "reqd": child_df.reqd,
+ }
+ )
+ field_data["child_fields"] = child_fields
+ fields.append(field_data)
+ return fields
+
+
+def validate_custom_form(event_route: str, form_route: str):
+ event_name = frappe.get_cached_value("Pohodex Event Manager Event", {"route": event_route}, "name")
+ if not event_name:
+ frappe.throw(_("Event not found"), frappe.DoesNotExistError)
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", event_name)
+
+ if not event_doc.is_published:
+ frappe.throw(_("Event not found"), frappe.DoesNotExistError)
+
+ form_row = None
+ for row in event_doc.custom_forms:
+ if row.route == form_route and row.publish:
+ form_row = row
+ break
+
+ if not form_row:
+ frappe.throw(_("This form is not available for this event"), frappe.DoesNotExistError)
+
+ return event_doc, form_row
+
+
+def get_auto_set_fields(form_doctype: str):
+ meta = frappe.get_meta(form_doctype)
+ auto_set = {}
+ for df in meta.fields:
+ if df.fieldname == "event" and df.fieldtype == "Link":
+ auto_set["event"] = "from_route"
+ elif df.fieldname == "submitted_by" and df.fieldtype == "Link":
+ auto_set["submitted_by"] = "session_user"
+ return auto_set
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_custom_form_data(event_route: str, form_route: str) -> dict:
+ event_doc, form_row = validate_custom_form(event_route, form_route)
+ form_doctype = form_row.form_doctype
+ allow_guest_submission = sbool(event_doc.allow_guest_booking)
+
+ if not allow_guest_submission and frappe.session.user == "Guest":
+ frappe.throw(_("Please log in to submit this form"), frappe.AuthenticationError)
+
+ event_data = {
+ "name": event_doc.name,
+ "title": event_doc.title,
+ "route": event_doc.route,
+ "banner_image": event_doc.banner_image,
+ "start_date": event_doc.start_date,
+ "end_date": event_doc.end_date,
+ "start_time": event_doc.start_time,
+ "end_time": event_doc.end_time,
+ "time_zone": event_doc.time_zone,
+ "venue": event_doc.venue,
+ "medium": event_doc.medium,
+ "short_description": event_doc.short_description,
+ }
+
+ closed = False
+ if form_row.auto_close_at and get_datetime(form_row.auto_close_at) < now_datetime():
+ closed = True
+
+ if closed:
+ return {
+ "form_fields": [],
+ "custom_fields": [],
+ "form_title": form_doctype,
+ "event": event_data,
+ "closed": True,
+ "closed_title": form_row.closed_title or _("Submissions Closed"),
+ "closed_message": form_row.closed_message or _("Submissions for this form have closed."),
+ "success_title": "",
+ "success_message": "",
+ }
+
+ auto_set = get_auto_set_fields(form_doctype)
+ exclude_fields = STANDARD_EXCLUDE_FIELDS | set(auto_set.keys())
+ form_fields = get_form_fields(form_doctype, exclude_fields)
+
+ form_doctype_meta = frappe.get_meta(form_doctype)
+ custom_fields = []
+ if form_doctype_meta.has_field("additional_fields"):
+ custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={
+ "event": event_doc.name,
+ "applied_to": "Custom Form",
+ "custom_form_doctype": form_doctype,
+ "enabled": 1,
+ },
+ fields=[
+ "label",
+ "fieldname",
+ "fieldtype",
+ "options",
+ "mandatory",
+ "placeholder",
+ "default_value",
+ "order",
+ ],
+ order_by="order asc",
+ )
+
+ return {
+ "form_fields": form_fields,
+ "custom_fields": custom_fields,
+ "form_title": form_doctype_meta.name,
+ "event": event_data,
+ "closed": False,
+ "closed_title": form_row.closed_title or _("Submissions Closed"),
+ "closed_message": form_row.closed_message or _("Submissions for this form have closed."),
+ "success_title": form_row.success_title or _("Thank you!"),
+ "success_message": form_row.success_message or "",
+ }
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def submit_custom_form(
+ event_route: str, form_route: str, data: dict | str, custom_fields_data: dict | str | None = None
+) -> None:
+ event_doc, form_row = validate_custom_form(event_route, form_route)
+ form_doctype = form_row.form_doctype
+
+ if not event_doc.allow_guest_booking and frappe.session.user == "Guest":
+ frappe.throw(_("Please login to submit this form"), frappe.AuthenticationError)
+
+ if form_row.auto_close_at and get_datetime(form_row.auto_close_at) < now_datetime():
+ frappe.throw(_("Submissions for this form have closed."))
+
+ data = frappe.parse_json(data) or {}
+ custom_fields_data = frappe.parse_json(custom_fields_data) or {}
+
+ auto_set = get_auto_set_fields(form_doctype)
+ exclude_fields = STANDARD_EXCLUDE_FIELDS | set(auto_set.keys())
+
+ doc_data = {"doctype": form_doctype}
+
+ for field, source in auto_set.items():
+ if source == "from_route":
+ doc_data[field] = event_doc.name
+ elif source == "session_user":
+ doc_data[field] = frappe.session.user
+
+ allowed_fieldnames = {f["fieldname"] for f in get_form_fields(form_doctype, exclude_fields)}
+ for fieldname, value in data.items():
+ if fieldname in allowed_fieldnames:
+ doc_data[fieldname] = value
+
+ meta = frappe.get_meta(form_doctype)
+ for df in meta.fields:
+ if df.fieldtype == "Table" and df.fieldname not in exclude_fields:
+ if df.fieldname in data and isinstance(data[df.fieldname], list):
+ doc_data[df.fieldname] = data[df.fieldname]
+
+ doc = frappe.get_doc(doc_data)
+
+ if custom_fields_data and meta.has_field("additional_fields"):
+ custom_field_definitions = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={
+ "event": event_doc.name,
+ "applied_to": "Custom Form",
+ "custom_form_doctype": form_doctype,
+ "enabled": 1,
+ },
+ fields=["fieldname", "label", "fieldtype"],
+ )
+ allowed_custom = {cf["fieldname"]: cf for cf in custom_field_definitions}
+
+ for fieldname, value in custom_fields_data.items():
+ if fieldname in allowed_custom and value not in (None, ""):
+ cf = allowed_custom[fieldname]
+ doc.append(
+ "additional_fields",
+ {
+ "label": cf["label"],
+ "fieldname": fieldname,
+ "fieldtype": cf["fieldtype"],
+ "value": cstr(value),
+ },
+ )
+
+ doc.insert(ignore_permissions=True)
+
+
+def validate_event_proposal_settings():
+ settings = frappe.get_cached_doc("Pohodex Event Manager Settings")
+ if not settings.accept_event_proposals:
+ frappe.throw(_("Event Proposals are not being accepted"), frappe.DoesNotExistError)
+
+ if not settings.allow_guest_event_proposals and frappe.session.user == "Guest":
+ frappe.throw(_("Please log in to submit a proposal"), frappe.AuthenticationError)
+
+ return settings
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_event_proposal_form_data() -> dict:
+ settings = validate_event_proposal_settings()
+ form_fields = get_form_fields("Event Proposal", EVENT_PROPOSAL_EXCLUDE_FIELDS)
+
+ return {
+ "form_fields": form_fields,
+ "form_title": _("Event Proposal"),
+ "banner_title": settings.event_proposal_banner_title or _("Propose an Event"),
+ "success_title": settings.event_proposal_success_title or _("Thank you!"),
+ "success_message": settings.event_proposal_success_message or "",
+ }
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def submit_event_proposal(data: dict | str) -> None:
+ validate_event_proposal_settings()
+
+ data = frappe.parse_json(data) or {}
+
+ doc_data = {"doctype": "Event Proposal"}
+
+ if frappe.session.user != "Guest":
+ doc_data["submitted_by"] = frappe.session.user
+
+ allowed_fieldnames = {
+ f["fieldname"] for f in get_form_fields("Event Proposal", EVENT_PROPOSAL_EXCLUDE_FIELDS)
+ }
+ for fieldname, value in data.items():
+ if fieldname in allowed_fieldnames:
+ doc_data[fieldname] = value
+
+ meta = frappe.get_meta("Event Proposal")
+ for df in meta.fields:
+ if df.fieldtype == "Table" and df.fieldname not in EVENT_PROPOSAL_EXCLUDE_FIELDS:
+ if df.fieldname in data and isinstance(data[df.fieldname], list):
+ doc_data[df.fieldname] = data[df.fieldname]
+
+ frappe.get_doc(doc_data).insert(ignore_permissions=True)
diff --git a/event_manager/buzz/__init__.py b/event_manager/buzz/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/doctype/__init__.py b/event_manager/buzz/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/doctype/buzz_custom_field/__init__.py b/event_manager/buzz/doctype/buzz_custom_field/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.js b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.js
new file mode 100644
index 0000000..d25ef2b
--- /dev/null
+++ b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.js
@@ -0,0 +1,15 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Pohodex Event Manager Custom Field", {
+ refresh(frm) {
+ frm.set_query("custom_form_doctype", function () {
+ return {
+ filters: {
+ istable: 0,
+ issingle: 0,
+ },
+ };
+ });
+ },
+});
diff --git a/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.json b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.json
new file mode 100644
index 0000000..300311e
--- /dev/null
+++ b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.json
@@ -0,0 +1,144 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-11-01 11:29:38.327158",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "event",
+ "label",
+ "fieldname",
+ "mandatory",
+ "placeholder",
+ "default_value",
+ "column_break_fpgn",
+ "applied_to",
+ "custom_form_doctype",
+ "offline_payment_method",
+ "fieldtype",
+ "options",
+ "order"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_fpgn",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Label",
+ "reqd": 1
+ },
+ {
+ "fieldname": "fieldname",
+ "fieldtype": "Data",
+ "label": "Name"
+ },
+ {
+ "default": "Data",
+ "fieldname": "fieldtype",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Type",
+ "options": "Data\nCheck\nSmall Text\nPhone\nEmail\nSelect\nDate\nNumber\nMulti Select\nRating\nAttach\nAttach Image",
+ "reqd": 1
+ },
+ {
+ "fieldname": "options",
+ "fieldtype": "Small Text",
+ "label": "Options"
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled?"
+ },
+ {
+ "default": "0",
+ "fieldname": "mandatory",
+ "fieldtype": "Check",
+ "label": "Mandatory?"
+ },
+ {
+ "fieldname": "placeholder",
+ "fieldtype": "Data",
+ "label": "Placeholder"
+ },
+ {
+ "default": "1",
+ "fieldname": "order",
+ "fieldtype": "Int",
+ "label": "Order",
+ "non_negative": 1
+ },
+ {
+ "fieldname": "default_value",
+ "fieldtype": "Data",
+ "label": "Default Value"
+ },
+ {
+ "default": "Booking",
+ "fieldname": "applied_to",
+ "fieldtype": "Select",
+ "label": "Applied To",
+ "options": "Booking\nTicket\nOffline Payment Form\nCustom Form"
+ },
+ {
+ "depends_on": "eval:doc.applied_to === 'Custom Form'",
+ "fieldname": "custom_form_doctype",
+ "fieldtype": "Link",
+ "label": "Custom Form DocType",
+ "mandatory_depends_on": "eval:doc.applied_to === 'Custom Form'",
+ "options": "DocType"
+ },
+ {
+ "depends_on": "eval:doc.applied_to === 'Offline Payment Form'",
+ "fieldname": "offline_payment_method",
+ "fieldtype": "Link",
+ "label": "Offline Payment Method",
+ "mandatory_depends_on": "eval:doc.applied_to === 'Offline Payment Form'",
+ "options": "Offline Payment Method"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-02-06 15:26:27.195623",
+ "modified_by": "Administrator",
+ "module": "Pohodex Event Manager",
+ "name": "Pohodex Event Manager Custom Field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "label"
+}
diff --git a/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.py b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.py
new file mode 100644
index 0000000..1722f60
--- /dev/null
+++ b/event_manager/buzz/doctype/buzz_custom_field/buzz_custom_field.py
@@ -0,0 +1,72 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class BuzzCustomField(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ applied_to: DF.Literal["Booking", "Ticket", "Offline Payment Form"]
+ default_value: DF.Data | None
+ enabled: DF.Check
+ event: DF.Link
+ fieldname: DF.Data | None
+ fieldtype: DF.Literal[
+ "Data", "Check", "Small Text", "Phone", "Email", "Select", "Date", "Number", "Multi Select"
+ ]
+ label: DF.Data
+ mandatory: DF.Check
+ options: DF.SmallText | None
+ order: DF.Int
+ placeholder: DF.Data | None
+ # end: auto-generated types
+
+ def validate(self):
+ if not self.fieldname:
+ self.fieldname = frappe.scrub(self.label)
+
+ def on_update(self):
+ if self.applied_to == "Custom Form" and self.custom_form_doctype:
+ self.create_additional_fields_if_missing()
+
+ def create_additional_fields_if_missing(self):
+ meta = frappe.get_meta(self.custom_form_doctype)
+
+ if meta.has_field("additional_fields"):
+ return
+
+ frappe.get_doc(
+ {
+ "doctype": "Custom Field",
+ "dt": self.custom_form_doctype,
+ "fieldname": "section_break_additional",
+ "label": "Additional Fields",
+ "fieldtype": "Section Break",
+ }
+ ).insert(ignore_permissions=True)
+
+ frappe.get_doc(
+ {
+ "doctype": "Custom Field",
+ "dt": self.custom_form_doctype,
+ "fieldname": "additional_fields",
+ "label": "Additional Fields",
+ "fieldtype": "Table",
+ "options": "Additional Field",
+ "insert_after": "section_break_additional",
+ }
+ ).insert(ignore_permissions=True)
+
+ frappe.msgprint(
+ _("Added 'Additional Fields' table to {0}").format(self.custom_form_doctype),
+ alert=True,
+ )
diff --git a/event_manager/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py b/event_manager/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py
new file mode 100644
index 0000000..c6ba3fe
--- /dev/null
+++ b/event_manager/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestBuzzCustomField(IntegrationTestCase):
+ """
+ Integration tests for BuzzCustomField.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/buzz/web_form/__init__.py b/event_manager/buzz/web_form/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/web_form/apply_for_sponsorship/__init__.py b/event_manager/buzz/web_form/apply_for_sponsorship/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.js b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.js
new file mode 100644
index 0000000..8f56ebb
--- /dev/null
+++ b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.js
@@ -0,0 +1,3 @@
+frappe.ready(function () {
+ // bind events here
+});
diff --git a/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.json b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.json
new file mode 100644
index 0000000..829268a
--- /dev/null
+++ b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.json
@@ -0,0 +1,135 @@
+{
+ "accept_payment": 0,
+ "allow_comments": 0,
+ "allow_delete": 0,
+ "allow_edit": 0,
+ "allow_incomplete": 0,
+ "allow_multiple": 1,
+ "allow_print": 0,
+ "amount": 0.0,
+ "amount_based_on_field": 0,
+ "anonymous": 0,
+ "apply_document_permissions": 0,
+ "button_label": "Submit",
+ "condition_json": "[]",
+ "creation": "2025-10-09 18:53:54.552643",
+ "currency": "INR",
+ "doc_type": "Sponsorship Enquiry",
+ "docstatus": 0,
+ "doctype": "Web Form",
+ "hide_footer": 1,
+ "hide_navbar": 1,
+ "idx": 0,
+ "is_standard": 1,
+ "list_columns": [],
+ "login_required": 1,
+ "max_attachment_size": 5,
+ "modified": "2025-10-30 16:52:36.708858",
+ "modified_by": "Administrator",
+ "module": "Pohodex Event Manager",
+ "name": "apply-for-sponsorship",
+ "owner": "Administrator",
+ "payment_button_label": "Buy Now",
+ "published": 1,
+ "route": "apply-for-sponsorship",
+ "show_attachments": 0,
+ "show_list": 1,
+ "show_sidebar": 0,
+ "success_url": "/dashboard/account/sponsorships",
+ "title": "Apply for Sponsorship",
+ "web_form_fields": [
+ {
+ "allow_read_on_all_link_options": 1,
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "label": "Event",
+ "max_length": 0,
+ "max_value": 0,
+ "options": "Pohodex Event Manager Event",
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "company_name",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "label": "Company Name",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "company_logo",
+ "fieldtype": "Attach Image",
+ "hidden": 0,
+ "label": "Company Logo",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "column_break_fhgg",
+ "fieldtype": "Column Break",
+ "hidden": 0,
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "website",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "label": "Website",
+ "max_length": 0,
+ "max_value": 0,
+ "options": "URL",
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 1,
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "label": "Country",
+ "max_length": 0,
+ "max_value": 0,
+ "options": "Country",
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "phone",
+ "fieldtype": "Phone",
+ "hidden": 0,
+ "label": "Phone Number",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ }
+ ]
+}
diff --git a/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.py b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.py
new file mode 100644
index 0000000..80b7b87
--- /dev/null
+++ b/event_manager/buzz/web_form/apply_for_sponsorship/apply_for_sponsorship.py
@@ -0,0 +1,6 @@
+import frappe
+
+
+def get_context(context):
+ # do your magic here
+ pass
diff --git a/event_manager/buzz/web_form/propose_a_talk/__init__.py b/event_manager/buzz/web_form/propose_a_talk/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.js b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.js
new file mode 100644
index 0000000..8f56ebb
--- /dev/null
+++ b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.js
@@ -0,0 +1,3 @@
+frappe.ready(function () {
+ // bind events here
+});
diff --git a/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.json b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.json
new file mode 100644
index 0000000..1561695
--- /dev/null
+++ b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.json
@@ -0,0 +1,133 @@
+{
+ "accept_payment": 0,
+ "allow_comments": 0,
+ "allow_delete": 1,
+ "allow_edit": 0,
+ "allow_incomplete": 0,
+ "allow_multiple": 1,
+ "allow_print": 0,
+ "amount": 0.0,
+ "amount_based_on_field": 0,
+ "anonymous": 0,
+ "apply_document_permissions": 0,
+ "button_label": "Submit",
+ "condition_json": "[]",
+ "creation": "2025-10-09 18:57:22.187906",
+ "currency": "INR",
+ "doc_type": "Talk Proposal",
+ "docstatus": 0,
+ "doctype": "Web Form",
+ "hide_footer": 1,
+ "hide_navbar": 1,
+ "idx": 0,
+ "introduction_text": "Apply for giving a talk at the event.
",
+ "is_standard": 1,
+ "list_columns": [],
+ "login_required": 1,
+ "max_attachment_size": 0,
+ "modified": "2025-10-28 17:42:31.607629",
+ "modified_by": "Administrator",
+ "module": "Pohodex Event Manager",
+ "name": "propose-a-talk",
+ "owner": "Administrator",
+ "payment_button_label": "Buy Now",
+ "published": 1,
+ "route": "propose-a-talk",
+ "show_attachments": 0,
+ "show_list": 1,
+ "show_sidebar": 0,
+ "title": "Propose a Talk",
+ "web_form_fields": [
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "label": "Title of your talk",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "column_break_esac",
+ "fieldtype": "Column Break",
+ "hidden": 0,
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 1,
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "label": "Event",
+ "max_length": 0,
+ "max_value": 0,
+ "options": "Pohodex Event Manager Event",
+ "precision": "",
+ "read_only": 0,
+ "reqd": 1,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "section_break_yqfb",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "hidden": 0,
+ "label": "Description",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "speakers",
+ "fieldtype": "Table",
+ "hidden": 0,
+ "label": "Speakers",
+ "max_length": 0,
+ "max_value": 0,
+ "options": "Proposal Speaker",
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ },
+ {
+ "allow_read_on_all_link_options": 0,
+ "fieldname": "phone",
+ "fieldtype": "Phone",
+ "hidden": 0,
+ "label": "Phone Number",
+ "max_length": 0,
+ "max_value": 0,
+ "precision": "",
+ "read_only": 0,
+ "reqd": 0,
+ "show_in_filter": 0
+ }
+ ]
+}
diff --git a/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.py b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.py
new file mode 100644
index 0000000..80b7b87
--- /dev/null
+++ b/event_manager/buzz/web_form/propose_a_talk/propose_a_talk.py
@@ -0,0 +1,6 @@
+import frappe
+
+
+def get_context(context):
+ # do your magic here
+ pass
diff --git a/event_manager/buzz/workspace/buzz/buzz.json b/event_manager/buzz/workspace/buzz/buzz.json
new file mode 100644
index 0000000..d5720fe
--- /dev/null
+++ b/event_manager/buzz/workspace/buzz/buzz.json
@@ -0,0 +1,29 @@
+{
+ "app": "Pohodex Event Manager",
+ "charts": [],
+ "content": "[]",
+ "creation": "2026-03-05 19:10:56.484378",
+ "custom_blocks": [],
+ "docstatus": 0,
+ "doctype": "Workspace",
+ "hide_custom": 0,
+ "idx": 0,
+ "indicator_color": "green",
+ "is_hidden": 0,
+ "label": "Pohodex Event Manager",
+ "link_type": "DocType",
+ "links": [],
+ "modified": "2026-03-05 19:24:18.378896",
+ "modified_by": "Administrator",
+ "module": "Pohodex Event Manager",
+ "name": "Pohodex Event Manager",
+ "number_cards": [],
+ "owner": "Administrator",
+ "public": 1,
+ "quick_lists": [],
+ "roles": [],
+ "sequence_id": 0.0,
+ "shortcuts": [],
+ "title": "Pohodex Event Manager",
+ "type": "Workspace"
+}
diff --git a/event_manager/buzz_marketing/__init__.py b/event_manager/buzz_marketing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz_marketing/doctype/__init__.py b/event_manager/buzz_marketing/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz_marketing/doctype/buzz_campaign/__init__.py b/event_manager/buzz_marketing/doctype/buzz_campaign/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.js b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.js
new file mode 100644
index 0000000..a79865f
--- /dev/null
+++ b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2026, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Pohodex Event Manager Campaign", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.json b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.json
new file mode 100644
index 0000000..0b01c22
--- /dev/null
+++ b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.json
@@ -0,0 +1,97 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "prompt",
+ "creation": "2026-01-23 14:27:04.947771",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "title",
+ "event",
+ "qr_code",
+ "column_break_onhk",
+ "description"
+ ],
+ "fields": [
+ {
+ "description": "Will be shown to the user",
+ "fieldname": "description",
+ "fieldtype": "Markdown Editor",
+ "in_list_view": 1,
+ "label": "Description",
+ "reqd": 1
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "label": "Event",
+ "options": "Pohodex Event Manager Event"
+ },
+ {
+ "fieldname": "column_break_onhk",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "qr_code",
+ "fieldtype": "Attach Image",
+ "label": "QR Code",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled?"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "qr_code",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-01-23 17:23:01.011762",
+ "modified_by": "Administrator",
+ "module": "Pohodex Event Manager Marketing",
+ "name": "Pohodex Event Manager Campaign",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.py b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.py
new file mode 100644
index 0000000..638f52d
--- /dev/null
+++ b/event_manager/buzz_marketing/doctype/buzz_campaign/buzz_campaign.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+from event_manager.utils import generate_qr_code_file, is_app_installed
+
+
+class BuzzCampaign(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ description: DF.MarkdownEditor
+ enabled: DF.Check
+ event: DF.Link | None
+ qr_code: DF.AttachImage | None
+ title: DF.Data
+ # end: auto-generated types
+
+ def before_save(self):
+ if not self.enabled:
+ return
+
+ previous = self.get_doc_before_save()
+ name_changed = previous and previous.name != self.name
+ if not self.qr_code or name_changed:
+ self.generate_qr_code()
+
+ def validate(self):
+ self.validate_crm_installed()
+
+ def validate_crm_installed(self):
+ if self.enabled and not is_app_installed("crm"):
+ frappe.throw(_("Please install Frappe CRM to use campaigns feature"))
+
+ def generate_qr_code(self):
+ register_url = f"{frappe.utils.get_url()}/dashboard/register-interest/{self.name}"
+ self.qr_code = generate_qr_code_file(
+ doc=self,
+ data=register_url,
+ field_name="qr_code",
+ file_prefix="campaign-qr-code",
+ )
diff --git a/event_manager/buzz_marketing/doctype/buzz_campaign/test_buzz_campaign.py b/event_manager/buzz_marketing/doctype/buzz_campaign/test_buzz_campaign.py
new file mode 100644
index 0000000..0539f79
--- /dev/null
+++ b/event_manager/buzz_marketing/doctype/buzz_campaign/test_buzz_campaign.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2026, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestBuzzCampaign(IntegrationTestCase):
+ """
+ Integration tests for BuzzCampaign.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/config/__init__.py b/event_manager/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/desktop_icon/buzz.json b/event_manager/desktop_icon/buzz.json
new file mode 100644
index 0000000..f385f7a
--- /dev/null
+++ b/event_manager/desktop_icon/buzz.json
@@ -0,0 +1,21 @@
+{
+ "app": "event_manager",
+ "bg_color": "blue",
+ "creation": "2026-03-05 19:10:56.544723",
+ "docstatus": 0,
+ "doctype": "Desktop Icon",
+ "hidden": 0,
+ "icon_type": "Link",
+ "idx": 0,
+ "label": "Pohodex Event Manager",
+ "link_to": "Pohodex Event Manager",
+ "link_type": "Workspace Sidebar",
+ "modified": "2026-03-05 19:32:36.529508",
+ "modified_by": "Administrator",
+ "name": "Pohodex Event Manager",
+ "owner": "Administrator",
+ "restrict_removal": 0,
+ "roles": [],
+ "sidebar": "Pohodex Event Manager",
+ "standard": 1
+}
diff --git a/event_manager/events/.frappe b/event_manager/events/.frappe
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/__init__.py b/event_manager/events/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/__init__.py b/event_manager/events/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/additional_event_page/__init__.py b/event_manager/events/doctype/additional_event_page/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/additional_event_page/additional_event_page.js b/event_manager/events/doctype/additional_event_page/additional_event_page.js
new file mode 100644
index 0000000..c95943d
--- /dev/null
+++ b/event_manager/events/doctype/additional_event_page/additional_event_page.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+frappe.ui.form.on("Additional Event Page", {
+ refresh(frm) {
+ if (frm.doc.is_published) {
+ frappe.db.get_value("Pohodex Event Manager Event", frm.doc.event, "route").then(({ message }) => {
+ frm.add_web_link(`/events/${message.route}/${frm.doc.route}`);
+ });
+ }
+ },
+});
diff --git a/event_manager/events/doctype/additional_event_page/additional_event_page.json b/event_manager/events/doctype/additional_event_page/additional_event_page.json
new file mode 100644
index 0000000..3257d2b
--- /dev/null
+++ b/event_manager/events/doctype/additional_event_page/additional_event_page.json
@@ -0,0 +1,85 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-09-25 18:54:07.782051",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "route",
+ "column_break_gagp",
+ "event",
+ "is_published",
+ "section_break_pifr",
+ "content"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "content",
+ "fieldtype": "Text Editor",
+ "label": "Content",
+ "reqd": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "route",
+ "fieldtype": "Data",
+ "label": "Route"
+ },
+ {
+ "fieldname": "column_break_gagp",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_pifr",
+ "fieldtype": "Section Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_published",
+ "fieldtype": "Check",
+ "label": "Is Published?"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-12-01 16:45:14.211143",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Additional Event Page",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/additional_event_page/additional_event_page.py b/event_manager/events/doctype/additional_event_page/additional_event_page.py
new file mode 100644
index 0000000..1595081
--- /dev/null
+++ b/event_manager/events/doctype/additional_event_page/additional_event_page.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class AdditionalEventPage(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ content: DF.TextEditor
+ event: DF.Link
+ is_published: DF.Check
+ route: DF.Data | None
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_route()
+ self.validate_duplicate()
+
+ def validate_route(self):
+ if self.is_published and not self.route:
+ self.route = frappe.website.utils.cleanup_page_name(self.title).replace("_", "-")
+
+ def validate_duplicate(self):
+ if not self.route:
+ return
+
+ if frappe.db.exists(
+ "Additional Event Page",
+ {"route": self.route, "event": self.event, "name": ["!=", self.name]},
+ ):
+ frappe.throw(
+ frappe._("An Additional Event Page with the same route already exists for this event.")
+ )
diff --git a/event_manager/events/doctype/additional_event_page/test_additional_event_page.py b/event_manager/events/doctype/additional_event_page/test_additional_event_page.py
new file mode 100644
index 0000000..6d9dae8
--- /dev/null
+++ b/event_manager/events/doctype/additional_event_page/test_additional_event_page.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestAdditionalEventPage(IntegrationTestCase):
+ """
+ Integration tests for AdditionalEventPage.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/buzz_event/__init__.py b/event_manager/events/doctype/buzz_event/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/buzz_event/buzz_event.js b/event_manager/events/doctype/buzz_event/buzz_event.js
new file mode 100644
index 0000000..4bbdcbe
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event/buzz_event.js
@@ -0,0 +1,527 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+const FIELD_LABELS = {
+ category: __("Category"),
+ host: __("Host"),
+ banner_image: __("Banner Image"),
+ short_description: __("Short Description"),
+ about: __("About"),
+ medium: __("Medium"),
+ venue: __("Venue"),
+ allow_guest_booking: __("Allow Guest Booking"),
+ guest_verification_method: __("Guest Verification Method"),
+ time_zone: __("Time Zone"),
+ send_ticket_email: __("Send Ticket Email"),
+ apply_tax: __("Tax Settings"),
+ tax_label: __("Tax Label"),
+ tax_percentage: __("Tax Percentage"),
+ ticket_email_template: __("Ticket Email Template"),
+ ticket_print_format: __("Ticket Print Format"),
+ auto_send_pitch_deck: __("Auto Send Pitch Deck"),
+ sponsor_deck_email_template: __("Sponsor Deck Email Template"),
+ sponsor_deck_reply_to: __("Sponsor Deck Reply To"),
+ sponsor_deck_cc: __("Sponsor Deck CC"),
+ sponsor_deck_attachments: __("Sponsor Deck Attachments"),
+ payment_gateways: __("Payment Gateways"),
+ ticket_types: __("Ticket Types"),
+ add_ons: __("Add-ons"),
+ custom_fields: __("Custom Fields"),
+};
+
+function get_field_label(field) {
+ return FIELD_LABELS[field] || field;
+}
+
+function render_save_template_field_group(fields, doc) {
+ let html = "";
+ for (let field of fields) {
+ let value = doc[field];
+ let has_value = value !== null && value !== undefined && value !== "" && value !== 0;
+ if (Array.isArray(value)) {
+ has_value = value.length > 0;
+ }
+ let label = get_field_label(field);
+
+ html += `
+
+
+
+ ${label}
+ ${!has_value ? '(' + __("Not set") + ") " : ""}
+
+
+ `;
+ }
+ return html;
+}
+
+function render_save_template_options(dialog, frm) {
+ let html = "";
+ let doc = frm.doc;
+
+ let buttons_html = `
+
+ ${__("Select All")}
+ ${__("Unselect All")}
+
+ `;
+ dialog.get_field("select_buttons").$wrapper.html(buttons_html);
+
+ // Event Details
+ html += '';
+ html += `
${__("Event Details")} `;
+ html += '
';
+ html += render_save_template_field_group(
+ [
+ "category",
+ "host",
+ "banner_image",
+ "short_description",
+ "about",
+ "medium",
+ "venue",
+ "allow_guest_booking",
+ "guest_verification_method",
+ "time_zone",
+ ],
+ doc
+ );
+ html += "
";
+
+ // Ticketing Settings
+ html += '';
+ html += `
${__("Ticketing Settings")} `;
+ html += '
';
+ html += render_save_template_field_group(
+ [
+ "send_ticket_email",
+ "apply_tax",
+ "tax_label",
+ "tax_percentage",
+ "ticket_email_template",
+ "ticket_print_format",
+ ],
+ doc
+ );
+ html += "
";
+
+ // Sponsorship Settings
+ html += '';
+ html += `
${__("Sponsorship Settings")} `;
+ html += '
';
+ html += render_save_template_field_group(
+ [
+ "auto_send_pitch_deck",
+ "sponsor_deck_email_template",
+ "sponsor_deck_reply_to",
+ "sponsor_deck_cc",
+ "sponsor_deck_attachments",
+ ],
+ doc
+ );
+ html += "
";
+
+ // Related Documents
+ html += '";
+
+ dialog.get_field("field_options").$wrapper.html(html);
+
+ let $wrapper = dialog.get_field("field_options").$wrapper;
+
+ const linked_doctypes = [
+ {
+ id: "ticket-types-option",
+ doctype: "Event Ticket Type",
+ option: "ticket_types",
+ label: __("Ticket Types"),
+ },
+ {
+ id: "add-ons-option",
+ doctype: "Ticket Add-on",
+ option: "add_ons",
+ label: __("Add-ons"),
+ },
+ {
+ id: "custom-fields-option",
+ doctype: "Pohodex Event Manager Custom Field",
+ option: "custom_fields",
+ label: __("Custom Fields"),
+ },
+ ];
+
+ for (let item of linked_doctypes) {
+ frappe.call({
+ method: "frappe.client.get_count",
+ args: { doctype: item.doctype, filters: { event: doc.name } },
+ callback: function (r) {
+ let count = r.message || 0;
+ $wrapper.find(`#${item.id}`).html(`
+
+ 0 ? "checked" : ""
+ } ${count === 0 ? "disabled" : ""}>
+ ${item.label} ${
+ count > 0
+ ? `(${count}) `
+ : '(' + __("None") + ") "
+ }
+
+ `);
+ },
+ });
+ }
+
+ dialog
+ .get_field("select_buttons")
+ .$wrapper.find(".select-all-btn")
+ .on("click", function () {
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option:not(:disabled)")
+ .prop("checked", true);
+ });
+
+ dialog
+ .get_field("select_buttons")
+ .$wrapper.find(".unselect-all-btn")
+ .on("click", function () {
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option")
+ .prop("checked", false);
+ });
+}
+
+function show_save_as_template_dialog(frm) {
+ let dialog = new frappe.ui.Dialog({
+ title: __("Save Event as Template"),
+ fields: [
+ {
+ fieldtype: "Data",
+ fieldname: "template_name",
+ label: __("Template Name"),
+ reqd: 1,
+ default: frm.doc.title + " Template",
+ },
+ {
+ fieldtype: "Section Break",
+ label: __("Select What to Include"),
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "select_buttons",
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "field_options",
+ },
+ ],
+ size: "large",
+ primary_action_label: __("Save Template"),
+ primary_action: function (values) {
+ let options = {};
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option:checked")
+ .each(function () {
+ options[$(this).data("option")] = 1;
+ });
+
+ frappe.call({
+ method: "event_manager.events.doctype.event_template.event_template.create_template_from_event",
+ args: {
+ event_name: frm.doc.name,
+ template_name: values.template_name,
+ options: JSON.stringify(options),
+ },
+ freeze: true,
+ freeze_message: __("Creating Template..."),
+ callback: function (r) {
+ if (r.message) {
+ dialog.hide();
+ frappe.show_alert({
+ message: __("Template {0} created successfully", [r.message]),
+ indicator: "green",
+ });
+ frappe.set_route("Form", "Event Template", r.message);
+ }
+ },
+ });
+ },
+ });
+
+ render_save_template_options(dialog, frm);
+ dialog.show();
+}
+
+frappe.ui.form.on("Pohodex Event Manager Event Form", {
+ copy_to_clipboard(frm, cdt, cdn) {
+ const row = frappe.get_doc(cdt, cdn);
+ const url = `${window.location.origin}/dashboard/events/${frm.doc.route}/forms/${row.route}`;
+ navigator.clipboard.writeText(url);
+ frappe.show_alert({ message: __("Link copied!"), indicator: "green" });
+ },
+});
+
+frappe.ui.form.on("Pohodex Event Manager Event", {
+ refresh(frm) {
+ frm.fields_dict.time_zone.set_data(getZoomSupportedTimezones());
+
+ if (frm.doc.route && frm.doc.is_published) {
+ frm.add_web_link(`/events/${frm.doc.route}`);
+ }
+
+ if (frm.doc.route) {
+ frm.add_web_link(`/dashboard/book-tickets/${frm.doc.route}`, "View Registration Page");
+ }
+
+ if (!frm.is_new()) {
+ frm.add_web_link(`/dashboard/check-in/${frm.doc.name}`, __("Open Check-in"));
+ }
+
+ const button_label = frm.doc.is_published ? __("Unpublish") : __("Publish");
+ frm.add_custom_button(button_label, () => {
+ frm.set_value("is_published", !frm.doc.is_published);
+ frm.save();
+ });
+
+ frm.set_query("track", "schedule", (doc, cdt, cdn) => {
+ return {
+ filters: {
+ event: doc.name,
+ },
+ };
+ });
+
+ frm.set_query("default_ticket_type", (doc) => {
+ return {
+ filters: {
+ event: doc.name,
+ is_published: 1,
+ },
+ };
+ });
+
+ // Save as Template button
+ if (!frm.is_new()) {
+ frm.add_custom_button(
+ __("Save as Template"),
+ function () {
+ show_save_as_template_dialog(frm);
+ },
+ __("Actions")
+ );
+ }
+
+ frm.trigger("add_zoom_custom_actions");
+ },
+
+ add_zoom_custom_actions(frm) {
+ const installed_apps = frappe.boot.app_data.map((app) => app.app_name);
+ if (!installed_apps.includes("zoom_integration") || frm.doc.category != "Webinars") {
+ return;
+ }
+
+ if (frm.doc.zoom_webinar) {
+ frm.add_custom_button(__("View Webinar on Zoom"), () => {
+ window.open(`https://zoom.us/webinar/${frm.doc.zoom_webinar}`, "_blank");
+ });
+ return;
+ }
+
+ const btn = frm.add_custom_button(__("Create Webinar on Zoom"), () => {
+ frm.call({
+ doc: frm.doc,
+ method: "create_webinar_on_zoom",
+ btn,
+ freeze: true,
+ }).then(({ message }) => {
+ frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active();
+ });
+ });
+ },
+ category(frm) {
+ if (!frm.is_new()) return;
+
+ if (frm.doc.category === "Webinars") {
+ frm.set_value("attach_email_ticket", 0);
+ } else {
+ frm.set_value("attach_email_ticket", 1);
+ }
+ },
+});
+
+function getZoomSupportedTimezones() {
+ return [
+ "Pacific/Midway",
+ "Pacific/Pago_Pago",
+ "Pacific/Honolulu",
+ "America/Anchorage",
+ "America/Vancouver",
+ "America/Los_Angeles",
+ "America/Tijuana",
+ "America/Edmonton",
+ "America/Denver",
+ "America/Phoenix",
+ "America/Mazatlan",
+ "America/Winnipeg",
+ "America/Regina",
+ "America/Chicago",
+ "America/Mexico_City",
+ "America/Guatemala",
+ "America/El_Salvador",
+ "America/Managua",
+ "America/Costa_Rica",
+ "America/Montreal",
+ "America/New_York",
+ "America/Indianapolis",
+ "America/Panama",
+ "America/Bogota",
+ "America/Lima",
+ "America/Halifax",
+ "America/Puerto_Rico",
+ "America/Caracas",
+ "America/Santiago",
+ "America/St_Johns",
+ "America/Montevideo",
+ "America/Araguaina",
+ "America/Argentina/Buenos_Aires",
+ "America/Godthab",
+ "America/Sao_Paulo",
+ "Atlantic/Azores",
+ "Canada/Atlantic",
+ "Atlantic/Cape_Verde",
+ "UTC",
+ "Etc/Greenwich",
+ "Europe/Belgrade",
+ "CET",
+ "Atlantic/Reykjavik",
+ "Europe/Dublin",
+ "Europe/London",
+ "Europe/Lisbon",
+ "Africa/Casablanca",
+ "Africa/Nouakchott",
+ "Europe/Oslo",
+ "Europe/Copenhagen",
+ "Europe/Brussels",
+ "Europe/Berlin",
+ "Europe/Helsinki",
+ "Europe/Amsterdam",
+ "Europe/Rome",
+ "Europe/Stockholm",
+ "Europe/Vienna",
+ "Europe/Luxembourg",
+ "Europe/Paris",
+ "Europe/Zurich",
+ "Europe/Madrid",
+ "Africa/Bangui",
+ "Africa/Algiers",
+ "Africa/Tunis",
+ "Africa/Harare",
+ "Africa/Nairobi",
+ "Europe/Warsaw",
+ "Europe/Prague",
+ "Europe/Budapest",
+ "Europe/Sofia",
+ "Europe/Istanbul",
+ "Europe/Athens",
+ "Europe/Bucharest",
+ "Asia/Nicosia",
+ "Asia/Beirut",
+ "Asia/Damascus",
+ "Asia/Jerusalem",
+ "Asia/Amman",
+ "Africa/Tripoli",
+ "Africa/Cairo",
+ "Africa/Johannesburg",
+ "Europe/Moscow",
+ "Asia/Baghdad",
+ "Asia/Kuwait",
+ "Asia/Riyadh",
+ "Asia/Bahrain",
+ "Asia/Qatar",
+ "Asia/Aden",
+ "Asia/Tehran",
+ "Africa/Khartoum",
+ "Africa/Djibouti",
+ "Africa/Mogadishu",
+ "Asia/Dubai",
+ "Asia/Muscat",
+ "Asia/Baku",
+ "Asia/Kabul",
+ "Asia/Yekaterinburg",
+ "Asia/Tashkent",
+ "Asia/Calcutta",
+ "Asia/Kathmandu",
+ "Asia/Novosibirsk",
+ "Asia/Almaty",
+ "Asia/Dacca",
+ "Asia/Krasnoyarsk",
+ "Asia/Dhaka",
+ "Asia/Bangkok",
+ "Asia/Saigon",
+ "Asia/Jakarta",
+ "Asia/Irkutsk",
+ "Asia/Shanghai",
+ "Asia/Hong_Kong",
+ "Asia/Taipei",
+ "Asia/Kuala_Lumpur",
+ "Asia/Singapore",
+ "Australia/Perth",
+ "Asia/Yakutsk",
+ "Asia/Seoul",
+ "Asia/Tokyo",
+ "Australia/Darwin",
+ "Australia/Adelaide",
+ "Asia/Vladivostok",
+ "Pacific/Port_Moresby",
+ "Australia/Brisbane",
+ "Australia/Sydney",
+ "Australia/Hobart",
+ "Asia/Magadan",
+ "SST",
+ "Pacific/Noumea",
+ "Asia/Kamchatka",
+ "Pacific/Fiji",
+ "Pacific/Auckland",
+ "Asia/Kolkata",
+ "Europe/Kiev",
+ "America/Tegucigalpa",
+ "Pacific/Apia",
+ ];
+}
diff --git a/event_manager/events/doctype/buzz_event/buzz_event.json b/event_manager/events/doctype/buzz_event/buzz_event.json
new file mode 100644
index 0000000..6cf2f91
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event/buzz_event.json
@@ -0,0 +1,637 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2025-07-19 11:28:39.634220",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "category",
+ "free_webinar",
+ "medium",
+ "column_break_cxqh",
+ "banner_image",
+ "host",
+ "venue",
+ "section_break_naqe",
+ "start_date",
+ "start_time",
+ "time_zone",
+ "column_break_cjby",
+ "end_date",
+ "end_time",
+ "section_break_zukm",
+ "short_description",
+ "about",
+ "tab_2_tab",
+ "schedule",
+ "website_tab",
+ "is_published",
+ "route",
+ "default_ticket_type",
+ "column_break_ndtl",
+ "external_registration_page",
+ "registration_url",
+ "meta_image",
+ "card_image",
+ "section_break_rmtj",
+ "allow_guest_booking",
+ "column_break_lzcw",
+ "guest_verification_method",
+ "auto_closures_section",
+ "registrations_close_at",
+ "section_break_kwlt",
+ "featured_speakers",
+ "payments_tab",
+ "section_break_owtc",
+ "payment_gateways",
+ "tax_settings_section",
+ "apply_tax",
+ "tax_inclusive",
+ "column_break_gehk",
+ "tax_label",
+ "tax_percentage",
+ "sponsorships_tab",
+ "show_sponsorship_section",
+ "automations_section",
+ "auto_send_pitch_deck",
+ "section_break_edar",
+ "sponsor_deck_email_template",
+ "sponsor_deck_reply_to",
+ "column_break_awpd",
+ "sponsor_deck_cc",
+ "section_break_mieu",
+ "sponsor_deck_attachments",
+ "customisations_tab",
+ "send_ticket_email",
+ "section_break_uoke",
+ "attach_calendar_invite",
+ "ticket_email_template",
+ "column_break_ukql",
+ "attach_email_ticket",
+ "ticket_print_format",
+ "talks_section",
+ "allow_editing_talks_after_acceptance",
+ "custom_forms_tab",
+ "forms_section",
+ "custom_forms",
+ "connections_tab",
+ "proposal"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_cxqh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "start_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Start Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "category",
+ "fieldtype": "Link",
+ "label": "Category",
+ "options": "Event Category",
+ "reqd": 1
+ },
+ {
+ "depends_on": "eval:doc.medium!=\"Online\"",
+ "fieldname": "venue",
+ "fieldtype": "Link",
+ "label": "Venue",
+ "mandatory_depends_on": "eval:doc.medium!=\"Online\"",
+ "options": "Event Venue"
+ },
+ {
+ "default": "In Person",
+ "fieldname": "medium",
+ "fieldtype": "Select",
+ "label": "Medium",
+ "options": "In Person\nOnline"
+ },
+ {
+ "fieldname": "section_break_naqe",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_cjby",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "end_date",
+ "fieldtype": "Date",
+ "label": "End Date"
+ },
+ {
+ "fieldname": "section_break_zukm",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "short_description",
+ "fieldtype": "Small Text",
+ "label": "Short Description"
+ },
+ {
+ "description": "Description of the event",
+ "fieldname": "about",
+ "fieldtype": "Text Editor",
+ "label": "About"
+ },
+ {
+ "fieldname": "start_time",
+ "fieldtype": "Time",
+ "label": "Start Time",
+ "reqd": 1
+ },
+ {
+ "fieldname": "end_time",
+ "fieldtype": "Time",
+ "label": "End Time",
+ "reqd": 1
+ },
+ {
+ "fieldname": "host",
+ "fieldtype": "Link",
+ "label": "Host",
+ "options": "Event Host",
+ "reqd": 1
+ },
+ {
+ "fieldname": "time_zone",
+ "fieldtype": "Autocomplete",
+ "label": "Time Zone"
+ },
+ {
+ "fieldname": "banner_image",
+ "fieldtype": "Attach Image",
+ "label": "Banner Image"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_published",
+ "fieldtype": "Check",
+ "label": "Is Published?"
+ },
+ {
+ "fieldname": "tab_2_tab",
+ "fieldtype": "Tab Break",
+ "label": "Schedule"
+ },
+ {
+ "fieldname": "schedule",
+ "fieldtype": "Table",
+ "label": "Schedule",
+ "options": "Schedule Item"
+ },
+ {
+ "fieldname": "payments_tab",
+ "fieldtype": "Tab Break",
+ "label": "Payments"
+ },
+ {
+ "description": "Used by Frappe Builder",
+ "fieldname": "route",
+ "fieldtype": "Data",
+ "label": "Route",
+ "no_copy": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "meta_image",
+ "fieldtype": "Attach Image",
+ "label": "Meta Image"
+ },
+ {
+ "default": "0",
+ "fieldname": "external_registration_page",
+ "fieldtype": "Check",
+ "label": "External Registration Page?"
+ },
+ {
+ "depends_on": "eval:doc.external_registration_page==1",
+ "fieldname": "registration_url",
+ "fieldtype": "Data",
+ "label": "Registration URL",
+ "mandatory_depends_on": "eval:doc.external_registration_page==1"
+ },
+ {
+ "fieldname": "website_tab",
+ "fieldtype": "Tab Break",
+ "label": "Website"
+ },
+ {
+ "fieldname": "column_break_ndtl",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.send_ticket_email && doc.attach_email_ticket;",
+ "fieldname": "ticket_print_format",
+ "fieldtype": "Link",
+ "label": "Ticket Print Format",
+ "link_filters": "[[\"Print Format\",\"doc_type\",\"=\",\"Event Ticket\"]]",
+ "options": "Print Format"
+ },
+ {
+ "fieldname": "forms_section",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "custom_forms",
+ "fieldtype": "Table",
+ "label": "Custom Forms",
+ "options": "Pohodex Event Manager Event Form"
+ },
+ {
+ "fieldname": "talks_section",
+ "fieldtype": "Section Break",
+ "label": "Talk Settings"
+ },
+ {
+ "default": "0",
+ "description": "When enabled, speakers can edit their talk title and description after acceptance",
+ "fieldname": "allow_editing_talks_after_acceptance",
+ "fieldtype": "Check",
+ "label": "Allow Editing Talks After Acceptance"
+ },
+ {
+ "depends_on": "eval:doc.send_ticket_email;",
+ "fieldname": "ticket_email_template",
+ "fieldtype": "Link",
+ "label": "Ticket Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "customisations_tab",
+ "fieldtype": "Tab Break",
+ "label": "Customisations"
+ },
+ {
+ "fieldname": "column_break_ukql",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_kwlt",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "featured_speakers",
+ "fieldtype": "Table",
+ "label": "Featured Speakers",
+ "options": "Event Featured Speaker"
+ },
+ {
+ "fieldname": "sponsorships_tab",
+ "fieldtype": "Tab Break",
+ "label": "Sponsorships"
+ },
+ {
+ "default": "1",
+ "description": "Show or hide the sponsorship section on the event website",
+ "fieldname": "show_sponsorship_section",
+ "fieldtype": "Check",
+ "label": "Show Sponsorship Section on Website"
+ },
+ {
+ "fieldname": "automations_section",
+ "fieldtype": "Section Break",
+ "label": "Automations"
+ },
+ {
+ "default": "1",
+ "fieldname": "auto_send_pitch_deck",
+ "fieldtype": "Check",
+ "label": "Auto Send Pitch Deck?"
+ },
+ {
+ "fieldname": "section_break_edar",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_awpd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_mieu",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "description": "Default template will be used if not set",
+ "fieldname": "sponsor_deck_email_template",
+ "fieldtype": "Link",
+ "label": "Email Template",
+ "options": "Email Template"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_cc",
+ "fieldtype": "Small Text",
+ "label": "CC"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_reply_to",
+ "fieldtype": "Data",
+ "label": "Reply To"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_attachments",
+ "fieldtype": "Table",
+ "label": "Attachments",
+ "options": "Sponsorship Deck Item"
+ },
+ {
+ "description": "Will be selected by default in the booking form",
+ "fieldname": "default_ticket_type",
+ "fieldtype": "Link",
+ "label": "Default Ticket Type",
+ "options": "Event Ticket Type"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.category==\"Webinars\"",
+ "fieldname": "free_webinar",
+ "fieldtype": "Check",
+ "label": "Free Webinar?"
+ },
+ {
+ "fieldname": "connections_tab",
+ "fieldtype": "Tab Break",
+ "label": "Connections"
+ },
+ {
+ "fieldname": "proposal",
+ "fieldtype": "Link",
+ "label": "Proposal",
+ "options": "Event Proposal",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_owtc",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "payment_gateways",
+ "fieldtype": "Table",
+ "label": "Payment Gateways",
+ "options": "Event Payment Gateway"
+ },
+ {
+ "fieldname": "tax_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Tax Settings"
+ },
+ {
+ "default": "0",
+ "fieldname": "apply_tax",
+ "fieldtype": "Check",
+ "label": "Apply Tax on Bookings?"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.apply_tax==1",
+ "description": "When enabled, ticket prices are treated as tax-inclusive. Tax is back-calculated from the price instead of added on top.",
+ "fieldname": "tax_inclusive",
+ "fieldtype": "Check",
+ "label": "Tax Inclusive"
+ },
+ {
+ "default": "GST",
+ "depends_on": "eval:doc.apply_tax==1",
+ "description": "Label displayed to customers (e.g., GST, VAT, Sales Tax)",
+ "fieldname": "tax_label",
+ "fieldtype": "Data",
+ "label": "Tax Label",
+ "mandatory_depends_on": "eval:doc.apply_tax==1"
+ },
+ {
+ "default": "18",
+ "depends_on": "eval:doc.apply_tax==1",
+ "description": "Tax rate to apply on bookings",
+ "fieldname": "tax_percentage",
+ "fieldtype": "Percent",
+ "label": "Tax Percentage",
+ "mandatory_depends_on": "eval:doc.apply_tax==1"
+ },
+ {
+ "fieldname": "card_image",
+ "fieldtype": "Attach Image",
+ "label": "Card Image"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval:doc.send_ticket_email",
+ "fieldname": "attach_calendar_invite",
+ "fieldtype": "Check",
+ "label": "Attach Calendar Invite"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_guest_booking",
+ "fieldtype": "Check",
+ "label": "Allow Guest Booking"
+ },
+ {
+ "default": "None",
+ "depends_on": "eval:doc.allow_guest_booking",
+ "description": "How to verify guest identity before booking",
+ "fieldname": "guest_verification_method",
+ "fieldtype": "Select",
+ "label": "Guest Verification Method",
+ "options": "None\nEmail OTP\nPhone OTP"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval:doc.send_ticket_email;",
+ "fieldname": "attach_email_ticket",
+ "fieldtype": "Check",
+ "label": "Attach Email Ticket"
+ },
+ {
+ "fieldname": "section_break_uoke",
+ "fieldtype": "Section Break"
+ },
+ {
+ "default": "1",
+ "fieldname": "send_ticket_email",
+ "fieldtype": "Check",
+ "label": "Send Ticket Email"
+ },
+ {
+ "fieldname": "section_break_rmtj",
+ "fieldtype": "Section Break",
+ "label": "Guest Booking"
+ },
+ {
+ "fieldname": "column_break_gehk",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_lzcw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "auto_closures_section",
+ "fieldtype": "Section Break",
+ "label": "Auto Closures"
+ },
+ {
+ "fieldname": "registrations_close_at",
+ "fieldtype": "Datetime",
+ "label": "Registrations Close At"
+ },
+ {
+ "fieldname": "custom_forms_tab",
+ "fieldtype": "Tab Break",
+ "label": "Forms"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "banner_image",
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "group": "Sponsorship",
+ "link_doctype": "Event Sponsor",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Sponsorship",
+ "link_doctype": "Sponsorship Tier",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Event Ticket Type",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Ticket Add-on",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Event Booking",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Event Ticket",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Proposals",
+ "link_doctype": "Sponsorship Enquiry",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Proposals",
+ "link_doctype": "Talk Proposal",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "General",
+ "link_doctype": "Event Track",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "General",
+ "link_doctype": "Event Check In",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Pohodex Event Manager Coupon Code",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "General",
+ "link_doctype": "Additional Event Page",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Ticketing",
+ "link_doctype": "Offline Payment Method",
+ "link_fieldname": "event"
+ },
+ {
+ "group": "Customisations",
+ "link_doctype": "Pohodex Event Manager Custom Field",
+ "link_fieldname": "event"
+ }
+ ],
+ "modified": "2026-03-23 17:37:37.770911",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Pohodex Event Manager Event",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "All",
+ "select": 1,
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "search_fields": "start_date",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/buzz_event/buzz_event.py b/event_manager/events/doctype/buzz_event/buzz_event.py
new file mode 100644
index 0000000..3d7d4a9
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event/buzz_event.py
@@ -0,0 +1,342 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.naming import append_number_if_name_exists
+from frappe.utils.data import get_time, time_diff_in_seconds
+
+from event_manager.utils import only_if_app_installed
+
+
+class BuzzEvent(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.buzz_event_form.buzz_event_form import BuzzEventForm
+ from event_manager.events.doctype.event_featured_speaker.event_featured_speaker import EventFeaturedSpeaker
+ from event_manager.events.doctype.event_payment_gateway.event_payment_gateway import EventPaymentGateway
+ from event_manager.events.doctype.schedule_item.schedule_item import ScheduleItem
+ from event_manager.proposals.doctype.sponsorship_deck_item.sponsorship_deck_item import SponsorshipDeckItem
+
+ about: DF.TextEditor | None
+ allow_editing_talks_after_acceptance: DF.Check
+ allow_guest_booking: DF.Check
+ apply_tax: DF.Check
+ attach_calendar_invite: DF.Check
+ attach_email_ticket: DF.Check
+ auto_send_pitch_deck: DF.Check
+ banner_image: DF.AttachImage | None
+ card_image: DF.AttachImage | None
+ category: DF.Link
+ custom_forms: DF.Table[BuzzEventForm]
+ default_ticket_type: DF.Link | None
+ end_date: DF.Date | None
+ end_time: DF.Time
+ external_registration_page: DF.Check
+ featured_speakers: DF.Table[EventFeaturedSpeaker]
+ free_webinar: DF.Check
+ guest_verification_method: DF.Literal["None", "Email OTP", "Phone OTP"]
+ host: DF.Link
+ is_published: DF.Check
+ medium: DF.Literal["In Person", "Online"]
+ meta_image: DF.AttachImage | None
+ name: DF.Int | None
+ payment_gateways: DF.Table[EventPaymentGateway]
+ proposal: DF.Link | None
+ registration_url: DF.Data | None
+ registrations_close_at: DF.Datetime | None
+ route: DF.Data | None
+ schedule: DF.Table[ScheduleItem]
+ send_ticket_email: DF.Check
+ short_description: DF.SmallText | None
+ show_sponsorship_section: DF.Check
+ sponsor_deck_attachments: DF.Table[SponsorshipDeckItem]
+ sponsor_deck_cc: DF.SmallText | None
+ sponsor_deck_email_template: DF.Link | None
+ sponsor_deck_reply_to: DF.Data | None
+ start_date: DF.Date
+ start_time: DF.Time
+ tax_inclusive: DF.Check
+ tax_label: DF.Data | None
+ tax_percentage: DF.Percent
+ ticket_email_template: DF.Link | None
+ ticket_print_format: DF.Link | None
+ time_zone: DF.Autocomplete | None
+ title: DF.Data
+ venue: DF.Link | None
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_dates()
+ self.validate_schedule()
+ self.validate_route()
+ self.validate_tax_settings()
+ self.validate_guest_verification_config()
+
+ def validate_schedule(self):
+ end_date = self.end_date or self.start_date
+ for item in self.schedule:
+ if item.date < self.start_date or item.date > end_date:
+ frappe.throw(
+ frappe._("Schedule row #{0}: Date must be within event dates").format(
+ item.idx
+ )
+ )
+
+ if time_diff_in_seconds(item.end_time, item.start_time) <= 0:
+ frappe.throw(
+ frappe._(
+ "Schedule row #{0}: End Time must be after Start Time "
+ ).format(item.idx)
+ )
+
+ if (
+ item.date == self.start_date
+ and self.start_time
+ and get_time(item.start_time) < get_time(self.start_time)
+ ):
+ frappe.throw(
+ frappe._(
+ "Schedule row #{0}: Start Time cannot be before event start time"
+ ).format(item.idx)
+ )
+
+ if item.date == end_date and self.end_time and get_time(item.end_time) > get_time(self.end_time):
+ frappe.throw(
+ frappe._(
+ "Schedule row #{0}: End Time cannot be after event end time"
+ ).format(item.idx)
+ )
+
+ def validate_dates(self):
+ self.validate_from_to_dates("start_date", "end_date")
+ if (
+ (not self.end_date or self.start_date == self.end_date)
+ and self.start_time
+ and self.end_time
+ and time_diff_in_seconds(self.end_time, self.start_time) <= 0
+ ):
+ frappe.throw(frappe._("End Time must be after Start Time "))
+
+ def validate_tax_settings(self):
+ """Set default tax values when tax is enabled."""
+ if self.apply_tax:
+ if not self.tax_label:
+ self.tax_label = "GST"
+ if not self.tax_percentage:
+ self.tax_percentage = 18
+
+ def validate_route(self):
+ if self.is_published and not self.route:
+ route = frappe.website.utils.cleanup_page_name(self.title).replace("_", "-")
+ self.route = append_number_if_name_exists("Pohodex Event Manager Event", route, fieldname="route")
+
+ def validate_guest_verification_config(self):
+ """Ensure email/SMS is configured when OTP verification is enabled."""
+ if frappe.in_test or not self.allow_guest_booking:
+ return
+
+ if self.guest_verification_method == "Email OTP":
+ has_email = frappe.db.exists("Email Account", {"default_outgoing": 1, "enable_outgoing": 1})
+ if not has_email:
+ frappe.throw(
+ frappe._(
+ "Please configure an outgoing Email Account before enabling Email OTP verification."
+ ),
+ title=frappe._("Email Not Configured"),
+ )
+
+ @frappe.whitelist()
+ def after_insert(self):
+ self.create_default_records()
+
+ def create_default_records(self):
+ records = [
+ {"doctype": "Sponsorship Tier", "title": "Normal"},
+ {"doctype": "Event Ticket Type", "title": "Normal"},
+ ]
+ for record in records:
+ frappe.get_doc({**record, "event": self.name}).insert(ignore_permissions=True)
+
+ default_forms = [
+ {"form_doctype": "Event Feedback", "route": "feedback"},
+ {"form_doctype": "Talk Proposal", "route": "propose-talk"},
+ {"form_doctype": "Sponsorship Enquiry", "route": "enquire-sponsorship"},
+ ]
+ for form in default_forms:
+ self.append("custom_forms", form)
+ self.save(ignore_permissions=True)
+
+ @frappe.whitelist()
+ @only_if_app_installed("zoom_integration", raise_exception=True)
+ def create_webinar_on_zoom(self):
+ if not self.end_time:
+ frappe.throw(frappe._("End time is needed for Zoom Webinar creation"))
+
+ zoom_webinar = frappe.get_doc(
+ {
+ "doctype": "Zoom Webinar",
+ "title": self.title,
+ "date": self.start_date,
+ "start_time": self.start_time,
+ "duration": int(time_diff_in_seconds(self.end_time, self.start_time)),
+ "timezone": self.time_zone,
+ "template": frappe.get_cached_doc("Pohodex Event Manager Settings").default_webinar_template,
+ }
+ ).insert()
+
+ self.db_set("zoom_webinar", zoom_webinar.name)
+
+ return zoom_webinar
+
+ def on_update(self):
+ self.update_zoom_webinar()
+
+ @only_if_app_installed("zoom_integration")
+ def update_zoom_webinar(self):
+ if not self.zoom_webinar:
+ return
+
+ if (
+ self.has_value_changed("start_date")
+ or self.has_value_changed("end_time")
+ or self.has_value_changed("start_time")
+ or self.has_value_changed("time_zone")
+ ):
+ webinar = frappe.get_doc("Zoom Webinar", self.zoom_webinar)
+ webinar.update(
+ {
+ "date": self.start_date,
+ "start_time": self.start_time,
+ "duration": int(time_diff_in_seconds(self.end_time, self.start_time)),
+ "timezone": self.time_zone,
+ }
+ )
+ webinar.save()
+
+
+@frappe.whitelist()
+def create_from_template(template_name: str, options: str, additional_fields: str = "{}") -> str:
+ """
+ Create a new Pohodex Event Manager Event from a template.
+
+ Args:
+ template_name: Name of the Event Template
+ options: JSON string of what to copy (e.g., {"category": 1, "ticket_types": 1, ...})
+ additional_fields: JSON string of additional field values for mandatory fields not in template
+
+ Returns:
+ New Pohodex Event Manager Event document name
+ """
+ if not frappe.has_permission("Event Template", "read"):
+ frappe.throw(_("You don't have permission to use templates"))
+
+ if not frappe.has_permission("Pohodex Event Manager Event", "create"):
+ frappe.throw(_("You don't have permission to create events"))
+
+ template = frappe.get_doc("Event Template", template_name)
+ options = frappe.parse_json(options)
+ additional_fields = frappe.parse_json(additional_fields)
+
+ # Create new event with required fields
+ event = frappe.new_doc("Pohodex Event Manager Event")
+ event.title = f"New Event from {template.template_name}"
+ event.start_date = frappe.utils.today()
+ event.start_time = "09:00:00"
+ event.end_time = "18:00:00"
+
+ # Apply additional fields first (these are mandatory fields provided by user)
+ for field, value in additional_fields.items():
+ if value:
+ event.set(field, value)
+
+ # Field mapping for direct copy
+ field_map = {
+ "category": "category",
+ "host": "host",
+ "banner_image": "banner_image",
+ "short_description": "short_description",
+ "about": "about",
+ "medium": "medium",
+ "venue": "venue",
+ "allow_guest_booking": "allow_guest_booking",
+ "guest_verification_method": "guest_verification_method",
+ "time_zone": "time_zone",
+ "send_ticket_email": "send_ticket_email",
+ "ticket_email_template": "ticket_email_template",
+ "ticket_print_format": "ticket_print_format",
+ "apply_tax": "apply_tax",
+ "tax_inclusive": "tax_inclusive",
+ "tax_label": "tax_label",
+ "tax_percentage": "tax_percentage",
+ "auto_send_pitch_deck": "auto_send_pitch_deck",
+ "sponsor_deck_email_template": "sponsor_deck_email_template",
+ "sponsor_deck_reply_to": "sponsor_deck_reply_to",
+ "sponsor_deck_cc": "sponsor_deck_cc",
+ }
+
+ for option_key, field_name in field_map.items():
+ if options.get(option_key):
+ event.set(field_name, template.get(field_name))
+
+ # Copy child tables
+ if options.get("payment_gateways"):
+ for pg in template.payment_gateways:
+ event.append("payment_gateways", {"payment_gateway": pg.payment_gateway})
+
+ if options.get("sponsor_deck_attachments"):
+ for attachment in template.sponsor_deck_attachments:
+ event.append("sponsor_deck_attachments", {"file": attachment.file})
+
+ event.insert()
+
+ # Create linked documents (Ticket Types, Add-ons, Custom Fields)
+ if options.get("ticket_types"):
+ for tt in template.template_ticket_types:
+ ticket_type = frappe.new_doc("Event Ticket Type")
+ ticket_type.event = event.name
+ ticket_type.title = tt.title
+ ticket_type.price = tt.price
+ ticket_type.currency = tt.currency
+ ticket_type.is_published = tt.is_published
+ ticket_type.max_tickets_available = tt.max_tickets_available
+ ticket_type.auto_unpublish_after = tt.auto_unpublish_after
+ ticket_type.insert()
+
+ if options.get("add_ons"):
+ for addon in template.template_add_ons:
+ add_on = frappe.new_doc("Ticket Add-on")
+ add_on.event = event.name
+ add_on.title = addon.title
+ add_on.price = addon.price
+ add_on.currency = addon.currency
+ add_on.description = addon.description
+ add_on.user_selects_option = addon.user_selects_option
+ add_on.options = addon.options
+ add_on.enabled = addon.enabled
+ add_on.insert()
+
+ if options.get("custom_fields"):
+ for cf in template.template_custom_fields:
+ custom_field = frappe.new_doc("Pohodex Event Manager Custom Field")
+ custom_field.event = event.name
+ custom_field.label = cf.label
+ custom_field.fieldname = cf.fieldname
+ custom_field.fieldtype = cf.fieldtype
+ custom_field.options = cf.options
+ custom_field.applied_to = cf.applied_to
+ custom_field.enabled = cf.enabled
+ custom_field.mandatory = cf.mandatory
+ custom_field.placeholder = cf.placeholder
+ custom_field.default_value = cf.default_value
+ custom_field.order = cf.order
+ custom_field.insert()
+
+ return event.name
diff --git a/event_manager/events/doctype/buzz_event/buzz_event_list.js b/event_manager/events/doctype/buzz_event/buzz_event_list.js
new file mode 100644
index 0000000..3f1910f
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event/buzz_event_list.js
@@ -0,0 +1,379 @@
+// Field groups for template options
+const TEMPLATE_FIELD_GROUPS = {
+ event_details: {
+ label: __("Event Details"),
+ fields: [
+ "category",
+ "host",
+ "banner_image",
+ "short_description",
+ "about",
+ "medium",
+ "venue",
+ "allow_guest_booking",
+ "guest_verification_method",
+ "time_zone",
+ ],
+ },
+ ticketing_settings: {
+ label: __("Ticketing Settings"),
+ fields: [
+ "send_ticket_email",
+ "apply_tax",
+ "tax_label",
+ "tax_percentage",
+ "ticket_email_template",
+ "ticket_print_format",
+ ],
+ },
+ sponsorship_settings: {
+ label: __("Sponsorship Settings"),
+ fields: [
+ "auto_send_pitch_deck",
+ "sponsor_deck_email_template",
+ "sponsor_deck_reply_to",
+ "sponsor_deck_cc",
+ "sponsor_deck_attachments",
+ ],
+ },
+};
+
+const MANDATORY_FIELDS = ["category", "host"];
+
+const FIELD_LABELS = {
+ category: __("Category"),
+ host: __("Host"),
+ banner_image: __("Banner Image"),
+ short_description: __("Short Description"),
+ about: __("About"),
+ medium: __("Medium"),
+ venue: __("Venue"),
+ allow_guest_booking: __("Allow Guest Booking"),
+ guest_verification_method: __("Guest Verification Method"),
+ time_zone: __("Time Zone"),
+ send_ticket_email: __("Send Ticket Email"),
+ apply_tax: __("Tax Settings"),
+ tax_label: __("Tax Label"),
+ tax_percentage: __("Tax Percentage"),
+ ticket_email_template: __("Ticket Email Template"),
+ ticket_print_format: __("Ticket Print Format"),
+ auto_send_pitch_deck: __("Auto Send Pitch Deck"),
+ sponsor_deck_email_template: __("Sponsor Deck Email Template"),
+ sponsor_deck_reply_to: __("Sponsor Deck Reply To"),
+ sponsor_deck_cc: __("Sponsor Deck CC"),
+ sponsor_deck_attachments: __("Sponsor Deck Attachments"),
+ payment_gateways: __("Payment Gateways"),
+ ticket_types: __("Ticket Types"),
+ add_ons: __("Add-ons"),
+ custom_fields: __("Custom Fields"),
+};
+
+function get_field_label(field) {
+ return FIELD_LABELS[field] || field;
+}
+
+function render_field_group(group_key, template) {
+ let group = TEMPLATE_FIELD_GROUPS[group_key];
+ let html = '';
+ html += `
${group.label} `;
+ html += '
';
+
+ for (let field of group.fields) {
+ let value = template[field];
+ let has_value = value !== null && value !== undefined && value !== "" && value !== 0;
+
+ if (Array.isArray(value)) {
+ has_value = value.length > 0;
+ }
+
+ let label = get_field_label(field);
+
+ html += `
+
+
+
+ ${label}
+ ${!has_value ? '(' + __("Not set") + ") " : ""}
+
+
+ `;
+ }
+
+ html += "
";
+ return html;
+}
+
+function render_related_documents(template) {
+ let html = '';
+ html += `
${__("Related Documents")} `;
+ html += '
';
+
+ const related_items = [
+ { key: "payment_gateways", label: __("Payment Gateways"), data_key: "payment_gateways" },
+ {
+ key: "ticket_types",
+ label: __("Ticket Types"),
+ data_key: "template_ticket_types",
+ },
+ { key: "add_ons", label: __("Add-ons"), data_key: "template_add_ons" },
+ {
+ key: "custom_fields",
+ label: __("Custom Fields"),
+ data_key: "template_custom_fields",
+ },
+ ];
+
+ for (let item of related_items) {
+ let count = template[item.data_key] ? template[item.data_key].length : 0;
+ html += `
+
+
+ 0 ? "checked" : ""
+ } ${count === 0 ? "disabled" : ""}>
+ ${item.label} ${
+ count > 0
+ ? `(${count}) `
+ : '(' + __("None") + ") "
+ }
+
+
+ `;
+ }
+
+ html += "
";
+ return html;
+}
+
+function update_mandatory_fields_visibility(dialog, template) {
+ let missing_fields = [];
+
+ for (let field of MANDATORY_FIELDS) {
+ let template_has_value = template[field] && template[field] !== "";
+ let checkbox = dialog
+ .get_field("field_options")
+ .$wrapper.find(`.template-option[data-option="${field}"]`);
+ let is_checked = checkbox.is(":checked");
+
+ if (!template_has_value || !is_checked) {
+ missing_fields.push(field);
+ dialog.get_field(field).df.hidden = 0;
+ dialog.get_field(field).df.reqd = 1;
+ dialog.get_field(field).refresh();
+ } else {
+ dialog.get_field(field).df.hidden = 1;
+ dialog.get_field(field).df.reqd = 0;
+ dialog.get_field(field).refresh();
+ }
+ }
+
+ if (missing_fields.length > 0) {
+ dialog.get_field("missing_fields_section").df.hidden = 0;
+ dialog.get_field("missing_fields_section").refresh();
+ dialog
+ .get_field("missing_fields_info")
+ .$wrapper.html(
+ `${__(
+ "The following required fields are not set in the template or not selected. Please fill them in:"
+ )}
`
+ );
+ } else {
+ dialog.get_field("missing_fields_section").df.hidden = 1;
+ dialog.get_field("missing_fields_section").refresh();
+ dialog.get_field("missing_fields_info").$wrapper.html("");
+ }
+}
+
+function bind_select_buttons(dialog) {
+ dialog
+ .get_field("select_buttons")
+ .$wrapper.find(".select-all-btn")
+ .on("click", function () {
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option:not(:disabled)")
+ .prop("checked", true);
+ update_mandatory_fields_visibility(dialog, dialog.template_data);
+ });
+
+ dialog
+ .get_field("select_buttons")
+ .$wrapper.find(".unselect-all-btn")
+ .on("click", function () {
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option")
+ .prop("checked", false);
+ update_mandatory_fields_visibility(dialog, dialog.template_data);
+ });
+}
+
+function render_template_options(dialog, template) {
+ let buttons_html = `
+
+ ${__("Select All")}
+ ${__("Unselect All")}
+
+ `;
+ dialog.get_field("select_buttons").$wrapper.html(buttons_html);
+
+ let html = "";
+ html += render_field_group("event_details", template);
+ html += render_field_group("ticketing_settings", template);
+ html += render_field_group("sponsorship_settings", template);
+ html += render_related_documents(template);
+
+ dialog.get_field("field_options").$wrapper.html(html);
+ dialog.template_data = template;
+
+ update_mandatory_fields_visibility(dialog, template);
+ bind_select_buttons(dialog);
+
+ dialog.get_field("field_options").$wrapper.on("change", ".template-option", function () {
+ update_mandatory_fields_visibility(dialog, dialog.template_data);
+ });
+}
+
+function on_template_selected(dialog) {
+ let template_name = dialog.get_value("template");
+ if (!template_name) {
+ dialog.get_field("field_options").$wrapper.html("");
+ dialog.get_field("select_buttons").$wrapper.html("");
+ return;
+ }
+
+ frappe.call({
+ method: "frappe.client.get",
+ args: {
+ doctype: "Event Template",
+ name: template_name,
+ },
+ callback: function (r) {
+ if (r.message) {
+ render_template_options(dialog, r.message);
+ }
+ },
+ });
+}
+
+function create_event_from_template(dialog, values) {
+ let template_name = values.template;
+ let options = {};
+
+ dialog
+ .get_field("field_options")
+ .$wrapper.find(".template-option:checked")
+ .each(function () {
+ options[$(this).data("option")] = 1;
+ });
+
+ let additional_fields = {};
+ for (let field of MANDATORY_FIELDS) {
+ let field_obj = dialog.get_field(field);
+ if (!field_obj.df.hidden && values[field]) {
+ additional_fields[field] = values[field];
+ }
+ }
+
+ frappe.call({
+ method: "event_manager.events.doctype.buzz_event.buzz_event.create_from_template",
+ args: {
+ template_name: template_name,
+ options: JSON.stringify(options),
+ additional_fields: JSON.stringify(additional_fields),
+ },
+ freeze: true,
+ freeze_message: __("Creating Event..."),
+ callback: function (r) {
+ if (r.message) {
+ dialog.hide();
+ frappe.show_alert({
+ message: __("Event created successfully"),
+ indicator: "green",
+ });
+ frappe.set_route("Form", "Pohodex Event Manager Event", r.message);
+ }
+ },
+ });
+}
+
+function show_create_from_template_dialog() {
+ let dialog = new frappe.ui.Dialog({
+ title: __("Create Event from Template"),
+ fields: [
+ {
+ fieldtype: "Link",
+ fieldname: "template",
+ label: __("Select Template"),
+ options: "Event Template",
+ reqd: 1,
+ change: function () {
+ on_template_selected(dialog);
+ },
+ },
+ {
+ fieldtype: "Section Break",
+ fieldname: "missing_fields_section",
+ label: __("Required Fields"),
+ depends_on: "eval:doc.template",
+ hidden: 1,
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "missing_fields_info",
+ },
+ {
+ fieldtype: "Link",
+ fieldname: "category",
+ label: __("Category"),
+ options: "Event Category",
+ hidden: 1,
+ },
+ {
+ fieldtype: "Column Break",
+ },
+ {
+ fieldtype: "Link",
+ fieldname: "host",
+ label: __("Host"),
+ options: "Event Host",
+ hidden: 1,
+ },
+ {
+ fieldtype: "Section Break",
+ fieldname: "options_section",
+ label: __("Select What to Copy"),
+ depends_on: "eval:doc.template",
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "select_buttons",
+ depends_on: "eval:doc.template",
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "field_options",
+ depends_on: "eval:doc.template",
+ },
+ ],
+ size: "large",
+ primary_action_label: __("Create Event"),
+ primary_action: function (values) {
+ create_event_from_template(dialog, values);
+ },
+ });
+
+ dialog.show();
+}
+
+frappe.listview_settings["Pohodex Event Manager Event"] = {
+ onload: function (listview) {
+ if (frappe.perm.has_perm("Event Template", 0, "read")) {
+ listview.page.add_inner_button(__("Create from Template"), function () {
+ show_create_from_template_dialog();
+ });
+ }
+ },
+};
diff --git a/event_manager/events/doctype/buzz_event/test_buzz_event.py b/event_manager/events/doctype/buzz_event/test_buzz_event.py
new file mode 100644
index 0000000..848fec7
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event/test_buzz_event.py
@@ -0,0 +1,731 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from event_manager.api import are_registrations_closed
+from event_manager.events.doctype.buzz_event.buzz_event import create_from_template
+from event_manager.events.doctype.event_template.event_template import create_template_from_event
+
+
+class TestBuzzEvent(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.create_test_fixtures()
+
+ @classmethod
+ def create_test_fixtures(cls):
+ if not frappe.db.exists("Event Category", "Test Category"):
+ frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert(
+ ignore_permissions=True
+ )
+
+ if not frappe.db.exists("Event Host", "Test Host"):
+ frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert(
+ ignore_permissions=True
+ )
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ # ==================== Schedule Validation Tests ====================
+
+ def _make_event_with_schedule(self, schedule_overrides, **event_overrides):
+ """Helper to create a Pohodex Event Manager Event with a single schedule item for validation tests."""
+ event_defaults = {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Schedule Test Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": "2026-03-05",
+ "end_date": "2026-03-06",
+ "start_time": "9:00:00",
+ "end_time": "18:00:00",
+ }
+ event_defaults.update(event_overrides)
+ event = frappe.get_doc(event_defaults)
+
+ # Directly call validate_schedule instead of insert to avoid
+ # needing linked Event Track records in the test database
+ for row in schedule_overrides:
+ event.append("schedule", row)
+ return event
+
+ def test_schedule_start_time_after_event_start_is_valid(self):
+ """Schedule at 11:00 should be valid when event starts at 9:00 (regression: string comparison bug)"""
+ event = self._make_event_with_schedule(
+ [{"date": "2026-03-05", "start_time": "11:00:00", "end_time": "12:00:00"}]
+ )
+ # Should not raise
+ event.validate_schedule()
+
+ def test_schedule_start_time_before_event_start_is_rejected(self):
+ """Schedule at 08:00 should be rejected when event starts at 9:00"""
+ event = self._make_event_with_schedule(
+ [{"date": "2026-03-05", "start_time": "08:00:00", "end_time": "08:30:00"}]
+ )
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ event.validate_schedule()
+
+ def test_schedule_end_time_after_event_end_is_rejected(self):
+ """Schedule ending at 19:00 should be rejected when event ends at 18:00"""
+ event = self._make_event_with_schedule(
+ [{"date": "2026-03-06", "start_time": "17:00:00", "end_time": "19:00:00"}]
+ )
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ event.validate_schedule()
+
+ def test_schedule_end_time_before_event_end_is_valid(self):
+ """Schedule ending at 16:30 should be valid when event ends at 18:00"""
+ event = self._make_event_with_schedule(
+ [{"date": "2026-03-06", "start_time": "16:00:00", "end_time": "16:30:00"}]
+ )
+ # Should not raise
+ event.validate_schedule()
+
+ # ==================== Create from Template Tests ====================
+
+ def test_create_from_template_copies_direct_fields(self):
+ """Test that direct fields are copied from template to event"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Direct Fields Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "medium": "Online",
+ "about": "About text",
+ "short_description": "Short desc",
+ "time_zone": "Asia/Kolkata",
+ "allow_guest_booking": 1,
+ "guest_verification_method": "Email OTP",
+ "send_ticket_email": 1,
+ "apply_tax": 1,
+ "tax_label": "GST",
+ "tax_percentage": 18,
+ }
+ )
+ template.insert()
+
+ options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "short_description": 1,
+ "time_zone": 1,
+ "allow_guest_booking": 1,
+ "guest_verification_method": 1,
+ "send_ticket_email": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ }
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ self.assertEqual(event.category, "Test Category")
+ self.assertEqual(event.host, "Test Host")
+ self.assertEqual(event.medium, "Online")
+ self.assertEqual(event.about, "About text")
+ self.assertEqual(event.short_description, "Short desc")
+ self.assertEqual(event.time_zone, "Asia/Kolkata")
+ self.assertEqual(event.allow_guest_booking, 1)
+ self.assertEqual(event.guest_verification_method, "Email OTP")
+ self.assertEqual(event.send_ticket_email, 1)
+ self.assertEqual(event.apply_tax, 1)
+ self.assertEqual(event.tax_label, "GST")
+ self.assertEqual(event.tax_percentage, 18)
+
+ def test_create_from_template_respects_unselected_options(self):
+ """Test that unselected options are not copied"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Selective Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "medium": "In Person",
+ "about": "Should not appear",
+ "apply_tax": 1,
+ "tax_percentage": 18,
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1, "medium": 0, "about": 0, "apply_tax": 0}
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ self.assertEqual(event.category, "Test Category")
+ self.assertEqual(event.host, "Test Host")
+ self.assertFalse(event.about)
+ self.assertFalse(event.apply_tax)
+
+ def test_create_from_template_additional_fields_override(self):
+ """Test that additional_fields override template values"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Override Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ }
+ )
+ template.insert()
+
+ # Don't copy category from template, provide via additional_fields
+ options = {"host": 1}
+ additional_fields = {"category": "Test Category"}
+
+ event_name = create_from_template(
+ template.name, frappe.as_json(options), frappe.as_json(additional_fields)
+ )
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ self.assertEqual(event.category, "Test Category")
+ self.assertEqual(event.host, "Test Host")
+
+ def test_create_from_template_creates_ticket_types(self):
+ """Test that ticket types are created as linked documents"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Ticket Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_ticket_types": [
+ {
+ "title": "Early Bird",
+ "price": 500,
+ "currency": "INR",
+ "is_published": 1,
+ "max_tickets_available": 100,
+ },
+ {"title": "Regular", "price": 1000, "currency": "INR", "is_published": 1},
+ ],
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1, "ticket_types": 1}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ ticket_types = frappe.get_all(
+ "Event Ticket Type",
+ filters={"event": event_name, "title": ["in", ["Early Bird", "Regular"]]},
+ fields=["title", "price", "max_tickets_available"],
+ order_by="price",
+ )
+ self.assertEqual(len(ticket_types), 2)
+ self.assertEqual(ticket_types[0].title, "Early Bird")
+ self.assertEqual(ticket_types[0].price, 500)
+ self.assertEqual(ticket_types[0].max_tickets_available, 100)
+ self.assertEqual(ticket_types[1].title, "Regular")
+ self.assertEqual(ticket_types[1].price, 1000)
+
+ def test_create_from_template_creates_add_ons(self):
+ """Test that add-ons are created as linked documents"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "AddOn Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_add_ons": [
+ {
+ "title": "Workshop Access",
+ "price": 2000,
+ "currency": "INR",
+ "enabled": 1,
+ "user_selects_option": 1,
+ "options": "Morning\nAfternoon",
+ }
+ ],
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1, "add_ons": 1}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ add_ons = frappe.get_all(
+ "Ticket Add-on",
+ filters={"event": event_name},
+ fields=["title", "price", "user_selects_option", "options"],
+ )
+ self.assertEqual(len(add_ons), 1)
+ self.assertEqual(add_ons[0].title, "Workshop Access")
+ self.assertEqual(add_ons[0].price, 2000)
+ self.assertEqual(add_ons[0].user_selects_option, 1)
+ self.assertEqual(add_ons[0].options, "Morning\nAfternoon")
+
+ def test_create_from_template_creates_custom_fields(self):
+ """Test that custom fields are created as linked documents"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "CustomField Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_custom_fields": [
+ {
+ "label": "Company",
+ "fieldname": "company",
+ "fieldtype": "Data",
+ "applied_to": "Booking",
+ "mandatory": 1,
+ "enabled": 1,
+ "placeholder": "Enter company name",
+ }
+ ],
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1, "custom_fields": 1}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event_name},
+ fields=["label", "fieldtype", "mandatory", "placeholder"],
+ )
+ self.assertEqual(len(custom_fields), 1)
+ self.assertEqual(custom_fields[0].label, "Company")
+ self.assertEqual(custom_fields[0].fieldtype, "Data")
+ self.assertEqual(custom_fields[0].mandatory, 1)
+ self.assertEqual(custom_fields[0].placeholder, "Enter company name")
+
+ def test_create_from_template_skips_linked_docs_when_unselected(self):
+ """Test that linked docs are not created when options are 0"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Skip Linked Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_ticket_types": [
+ {"title": "Skipped", "price": 100, "currency": "INR", "is_published": 1}
+ ],
+ "template_add_ons": [
+ {"title": "Skipped Addon", "price": 50, "currency": "INR", "enabled": 1}
+ ],
+ "template_custom_fields": [
+ {
+ "label": "Skipped Field",
+ "fieldname": "skipped",
+ "fieldtype": "Data",
+ "applied_to": "Booking",
+ "enabled": 1,
+ }
+ ],
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ self.assertEqual(
+ len(frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "Skipped"})), 0
+ )
+ self.assertEqual(len(frappe.get_all("Ticket Add-on", filters={"event": event_name})), 0)
+ self.assertEqual(len(frappe.get_all("Pohodex Event Manager Custom Field", filters={"event": event_name})), 0)
+
+ def test_create_from_template_sets_default_title_and_date(self):
+ """Test that event gets a default title and today's date"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Defaults Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ self.assertIn("Defaults Template", event.title)
+ self.assertEqual(str(event.start_date), frappe.utils.today())
+
+ def test_create_from_template_copies_sponsorship_settings(self):
+ """Test that sponsorship settings are copied"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Sponsor Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "auto_send_pitch_deck": 1,
+ "sponsor_deck_reply_to": "test@example.com",
+ "sponsor_deck_cc": "cc@example.com",
+ }
+ )
+ template.insert()
+
+ options = {
+ "category": 1,
+ "host": 1,
+ "auto_send_pitch_deck": 1,
+ "sponsor_deck_reply_to": 1,
+ "sponsor_deck_cc": 1,
+ }
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ self.assertEqual(event.auto_send_pitch_deck, 1)
+ self.assertEqual(event.sponsor_deck_reply_to, "test@example.com")
+ self.assertEqual(event.sponsor_deck_cc, "cc@example.com")
+
+ # ==================== Save as Template Tests ====================
+
+ def test_save_event_as_template_all_options(self):
+ """Test saving an event as template with all field options"""
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Full Save Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "Online",
+ "about": "Full event description",
+ "apply_tax": 1,
+ "tax_label": "GST",
+ "tax_percentage": 18,
+ }
+ )
+ event.insert()
+
+ # Create linked docs
+ frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": event.name,
+ "title": "Gold",
+ "price": 5000,
+ "currency": "INR",
+ "is_published": 1,
+ }
+ ).insert()
+
+ frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": event.name,
+ "title": "Parking",
+ "price": 200,
+ "currency": "INR",
+ "enabled": 1,
+ }
+ ).insert()
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Custom Field",
+ "event": event.name,
+ "label": "Designation",
+ "fieldname": "designation",
+ "fieldtype": "Data",
+ "applied_to": "Booking",
+ "enabled": 1,
+ }
+ ).insert()
+
+ options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ "ticket_types": 1,
+ "add_ons": 1,
+ "custom_fields": 1,
+ }
+
+ template_name = create_template_from_event(
+ str(event.name), "Full Save Template", frappe.as_json(options)
+ )
+ template = frappe.get_doc("Event Template", template_name)
+
+ self.assertEqual(template.category, "Test Category")
+ self.assertEqual(template.host, "Test Host")
+ self.assertEqual(template.medium, "Online")
+ self.assertEqual(template.about, "Full event description")
+ self.assertEqual(template.apply_tax, 1)
+ self.assertEqual(template.tax_percentage, 18)
+
+ # Check linked docs (ticket types include default "Normal" created on event insert)
+ gold_tickets = [t for t in template.template_ticket_types if t.title == "Gold"]
+ self.assertEqual(len(gold_tickets), 1)
+ self.assertEqual(gold_tickets[0].price, 5000)
+
+ self.assertEqual(len(template.template_add_ons), 1)
+ self.assertEqual(template.template_add_ons[0].title, "Parking")
+
+ self.assertEqual(len(template.template_custom_fields), 1)
+ self.assertEqual(template.template_custom_fields[0].label, "Designation")
+
+ def test_save_event_as_template_partial(self):
+ """Test saving event as template with partial options"""
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Partial Save Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "In Person",
+ "about": "Included",
+ "apply_tax": 1,
+ "tax_percentage": 18,
+ }
+ )
+ event.insert()
+
+ options = {"category": 1, "about": 1, "medium": 0, "apply_tax": 0}
+
+ template_name = create_template_from_event(
+ str(event.name), "Partial Save Template", frappe.as_json(options)
+ )
+ template = frappe.get_doc("Event Template", template_name)
+
+ self.assertEqual(template.category, "Test Category")
+ self.assertEqual(template.about, "Included")
+ self.assertFalse(template.host)
+ self.assertFalse(template.apply_tax)
+
+ # ==================== Round Trip Test ====================
+
+ def test_round_trip_preserves_data(self):
+ """Test Event -> Template -> Event preserves all data"""
+ original = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Round Trip Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "Online",
+ "about": "Round trip description",
+ "apply_tax": 1,
+ "tax_label": "Service Tax",
+ "tax_percentage": 12,
+ }
+ )
+ original.insert()
+
+ frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": original.name,
+ "title": "Platinum",
+ "price": 10000,
+ "currency": "INR",
+ "is_published": 1,
+ "max_tickets_available": 25,
+ }
+ ).insert()
+
+ # Event -> Template
+ all_options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ "ticket_types": 1,
+ }
+ template_name = create_template_from_event(
+ str(original.name), "Round Trip Template", frappe.as_json(all_options)
+ )
+
+ # Template -> New Event
+ new_event_name = create_from_template(template_name, frappe.as_json(all_options))
+ new_event = frappe.get_doc("Pohodex Event Manager Event", new_event_name)
+
+ self.assertEqual(new_event.category, original.category)
+ self.assertEqual(new_event.host, original.host)
+ self.assertEqual(new_event.medium, original.medium)
+ self.assertEqual(new_event.about, original.about)
+ self.assertEqual(new_event.tax_label, original.tax_label)
+ self.assertEqual(new_event.tax_percentage, original.tax_percentage)
+
+ platinum_tickets = frappe.get_all(
+ "Event Ticket Type",
+ filters={"event": new_event_name, "title": "Platinum"},
+ fields=["price", "max_tickets_available"],
+ )
+ self.assertEqual(len(platinum_tickets), 1)
+ self.assertEqual(platinum_tickets[0].price, 10000)
+ self.assertEqual(platinum_tickets[0].max_tickets_available, 25)
+
+ # ==================== Permission Tests ====================
+
+ def test_create_from_template_requires_template_read_permission(self):
+ """Test that creating from template requires read permission on Event Template"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Perm Test Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ }
+ )
+ template.insert()
+
+ # Create a user without Event Template read permission
+ frappe.set_user("Guest")
+ try:
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ create_from_template(template.name, frappe.as_json({"category": 1, "host": 1}))
+ finally:
+ frappe.set_user("Administrator")
+
+ def test_save_as_template_requires_create_permission(self):
+ """Test that saving as template requires create permission on Event Template"""
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Perm Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ }
+ )
+ event.insert()
+
+ frappe.set_user("Guest")
+ try:
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ create_template_from_event(str(event.name), "Perm Template", frappe.as_json({"category": 1}))
+ finally:
+ frappe.set_user("Administrator")
+
+
+class TestRegistrationsClosed(FrappeTestCase):
+ """Tests for the are_registrations_closed function with timezone handling."""
+
+ def _make_event(self, registrations_close_at=None, time_zone=None):
+ """Create a minimal event _dict for testing (no DB insert needed)."""
+ return frappe._dict(
+ registrations_close_at=registrations_close_at,
+ time_zone=time_zone,
+ )
+
+ def test_no_close_at_returns_false(self):
+ """When registrations_close_at is not set, registrations are open."""
+ event = self._make_event()
+ self.assertFalse(are_registrations_closed(event))
+
+ def test_future_close_at_returns_false(self):
+ """When close_at is in the future, registrations are open."""
+ fake_now = datetime(2026, 6, 15, 10, 0, 0)
+ event = self._make_event(
+ registrations_close_at="2026-06-15 12:00:00", # 2 hours after fake_now
+ time_zone="UTC",
+ )
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_now):
+ self.assertFalse(are_registrations_closed(event))
+
+ def test_past_close_at_returns_true(self):
+ """When close_at is in the past, registrations are closed."""
+ fake_now = datetime(2026, 6, 15, 14, 0, 0)
+ event = self._make_event(
+ registrations_close_at="2026-06-15 12:00:00", # 2 hours before fake_now
+ time_zone="UTC",
+ )
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_now):
+ self.assertTrue(are_registrations_closed(event))
+
+ def test_timezone_ahead_of_utc_closes_earlier(self):
+ """An event in Asia/Kolkata (UTC+5:30) should close before the same wall-clock time in UTC.
+
+ If it's 14:00 UTC, that's 19:30 IST.
+ A close_at of 18:00 (naive, in event tz) is already past in IST but not in UTC.
+ """
+ # Simulate 19:30 IST (= 14:00 UTC)
+ fake_ist_now = datetime(2026, 6, 15, 19, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
+
+ event = self._make_event(
+ registrations_close_at="2026-06-15 18:00:00", # 18:00 in event tz (IST)
+ time_zone="Asia/Kolkata",
+ )
+
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_ist_now):
+ # 19:30 IST > 18:00 IST → closed
+ self.assertTrue(are_registrations_closed(event))
+
+ def test_timezone_behind_utc_stays_open_longer(self):
+ """An event in US/Pacific (UTC-7) should stay open longer than the same wall-clock in UTC.
+
+ If it's 23:00 UTC on June 15, that's 16:00 PDT on June 15.
+ A close_at of 18:00 (naive, in event tz) is still in the future in PDT.
+ """
+ # Simulate 16:00 PDT (= 23:00 UTC)
+ fake_pdt_now = datetime(2026, 6, 15, 16, 0, 0, tzinfo=timezone(timedelta(hours=-7)))
+
+ event = self._make_event(
+ registrations_close_at="2026-06-15 18:00:00", # 18:00 in event tz (PDT)
+ time_zone="US/Pacific",
+ )
+
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_pdt_now):
+ # 16:00 PDT < 18:00 PDT → still open
+ self.assertFalse(are_registrations_closed(event))
+
+ def test_same_close_time_different_timezones(self):
+ """Same UTC instant, same close_at string — different result depending on event timezone.
+
+ At 2026-06-15 17:30 UTC:
+ - Asia/Kolkata: 23:00 IST → 23:00 > 18:00 → closed
+ - US/Pacific: 10:30 PDT → 10:30 < 18:00 → open
+ """
+ close_at = "2026-06-15 18:00:00"
+
+ event_ist = self._make_event(registrations_close_at=close_at, time_zone="Asia/Kolkata")
+ event_pdt = self._make_event(registrations_close_at=close_at, time_zone="US/Pacific")
+
+ # 17:30 UTC = 23:00 IST
+ fake_ist_now = datetime(2026, 6, 15, 23, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_ist_now):
+ self.assertTrue(are_registrations_closed(event_ist))
+
+ # 17:30 UTC = 10:30 PDT
+ fake_pdt_now = datetime(2026, 6, 15, 10, 30, 0, tzinfo=timezone(timedelta(hours=-7)))
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_pdt_now):
+ self.assertFalse(are_registrations_closed(event_pdt))
+
+ def test_falls_back_to_system_timezone_when_event_tz_not_set(self):
+ """When event has no time_zone, system timezone is used."""
+ fake_now = datetime(2026, 6, 15, 14, 0, 0)
+ event = self._make_event(
+ registrations_close_at="2026-06-15 13:00:00", # 1 hour before fake_now
+ time_zone=None,
+ )
+ with patch("event_manager.api.get_datetime_in_timezone", return_value=fake_now):
+ self.assertTrue(are_registrations_closed(event))
diff --git a/event_manager/events/doctype/buzz_event_form/__init__.py b/event_manager/events/doctype/buzz_event_form/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/buzz_event_form/buzz_event_form.json b/event_manager/events/doctype/buzz_event_form/buzz_event_form.json
new file mode 100644
index 0000000..f32e881
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event_form/buzz_event_form.json
@@ -0,0 +1,105 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2026-03-20 00:00:00",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "form_doctype",
+ "route",
+ "copy_to_clipboard",
+ "publish",
+ "column_break_main",
+ "auto_close_at",
+ "section_break_success",
+ "success_title",
+ "success_message",
+ "section_break_closed",
+ "closed_title",
+ "closed_message"
+ ],
+ "fields": [
+ {
+ "fieldname": "form_doctype",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "DocType",
+ "options": "DocType",
+ "reqd": 1
+ },
+ {
+ "fieldname": "route",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Route",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "publish",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Publish"
+ },
+ {
+ "fieldname": "column_break_main",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "auto_close_at",
+ "fieldtype": "Datetime",
+ "label": "Auto Close At"
+ },
+ {
+ "fieldname": "section_break_success",
+ "fieldtype": "Section Break",
+ "label": "Success"
+ },
+ {
+ "fieldname": "success_title",
+ "fieldtype": "Data",
+ "label": "Success Title"
+ },
+ {
+ "fieldname": "success_message",
+ "fieldtype": "Markdown Editor",
+ "label": "Success Message"
+ },
+ {
+ "fieldname": "section_break_closed",
+ "fieldtype": "Section Break",
+ "label": "Closed"
+ },
+ {
+ "fieldname": "closed_title",
+ "fieldtype": "Data",
+ "label": "Closed Title"
+ },
+ {
+ "fieldname": "closed_message",
+ "fieldtype": "Small Text",
+ "label": "Closed Message"
+ },
+ {
+ "fieldname": "copy_to_clipboard",
+ "fieldtype": "Button",
+ "in_list_view": 1,
+ "label": "Copy link to clipboard"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-04-14 19:33:17.779162",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Pohodex Event Manager Event Form",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/buzz_event_form/buzz_event_form.py b/event_manager/events/doctype/buzz_event_form/buzz_event_form.py
new file mode 100644
index 0000000..9f9245b
--- /dev/null
+++ b/event_manager/events/doctype/buzz_event_form/buzz_event_form.py
@@ -0,0 +1,38 @@
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class BuzzEventForm(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ auto_close_at: DF.Datetime | None
+ closed_message: DF.SmallText | None
+ closed_title: DF.Data | None
+ form_doctype: DF.Link
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ publish: DF.Check
+ route: DF.Data
+ success_message: DF.MarkdownEditor | None
+ success_title: DF.Data | None
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_unique_route()
+
+ def validate_unique_route(self):
+ for row in self.parentdoc.custom_forms:
+ if row.name != self.name and row.route == self.route:
+ frappe.throw(
+ _("Duplicate route '{0}' in custom forms. Each form must have a unique route.").format(
+ self.route
+ )
+ )
diff --git a/event_manager/events/doctype/buzz_settings/__init__.py b/event_manager/events/doctype/buzz_settings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/buzz_settings/buzz_settings.js b/event_manager/events/doctype/buzz_settings/buzz_settings.js
new file mode 100644
index 0000000..8439a22
--- /dev/null
+++ b/event_manager/events/doctype/buzz_settings/buzz_settings.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Pohodex Event Manager Settings", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/buzz_settings/buzz_settings.json b/event_manager/events/doctype/buzz_settings/buzz_settings.json
new file mode 100644
index 0000000..bb10c22
--- /dev/null
+++ b/event_manager/events/doctype/buzz_settings/buzz_settings.json
@@ -0,0 +1,251 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-08-17 22:08:28.579289",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "general_section",
+ "support_email",
+ "ticketing_section",
+ "allow_transfer_ticket_before_event_start_days",
+ "column_break_ehsr",
+ "allow_add_ons_change_before_event_start_days",
+ "column_break_hagy",
+ "allow_ticket_cancellation_request_before_event_start_days",
+ "proposals_tab",
+ "event_proposals_section",
+ "accept_event_proposals",
+ "event_proposal_banner_title",
+ "column_break_lxjh",
+ "allow_guest_event_proposals",
+ "success_section",
+ "event_proposal_success_title",
+ "event_proposal_success_message",
+ "login_tab",
+ "login_banner_section",
+ "login_banner",
+ "communications_tab",
+ "ticketing_emails_section",
+ "default_ticket_email_template",
+ "sponsorship_emails_section",
+ "auto_send_pitch_deck",
+ "sponsor_email_settings_section",
+ "default_sponsor_deck_email_template",
+ "default_sponsor_deck_reply_to",
+ "column_break_sponsor",
+ "default_sponsor_deck_cc",
+ "section_break_vtep",
+ "custom_fields_go_after_this"
+ ],
+ "fields": [
+ {
+ "fieldname": "ticketing_section",
+ "fieldtype": "Section Break",
+ "label": "Ticketing"
+ },
+ {
+ "default": "7",
+ "fieldname": "allow_transfer_ticket_before_event_start_days",
+ "fieldtype": "Int",
+ "label": "Allow Transfer Ticket Before Event Start (Days)",
+ "non_negative": 1
+ },
+ {
+ "default": "7",
+ "fieldname": "allow_add_ons_change_before_event_start_days",
+ "fieldtype": "Int",
+ "label": "Allow Add Ons Change Before Event Start (Days)",
+ "non_negative": 1
+ },
+ {
+ "default": "7",
+ "fieldname": "allow_ticket_cancellation_request_before_event_start_days",
+ "fieldtype": "Int",
+ "label": "Allow Ticket Cancellation Request Before Event Start (Days)",
+ "non_negative": 1
+ },
+ {
+ "fieldname": "column_break_ehsr",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_hagy",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "general_section",
+ "fieldtype": "Section Break",
+ "label": "General"
+ },
+ {
+ "description": "Will be linked in emails, etc.",
+ "fieldname": "support_email",
+ "fieldtype": "Data",
+ "label": "Support Email",
+ "options": "Email"
+ },
+ {
+ "fieldname": "proposals_tab",
+ "fieldtype": "Tab Break",
+ "label": "Proposals"
+ },
+ {
+ "fieldname": "event_proposals_section",
+ "fieldtype": "Section Break",
+ "label": "Event Proposals"
+ },
+ {
+ "default": "0",
+ "fieldname": "accept_event_proposals",
+ "fieldtype": "Check",
+ "label": "Accept Event Proposals"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.accept_event_proposals",
+ "fieldname": "allow_guest_event_proposals",
+ "fieldtype": "Check",
+ "label": "Allow Guest Submission"
+ },
+ {
+ "depends_on": "eval:doc.accept_event_proposals",
+ "fieldname": "event_proposal_banner_title",
+ "fieldtype": "Data",
+ "label": "Banner Title"
+ },
+ {
+ "depends_on": "eval:doc.accept_event_proposals",
+ "fieldname": "event_proposal_success_title",
+ "fieldtype": "Data",
+ "label": "Success Title"
+ },
+ {
+ "depends_on": "eval:doc.accept_event_proposals",
+ "fieldname": "event_proposal_success_message",
+ "fieldtype": "Markdown Editor",
+ "label": "Success Message"
+ },
+ {
+ "fieldname": "login_tab",
+ "fieldtype": "Tab Break",
+ "label": "Login"
+ },
+ {
+ "fieldname": "login_banner_section",
+ "fieldtype": "Section Break",
+ "label": "Login Banner Config"
+ },
+ {
+ "description": "Promotional message shown in the login/signup modal. Supports Markdown.",
+ "fieldname": "login_banner",
+ "fieldtype": "Markdown Editor",
+ "label": "Login Banner"
+ },
+ {
+ "fieldname": "communications_tab",
+ "fieldtype": "Tab Break",
+ "label": "Communications"
+ },
+ {
+ "fieldname": "ticketing_emails_section",
+ "fieldtype": "Section Break",
+ "label": "Ticketing"
+ },
+ {
+ "description": "Default template for ticket confirmation emails. Can be overridden per event.",
+ "fieldname": "default_ticket_email_template",
+ "fieldtype": "Link",
+ "label": "Default Ticket Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "sponsorship_emails_section",
+ "fieldtype": "Section Break",
+ "label": "Sponsorships"
+ },
+ {
+ "default": "0",
+ "fieldname": "auto_send_pitch_deck",
+ "fieldtype": "Check",
+ "label": "Auto Send Pitch Deck"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck",
+ "fieldname": "sponsor_email_settings_section",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck",
+ "description": "Default template for sponsorship pitch deck emails. Can be overridden per event.",
+ "fieldname": "default_sponsor_deck_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "mandatory_depends_on": "eval:doc.auto_send_pitch_deck",
+ "options": "Email Template"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck",
+ "fieldname": "default_sponsor_deck_reply_to",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Default Reply To",
+ "mandatory_depends_on": "eval:doc.auto_send_pitch_deck",
+ "options": "Email"
+ },
+ {
+ "fieldname": "column_break_sponsor",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck",
+ "fieldname": "default_sponsor_deck_cc",
+ "fieldtype": "Small Text",
+ "label": "Default CC"
+ },
+ {
+ "fieldname": "section_break_vtep",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "custom_fields_go_after_this",
+ "fieldtype": "HTML",
+ "label": "Custom Fields Go After This"
+ },
+ {
+ "fieldname": "column_break_lxjh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.accept_event_proposals",
+ "fieldname": "success_section",
+ "fieldtype": "Section Break",
+ "label": "Success"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2026-03-29 09:46:29.455686",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Pohodex Event Manager Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/buzz_settings/buzz_settings.py b/event_manager/events/doctype/buzz_settings/buzz_settings.py
new file mode 100644
index 0000000..2902d21
--- /dev/null
+++ b/event_manager/events/doctype/buzz_settings/buzz_settings.py
@@ -0,0 +1,45 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class BuzzSettings(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ accept_event_proposals: DF.Check
+ allow_add_ons_change_before_event_start_days: DF.Int
+ allow_guest_event_proposals: DF.Check
+ allow_ticket_cancellation_request_before_event_start_days: DF.Int
+ allow_transfer_ticket_before_event_start_days: DF.Int
+ auto_send_pitch_deck: DF.Check
+ default_sponsor_deck_cc: DF.SmallText | None
+ default_sponsor_deck_email_template: DF.Link | None
+ default_sponsor_deck_reply_to: DF.Data | None
+ default_ticket_email_template: DF.Link | None
+ event_proposal_banner_title: DF.Data | None
+ event_proposal_success_message: DF.MarkdownEditor | None
+ event_proposal_success_title: DF.Data | None
+ login_banner: DF.MarkdownEditor | None
+ support_email: DF.Data | None
+ # end: auto-generated types
+
+ def validate(self):
+ """Validate the settings."""
+ self.validate_transfer_days()
+
+ def validate_transfer_days(self):
+ """Validate that transfer days is a reasonable value."""
+ if self.allow_transfer_ticket_before_event_start_days is not None:
+ if self.allow_transfer_ticket_before_event_start_days < 0:
+ frappe.throw(_("Allow Transfer Ticket Before Event Start Days cannot be negative."))
+ elif self.allow_transfer_ticket_before_event_start_days > 365:
+ frappe.throw(_("Allow Transfer Ticket Before Event Start Days cannot be more than 365 days."))
diff --git a/event_manager/events/doctype/buzz_settings/test_buzz_settings.py b/event_manager/events/doctype/buzz_settings/test_buzz_settings.py
new file mode 100644
index 0000000..1a81a8b
--- /dev/null
+++ b/event_manager/events/doctype/buzz_settings/test_buzz_settings.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestBuzzSettings(IntegrationTestCase):
+ """
+ Integration tests for BuzzSettings.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_category/__init__.py b/event_manager/events/doctype/event_category/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_category/event_category.js b/event_manager/events/doctype/event_category/event_category.js
new file mode 100644
index 0000000..8e70836
--- /dev/null
+++ b/event_manager/events/doctype/event_category/event_category.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Category", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_category/event_category.json b/event_manager/events/doctype/event_category/event_category.json
new file mode 100644
index 0000000..3705a1a
--- /dev/null
+++ b/event_manager/events/doctype/event_category/event_category.json
@@ -0,0 +1,103 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "prompt",
+ "creation": "2025-07-19 11:30:07.885513",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "slug",
+ "description",
+ "column_break_mrmh",
+ "banner_image",
+ "meta_image",
+ "icon_svg"
+ ],
+ "fields": [
+ {
+ "fieldname": "icon_svg",
+ "fieldtype": "Code",
+ "label": "Icon SVG",
+ "options": "HTML"
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled"
+ },
+ {
+ "fieldname": "banner_image",
+ "fieldtype": "Attach Image",
+ "label": "Banner Image"
+ },
+ {
+ "fieldname": "slug",
+ "fieldtype": "Data",
+ "label": "Slug",
+ "unique": 1
+ },
+ {
+ "fieldname": "column_break_mrmh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "label": "Description"
+ },
+ {
+ "fieldname": "meta_image",
+ "fieldtype": "Attach Image",
+ "label": "Meta Image"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "banner_image",
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "link_doctype": "Pohodex Event Manager Coupon Code",
+ "link_fieldname": "event_category"
+ }
+ ],
+ "modified": "2026-05-08 12:38:57.139602",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Category",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_category/event_category.py b/event_manager/events/doctype/event_category/event_category.py
new file mode 100644
index 0000000..9f69ccf
--- /dev/null
+++ b/event_manager/events/doctype/event_category/event_category.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventCategory(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ banner_image: DF.AttachImage | None
+ description: DF.SmallText | None
+ enabled: DF.Check
+ icon_svg: DF.Code | None
+ meta_image: DF.AttachImage | None
+ slug: DF.Data | None
+ # end: auto-generated types
+
+ def validate(self):
+ if not self.slug:
+ self.set_slug()
+
+ def set_slug(self):
+ self.slug = frappe.website.utils.cleanup_page_name(self.name).replace("_", "-")
diff --git a/event_manager/events/doctype/event_category/test_event_category.py b/event_manager/events/doctype/event_category/test_event_category.py
new file mode 100644
index 0000000..43e0e2e
--- /dev/null
+++ b/event_manager/events/doctype/event_category/test_event_category.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventCategory(IntegrationTestCase):
+ """
+ Integration tests for EventCategory.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_check_in/__init__.py b/event_manager/events/doctype/event_check_in/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_check_in/event_check_in.js b/event_manager/events/doctype/event_check_in/event_check_in.js
new file mode 100644
index 0000000..d4d69d9
--- /dev/null
+++ b/event_manager/events/doctype/event_check_in/event_check_in.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Check In", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_check_in/event_check_in.json b/event_manager/events/doctype/event_check_in/event_check_in.json
new file mode 100644
index 0000000..c67ddad
--- /dev/null
+++ b/event_manager/events/doctype/event_check_in/event_check_in.json
@@ -0,0 +1,110 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-29 20:00:04.578374",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "event",
+ "date",
+ "column_break_fxzb",
+ "ticket",
+ "section_break_tt1x",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "section_break_tt1x",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Event Check In",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fetch_from": "ticket.event",
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "ticket",
+ "fieldtype": "Link",
+ "label": "Ticket",
+ "options": "Event Ticket",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_fxzb",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "label": "Date"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2025-10-18 16:51:30.061975",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Check In",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Frontdesk Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_check_in/event_check_in.py b/event_manager/events/doctype/event_check_in/event_check_in.py
new file mode 100644
index 0000000..9e0873f
--- /dev/null
+++ b/event_manager/events/doctype/event_check_in/event_check_in.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventCheckIn(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amended_from: DF.Link | None
+ date: DF.Date | None
+ event: DF.Link
+ ticket: DF.Link
+ # end: auto-generated types
+
+ def before_insert(self):
+ if not self.date:
+ self.date = frappe.utils.today()
diff --git a/event_manager/events/doctype/event_check_in/test_event_check_in.py b/event_manager/events/doctype/event_check_in/test_event_check_in.py
new file mode 100644
index 0000000..132e600
--- /dev/null
+++ b/event_manager/events/doctype/event_check_in/test_event_check_in.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventCheckIn(IntegrationTestCase):
+ """
+ Integration tests for EventCheckIn.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_featured_speaker/__init__.py b/event_manager/events/doctype/event_featured_speaker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.json b/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.json
new file mode 100644
index 0000000..74e890b
--- /dev/null
+++ b/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.json
@@ -0,0 +1,35 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-09-25 19:04:14.697421",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "speaker"
+ ],
+ "fields": [
+ {
+ "fieldname": "speaker",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Speaker",
+ "options": "Speaker Profile",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-09-25 19:04:45.486029",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Featured Speaker",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.py b/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.py
new file mode 100644
index 0000000..6b9aaf4
--- /dev/null
+++ b/event_manager/events/doctype/event_featured_speaker/event_featured_speaker.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventFeaturedSpeaker(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ speaker: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_feedback/__init__.py b/event_manager/events/doctype/event_feedback/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_feedback/event_feedback.js b/event_manager/events/doctype/event_feedback/event_feedback.js
new file mode 100644
index 0000000..d9b78b2
--- /dev/null
+++ b/event_manager/events/doctype/event_feedback/event_feedback.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Feedback", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_feedback/event_feedback.json b/event_manager/events/doctype/event_feedback/event_feedback.json
new file mode 100644
index 0000000..eb74e86
--- /dev/null
+++ b/event_manager/events/doctype/event_feedback/event_feedback.json
@@ -0,0 +1,70 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-08-14 14:10:08.833301",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "event",
+ "how_can_we_improve",
+ "section_break_additional",
+ "additional_fields"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "how_can_we_improve",
+ "fieldtype": "Small Text",
+ "label": "How can we improve?"
+ },
+ {
+ "fieldname": "section_break_additional",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "additional_fields",
+ "fieldtype": "Table",
+ "label": "Additional Fields",
+ "options": "Additional Field"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-08-14 14:11:07.484890",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Feedback",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "if_owner": 1,
+ "read": 1,
+ "role": "Pohodex Event Manager User"
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_feedback/event_feedback.py b/event_manager/events/doctype/event_feedback/event_feedback.py
new file mode 100644
index 0000000..1412968
--- /dev/null
+++ b/event_manager/events/doctype/event_feedback/event_feedback.py
@@ -0,0 +1,21 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventFeedback(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ event: DF.Link
+ how_can_we_improve: DF.SmallText | None
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_feedback/test_event_feedback.py b/event_manager/events/doctype/event_feedback/test_event_feedback.py
new file mode 100644
index 0000000..aa31bb1
--- /dev/null
+++ b/event_manager/events/doctype/event_feedback/test_event_feedback.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventFeedback(IntegrationTestCase):
+ """
+ Integration tests for EventFeedback.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_host/__init__.py b/event_manager/events/doctype/event_host/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_host/event_host.js b/event_manager/events/doctype/event_host/event_host.js
new file mode 100644
index 0000000..714b2fd
--- /dev/null
+++ b/event_manager/events/doctype/event_host/event_host.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Host", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_host/event_host.json b/event_manager/events/doctype/event_host/event_host.json
new file mode 100644
index 0000000..9f0ec41
--- /dev/null
+++ b/event_manager/events/doctype/event_host/event_host.json
@@ -0,0 +1,105 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "prompt",
+ "creation": "2025-07-19 11:36:30.869780",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "logo",
+ "country",
+ "social_media_links",
+ "column_break_udkj",
+ "by_line",
+ "address",
+ "section_break_hgnb",
+ "about"
+ ],
+ "fields": [
+ {
+ "allow_in_quick_entry": 1,
+ "fieldname": "logo",
+ "fieldtype": "Attach Image",
+ "label": "Logo"
+ },
+ {
+ "fieldname": "column_break_udkj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "allow_in_quick_entry": 1,
+ "fieldname": "by_line",
+ "fieldtype": "Data",
+ "label": "By Line"
+ },
+ {
+ "fieldname": "section_break_hgnb",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "about",
+ "fieldtype": "Text Editor",
+ "label": "About"
+ },
+ {
+ "allow_in_quick_entry": 1,
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "label": "Country",
+ "options": "Country"
+ },
+ {
+ "fieldname": "address",
+ "fieldtype": "Small Text",
+ "label": "Address"
+ },
+ {
+ "fieldname": "social_media_links",
+ "fieldtype": "Table",
+ "label": "Social Media Links",
+ "options": "Social Media Link"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "logo",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-07-26 12:24:58.729143",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Host",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_host/event_host.py b/event_manager/events/doctype/event_host/event_host.py
new file mode 100644
index 0000000..bdbd9c7
--- /dev/null
+++ b/event_manager/events/doctype/event_host/event_host.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventHost(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.social_media_link.social_media_link import SocialMediaLink
+
+ about: DF.TextEditor | None
+ address: DF.SmallText | None
+ by_line: DF.Data | None
+ country: DF.Link | None
+ logo: DF.AttachImage | None
+ social_media_links: DF.Table[SocialMediaLink]
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_host/test_event_host.py b/event_manager/events/doctype/event_host/test_event_host.py
new file mode 100644
index 0000000..4aa7447
--- /dev/null
+++ b/event_manager/events/doctype/event_host/test_event_host.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventHost(IntegrationTestCase):
+ """
+ Integration tests for EventHost.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_payment_gateway/__init__.py b/event_manager/events/doctype/event_payment_gateway/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.json b/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.json
new file mode 100644
index 0000000..531aaaa
--- /dev/null
+++ b/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.json
@@ -0,0 +1,36 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-22 14:26:23.528630",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "payment_gateway"
+ ],
+ "fields": [
+ {
+ "fieldname": "payment_gateway",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Payment Gateway",
+ "options": "Payment Gateway",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-22 14:27:25.962536",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Payment Gateway",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.py b/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.py
new file mode 100644
index 0000000..898f95c
--- /dev/null
+++ b/event_manager/events/doctype/event_payment_gateway/event_payment_gateway.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventPaymentGateway(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ payment_gateway: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_sponsor/__init__.py b/event_manager/events/doctype/event_sponsor/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_sponsor/event_sponsor.js b/event_manager/events/doctype/event_sponsor/event_sponsor.js
new file mode 100644
index 0000000..e894d66
--- /dev/null
+++ b/event_manager/events/doctype/event_sponsor/event_sponsor.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Sponsor", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_sponsor/event_sponsor.json b/event_manager/events/doctype/event_sponsor/event_sponsor.json
new file mode 100644
index 0000000..559743c
--- /dev/null
+++ b/event_manager/events/doctype/event_sponsor/event_sponsor.json
@@ -0,0 +1,118 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-19 12:48:18.436949",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "company_name",
+ "company_logo",
+ "website",
+ "column_break_ozou",
+ "event",
+ "tier",
+ "country",
+ "additional_section",
+ "enquiry"
+ ],
+ "fields": [
+ {
+ "fieldname": "column_break_ozou",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "company_name",
+ "fieldtype": "Data",
+ "label": "Company Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "tier",
+ "fieldtype": "Link",
+ "label": "Tier",
+ "options": "Sponsorship Tier",
+ "reqd": 1
+ },
+ {
+ "fieldname": "company_logo",
+ "fieldtype": "Attach Image",
+ "label": "Company Logo",
+ "reqd": 1
+ },
+ {
+ "fieldname": "additional_section",
+ "fieldtype": "Section Break",
+ "label": "Additional"
+ },
+ {
+ "fieldname": "enquiry",
+ "fieldtype": "Link",
+ "label": "Enquiry",
+ "options": "Sponsorship Enquiry"
+ },
+ {
+ "fieldname": "website",
+ "fieldtype": "Data",
+ "label": "Website",
+ "options": "URL"
+ },
+ {
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "label": "Country",
+ "options": "Country"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "company_logo",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-28 16:18:05.658346",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Sponsor",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "company_name",
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/event_sponsor/event_sponsor.py b/event_manager/events/doctype/event_sponsor/event_sponsor.py
new file mode 100644
index 0000000..11e51a3
--- /dev/null
+++ b/event_manager/events/doctype/event_sponsor/event_sponsor.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventSponsor(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ company_logo: DF.AttachImage
+ company_name: DF.Data
+ country: DF.Link | None
+ enquiry: DF.Link | None
+ event: DF.Link
+ tier: DF.Link
+ website: DF.Data | None
+ # end: auto-generated types
+
+ def validate(self):
+ already_exists = frappe.db.exists(
+ "Event Sponsor", {"event": self.event, "enquiry": self.enquiry, "name": ("!=", self.name)}
+ )
+
+ if already_exists:
+ frappe.throw(frappe._("Sponsor for this enquiry already exists!"))
diff --git a/event_manager/events/doctype/event_sponsor/test_event_sponsor.py b/event_manager/events/doctype/event_sponsor/test_event_sponsor.py
new file mode 100644
index 0000000..24b2179
--- /dev/null
+++ b/event_manager/events/doctype/event_sponsor/test_event_sponsor.py
@@ -0,0 +1,46 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventSponsor(IntegrationTestCase):
+ """
+ Integration tests for EventSponsor.
+ Use this class for testing interactions between multiple components.
+ """
+
+ def test_enquiry_to_sponsor_flow(self):
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_sponsorship_tier = frappe.get_doc(
+ {
+ "doctype": "Sponsorship Tier",
+ "event": test_event.name,
+ "title": "Super Platinum",
+ "price": 1000,
+ "currency": "INR",
+ }
+ ).insert()
+
+ test_enquiry = frappe.get_doc(
+ {
+ "doctype": "Sponsorship Enquiry",
+ "event": test_event.name,
+ "company_name": "Test Studios",
+ "company_logo": "https://buildwithhussain.com/files/youtube2.png",
+ "tier": test_sponsorship_tier.name,
+ }
+ ).insert()
+
+ # "Payment Success trigger"
+ test_enquiry.on_payment_authorized("Completed")
+ self.assertEqual(test_enquiry.status, "Paid")
+
+ self.assertTrue(frappe.db.exists("Event Sponsor", {"enquiry": test_enquiry.name}))
diff --git a/event_manager/events/doctype/event_talk/__init__.py b/event_manager/events/doctype/event_talk/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_talk/event_talk.js b/event_manager/events/doctype/event_talk/event_talk.js
new file mode 100644
index 0000000..5a71dff
--- /dev/null
+++ b/event_manager/events/doctype/event_talk/event_talk.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Talk", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_talk/event_talk.json b/event_manager/events/doctype/event_talk/event_talk.json
new file mode 100644
index 0000000..90c0354
--- /dev/null
+++ b/event_manager/events/doctype/event_talk/event_talk.json
@@ -0,0 +1,132 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2025-07-19 12:15:38.420883",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "submitted_by",
+ "column_break_npnb",
+ "proposal",
+ "event",
+ "section_break_rnda",
+ "speakers",
+ "section_break_nrdk",
+ "description"
+ ],
+ "fields": [
+ {
+ "fetch_from": "proposal.title",
+ "fetch_if_empty": 1,
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_rnda",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "speakers",
+ "fieldtype": "Table",
+ "label": "Speakers",
+ "options": "Talk Speaker"
+ },
+ {
+ "fetch_from": "proposal.submitted_by",
+ "fieldname": "submitted_by",
+ "fieldtype": "Link",
+ "label": "Submitted By",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_npnb",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "proposal",
+ "fieldtype": "Link",
+ "label": "Proposal",
+ "options": "Talk Proposal"
+ },
+ {
+ "fetch_from": "proposal.event",
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_nrdk",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fetch_from": "proposal.description",
+ "fetch_if_empty": 1,
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Description"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-01-14 12:38:51.583900",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Talk",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title"
+}
diff --git a/event_manager/events/doctype/event_talk/event_talk.py b/event_manager/events/doctype/event_talk/event_talk.py
new file mode 100644
index 0000000..5cebe17
--- /dev/null
+++ b/event_manager/events/doctype/event_talk/event_talk.py
@@ -0,0 +1,30 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventTalk(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.talk_speaker.talk_speaker import TalkSpeaker
+
+ description: DF.TextEditor | None
+ event: DF.Link
+ name: DF.Int | None
+ proposal: DF.Link | None
+ speakers: DF.Table[TalkSpeaker]
+ submitted_by: DF.Link
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ if frappe.db.exists("Event Talk", {"proposal": self.proposal, "name": ["!=", self.name]}):
+ frappe.throw("Talk already created for this proposal!")
diff --git a/event_manager/events/doctype/event_talk/test_event_talk.py b/event_manager/events/doctype/event_talk/test_event_talk.py
new file mode 100644
index 0000000..2509850
--- /dev/null
+++ b/event_manager/events/doctype/event_talk/test_event_talk.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventTalk(IntegrationTestCase):
+ """
+ Integration tests for EventTalk.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_template/__init__.py b/event_manager/events/doctype/event_template/__init__.py
new file mode 100644
index 0000000..f66d03d
--- /dev/null
+++ b/event_manager/events/doctype/event_template/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
diff --git a/event_manager/events/doctype/event_template/event_template.json b/event_manager/events/doctype/event_template/event_template.json
new file mode 100644
index 0000000..2e1dec5
--- /dev/null
+++ b/event_manager/events/doctype/event_template/event_template.json
@@ -0,0 +1,327 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "field:template_name",
+ "creation": "2025-12-31 12:00:00.000000",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "template_name",
+ "category",
+ "medium",
+ "column_break_main",
+ "banner_image",
+ "host",
+ "venue",
+ "allow_guest_booking",
+ "guest_verification_method",
+ "section_break_details",
+ "time_zone",
+ "short_description",
+ "about",
+ "payments_tab",
+ "section_break_payments",
+ "payment_gateways",
+ "tax_settings_section",
+ "apply_tax",
+ "tax_label",
+ "tax_percentage",
+ "sponsorships_tab",
+ "automations_section",
+ "auto_send_pitch_deck",
+ "section_break_sponsor",
+ "sponsor_deck_reply_to",
+ "sponsor_deck_email_template",
+ "column_break_sponsor",
+ "sponsor_deck_cc",
+ "section_break_attachments",
+ "sponsor_deck_attachments",
+ "customisations_tab",
+ "send_ticket_email",
+ "ticket_email_template",
+ "column_break_custom",
+ "ticket_print_format",
+ "ticket_types_tab",
+ "template_ticket_types",
+ "add_ons_tab",
+ "template_add_ons",
+ "custom_fields_tab",
+ "template_custom_fields"
+ ],
+ "fields": [
+ {
+ "fieldname": "template_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Template Name",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "category",
+ "fieldtype": "Link",
+ "label": "Category",
+ "options": "Event Category"
+ },
+ {
+ "default": "In Person",
+ "fieldname": "medium",
+ "fieldtype": "Select",
+ "label": "Medium",
+ "options": "In Person\nOnline"
+ },
+ {
+ "fieldname": "column_break_main",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "banner_image",
+ "fieldtype": "Attach Image",
+ "label": "Banner Image"
+ },
+ {
+ "fieldname": "host",
+ "fieldtype": "Link",
+ "label": "Host",
+ "options": "Event Host"
+ },
+ {
+ "depends_on": "eval:doc.medium!=\"Online\"",
+ "fieldname": "venue",
+ "fieldtype": "Link",
+ "label": "Venue",
+ "options": "Event Venue"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_guest_booking",
+ "fieldtype": "Check",
+ "label": "Allow Guest Booking"
+ },
+ {
+ "default": "Email OTP",
+ "depends_on": "eval:doc.allow_guest_booking",
+ "description": "How to verify guest identity before booking",
+ "fieldname": "guest_verification_method",
+ "fieldtype": "Select",
+ "label": "Guest Verification Method",
+ "options": "None\nEmail OTP\nPhone OTP"
+ },
+ {
+ "fieldname": "section_break_details",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "time_zone",
+ "fieldtype": "Autocomplete",
+ "label": "Time Zone"
+ },
+ {
+ "fieldname": "short_description",
+ "fieldtype": "Small Text",
+ "label": "Short Description"
+ },
+ {
+ "fieldname": "about",
+ "fieldtype": "Text Editor",
+ "label": "About"
+ },
+ {
+ "fieldname": "payments_tab",
+ "fieldtype": "Tab Break",
+ "label": "Payments"
+ },
+ {
+ "fieldname": "section_break_payments",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "payment_gateways",
+ "fieldtype": "Table",
+ "label": "Payment Gateways",
+ "options": "Event Payment Gateway"
+ },
+ {
+ "fieldname": "tax_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Tax Settings"
+ },
+ {
+ "default": "0",
+ "fieldname": "apply_tax",
+ "fieldtype": "Check",
+ "label": "Apply Tax on Bookings?"
+ },
+ {
+ "default": "GST",
+ "depends_on": "eval:doc.apply_tax==1",
+ "fieldname": "tax_label",
+ "fieldtype": "Data",
+ "label": "Tax Label"
+ },
+ {
+ "default": "18",
+ "depends_on": "eval:doc.apply_tax==1",
+ "fieldname": "tax_percentage",
+ "fieldtype": "Percent",
+ "label": "Tax Percentage"
+ },
+ {
+ "fieldname": "sponsorships_tab",
+ "fieldtype": "Tab Break",
+ "label": "Sponsorships"
+ },
+ {
+ "fieldname": "automations_section",
+ "fieldtype": "Section Break",
+ "label": "Automations"
+ },
+ {
+ "default": "0",
+ "fieldname": "auto_send_pitch_deck",
+ "fieldtype": "Check",
+ "label": "Auto Send Pitch Deck?"
+ },
+ {
+ "fieldname": "section_break_sponsor",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_reply_to",
+ "fieldtype": "Data",
+ "label": "Reply To"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_email_template",
+ "fieldtype": "Link",
+ "label": "Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_sponsor",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_cc",
+ "fieldtype": "Small Text",
+ "label": "CC"
+ },
+ {
+ "fieldname": "section_break_attachments",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.auto_send_pitch_deck==true",
+ "fieldname": "sponsor_deck_attachments",
+ "fieldtype": "Table",
+ "label": "Attachments",
+ "options": "Sponsorship Deck Item"
+ },
+ {
+ "fieldname": "customisations_tab",
+ "fieldtype": "Tab Break",
+ "label": "Customisations"
+ },
+ {
+ "default": "1",
+ "fieldname": "send_ticket_email",
+ "fieldtype": "Check",
+ "label": "Send Ticket Email"
+ },
+ {
+ "fieldname": "ticket_email_template",
+ "fieldtype": "Link",
+ "label": "Ticket Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_custom",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ticket_print_format",
+ "fieldtype": "Link",
+ "label": "Ticket Print Format",
+ "link_filters": "[[\"Print Format\",\"doc_type\",\"=\",\"Event Ticket\"]]",
+ "options": "Print Format"
+ },
+ {
+ "fieldname": "ticket_types_tab",
+ "fieldtype": "Tab Break",
+ "label": "Ticket Types"
+ },
+ {
+ "fieldname": "template_ticket_types",
+ "fieldtype": "Table",
+ "label": "Ticket Types",
+ "options": "Event Template Ticket Type"
+ },
+ {
+ "fieldname": "add_ons_tab",
+ "fieldtype": "Tab Break",
+ "label": "Add-ons"
+ },
+ {
+ "fieldname": "template_add_ons",
+ "fieldtype": "Table",
+ "label": "Add-ons",
+ "options": "Event Template Add-on"
+ },
+ {
+ "fieldname": "custom_fields_tab",
+ "fieldtype": "Tab Break",
+ "label": "Custom Fields"
+ },
+ {
+ "fieldname": "template_custom_fields",
+ "fieldtype": "Table",
+ "label": "Custom Fields",
+ "options": "Event Template Custom Field"
+ }
+ ],
+ "image_field": "banner_image",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-12-31 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Template",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "template_name",
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/event_template/event_template.py b/event_manager/events/doctype/event_template/event_template.py
new file mode 100644
index 0000000..b23fe52
--- /dev/null
+++ b/event_manager/events/doctype/event_template/event_template.py
@@ -0,0 +1,197 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class EventTemplate(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.event_payment_gateway.event_payment_gateway import EventPaymentGateway
+ from event_manager.events.doctype.event_template_add_on.event_template_add_on import EventTemplateAddOn
+ from event_manager.events.doctype.event_template_custom_field.event_template_custom_field import (
+ EventTemplateCustomField,
+ )
+ from event_manager.events.doctype.event_template_ticket_type.event_template_ticket_type import (
+ EventTemplateTicketType,
+ )
+ from event_manager.proposals.doctype.sponsorship_deck_item.sponsorship_deck_item import SponsorshipDeckItem
+
+ about: DF.TextEditor | None
+ apply_tax: DF.Check
+ auto_send_pitch_deck: DF.Check
+ banner_image: DF.AttachImage | None
+ category: DF.Link | None
+ host: DF.Link | None
+ medium: DF.Literal["In Person", "Online"]
+ payment_gateways: DF.Table[EventPaymentGateway]
+ short_description: DF.SmallText | None
+ sponsor_deck_attachments: DF.Table[SponsorshipDeckItem]
+ sponsor_deck_cc: DF.SmallText | None
+ sponsor_deck_email_template: DF.Link | None
+ sponsor_deck_reply_to: DF.Data | None
+ tax_label: DF.Data | None
+ tax_percentage: DF.Percent
+ template_add_ons: DF.Table[EventTemplateAddOn]
+ template_custom_fields: DF.Table[EventTemplateCustomField]
+ template_name: DF.Data
+ template_ticket_types: DF.Table[EventTemplateTicketType]
+ ticket_email_template: DF.Link | None
+ ticket_print_format: DF.Link | None
+ time_zone: DF.Autocomplete | None
+ venue: DF.Link | None
+ # end: auto-generated types
+
+ pass
+
+
+@frappe.whitelist()
+def create_template_from_event(event_name: str, template_name: str, options: str) -> str:
+ """
+ Create an Event Template from an existing Pohodex Event Manager Event.
+
+ Args:
+ event_name: Name of the source Pohodex Event Manager Event
+ template_name: Name for the new template
+ options: JSON string of what to include
+
+ Returns:
+ New Event Template document name
+ """
+ if not frappe.has_permission("Event Template", "create"):
+ frappe.throw(_("You don't have permission to create templates"))
+
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+ options = frappe.parse_json(options)
+
+ template = frappe.new_doc("Event Template")
+ template.template_name = template_name
+
+ # Field mapping for direct copy
+ field_map = {
+ "category": "category",
+ "host": "host",
+ "banner_image": "banner_image",
+ "short_description": "short_description",
+ "about": "about",
+ "medium": "medium",
+ "venue": "venue",
+ "allow_guest_booking": "allow_guest_booking",
+ "guest_verification_method": "guest_verification_method",
+ "time_zone": "time_zone",
+ "send_ticket_email": "send_ticket_email",
+ "ticket_email_template": "ticket_email_template",
+ "ticket_print_format": "ticket_print_format",
+ "apply_tax": "apply_tax",
+ "tax_label": "tax_label",
+ "tax_percentage": "tax_percentage",
+ "auto_send_pitch_deck": "auto_send_pitch_deck",
+ "sponsor_deck_email_template": "sponsor_deck_email_template",
+ "sponsor_deck_reply_to": "sponsor_deck_reply_to",
+ "sponsor_deck_cc": "sponsor_deck_cc",
+ }
+
+ for option_key, field_name in field_map.items():
+ if options.get(option_key):
+ template.set(field_name, event.get(field_name))
+
+ # Copy child tables from event
+ if options.get("payment_gateways"):
+ for pg in event.payment_gateways:
+ template.append("payment_gateways", {"payment_gateway": pg.payment_gateway})
+
+ if options.get("sponsor_deck_attachments"):
+ for attachment in event.sponsor_deck_attachments:
+ template.append("sponsor_deck_attachments", {"file": attachment.file})
+
+ # Copy linked documents (Ticket Types, Add-ons, Custom Fields)
+ if options.get("ticket_types"):
+ ticket_types = frappe.get_all(
+ "Event Ticket Type",
+ filters={"event": event_name},
+ fields=[
+ "title",
+ "price",
+ "currency",
+ "is_published",
+ "max_tickets_available",
+ "auto_unpublish_after",
+ ],
+ )
+ for tt in ticket_types:
+ template.append(
+ "template_ticket_types",
+ {
+ "title": tt.title,
+ "price": tt.price,
+ "currency": tt.currency,
+ "is_published": tt.is_published,
+ "max_tickets_available": tt.max_tickets_available,
+ "auto_unpublish_after": tt.auto_unpublish_after,
+ },
+ )
+
+ if options.get("add_ons"):
+ add_ons = frappe.get_all(
+ "Ticket Add-on",
+ filters={"event": event_name},
+ fields=["title", "price", "currency", "description", "user_selects_option", "options", "enabled"],
+ )
+ for addon in add_ons:
+ template.append(
+ "template_add_ons",
+ {
+ "title": addon.title,
+ "price": addon.price,
+ "currency": addon.currency,
+ "description": addon.description,
+ "user_selects_option": addon.user_selects_option,
+ "options": addon.options,
+ "enabled": addon.enabled,
+ },
+ )
+
+ if options.get("custom_fields"):
+ custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event_name},
+ fields=[
+ "label",
+ "fieldname",
+ "fieldtype",
+ "options",
+ "applied_to",
+ "enabled",
+ "mandatory",
+ "placeholder",
+ "default_value",
+ "order",
+ ],
+ )
+ for cf in custom_fields:
+ template.append(
+ "template_custom_fields",
+ {
+ "label": cf.label,
+ "fieldname": cf.fieldname,
+ "fieldtype": cf.fieldtype,
+ "options": cf.options,
+ "applied_to": cf.applied_to,
+ "enabled": cf.enabled,
+ "mandatory": cf.mandatory,
+ "placeholder": cf.placeholder,
+ "default_value": cf.default_value,
+ "order": cf.order,
+ },
+ )
+
+ template.insert()
+ return template.name
diff --git a/event_manager/events/doctype/event_template/test_event_template.py b/event_manager/events/doctype/event_template/test_event_template.py
new file mode 100644
index 0000000..434403d
--- /dev/null
+++ b/event_manager/events/doctype/event_template/test_event_template.py
@@ -0,0 +1,505 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+from event_manager.events.doctype.buzz_event.buzz_event import create_from_template
+from event_manager.events.doctype.event_template.event_template import create_template_from_event
+
+
+class TestEventTemplate(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.create_test_fixtures()
+
+ @classmethod
+ def create_test_fixtures(cls):
+ """Create required test data: Event Category, Host, etc."""
+ # Create Event Category if not exists
+ if not frappe.db.exists("Event Category", "Test Category"):
+ frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert(
+ ignore_permissions=True
+ )
+
+ # Create Event Host if not exists
+ if not frappe.db.exists("Event Host", "Test Host"):
+ frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert(
+ ignore_permissions=True
+ )
+
+ def tearDown(self):
+ """Clean up test data after each test"""
+ frappe.db.rollback()
+
+ # ==================== Template Creation Tests ====================
+
+ def test_create_template_basic(self):
+ """Test creating a basic Event Template"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Test Webinar Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "medium": "Online",
+ "about": "Test description",
+ }
+ )
+ template.insert()
+
+ self.assertEqual(template.template_name, "Test Webinar Template")
+ self.assertEqual(template.category, "Test Category")
+ self.assertEqual(template.medium, "Online")
+
+ def test_create_template_with_ticket_types(self):
+ """Test creating a template with ticket types"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Template with Tickets",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_ticket_types": [
+ {
+ "title": "Early Bird",
+ "price": 100,
+ "currency": "INR",
+ "is_published": 1,
+ "max_tickets_available": 50,
+ },
+ {"title": "Regular", "price": 200, "currency": "INR", "is_published": 1},
+ ],
+ }
+ )
+ template.insert()
+
+ self.assertEqual(len(template.template_ticket_types), 2)
+ self.assertEqual(template.template_ticket_types[0].title, "Early Bird")
+ self.assertEqual(template.template_ticket_types[0].price, 100)
+
+ def test_create_template_with_add_ons(self):
+ """Test creating a template with add-ons"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Template with Add-ons",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_add_ons": [
+ {"title": "T-Shirt", "price": 500, "currency": "INR", "enabled": 1},
+ {
+ "title": "Lunch",
+ "price": 300,
+ "currency": "INR",
+ "user_selects_option": 1,
+ "options": "Veg\nNon-Veg",
+ "enabled": 1,
+ },
+ ],
+ }
+ )
+ template.insert()
+
+ self.assertEqual(len(template.template_add_ons), 2)
+ self.assertEqual(template.template_add_ons[1].user_selects_option, 1)
+
+ def test_create_template_with_custom_fields(self):
+ """Test creating a template with custom fields"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Template with Custom Fields",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_custom_fields": [
+ {
+ "label": "Company Name",
+ "fieldname": "company_name",
+ "fieldtype": "Data",
+ "applied_to": "Booking",
+ "mandatory": 1,
+ "enabled": 1,
+ },
+ {
+ "label": "Dietary Preference",
+ "fieldname": "dietary_preference",
+ "fieldtype": "Select",
+ "options": "Veg\nNon-Veg\nVegan",
+ "applied_to": "Ticket",
+ "enabled": 1,
+ },
+ ],
+ }
+ )
+ template.insert()
+
+ self.assertEqual(len(template.template_custom_fields), 2)
+ self.assertEqual(template.template_custom_fields[0].mandatory, 1)
+
+ # ==================== Create Event from Template Tests ====================
+
+ def test_create_event_from_template_all_options(self):
+ """Test creating an event from template with all options selected"""
+ # Create template
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Full Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "medium": "Online",
+ "about": "Template about text",
+ "apply_tax": 1,
+ "tax_label": "GST",
+ "tax_percentage": 18,
+ "template_ticket_types": [
+ {"title": "Standard", "price": 500, "currency": "INR", "is_published": 1}
+ ],
+ "template_add_ons": [{"title": "Workshop", "price": 1000, "currency": "INR", "enabled": 1}],
+ "template_custom_fields": [
+ {
+ "label": "Phone",
+ "fieldname": "phone",
+ "fieldtype": "Phone",
+ "applied_to": "Booking",
+ "enabled": 1,
+ }
+ ],
+ }
+ )
+ template.insert()
+
+ # Create event from template with all options
+ options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ "ticket_types": 1,
+ "add_ons": 1,
+ "custom_fields": 1,
+ }
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ # Verify event fields
+ self.assertEqual(event.category, "Test Category")
+ self.assertEqual(event.host, "Test Host")
+ self.assertEqual(event.medium, "Online")
+ self.assertEqual(event.about, "Template about text")
+ self.assertEqual(event.apply_tax, 1)
+ self.assertEqual(event.tax_percentage, 18)
+
+ # Verify ticket types created (excluding default "Normal" ticket type)
+ ticket_types = frappe.get_all(
+ "Event Ticket Type", filters={"event": event_name, "title": "Standard"}, fields=["title", "price"]
+ )
+ self.assertEqual(len(ticket_types), 1)
+ self.assertEqual(ticket_types[0].title, "Standard")
+
+ # Verify add-ons created
+ add_ons = frappe.get_all("Ticket Add-on", filters={"event": event_name}, fields=["title", "price"])
+ self.assertEqual(len(add_ons), 1)
+ self.assertEqual(add_ons[0].title, "Workshop")
+
+ # Verify custom fields created
+ custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field", filters={"event": event_name}, fields=["label", "fieldtype"]
+ )
+ self.assertEqual(len(custom_fields), 1)
+ self.assertEqual(custom_fields[0].fieldtype, "Phone")
+
+ def test_create_event_from_template_partial_options(self):
+ """Test creating an event with only some options selected"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Partial Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "medium": "In Person",
+ "about": "Should not be copied",
+ "template_ticket_types": [
+ {"title": "VIP", "price": 2000, "currency": "INR", "is_published": 1}
+ ],
+ }
+ )
+ template.insert()
+
+ # Copy category, host (required) and ticket types, but not medium/about
+ options = {"category": 1, "host": 1, "medium": 0, "about": 0, "ticket_types": 1}
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+
+ # Category should be copied
+ self.assertEqual(event.category, "Test Category")
+
+ # Host should be copied (it's mandatory)
+ self.assertEqual(event.host, "Test Host")
+
+ # About should NOT be copied
+ self.assertFalse(event.about)
+
+ # Ticket types should be copied
+ ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "VIP"})
+ self.assertEqual(len(ticket_types), 1)
+
+ def test_create_event_from_template_no_linked_docs(self):
+ """Test creating an event without copying linked documents"""
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "No Linked Docs Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ "template_ticket_types": [
+ {"title": "General", "price": 100, "currency": "INR", "is_published": 1}
+ ],
+ }
+ )
+ template.insert()
+
+ # Copy fields but not linked docs
+ options = {"category": 1, "host": 1, "ticket_types": 0, "add_ons": 0, "custom_fields": 0}
+
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ # Event fields should be copied
+ event = frappe.get_doc("Pohodex Event Manager Event", event_name)
+ self.assertEqual(event.category, "Test Category")
+
+ # No "General" ticket type should be created (only default "Normal")
+ ticket_types = frappe.get_all("Event Ticket Type", filters={"event": event_name, "title": "General"})
+ self.assertEqual(len(ticket_types), 0)
+
+ # ==================== Save as Template Tests ====================
+
+ def test_save_event_as_template(self):
+ """Test saving an existing event as a template"""
+ # Create an event with ticket types and add-ons
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Source Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "Online",
+ "about": "Event description",
+ }
+ )
+ event.insert()
+
+ # Create ticket type for the event
+ ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": event.name,
+ "title": "Premium",
+ "price": 1500,
+ "currency": "INR",
+ "is_published": 1,
+ }
+ )
+ ticket_type.insert()
+
+ # Create add-on for the event
+ add_on = frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": event.name,
+ "title": "Swag Kit",
+ "price": 500,
+ "currency": "INR",
+ "enabled": 1,
+ }
+ )
+ add_on.insert()
+
+ # Save as template (convert event.name to string as it's an int autoname)
+ options = {"category": 1, "host": 1, "medium": 1, "about": 1, "ticket_types": 1, "add_ons": 1}
+
+ template_name = create_template_from_event(
+ str(event.name), "My Event Template", frappe.as_json(options)
+ )
+ template = frappe.get_doc("Event Template", template_name)
+
+ # Verify template fields
+ self.assertEqual(template.template_name, "My Event Template")
+ self.assertEqual(template.category, "Test Category")
+ self.assertEqual(template.medium, "Online")
+
+ # Verify ticket types in template (excluding default "Normal")
+ premium_tickets = [t for t in template.template_ticket_types if t.title == "Premium"]
+ self.assertEqual(len(premium_tickets), 1)
+ self.assertEqual(premium_tickets[0].price, 1500)
+
+ # Verify add-ons in template
+ self.assertEqual(len(template.template_add_ons), 1)
+ self.assertEqual(template.template_add_ons[0].title, "Swag Kit")
+
+ def test_save_event_as_template_partial(self):
+ """Test saving event as template with only some options"""
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Partial Source Event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "In Person",
+ "about": "Should be copied",
+ "apply_tax": 1,
+ "tax_percentage": 18,
+ }
+ )
+ event.insert()
+
+ # Only save category and about (convert event.name to string as it's an int autoname)
+ options = {"category": 1, "host": 0, "medium": 0, "about": 1, "apply_tax": 0}
+
+ template_name = create_template_from_event(
+ str(event.name), "Partial Template 2", frappe.as_json(options)
+ )
+ template = frappe.get_doc("Event Template", template_name)
+
+ self.assertEqual(template.category, "Test Category")
+ self.assertEqual(template.about, "Should be copied")
+ self.assertFalse(template.host)
+ self.assertFalse(template.apply_tax)
+
+ # ==================== Round Trip Tests ====================
+
+ def test_round_trip_event_to_template_to_event(self):
+ """Test full round trip: Event -> Template -> New Event"""
+ # Step 1: Create original event
+ original_event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Original Conference",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "09:00:00",
+ "end_time": "18:00:00",
+ "medium": "In Person",
+ "about": "Annual conference description",
+ "apply_tax": 1,
+ "tax_label": "GST",
+ "tax_percentage": 18,
+ }
+ )
+ original_event.insert()
+
+ # Add ticket types
+ for ticket_data in [
+ {"title": "Early Bird", "price": 1000},
+ {"title": "Regular", "price": 1500},
+ {"title": "VIP", "price": 3000},
+ ]:
+ frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": original_event.name,
+ "title": ticket_data["title"],
+ "price": ticket_data["price"],
+ "currency": "INR",
+ "is_published": 1,
+ }
+ ).insert()
+
+ # Step 2: Save as template (convert event.name to string as it's an int autoname)
+ template_options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ "ticket_types": 1,
+ }
+ template_name = create_template_from_event(
+ str(original_event.name), "Conference Template", frappe.as_json(template_options)
+ )
+
+ # Step 3: Create new event from template
+ event_options = {
+ "category": 1,
+ "host": 1,
+ "medium": 1,
+ "about": 1,
+ "apply_tax": 1,
+ "tax_label": 1,
+ "tax_percentage": 1,
+ "ticket_types": 1,
+ }
+ new_event_name = create_from_template(template_name, frappe.as_json(event_options))
+ new_event = frappe.get_doc("Pohodex Event Manager Event", new_event_name)
+
+ # Verify new event matches original
+ self.assertEqual(new_event.category, original_event.category)
+ self.assertEqual(new_event.host, original_event.host)
+ self.assertEqual(new_event.medium, original_event.medium)
+ self.assertEqual(new_event.about, original_event.about)
+ self.assertEqual(new_event.tax_percentage, original_event.tax_percentage)
+
+ # Verify ticket types match (excluding default "Normal")
+ new_ticket_types = frappe.get_all(
+ "Event Ticket Type",
+ filters={"event": new_event_name, "title": ["in", ["Early Bird", "Regular", "VIP"]]},
+ fields=["title", "price"],
+ order_by="price",
+ )
+ self.assertEqual(len(new_ticket_types), 3)
+ self.assertEqual(new_ticket_types[0].title, "Early Bird")
+ self.assertEqual(new_ticket_types[0].price, 1000)
+
+ # ==================== Edge Case Tests ====================
+
+ def test_create_event_empty_template(self):
+ """Test creating event from template with minimal data"""
+ # Template with required fields for Pohodex Event Manager Event (category and host are mandatory)
+ template = frappe.get_doc(
+ {
+ "doctype": "Event Template",
+ "template_name": "Empty Template",
+ "category": "Test Category",
+ "host": "Test Host",
+ }
+ )
+ template.insert()
+
+ options = {"category": 1, "host": 1}
+ event_name = create_from_template(template.name, frappe.as_json(options))
+
+ # Should create event without errors
+ self.assertTrue(frappe.db.exists("Pohodex Event Manager Event", event_name))
+
+ def test_template_name_required(self):
+ """Test that template_name is required"""
+ template = frappe.get_doc({"doctype": "Event Template", "category": "Test Category"})
+
+ # Template uses autoname: field:template_name, so it raises ValidationError not MandatoryError
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ template.insert()
+
+ def test_duplicate_template_name(self):
+ """Test handling of duplicate template names"""
+ frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"}).insert()
+
+ duplicate = frappe.get_doc({"doctype": "Event Template", "template_name": "Duplicate Name"})
+
+ with self.assertRaises(frappe.exceptions.DuplicateEntryError):
+ duplicate.insert()
diff --git a/event_manager/events/doctype/event_template_add_on/__init__.py b/event_manager/events/doctype/event_template_add_on/__init__.py
new file mode 100644
index 0000000..f66d03d
--- /dev/null
+++ b/event_manager/events/doctype/event_template_add_on/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
diff --git a/event_manager/events/doctype/event_template_add_on/event_template_add_on.json b/event_manager/events/doctype/event_template_add_on/event_template_add_on.json
new file mode 100644
index 0000000..acc8c2b
--- /dev/null
+++ b/event_manager/events/doctype/event_template_add_on/event_template_add_on.json
@@ -0,0 +1,84 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-31 12:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "title",
+ "price",
+ "description",
+ "column_break_abcd",
+ "currency",
+ "user_selects_option",
+ "options"
+ ],
+ "fields": [
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled?"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "label": "Description"
+ },
+ {
+ "fieldname": "column_break_abcd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "default": "0",
+ "fieldname": "user_selects_option",
+ "fieldtype": "Check",
+ "label": "User Selects Option?"
+ },
+ {
+ "fieldname": "options",
+ "fieldtype": "Small Text",
+ "label": "Options",
+ "mandatory_depends_on": "eval:doc.user_selects_option==true"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-31 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Template Add-on",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/event_template_add_on/event_template_add_on.py b/event_manager/events/doctype/event_template_add_on/event_template_add_on.py
new file mode 100644
index 0000000..47a7237
--- /dev/null
+++ b/event_manager/events/doctype/event_template_add_on/event_template_add_on.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventTemplateAddon(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ currency: DF.Link | None
+ description: DF.SmallText | None
+ enabled: DF.Check
+ options: DF.SmallText | None
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ price: DF.Currency
+ title: DF.Data
+ user_selects_option: DF.Check
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_template_custom_field/__init__.py b/event_manager/events/doctype/event_template_custom_field/__init__.py
new file mode 100644
index 0000000..f66d03d
--- /dev/null
+++ b/event_manager/events/doctype/event_template_custom_field/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
diff --git a/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.json b/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.json
new file mode 100644
index 0000000..630b5dd
--- /dev/null
+++ b/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.json
@@ -0,0 +1,103 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-31 12:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "label",
+ "fieldname",
+ "mandatory",
+ "placeholder",
+ "default_value",
+ "column_break_efgh",
+ "applied_to",
+ "fieldtype",
+ "options",
+ "order"
+ ],
+ "fields": [
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled?"
+ },
+ {
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Label",
+ "reqd": 1
+ },
+ {
+ "fieldname": "fieldname",
+ "fieldtype": "Data",
+ "label": "Name"
+ },
+ {
+ "default": "0",
+ "fieldname": "mandatory",
+ "fieldtype": "Check",
+ "label": "Mandatory?"
+ },
+ {
+ "fieldname": "placeholder",
+ "fieldtype": "Data",
+ "label": "Placeholder"
+ },
+ {
+ "fieldname": "default_value",
+ "fieldtype": "Data",
+ "label": "Default Value"
+ },
+ {
+ "fieldname": "column_break_efgh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Booking",
+ "fieldname": "applied_to",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Applied To",
+ "options": "Booking\nTicket"
+ },
+ {
+ "default": "Data",
+ "fieldname": "fieldtype",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Type",
+ "options": "Data\nPhone\nEmail\nSelect\nDate\nNumber",
+ "reqd": 1
+ },
+ {
+ "fieldname": "options",
+ "fieldtype": "Small Text",
+ "label": "Options"
+ },
+ {
+ "default": "1",
+ "fieldname": "order",
+ "fieldtype": "Int",
+ "label": "Order",
+ "non_negative": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-31 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Template Custom Field",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.py b/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.py
new file mode 100644
index 0000000..6491e42
--- /dev/null
+++ b/event_manager/events/doctype/event_template_custom_field/event_template_custom_field.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventTemplateCustomField(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ applied_to: DF.Literal["Booking", "Ticket"]
+ default_value: DF.Data | None
+ enabled: DF.Check
+ fieldname: DF.Data | None
+ fieldtype: DF.Literal["Data", "Phone", "Email", "Select", "Date", "Number"]
+ label: DF.Data
+ mandatory: DF.Check
+ options: DF.SmallText | None
+ order: DF.Int
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ placeholder: DF.Data | None
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_template_ticket_type/__init__.py b/event_manager/events/doctype/event_template_ticket_type/__init__.py
new file mode 100644
index 0000000..f66d03d
--- /dev/null
+++ b/event_manager/events/doctype/event_template_ticket_type/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
diff --git a/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.json b/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.json
new file mode 100644
index 0000000..ebc8078
--- /dev/null
+++ b/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.json
@@ -0,0 +1,79 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-31 12:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "price",
+ "currency",
+ "column_break_xyzq",
+ "is_published",
+ "max_tickets_available",
+ "auto_unpublish_after"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Currency",
+ "options": "Currency",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_xyzq",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "1",
+ "fieldname": "is_published",
+ "fieldtype": "Check",
+ "label": "Is Published?"
+ },
+ {
+ "fieldname": "max_tickets_available",
+ "fieldtype": "Int",
+ "label": "Max Tickets Available",
+ "non_negative": 1
+ },
+ {
+ "depends_on": "eval:doc.is_published",
+ "fieldname": "auto_unpublish_after",
+ "fieldtype": "Date",
+ "label": "Auto Unpublish After"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-31 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Template Ticket Type",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.py b/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.py
new file mode 100644
index 0000000..0196664
--- /dev/null
+++ b/event_manager/events/doctype/event_template_ticket_type/event_template_ticket_type.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventTemplateTicketType(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ auto_unpublish_after: DF.Date | None
+ currency: DF.Link
+ is_published: DF.Check
+ max_tickets_available: DF.Int
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ price: DF.Currency
+ title: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_track/__init__.py b/event_manager/events/doctype/event_track/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_track/event_track.js b/event_manager/events/doctype/event_track/event_track.js
new file mode 100644
index 0000000..40a9f6a
--- /dev/null
+++ b/event_manager/events/doctype/event_track/event_track.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Track", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_track/event_track.json b/event_manager/events/doctype/event_track/event_track.json
new file mode 100644
index 0000000..e1cbb20
--- /dev/null
+++ b/event_manager/events/doctype/event_track/event_track.json
@@ -0,0 +1,63 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "prompt",
+ "creation": "2025-07-19 12:24:05.400029",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "event"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-28 16:18:05.552522",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Track",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "search_fields": "event",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_track/event_track.py b/event_manager/events/doctype/event_track/event_track.py
new file mode 100644
index 0000000..d0c0545
--- /dev/null
+++ b/event_manager/events/doctype/event_track/event_track.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventTrack(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ event: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_track/test_event_track.py b/event_manager/events/doctype/event_track/test_event_track.py
new file mode 100644
index 0000000..6b93b8b
--- /dev/null
+++ b/event_manager/events/doctype/event_track/test_event_track.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventTrack(IntegrationTestCase):
+ """
+ Integration tests for EventTrack.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_user_preferences/__init__.py b/event_manager/events/doctype/event_user_preferences/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_user_preferences/event_user_preferences.js b/event_manager/events/doctype/event_user_preferences/event_user_preferences.js
new file mode 100644
index 0000000..e590b46
--- /dev/null
+++ b/event_manager/events/doctype/event_user_preferences/event_user_preferences.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event User Preferences", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_user_preferences/event_user_preferences.json b/event_manager/events/doctype/event_user_preferences/event_user_preferences.json
new file mode 100644
index 0000000..7ea2ef6
--- /dev/null
+++ b/event_manager/events/doctype/event_user_preferences/event_user_preferences.json
@@ -0,0 +1,49 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "field:user",
+ "creation": "2025-07-26 12:34:09.086468",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "user"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1,
+ "unique": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-07-26 12:34:34.202903",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event User Preferences",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_user_preferences/event_user_preferences.py b/event_manager/events/doctype/event_user_preferences/event_user_preferences.py
new file mode 100644
index 0000000..a43a626
--- /dev/null
+++ b/event_manager/events/doctype/event_user_preferences/event_user_preferences.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventUserPreferences(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ user: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/event_user_preferences/test_event_user_preferences.py b/event_manager/events/doctype/event_user_preferences/test_event_user_preferences.py
new file mode 100644
index 0000000..540766f
--- /dev/null
+++ b/event_manager/events/doctype/event_user_preferences/test_event_user_preferences.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventUserPreferences(IntegrationTestCase):
+ """
+ Integration tests for EventUserPreferences.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/event_venue/__init__.py b/event_manager/events/doctype/event_venue/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/event_venue/event_venue.js b/event_manager/events/doctype/event_venue/event_venue.js
new file mode 100644
index 0000000..4854b67
--- /dev/null
+++ b/event_manager/events/doctype/event_venue/event_venue.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Venue", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/event_venue/event_venue.json b/event_manager/events/doctype/event_venue/event_venue.json
new file mode 100644
index 0000000..b52316f
--- /dev/null
+++ b/event_manager/events/doctype/event_venue/event_venue.json
@@ -0,0 +1,115 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "prompt",
+ "creation": "2025-07-19 11:35:15.182035",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "address",
+ "column_break_kqaw",
+ "type",
+ "google_maps_embed_code",
+ "section_break_cexp",
+ "longitude",
+ "latitude",
+ "column_break_xfsv",
+ "location"
+ ],
+ "fields": [
+ {
+ "fieldname": "address",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Address",
+ "reqd": 1
+ },
+ {
+ "fieldname": "location",
+ "fieldtype": "Geolocation",
+ "label": "Location",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_kqaw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.type===\"Open Street Map\"",
+ "fieldname": "section_break_cexp",
+ "fieldtype": "Section Break",
+ "label": "Open Street Map"
+ },
+ {
+ "fieldname": "longitude",
+ "fieldtype": "Float",
+ "label": "Longitude",
+ "precision": "9"
+ },
+ {
+ "fieldname": "latitude",
+ "fieldtype": "Float",
+ "label": "Latitude",
+ "precision": "9"
+ },
+ {
+ "fieldname": "column_break_xfsv",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "type",
+ "fieldtype": "Select",
+ "label": "Type",
+ "options": "Embed Google Maps\nOpen Street Map"
+ },
+ {
+ "depends_on": "eval:doc.type===\"Embed Google Maps\"",
+ "description": "Open a place on Google maps then click on share",
+ "fieldname": "google_maps_embed_code",
+ "fieldtype": "Code",
+ "label": "Google Maps Embed Code",
+ "options": "HTML"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-09 10:08:41.169559",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Venue",
+ "naming_rule": "Set by user",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/event_venue/event_venue.py b/event_manager/events/doctype/event_venue/event_venue.py
new file mode 100644
index 0000000..4d287e1
--- /dev/null
+++ b/event_manager/events/doctype/event_venue/event_venue.py
@@ -0,0 +1,55 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import re
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventVenue(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ address: DF.SmallText
+ google_maps_embed_code: DF.Code | None
+ latitude: DF.Float
+ longitude: DF.Float
+ type: DF.Literal["Embed Google Maps", "Open Street Map"]
+ # end: auto-generated types
+
+ def validate(self):
+ self.set_geojson_for_location()
+ self.remove_fixed_dimensions_from_google_map_embed()
+
+ def remove_fixed_dimensions_from_google_map_embed(self):
+ if not self.google_maps_embed_code:
+ return
+
+ html = self.google_maps_embed_code
+ html = re.sub(r'height="(\d+)"', r'height="100%"', html)
+ html = re.sub(r'width="(\d+)"', r'width="100%"', html)
+
+ self.google_maps_embed_code = html
+
+ def set_geojson_for_location(self):
+ if self.latitude and self.longitude:
+ self.location = {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {},
+ "geometry": {
+ "type": "Point",
+ "coordinates": [self.longitude, self.latitude],
+ },
+ }
+ ],
+ }
+ self.location = frappe.as_json(self.location)
diff --git a/event_manager/events/doctype/event_venue/test_event_venue.py b/event_manager/events/doctype/event_venue/test_event_venue.py
new file mode 100644
index 0000000..938e086
--- /dev/null
+++ b/event_manager/events/doctype/event_venue/test_event_venue.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventVenue(IntegrationTestCase):
+ """
+ Integration tests for EventVenue.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/offline_payment_method/__init__.py b/event_manager/events/doctype/offline_payment_method/__init__.py
new file mode 100644
index 0000000..eeb9a8b
--- /dev/null
+++ b/event_manager/events/doctype/offline_payment_method/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
diff --git a/event_manager/events/doctype/offline_payment_method/offline_payment_method.json b/event_manager/events/doctype/offline_payment_method/offline_payment_method.json
new file mode 100644
index 0000000..d267352
--- /dev/null
+++ b/event_manager/events/doctype/offline_payment_method/offline_payment_method.json
@@ -0,0 +1,110 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2026-02-12 10:00:00",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "event",
+ "enabled",
+ "collect_payment_proof",
+ "column_break_desc",
+ "description"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Enabled"
+ },
+ {
+ "default": "0",
+ "fieldname": "collect_payment_proof",
+ "fieldtype": "Check",
+ "label": "Collect Proof of Payment"
+ },
+ {
+ "fieldname": "column_break_desc",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Description"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "make_attachments_public": 1,
+ "modified": "2026-02-19 13:27:02.702742",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Offline Payment Method",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "search_fields": "event",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title"
+}
diff --git a/event_manager/events/doctype/offline_payment_method/offline_payment_method.py b/event_manager/events/doctype/offline_payment_method/offline_payment_method.py
new file mode 100644
index 0000000..3d079bc
--- /dev/null
+++ b/event_manager/events/doctype/offline_payment_method/offline_payment_method.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2026, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class OfflinePaymentMethod(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ collect_payment_proof: DF.Check
+ description: DF.TextEditor | None
+ enabled: DF.Check
+ event: DF.Link
+ name: DF.Int | None
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_unique_title()
+
+ def validate_unique_title(self):
+ existing = frappe.db.exists(
+ "Offline Payment Method",
+ {"title": self.title, "event": self.event, "name": ("!=", self.name)},
+ )
+ if existing:
+ frappe.throw(
+ _("An offline payment method with title {0} already exists for this event").format(
+ frappe.bold(self.title)
+ )
+ )
diff --git a/event_manager/events/doctype/offline_payment_method/test_offline_payment_method.py b/event_manager/events/doctype/offline_payment_method/test_offline_payment_method.py
new file mode 100644
index 0000000..a3918fe
--- /dev/null
+++ b/event_manager/events/doctype/offline_payment_method/test_offline_payment_method.py
@@ -0,0 +1,61 @@
+# Copyright (c) 2026, BWH Studios and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+
+class TestOfflinePaymentMethod(IntegrationTestCase):
+ def setUp(self):
+ # Clean up any leftover Offline Payment Method records from previous tests
+ frappe.db.delete("Offline Payment Method")
+
+ def test_unique_title_per_event(self):
+ """Test that two methods with the same title cannot exist for the same event."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ def test_same_title_different_events(self):
+ """Test that two methods with the same title can exist for different events."""
+ events = frappe.get_all("Pohodex Event Manager Event", limit=2, pluck="name")
+ if len(events) < 2:
+ self.skipTest("Need at least 2 events")
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "UPI Payment",
+ "event": events[0],
+ "enabled": 1,
+ }
+ ).insert()
+
+ # Should not raise
+ method2 = frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "UPI Payment",
+ "event": events[1],
+ "enabled": 1,
+ }
+ ).insert()
+
+ self.assertTrue(method2.name)
diff --git a/event_manager/events/doctype/schedule_item/__init__.py b/event_manager/events/doctype/schedule_item/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/schedule_item/schedule_item.json b/event_manager/events/doctype/schedule_item/schedule_item.json
new file mode 100644
index 0000000..25a1239
--- /dev/null
+++ b/event_manager/events/doctype/schedule_item/schedule_item.json
@@ -0,0 +1,91 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-19 12:20:19.435621",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "date",
+ "track",
+ "type",
+ "description",
+ "column_break_veko",
+ "talk",
+ "start_time",
+ "end_time"
+ ],
+ "fields": [
+ {
+ "fieldname": "type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Type",
+ "options": "Talk\nBreak",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_veko",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "talk",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Talk",
+ "mandatory_depends_on": "eval:doc.type===\"Talk\"",
+ "options": "Event Talk"
+ },
+ {
+ "fieldname": "track",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Track",
+ "options": "Event Track",
+ "reqd": 1
+ },
+ {
+ "fieldname": "start_time",
+ "fieldtype": "Time",
+ "in_list_view": 1,
+ "label": "Start Time",
+ "reqd": 1
+ },
+ {
+ "fieldname": "end_time",
+ "fieldtype": "Time",
+ "in_list_view": 1,
+ "label": "End Time",
+ "reqd": 1
+ },
+ {
+ "description": "Tea Break, Lunch, etc.",
+ "fieldname": "description",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Description",
+ "mandatory_depends_on": "eval:doc.type!==\"Talk\""
+ },
+ {
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Date",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-10-28 18:54:43.428725",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Schedule Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/schedule_item/schedule_item.py b/event_manager/events/doctype/schedule_item/schedule_item.py
new file mode 100644
index 0000000..1780fea
--- /dev/null
+++ b/event_manager/events/doctype/schedule_item/schedule_item.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class ScheduleItem(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ date: DF.Date
+ description: DF.Data | None
+ end_time: DF.Time
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ start_time: DF.Time
+ talk: DF.Link | None
+ track: DF.Link
+ type: DF.Literal["Talk", "Break"]
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/social_media_link/__init__.py b/event_manager/events/doctype/social_media_link/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/social_media_link/social_media_link.json b/event_manager/events/doctype/social_media_link/social_media_link.json
new file mode 100644
index 0000000..9264849
--- /dev/null
+++ b/event_manager/events/doctype/social_media_link/social_media_link.json
@@ -0,0 +1,35 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-19 12:38:34.888204",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "url"
+ ],
+ "fields": [
+ {
+ "fieldname": "url",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "URL",
+ "options": "URL",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-07-19 12:38:48.912298",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Social Media Link",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/social_media_link/social_media_link.py b/event_manager/events/doctype/social_media_link/social_media_link.py
new file mode 100644
index 0000000..bc6feb0
--- /dev/null
+++ b/event_manager/events/doctype/social_media_link/social_media_link.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class SocialMediaLink(Document):
+ pass
diff --git a/event_manager/events/doctype/speaker_profile/__init__.py b/event_manager/events/doctype/speaker_profile/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/speaker_profile/speaker_profile.js b/event_manager/events/doctype/speaker_profile/speaker_profile.js
new file mode 100644
index 0000000..7d0045d
--- /dev/null
+++ b/event_manager/events/doctype/speaker_profile/speaker_profile.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Speaker Profile", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/speaker_profile/speaker_profile.json b/event_manager/events/doctype/speaker_profile/speaker_profile.json
new file mode 100644
index 0000000..afe77f5
--- /dev/null
+++ b/event_manager/events/doctype/speaker_profile/speaker_profile.json
@@ -0,0 +1,121 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2025-07-19 11:58:20.573125",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "display_name",
+ "column_break_dbuw",
+ "display_image",
+ "section_break_kmlw",
+ "designation",
+ "column_break_azvu",
+ "company",
+ "section_break_pvjm",
+ "social_media_links"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "column_break_dbuw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "user.full_name",
+ "fieldname": "display_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Display Name"
+ },
+ {
+ "fieldname": "designation",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Designation"
+ },
+ {
+ "fieldname": "company",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Company"
+ },
+ {
+ "fieldname": "display_image",
+ "fieldtype": "Attach Image",
+ "label": "Display Image"
+ },
+ {
+ "fieldname": "section_break_kmlw",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_azvu",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_pvjm",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "social_media_links",
+ "fieldtype": "Table",
+ "label": "Social Media Links",
+ "options": "Social Media Link"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "display_image",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-27 15:00:22.785410",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Speaker Profile",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "display_name"
+}
diff --git a/event_manager/events/doctype/speaker_profile/speaker_profile.py b/event_manager/events/doctype/speaker_profile/speaker_profile.py
new file mode 100644
index 0000000..fa0e153
--- /dev/null
+++ b/event_manager/events/doctype/speaker_profile/speaker_profile.py
@@ -0,0 +1,33 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class SpeakerProfile(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.social_media_link.social_media_link import SocialMediaLink
+
+ company: DF.Data | None
+ designation: DF.Data | None
+ display_image: DF.AttachImage | None
+ display_name: DF.Data | None
+ name: DF.Int | None
+ social_media_links: DF.Table[SocialMediaLink]
+ user: DF.Link
+ # end: auto-generated types
+
+ pass
+
+
+def update_speaker_display_name(doc, event=None):
+ if doc.has_value_changed("full_name"):
+ frappe.db.set_value("Speaker Profile", {"user": doc.name}, "display_name", doc.full_name)
diff --git a/event_manager/events/doctype/speaker_profile/test_speaker_profile.py b/event_manager/events/doctype/speaker_profile/test_speaker_profile.py
new file mode 100644
index 0000000..563c502
--- /dev/null
+++ b/event_manager/events/doctype/speaker_profile/test_speaker_profile.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestSpeakerProfile(IntegrationTestCase):
+ """
+ Integration tests for SpeakerProfile.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/sponsorship_tier/__init__.py b/event_manager/events/doctype/sponsorship_tier/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.js b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.js
new file mode 100644
index 0000000..446501e
--- /dev/null
+++ b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Sponsorship Tier", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.json b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.json
new file mode 100644
index 0000000..74656de
--- /dev/null
+++ b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.json
@@ -0,0 +1,100 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 12:17:11.916933",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "event",
+ "column_break_vmpf",
+ "price",
+ "currency"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_vmpf",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-10-28 16:17:50.389537",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Sponsorship Tier",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title"
+}
diff --git a/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.py b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.py
new file mode 100644
index 0000000..4d64219
--- /dev/null
+++ b/event_manager/events/doctype/sponsorship_tier/sponsorship_tier.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+from event_manager.payments import mark_payment_as_received
+
+
+class SponsorshipTier(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ currency: DF.Link | None
+ event: DF.Link
+ price: DF.Currency
+ title: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/doctype/sponsorship_tier/test_sponsorship_tier.py b/event_manager/events/doctype/sponsorship_tier/test_sponsorship_tier.py
new file mode 100644
index 0000000..7cdec1a
--- /dev/null
+++ b/event_manager/events/doctype/sponsorship_tier/test_sponsorship_tier.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestSponsorshipTier(IntegrationTestCase):
+ """
+ Integration tests for SponsorshipTier.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/events/doctype/talk_speaker/__init__.py b/event_manager/events/doctype/talk_speaker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/talk_speaker/talk_speaker.json b/event_manager/events/doctype/talk_speaker/talk_speaker.json
new file mode 100644
index 0000000..1aeffc3
--- /dev/null
+++ b/event_manager/events/doctype/talk_speaker/talk_speaker.json
@@ -0,0 +1,42 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-19 12:17:44.863579",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "speaker",
+ "social_media_links"
+ ],
+ "fields": [
+ {
+ "fieldname": "speaker",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Speaker",
+ "options": "Speaker Profile",
+ "reqd": 1
+ },
+ {
+ "fieldname": "social_media_links",
+ "fieldtype": "Table",
+ "label": "Social Media Links",
+ "options": "Social Media Link"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-07-19 12:39:15.298215",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Talk Speaker",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/talk_speaker/talk_speaker.py b/event_manager/events/doctype/talk_speaker/talk_speaker.py
new file mode 100644
index 0000000..9ea283a
--- /dev/null
+++ b/event_manager/events/doctype/talk_speaker/talk_speaker.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class TalkSpeaker(Document):
+ pass
diff --git a/event_manager/events/doctype/utm_parameter/__init__.py b/event_manager/events/doctype/utm_parameter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/doctype/utm_parameter/utm_parameter.json b/event_manager/events/doctype/utm_parameter/utm_parameter.json
new file mode 100644
index 0000000..d880fd8
--- /dev/null
+++ b/event_manager/events/doctype/utm_parameter/utm_parameter.json
@@ -0,0 +1,43 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-30 11:22:07.599974",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "utm_name",
+ "value"
+ ],
+ "fields": [
+ {
+ "fieldname": "utm_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "UTM Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "value",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Value",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-30 11:23:32.333785",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "UTM Parameter",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/events/doctype/utm_parameter/utm_parameter.py b/event_manager/events/doctype/utm_parameter/utm_parameter.py
new file mode 100644
index 0000000..671b94e
--- /dev/null
+++ b/event_manager/events/doctype/utm_parameter/utm_parameter.py
@@ -0,0 +1,24 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class UTMParameter(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ utm_name: DF.Data
+ value: DF.SmallText
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/events/report/__init__.py b/event_manager/events/report/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/report/event_attendance_summary/__init__.py b/event_manager/events/report/event_attendance_summary/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/report/event_attendance_summary/event_attendance_summary.js b/event_manager/events/report/event_attendance_summary/event_attendance_summary.js
new file mode 100644
index 0000000..32ad7a5
--- /dev/null
+++ b/event_manager/events/report/event_attendance_summary/event_attendance_summary.js
@@ -0,0 +1,14 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["Event Attendance Summary"] = {
+ filters: [
+ {
+ fieldname: "event",
+ label: __("Event"),
+ fieldtype: "Link",
+ options: "Pohodex Event Manager Event",
+ reqd: 1,
+ },
+ ],
+};
diff --git a/event_manager/events/report/event_attendance_summary/event_attendance_summary.json b/event_manager/events/report/event_attendance_summary/event_attendance_summary.json
new file mode 100644
index 0000000..397e44e
--- /dev/null
+++ b/event_manager/events/report/event_attendance_summary/event_attendance_summary.json
@@ -0,0 +1,32 @@
+{
+ "add_total_row": 0,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2025-12-12 15:56:38.533142",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "json": "{}",
+ "letter_head": null,
+ "modified": "2025-12-12 15:56:50.729859",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Attendance Summary",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Event Check In",
+ "report_name": "Event Attendance Summary",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Event Manager"
+ },
+ {
+ "role": "System Manager"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/event_manager/events/report/event_attendance_summary/event_attendance_summary.py b/event_manager/events/report/event_attendance_summary/event_attendance_summary.py
new file mode 100644
index 0000000..25f89e7
--- /dev/null
+++ b/event_manager/events/report/event_attendance_summary/event_attendance_summary.py
@@ -0,0 +1,201 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import formatdate
+
+
+def execute(filters: dict | None = None):
+ """Return columns and data for the report.
+
+ This is the main entry point for the report. It accepts the filters as a
+ dictionary and should return columns and data. It is called by the framework
+ every time the report is refreshed or a filter is updated.
+ """
+ event = filters.get("event")
+ if not event:
+ return [], []
+
+ # Get all unique check-in dates for this event (sorted)
+ check_in_dates = get_check_in_dates(event)
+
+ columns = get_columns(check_in_dates)
+ data = get_data(event, check_in_dates)
+ chart = get_chart(data, check_in_dates)
+ report_summary = get_report_summary(data, check_in_dates)
+
+ return columns, data, None, chart, report_summary
+
+
+def get_check_in_dates(event: str) -> list:
+ """Get all unique check-in dates for the event, sorted chronologically."""
+ dates = frappe.db.sql(
+ """
+ SELECT DISTINCT date
+ FROM `tabEvent Check In`
+ WHERE event = %s AND docstatus = 1 AND date IS NOT NULL
+ ORDER BY date ASC
+ """,
+ (event,),
+ as_dict=True,
+ )
+ return [d.date for d in dates]
+
+
+def get_columns(check_in_dates: list) -> list[dict]:
+ """Return columns for the report.
+
+ One field definition per column, just like a DocType field definition.
+ Dynamic columns are added for each check-in date.
+ """
+ columns = [
+ {
+ "label": _("Ticket"),
+ "fieldtype": "Link",
+ "options": "Event Ticket",
+ "fieldname": "ticket",
+ "width": 120,
+ },
+ {
+ "label": _("Attendee Name"),
+ "fieldtype": "Data",
+ "fieldname": "attendee_name",
+ "width": 180,
+ },
+ {
+ "label": _("Attendee Email"),
+ "fieldtype": "Data",
+ "fieldname": "attendee_email",
+ "width": 200,
+ },
+ {
+ "label": _("Ticket Type"),
+ "fieldtype": "Link",
+ "options": "Event Ticket Type",
+ "fieldname": "ticket_type",
+ "width": 150,
+ },
+ ]
+
+ # Add dynamic columns for each check-in date
+ for i, date in enumerate(check_in_dates):
+ columns.append(
+ {
+ "label": formatdate(date, "d MMM"), # e.g., "12 Dec"
+ "fieldtype": "Check",
+ "fieldname": f"day_{i}",
+ "width": 80,
+ }
+ )
+
+ return columns
+
+
+def get_data(event: str, check_in_dates: list) -> list[dict]:
+ """Return data for the report.
+
+ Shows each ticket with check-in status for each day.
+ """
+ # Get all check-ins for this event
+ check_ins = frappe.get_all(
+ "Event Check In",
+ filters={"event": event, "docstatus": 1},
+ fields=["ticket", "date"],
+ )
+
+ if not check_ins:
+ return []
+
+ # Build a map of ticket -> set of dates checked in
+ ticket_dates = {}
+ for ci in check_ins:
+ if ci.ticket not in ticket_dates:
+ ticket_dates[ci.ticket] = set()
+ if ci.date:
+ ticket_dates[ci.ticket].add(ci.date)
+
+ # Get ticket details
+ tickets = frappe.get_all(
+ "Event Ticket",
+ filters={"name": ["in", list(ticket_dates.keys())]},
+ fields=["name", "attendee_name", "attendee_email", "ticket_type"],
+ )
+
+ # Build the data rows
+ data = []
+ for ticket in tickets:
+ row = {
+ "ticket": ticket.name,
+ "attendee_name": ticket.attendee_name,
+ "attendee_email": ticket.attendee_email,
+ "ticket_type": ticket.ticket_type,
+ }
+
+ # Add check-in status for each date (1 or 0 for Check fieldtype)
+ checked_in_dates = ticket_dates.get(ticket.name, set())
+ for i, date in enumerate(check_in_dates):
+ row[f"day_{i}"] = 1 if date in checked_in_dates else 0
+
+ data.append(row)
+
+ # Sort by attendee name
+ data.sort(key=lambda x: (x.get("attendee_name") or "").lower())
+
+ return data
+
+
+def get_chart(data: list[dict], check_in_dates: list) -> dict:
+ """Return chart data showing attendance per day."""
+ if not data or not check_in_dates:
+ return {}
+
+ labels = []
+ values = []
+
+ for i, date in enumerate(check_in_dates):
+ labels.append(formatdate(date, "d MMM"))
+ day_count = sum(1 for row in data if row.get(f"day_{i}") == 1)
+ values.append(day_count)
+
+ return {
+ "data": {
+ "labels": labels,
+ "datasets": [{"name": _("Attendees"), "values": values}],
+ },
+ "type": "bar",
+ "colors": ["#4F46E5"],
+ }
+
+
+def get_report_summary(data: list[dict], check_in_dates: list) -> list[dict]:
+ """Return report summary with attendance counts per day and total unique attendees."""
+ if not data:
+ return []
+
+ summary = []
+
+ # Count attendance for each day
+ for i, date in enumerate(check_in_dates):
+ day_count = sum(1 for row in data if row.get(f"day_{i}") == 1)
+ summary.append(
+ {
+ "value": day_count,
+ "label": formatdate(date, "d MMM YYYY"),
+ "datatype": "Int",
+ "indicator": "blue",
+ }
+ )
+
+ # Total unique attendees (anyone who attended at least one day)
+ total_unique = len(data)
+ summary.append(
+ {
+ "value": total_unique,
+ "label": _("Total Unique Attendees"),
+ "datatype": "Int",
+ "indicator": "green",
+ }
+ )
+
+ return summary
diff --git a/event_manager/events/report/event_overview/__init__.py b/event_manager/events/report/event_overview/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/events/report/event_overview/event_overview.js b/event_manager/events/report/event_overview/event_overview.js
new file mode 100644
index 0000000..47c1cf1
--- /dev/null
+++ b/event_manager/events/report/event_overview/event_overview.js
@@ -0,0 +1,13 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["Event Overview"] = {
+ filters: [
+ {
+ fieldname: "event",
+ label: __("Event"),
+ fieldtype: "Link",
+ options: "Pohodex Event Manager Event",
+ },
+ ],
+};
diff --git a/event_manager/events/report/event_overview/event_overview.json b/event_manager/events/report/event_overview/event_overview.json
new file mode 100644
index 0000000..e9493e2
--- /dev/null
+++ b/event_manager/events/report/event_overview/event_overview.json
@@ -0,0 +1,28 @@
+{
+ "add_total_row": 0,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2025-07-30 20:02:36.464426",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letterhead": null,
+ "modified": "2025-07-30 20:02:40.968996",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Event Overview",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Pohodex Event Manager Event",
+ "report_name": "Event Overview",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Event Manager"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/event_manager/events/report/event_overview/event_overview.py b/event_manager/events/report/event_overview/event_overview.py
new file mode 100644
index 0000000..20cdb96
--- /dev/null
+++ b/event_manager/events/report/event_overview/event_overview.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+
+def execute(filters: dict | None = None):
+ """Return columns and data for the report.
+
+ This is the main entry point for the report. It accepts the filters as a
+ dictionary and should return columns and data. It is called by the framework
+ every time the report is refreshed or a filter is updated.
+ """
+ columns = get_columns()
+ data = get_data(filters)
+
+ return columns, data
+
+
+def get_columns() -> list[dict]:
+ """Return columns for the report.
+
+ One field definition per column, just like a DocType field definition.
+ """
+ return [
+ {
+ "label": _("Event"),
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "options": "Pohodex Event Manager Event",
+ "width": 200,
+ },
+ {
+ "label": _("Number of Tickets Sold"),
+ "fieldname": "num_tickets_sold",
+ "fieldtype": "Int",
+ },
+ {"label": _("Number of Add-ons Sold"), "fieldname": "num_add_ons_sold", "fieldtype": "Int"},
+ {"label": _("Ticket Sales"), "fieldname": "sales", "fieldtype": "Currency"},
+ ]
+
+
+def get_data(filters: dict) -> list[dict]:
+ """Return data for the report.
+
+ The report data is a list of rows, with each row being a list of cell values.
+ """
+ event = filters.get("event")
+
+ if event:
+ return [get_summary_for_event(event)]
+
+ data = []
+ events = frappe.db.get_all("Pohodex Event Manager Event", pluck="name")
+ for event in events:
+ summary = get_summary_for_event(event)
+ summary["event"] = event
+ data.append(summary)
+
+ return data
+
+
+def get_summary_for_event(event: str) -> dict:
+ """Return summary data for the event.
+
+ This function is used to get a summary of the event, such as total tickets sold,
+ total add-ons sold, and total sales.
+ """
+ event_tickets = frappe.db.get_all("Event Ticket", filters={"event": event, "docstatus": 1}, pluck="name")
+ num_tickets_sold = len(event_tickets)
+ sales = frappe.db.get_all(
+ "Event Booking",
+ filters={"docstatus": 1, "event": event},
+ fields=[{"SUM": "total_amount", "as": "sales"}],
+ pluck="sales",
+ )[0]
+
+ num_add_ons_sold = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parenttype": "Event Ticket", "parentfield": "add_ons", "parent": ["in", event_tickets]},
+ fields=[{"COUNT": "*", "as": "num_add_ons_sold"}],
+ pluck="num_add_ons_sold",
+ )[0]
+
+ return {
+ "event": event,
+ "num_tickets_sold": num_tickets_sold,
+ "num_add_ons_sold": num_add_ons_sold,
+ "sales": sales,
+ }
diff --git a/event_manager/fixtures/role.json b/event_manager/fixtures/role.json
new file mode 100644
index 0000000..9b307ff
--- /dev/null
+++ b/event_manager/fixtures/role.json
@@ -0,0 +1,28 @@
+[
+ {
+ "desk_access": 1,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Role",
+ "home_page": null,
+ "is_custom": 0,
+ "modified": "2025-10-27 15:00:39.981840",
+ "name": "Frontdesk Manager",
+ "restrict_to_domain": null,
+ "role_name": "Frontdesk Manager",
+ "two_factor_auth": 0
+ },
+ {
+ "desk_access": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Role",
+ "home_page": "/dashboard",
+ "is_custom": 0,
+ "modified": "2025-10-10 14:42:44.914821",
+ "name": "Pohodex Event Manager User",
+ "restrict_to_domain": null,
+ "role_name": "Pohodex Event Manager User",
+ "two_factor_auth": 0
+ }
+]
\ No newline at end of file
diff --git a/event_manager/hooks.py b/event_manager/hooks.py
new file mode 100644
index 0000000..5603658
--- /dev/null
+++ b/event_manager/hooks.py
@@ -0,0 +1,242 @@
+app_name = "event_manager"
+app_title = "Pohodex Event Manager"
+app_publisher = "Pohodex Team"
+app_description = "Pohodex Event Manager app built on Frappe"
+app_email = "info@pohodex.cz"
+app_license = "agpl-3.0"
+
+# Apps
+# ------------------
+
+required_apps = ["frappe/payments"]
+
+export_python_type_annotations = True
+
+after_install = "event_manager.install.after_install"
+
+before_uninstall = "event_manager.uninstall.before_uninstall"
+
+
+website_route_rules = [
+ {"from_route": "/dashboard/", "to_route": "dashboard"},
+]
+
+# Scheduled Tasks
+# ---------------
+
+scheduler_events = {"daily": ["event_manager.tasks.unpublish_ticket_types_after_last_date"]}
+
+# Testing
+# -------
+
+before_tests = "event_manager.install.before_tests"
+
+
+doc_events = {
+ "User": {
+ "after_insert": "event_manager.utils.add_event_manager_user_role",
+ "on_update": "event_manager.events.doctype.speaker_profile.speaker_profile.update_speaker_display_name",
+ },
+}
+
+fixtures = [{"dt": "Role", "filters": {"name": ["in", ["Pohodex Event Manager User", "Frontdesk Manager"]]}}]
+
+user_invitation = {"allowed_roles": {"Event Manager": ["Pohodex Event Manager User"], "Pohodex Event Manager User": ["Pohodex Event Manager User"]}}
+
+
+ignore_links_on_delete = ["Ticket Cancellation Request", "Ticket Add-on Value"]
+
+after_app_install = "event_manager.install.after_app_install"
+
+# Each item in the list will be shown as an app in the apps page
+add_to_apps_screen = [
+ {
+ "name": "event_manager",
+ "logo": "/assets/event_manager/images/event_manager-logo-rounded.png",
+ "title": "Pohodex Event Manager",
+ "route": "/app/event_manager",
+ "has_permission": "event_manager.api.has_app_permission",
+ }
+]
+
+# Includes in
+# ------------------
+
+# include js, css files in header of desk.html
+# app_include_css = "/assets/events/css/events.css"
+# app_include_js = "/assets/event_manager/js/events.js"
+
+# include js, css files in header of web template
+# web_include_css = "/assets/events/css/events.css"
+# web_include_js = "/assets/events/js/events.js"
+
+# include custom scss in every website theme (without file extension ".scss")
+# website_theme_scss = "events/public/scss/website"
+
+# include js, css files in header of web form
+# webform_include_js = {"doctype": "public/js/doctype.js"}
+# webform_include_css = {"doctype": "public/css/doctype.css"}
+
+# include js in page
+# page_js = {"page" : "public/js/file.js"}
+
+# include js in doctype views
+# doctype_js = {"doctype" : "public/js/doctype.js"}
+# doctype_list_js = {"doctype" : "public/js/doctype_list.js"}
+# doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"}
+# doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"}
+
+# Svg Icons
+# ------------------
+# include app icons in desk
+# app_include_icons = "events/public/icons.svg"
+
+# Home Pages
+# ----------
+
+# application home page (will override Website Settings)
+# home_page = "login"
+
+# website user home page (by Role)
+# role_home_page = {
+# "Role": "home_page"
+# }
+
+# Generators
+# ----------
+
+# automatically create page for each record of this doctype
+# website_generators = ["Web Page"]
+
+# automatically load and sync documents of this doctype from downstream apps
+# importable_doctypes = [doctype_1]
+
+# Jinja
+# ----------
+
+# add methods and filters to jinja environment
+# jinja = {
+# "methods": "events.utils.jinja_methods",
+# "filters": "events.utils.jinja_filters"
+# }
+
+# Installation
+# ------------
+
+# before_install = "events.install.before_install"
+
+# Uninstallation
+# ------------
+
+# before_uninstall = "events.uninstall.before_uninstall"
+# after_uninstall = "events.uninstall.after_uninstall"
+
+# Integration Setup
+# ------------------
+# To set up dependencies/integrations with other apps
+# Name of the app being installed is passed as an argument
+
+# before_app_install = "events.utils.before_app_install"
+
+
+# Integration Cleanup
+# -------------------
+# To clean up dependencies/integrations with other apps
+# Name of the app being uninstalled is passed as an argument
+
+# before_app_uninstall = "events.utils.before_app_uninstall"
+after_app_uninstall = "event_manager.install.after_app_uninstall"
+after_migrate = "event_manager.install.on_migrate"
+
+# Desk Notifications
+# ------------------
+# See frappe.core.notifications.get_notification_config
+
+# notification_config = "events.notifications.get_notification_config"
+
+# Permissions
+# -----------
+# Permissions evaluated in scripted ways
+
+# permission_query_conditions = {
+# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
+# }
+#
+# has_permission = {
+# "Event": "frappe.desk.doctype.event.event.has_permission",
+# }
+
+# Document Events
+# ---------------
+# Hook on document methods and events
+
+
+# Overriding Methods
+# ------------------------------
+#
+# override_whitelisted_methods = {
+# "frappe.desk.doctype.event.event.get_events": "events.event.get_events"
+# }
+#
+# each overriding function accepts a `data` argument;
+# generated from the base implementation of the doctype dashboard,
+# along with any modifications made in other Frappe apps
+# override_doctype_dashboards = {
+# "Task": "events.task.get_dashboard_data"
+# }
+
+# exempt linked doctypes from being automatically cancelled
+#
+# auto_cancel_exempted_doctypes = ["Auto Repeat"]
+
+# Ignore links to specified DocTypes when deleting documents
+# -----------------------------------------------------------
+
+# ignore_links_on_delete = ["Communication", "ToDo"]
+
+# Request Events
+# ----------------
+# before_request = ["events.utils.before_request"]
+# after_request = ["events.utils.after_request"]
+
+# Job Events
+# ----------
+# before_job = ["events.utils.before_job"]
+# after_job = ["events.utils.after_job"]
+
+# User Data Protection
+# --------------------
+
+# user_data_fields = [
+# {
+# "doctype": "{doctype_1}",
+# "filter_by": "{filter_by}",
+# "redact_fields": ["{field_1}", "{field_2}"],
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_2}",
+# "filter_by": "{filter_by}",
+# "partial": 1,
+# },
+# {
+# "doctype": "{doctype_3}",
+# "strict": False,
+# },
+# {
+# "doctype": "{doctype_4}"
+# }
+# ]
+
+# Authentication and authorization
+# --------------------------------
+
+# auth_hooks = [
+# "events.auth.validate"
+# ]
+
+# Automatically update python controller files with type annotations for this app.
+
+# default_log_clearing_doctypes = {
+# "Logging DocType Name": 30 # days to retain logs
+# }
diff --git a/event_manager/install.py b/event_manager/install.py
new file mode 100644
index 0000000..9b871f7
--- /dev/null
+++ b/event_manager/install.py
@@ -0,0 +1,199 @@
+import frappe
+
+from event_manager.utils import delete_custom_fields, get_custom_fields_creator
+
+_create_custom_fields = get_custom_fields_creator("Pohodex Event Manager")
+
+CRM_INTEGRATION_CUSTOM_FIELDS = {
+ "CRM Lead": [
+ {
+ "fieldname": "buzz_tab",
+ "label": "Pohodex Event Manager",
+ "fieldtype": "Tab Break",
+ "insert_after": "facebook_form_id",
+ },
+ {
+ "fieldname": "buzz_campaign",
+ "label": "Pohodex Event Manager Campaign",
+ "fieldtype": "Link",
+ "options": "Pohodex Event Manager Campaign",
+ "insert_after": "buzz_tab",
+ },
+ {
+ "fieldname": "buzz_column_break",
+ "fieldtype": "Column Break",
+ "insert_after": "buzz_campaign",
+ },
+ {
+ "fieldname": "event_ticket",
+ "label": "Event Ticket",
+ "fieldtype": "Link",
+ "options": "Event Ticket",
+ "insert_after": "buzz_column_break",
+ },
+ ],
+}
+
+ZOOM_INTEGRATION_CUSTOM_FIELDS = {
+ "Pohodex Event Manager Event": [
+ {
+ "fieldname": "zoom_integration_tab",
+ "label": "Zoom Integration",
+ "fieldtype": "Tab Break",
+ "insert_after": "ticket_print_format",
+ },
+ {
+ "fieldname": "zoom_webinar",
+ "label": "Zoom Webinar",
+ "fieldtype": "Link",
+ "options": "Zoom Webinar",
+ "insert_after": "zoom_integration_tab",
+ },
+ ],
+ "Pohodex Event Manager Settings": [
+ {
+ "fieldname": "zoom_integration_section",
+ "label": "Zoom Integration Settings",
+ "fieldtype": "Section Break",
+ "insert_after": "custom_fields_go_after_this",
+ },
+ {
+ "fieldname": "default_webinar_template",
+ "label": "Default Webinar Template",
+ "fieldtype": "Link",
+ "options": "Zoom Webinar Template",
+ "insert_after": "zoom_integration_section",
+ },
+ ],
+ "Event Ticket": [
+ {
+ "fieldname": "zoom_webinar_registration",
+ "label": "Zoom Webinar Registration",
+ "fieldtype": "Link",
+ "options": "Zoom Webinar Registration",
+ "insert_after": "ticket_type",
+ "read_only": 1,
+ },
+ ],
+}
+
+
+def before_tests():
+ setup_test_records()
+
+
+def setup_test_records():
+ test_category = frappe.get_doc({"doctype": "Event Category", "name": "Test Category"}).insert(
+ ignore_if_duplicate=True
+ )
+ test_venue = frappe.get_doc({"doctype": "Event Venue", "name": "Test Venue", "address": "test"}).insert(
+ ignore_if_duplicate=True
+ )
+ test_host = frappe.get_doc({"doctype": "Event Host", "name": "Test Host"}).insert(
+ ignore_if_duplicate=True
+ )
+
+ test_event_exists = frappe.db.exists("Pohodex Event Manager Event", {"route": "test-route"})
+ if test_event_exists:
+ frappe.delete_doc("Pohodex Event Manager Event", test_event_exists, force=True)
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "category": test_category.name,
+ "venue": test_venue.name,
+ "host": test_host.name,
+ "title": "Test Event",
+ "route": "test-route",
+ "start_date": frappe.utils.today(),
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ }
+ ).insert(ignore_if_duplicate=True)
+
+
+def after_install():
+ create_event_categories()
+ create_custom_fields()
+
+
+def on_migrate():
+ create_custom_fields()
+
+
+def after_app_install(app_name: str):
+ if app_name == "zoom_integration":
+ create_zoom_integration_custom_fields()
+ if app_name == "crm":
+ create_crm_integration_custom_fields()
+
+
+def after_app_uninstall(app_name: str):
+ if app_name == "zoom_integration":
+ delete_zoom_integration_custom_fields()
+ if app_name == "crm":
+ delete_crm_integration_custom_fields()
+
+
+def create_custom_fields():
+ installed_apps = frappe.get_installed_apps()
+
+ if "zoom_integration" in installed_apps:
+ create_zoom_integration_custom_fields()
+
+ if "crm" in installed_apps:
+ create_crm_integration_custom_fields()
+
+
+def create_zoom_integration_custom_fields():
+ _create_custom_fields(ZOOM_INTEGRATION_CUSTOM_FIELDS, ignore_validate=True)
+
+
+def create_crm_integration_custom_fields():
+ _create_custom_fields(CRM_INTEGRATION_CUSTOM_FIELDS, ignore_validate=True)
+
+
+def delete_zoom_integration_custom_fields():
+ delete_custom_fields(ZOOM_INTEGRATION_CUSTOM_FIELDS)
+
+
+def delete_crm_integration_custom_fields():
+ delete_custom_fields(CRM_INTEGRATION_CUSTOM_FIELDS)
+
+
+def create_event_categories():
+ categories = [
+ {
+ "name": "Meetups",
+ "icon_svg": """
+
+ """,
+ "enabled": 1,
+ },
+ {
+ "name": "Conferences",
+ "icon_svg": """
+
+
+""",
+ "enabled": 1,
+ },
+ {
+ "name": "Local",
+ "icon_svg": """
+
+
+
+""",
+ "enabled": 1,
+ },
+ {
+ "name": "Webinars",
+ "icon_svg": """
+
+ """,
+ "enabled": 1,
+ },
+ ]
+
+ for category in categories:
+ frappe.get_doc({"doctype": "Event Category", **category}).insert(ignore_if_duplicate=True)
diff --git a/event_manager/integrations/__init__.py b/event_manager/integrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/integrations/zoom.py b/event_manager/integrations/zoom.py
new file mode 100644
index 0000000..d7ced97
--- /dev/null
+++ b/event_manager/integrations/zoom.py
@@ -0,0 +1,5 @@
+import frappe
+
+
+def create_zoom_integration_custom_fields():
+ pass
diff --git a/event_manager/modules.txt b/event_manager/modules.txt
new file mode 100644
index 0000000..78da7e2
--- /dev/null
+++ b/event_manager/modules.txt
@@ -0,0 +1,5 @@
+Pohodex Event Manager
+Events
+Ticketing
+Proposals
+Pohodex Event Manager Marketing
\ No newline at end of file
diff --git a/event_manager/patches.txt b/event_manager/patches.txt
new file mode 100644
index 0000000..d7abd7c
--- /dev/null
+++ b/event_manager/patches.txt
@@ -0,0 +1,13 @@
+[pre_model_sync]
+event_manager.patches.rename_doctypes_for_buzz
+event_manager.patches.migrate_to_multi_payment_gateway
+event_manager.patches.migrate_offline_payment_to_methods
+
+# Patches added in this section will be executed before doctypes are migrated
+# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
+
+[post_model_sync]
+# Patches added in this section will be executed after doctypes are migrated
+event_manager.patches.populate_slug_in_event_category
+event_manager.patches.set_applies_to_for_existing_coupons
+event_manager.patches.set_payment_status_for_existing_bookings
\ No newline at end of file
diff --git a/event_manager/patches/migrate_offline_payment_to_methods.py b/event_manager/patches/migrate_offline_payment_to_methods.py
new file mode 100644
index 0000000..b915a0e
--- /dev/null
+++ b/event_manager/patches/migrate_offline_payment_to_methods.py
@@ -0,0 +1,41 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc("events", "doctype", "offline_payment_method")
+
+ # Skip if the old columns don't exist (fresh install or already migrated)
+ if not frappe.db.has_column("Pohodex Event Manager Event", "enable_offline_payments"):
+ return
+
+ events_with_offline = frappe.get_all(
+ "Pohodex Event Manager Event",
+ filters={"enable_offline_payments": 1},
+ fields=["name", "offline_payment_label", "offline_payment_details", "collect_payment_proof"],
+ )
+
+ for event in events_with_offline:
+ title = event.offline_payment_label or "Offline Payment"
+
+ method = frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": title,
+ "event": event.name,
+ "enabled": 1,
+ "collect_payment_proof": event.collect_payment_proof,
+ "description": event.offline_payment_details,
+ }
+ )
+ method.insert(ignore_permissions=True)
+
+ # Link any existing Pohodex Event Manager Custom Fields scoped to "Offline Payment Form" for this event
+ custom_fields = frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event.name, "applied_to": "Offline Payment Form"},
+ pluck="name",
+ )
+ for cf_name in custom_fields:
+ frappe.db.set_value("Pohodex Event Manager Custom Field", cf_name, "offline_payment_method", method.name)
+
+ frappe.reload_doc("events", "doctype", "buzz_event")
diff --git a/event_manager/patches/migrate_to_multi_payment_gateway.py b/event_manager/patches/migrate_to_multi_payment_gateway.py
new file mode 100644
index 0000000..1ce10c8
--- /dev/null
+++ b/event_manager/patches/migrate_to_multi_payment_gateway.py
@@ -0,0 +1,20 @@
+import frappe
+
+
+def execute():
+ events = frappe.get_all(
+ "Pohodex Event Manager Event", filters={"payment_gateway": ("is", "set")}, fields=["name", "payment_gateway"]
+ )
+
+ frappe.reload_doc("Events", "doctype", "buzz_event")
+ frappe.reload_doc("Events", "doctype", "event_payment_gateway")
+
+ for event in events:
+ doc = frappe.get_cached_doc("Pohodex Event Manager Event", event.name)
+ doc.append(
+ "payment_gateways",
+ {
+ "payment_gateway": event.payment_gateway,
+ },
+ )
+ doc.save()
diff --git a/event_manager/patches/populate_slug_in_event_category.py b/event_manager/patches/populate_slug_in_event_category.py
new file mode 100644
index 0000000..a2ce42b
--- /dev/null
+++ b/event_manager/patches/populate_slug_in_event_category.py
@@ -0,0 +1,9 @@
+import frappe
+
+
+def execute():
+ categories = frappe.db.get_all("Event Category", pluck="name")
+ for category in categories:
+ doc = frappe.get_cached_doc("Event Category", category)
+ doc.set_slug()
+ doc.save()
diff --git a/event_manager/patches/rename_doctypes_for_buzz.py b/event_manager/patches/rename_doctypes_for_buzz.py
new file mode 100644
index 0000000..d509fa0
--- /dev/null
+++ b/event_manager/patches/rename_doctypes_for_buzz.py
@@ -0,0 +1,29 @@
+import frappe
+
+doctypes = {
+ "Event Management Settings": "Pohodex Event Manager Settings",
+ "FE Event": "Pohodex Event Manager Event",
+}
+
+
+def execute():
+ rename_doctypes()
+ create_sequences()
+
+
+def rename_doctypes():
+ for old in doctypes:
+ new = doctypes[old]
+ if not frappe.db.exists("DocType", new):
+ print(f"Renaming {old} to {new}")
+ frappe.rename_doc("DocType", old, new, force=True, ignore_if_exists=True)
+
+
+def create_sequences():
+ doctype = "Pohodex Event Manager Event"
+ sequence_name = frappe.scrub(doctype + "_id_seq")
+ frappe.db.sql_ddl(f"drop sequence if exists {sequence_name}")
+ last_name = frappe.db.get_all(doctype, fields=["max(name) as last"])[0].last
+ start_value = (last_name or 0) + 1
+ print(f"Creating sequence for {doctype} starting at {start_value}")
+ frappe.db.create_sequence(doctype, start_value=start_value, check_not_exists=True)
diff --git a/event_manager/patches/set_applies_to_for_existing_coupons.py b/event_manager/patches/set_applies_to_for_existing_coupons.py
new file mode 100644
index 0000000..56b8bce
--- /dev/null
+++ b/event_manager/patches/set_applies_to_for_existing_coupons.py
@@ -0,0 +1,35 @@
+import frappe
+
+
+def execute():
+ BuzzCouponCode = frappe.qb.DocType("Pohodex Event Manager Coupon Code")
+
+ # 1. Set applies_to = "Event" for all Free Tickets coupons
+ (
+ frappe.qb.update(BuzzCouponCode)
+ .set(BuzzCouponCode.applies_to, "Event")
+ .where(BuzzCouponCode.coupon_type == "Free Tickets")
+ .where((BuzzCouponCode.applies_to.isnull()) | (BuzzCouponCode.applies_to == ""))
+ ).run()
+
+ # 2. Set applies_to = "Event" for Discount coupons that have event set
+ (
+ frappe.qb.update(BuzzCouponCode)
+ .set(BuzzCouponCode.applies_to, "Event")
+ .where(BuzzCouponCode.coupon_type == "Discount")
+ .where(BuzzCouponCode.event.isnotnull())
+ .where(BuzzCouponCode.event != "")
+ .where((BuzzCouponCode.applies_to.isnull()) | (BuzzCouponCode.applies_to == ""))
+ ).run()
+
+ # 3. Set applies_to = "Event Category" for Discount coupons that have event_category set
+ (
+ frappe.qb.update(BuzzCouponCode)
+ .set(BuzzCouponCode.applies_to, "Event Category")
+ .where(BuzzCouponCode.coupon_type == "Discount")
+ .where(BuzzCouponCode.event_category.isnotnull())
+ .where(BuzzCouponCode.event_category != "")
+ .where((BuzzCouponCode.applies_to.isnull()) | (BuzzCouponCode.applies_to == ""))
+ ).run()
+
+ # Coupons with neither event nor event_category remain as global (applies_to = '')
diff --git a/event_manager/patches/set_payment_status_for_existing_bookings.py b/event_manager/patches/set_payment_status_for_existing_bookings.py
new file mode 100644
index 0000000..e398cf3
--- /dev/null
+++ b/event_manager/patches/set_payment_status_for_existing_bookings.py
@@ -0,0 +1,13 @@
+import frappe
+
+
+def execute():
+ EventBooking = frappe.qb.DocType("Event Booking")
+
+ # Set payment_status to "Paid" and status to "Confirmed" for all submitted bookings
+ (
+ frappe.qb.update(EventBooking)
+ .set(EventBooking.payment_status, "Paid")
+ .set(EventBooking.status, "Confirmed")
+ .where(EventBooking.docstatus == 1)
+ ).run()
diff --git a/event_manager/payments.py b/event_manager/payments.py
new file mode 100644
index 0000000..6d1fa79
--- /dev/null
+++ b/event_manager/payments.py
@@ -0,0 +1,202 @@
+import frappe
+from payments.utils import get_payment_gateway_controller
+
+
+def get_payment_gateway_for_event(event: str):
+ return frappe.get_cached_value("Pohodex Event Manager Event", event, "payment_gateway")
+
+
+def get_payment_gateways_for_event(event: str) -> list[str]:
+ """Get all payment gateways configured for an event."""
+ gateways = frappe.get_all(
+ "Event Payment Gateway",
+ filters={"parent": event, "parenttype": "Pohodex Event Manager Event"},
+ pluck="payment_gateway",
+ )
+ if not gateways:
+ # Fallback to legacy field
+ legacy = frappe.get_cached_value("Pohodex Event Manager Event", event, "payment_gateway")
+ return [legacy] if legacy else []
+ return gateways
+
+
+def get_controller(payment_gateway):
+ return get_payment_gateway_controller(payment_gateway)
+
+
+@frappe.whitelist()
+def get_payment_link_for_booking(
+ booking_id: str, redirect_to: str = "/events", payment_gateway: str | None = None
+) -> str:
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+ event_title = frappe.get_cached_value("Pohodex Event Manager Event", booking_doc.event, "title")
+ if not payment_gateway:
+ gateways = get_payment_gateways_for_event(booking_doc.event)
+ if not gateways:
+ frappe.throw("No payment gateway configured for this event")
+ payment_gateway = gateways[0]
+ return get_payment_link(
+ "Event Booking",
+ booking_id,
+ booking_doc.total_amount,
+ booking_doc.currency,
+ payment_gateway,
+ redirect_to=redirect_to,
+ title=f"Payment for {event_title}",
+ )
+
+
+@frappe.whitelist()
+def get_payment_link_for_sponsorship(
+ sponsorship_enquiry: str,
+ sponsorship_tier: str,
+ redirect_to: str = "/events",
+ payment_gateway: str | None = None,
+) -> str:
+ tier_doc = frappe.get_cached_doc("Sponsorship Tier", sponsorship_tier)
+ if not payment_gateway:
+ gateways = get_payment_gateways_for_event(tier_doc.event)
+ if not gateways:
+ frappe.throw("No payment gateway configured for this event")
+ payment_gateway = gateways[0]
+ event_title = frappe.get_cached_value("Pohodex Event Manager Event", tier_doc.event, "title")
+ frappe.db.set_value(
+ "Sponsorship Enquiry", sponsorship_enquiry, "tier", sponsorship_tier
+ ) # TODO: rethink later
+
+ return get_payment_link(
+ "Sponsorship Enquiry",
+ sponsorship_enquiry,
+ tier_doc.price,
+ tier_doc.currency,
+ payment_gateway,
+ redirect_to,
+ f"Payment for {tier_doc.title} Sponsorship at {event_title}",
+ )
+
+
+def get_payment_link(
+ reference_doctype: str,
+ reference_docname: str,
+ amount: float,
+ currency: str,
+ payment_gateway: str,
+ redirect_to: str = "/events",
+ title: str | None = None,
+) -> str:
+ payment = record_payment(reference_doctype, reference_docname, amount, currency, payment_gateway)
+ controller = get_controller(payment_gateway)
+ user_full_name = frappe.get_cached_value("User", frappe.session.user, "full_name")
+
+ payment_details = {
+ "amount": amount,
+ "title": title or f"Payment for {reference_doctype}: {reference_docname}",
+ "description": f"{user_full_name}'s payment for {reference_doctype} (#{reference_docname})",
+ "reference_doctype": reference_doctype,
+ "reference_docname": reference_docname,
+ "payer_email": frappe.session.user,
+ "payer_name": user_full_name,
+ "currency": currency,
+ "payment_gateway": payment_gateway,
+ "redirect_to": redirect_to,
+ "payment": payment.name,
+ }
+ if payment_gateway == "Razorpay" or payment_gateway == "Paymob":
+ order = controller.create_order(**payment_details)
+ payment_details.update({"order_id": order.get("id")})
+
+ url = controller.get_payment_url(**payment_details)
+
+ return url
+
+
+def record_payment(
+ reference_doctype: str,
+ reference_docname: str,
+ amount: float,
+ currency: str,
+ payment_gateway: str | None = None,
+):
+ payment_doc = frappe.new_doc("Event Payment")
+ payment_doc.update(
+ {
+ "user": frappe.session.user,
+ "amount": amount,
+ "currency": currency,
+ "reference_doctype": reference_doctype,
+ "reference_docname": reference_docname,
+ "payment_gateway": payment_gateway,
+ }
+ )
+ payment_doc.save(ignore_permissions=True)
+ return payment_doc
+
+
+def mark_payment_as_received(reference_doctype: str, reference_docname: str):
+ if frappe.in_test:
+ return
+
+ import json
+
+ request = frappe.get_all(
+ "Integration Request",
+ {
+ "reference_doctype": reference_doctype,
+ "reference_docname": reference_docname,
+ },
+ order_by="creation desc",
+ limit=1,
+ )
+
+ if len(request):
+ data = frappe.db.get_value("Integration Request", request[0].name, "data")
+ data = frappe._dict(json.loads(data))
+
+ payment_gateway = data.get("payment_gateway")
+ if payment_gateway == "Razorpay":
+ payment_id = "razorpay_payment_id"
+
+ elif payment_gateway == "Paymob":
+ payment_id = "paymob_payment_id"
+
+ elif payment_gateway == "PayPal":
+ payment_id = "transaction_id"
+
+ elif "Stripe" in payment_gateway:
+ payment_id = "stripe_token_id"
+ else:
+ payment_id = "order_id"
+
+ frappe.db.set_value(
+ "Event Payment",
+ data.payment,
+ {
+ "payment_received": 1,
+ "payment_id": data.get(payment_id),
+ "order_id": data.get("order_id"),
+ },
+ )
+
+ frappe.db.commit()
+
+
+# TODO: use it later!
+def save_address(address):
+ filters = {"email_id": frappe.session.user}
+ exists = frappe.db.exists("Address", filters)
+ if exists:
+ address_doc = frappe.get_last_doc("Address", filters=filters)
+ else:
+ address_doc = frappe.new_doc("Address")
+
+ address_doc.update(address)
+ address_doc.update(
+ {
+ "address_title": frappe.db.get_value("User", frappe.session.user, "full_name"),
+ "address_type": "Billing",
+ "is_primary_address": 1,
+ "email_id": frappe.session.user,
+ }
+ )
+ address_doc.save(ignore_permissions=True)
+ return address_doc.name
diff --git a/event_manager/proposals/__init__.py b/event_manager/proposals/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/__init__.py b/event_manager/proposals/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/event_proposal/__init__.py b/event_manager/proposals/doctype/event_proposal/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/event_proposal/event_proposal.js b/event_manager/proposals/doctype/event_proposal/event_proposal.js
new file mode 100644
index 0000000..35d3644
--- /dev/null
+++ b/event_manager/proposals/doctype/event_proposal/event_proposal.js
@@ -0,0 +1,10 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Event Proposal", {
+ refresh(frm) {
+ if (!frm.is_new() && frm.doc.docstatus == 0) {
+ frm.set_intro("Pohodex Event Manager Event will be created on submission of this document", "yellow");
+ }
+ },
+});
diff --git a/event_manager/proposals/doctype/event_proposal/event_proposal.json b/event_manager/proposals/doctype/event_proposal/event_proposal.json
new file mode 100644
index 0000000..97e6d72
--- /dev/null
+++ b/event_manager/proposals/doctype/event_proposal/event_proposal.json
@@ -0,0 +1,215 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "naming_series:",
+ "creation": "2025-12-10 17:17:09.702412",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "category",
+ "free_webinar",
+ "medium",
+ "column_break_bixo",
+ "status",
+ "naming_series",
+ "section_break_psfj",
+ "start_date",
+ "start_time",
+ "column_break_wfaw",
+ "end_date",
+ "end_time",
+ "section_break_zajc",
+ "short_description",
+ "column_break_yxbj",
+ "about",
+ "host_tab",
+ "host",
+ "section_break_xzvg",
+ "host_company",
+ "additional_notes",
+ "host_company_logo",
+ "section_break_kray",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "default": "Received",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "label": "Status",
+ "no_copy": 1,
+ "options": "Received\nIn Review\nApproved\nEvent Created\nRejected"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Naming Series",
+ "options": "EPR-.###"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_bixo",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_zajc",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "host_tab",
+ "fieldtype": "Tab Break",
+ "label": "Host"
+ },
+ {
+ "fieldname": "host_company",
+ "fieldtype": "Data",
+ "label": "Host Company"
+ },
+ {
+ "fieldname": "additional_notes",
+ "fieldtype": "Small Text",
+ "label": "Additional Notes"
+ },
+ {
+ "description": "Required for creating an event",
+ "fieldname": "host",
+ "fieldtype": "Link",
+ "label": "Host",
+ "options": "Event Host"
+ },
+ {
+ "fieldname": "section_break_kray",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "host_company_logo",
+ "fieldtype": "Attach Image",
+ "label": "Host Company Logo"
+ },
+ {
+ "fieldname": "section_break_psfj",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "start_date",
+ "fieldtype": "Date",
+ "label": "Start Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "end_date",
+ "fieldtype": "Date",
+ "label": "End Date"
+ },
+ {
+ "fieldname": "column_break_wfaw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "start_time",
+ "fieldtype": "Time",
+ "label": "Start Time"
+ },
+ {
+ "fieldname": "end_time",
+ "fieldtype": "Time",
+ "label": "End Time"
+ },
+ {
+ "fieldname": "about",
+ "fieldtype": "Text Editor",
+ "label": "About the event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_yxbj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "short_description",
+ "fieldtype": "Small Text",
+ "label": "Short Description"
+ },
+ {
+ "default": "Online",
+ "fieldname": "medium",
+ "fieldtype": "Select",
+ "label": "Medium",
+ "options": "Online\nIn Person",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.event_category==\"Webinars\"",
+ "fieldname": "free_webinar",
+ "fieldtype": "Check",
+ "label": "Free Webinar?"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Event Proposal",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "category",
+ "fieldtype": "Link",
+ "label": "Event Category",
+ "options": "Event Category",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_xzvg",
+ "fieldtype": "Section Break"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2025-12-11 11:36:40.618179",
+ "modified_by": "Administrator",
+ "module": "Proposals",
+ "name": "Event Proposal",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [
+ {
+ "color": "Green",
+ "title": "Event Created"
+ },
+ {
+ "color": "Orange",
+ "title": "In Review"
+ }
+ ]
+}
diff --git a/event_manager/proposals/doctype/event_proposal/event_proposal.py b/event_manager/proposals/doctype/event_proposal/event_proposal.py
new file mode 100644
index 0000000..9bd9d10
--- /dev/null
+++ b/event_manager/proposals/doctype/event_proposal/event_proposal.py
@@ -0,0 +1,83 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils.data import get_url_to_form, getdate, today
+
+
+class EventProposal(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ about: DF.TextEditor
+ additional_notes: DF.SmallText | None
+ amended_from: DF.Link | None
+ category: DF.Link
+ end_date: DF.Date | None
+ end_time: DF.Time | None
+ free_webinar: DF.Check
+ host: DF.Link | None
+ host_company: DF.Data | None
+ host_company_logo: DF.AttachImage | None
+ medium: DF.Literal["Online", "In Person"]
+ naming_series: DF.Literal["EPR-.###"]
+ short_description: DF.SmallText | None
+ start_date: DF.Date
+ start_time: DF.Time | None
+ status: DF.Literal["Received", "In Review", "Approved", "Event Created", "Rejected"]
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_dates()
+ self.validate_times()
+
+ def validate_dates(self):
+ if getdate(self.start_date) < getdate(today()):
+ frappe.throw(_("Start Date cannot be in the past."))
+
+ if self.end_date and getdate(self.end_date) < getdate(self.start_date):
+ frappe.throw(_("End Date cannot be before Start Date."))
+
+ def validate_times(self):
+ if not self.start_time or not self.end_time:
+ return
+
+ same_day = not self.end_date or getdate(self.end_date) == getdate(self.start_date)
+ if same_day and self.end_time <= self.start_time:
+ frappe.throw(_("End Time must be after Start Time for same-day events."))
+
+ def before_submit(self):
+ if self.status not in ("Approved", "Rejected"):
+ frappe.throw(_("Only Approved or Rejected proposals can be submitted."))
+
+ self.create_event()
+
+ def create_event(self):
+ if self.status == "Rejected":
+ return
+
+ if not self.host:
+ frappe.throw(_("Please create or set a Host before submitting the proposal."))
+
+ buzz_event = get_mapped_doc(
+ "Event Proposal", self.name, {"Event Proposal": {"doctype": "Pohodex Event Manager Event"}}
+ )
+ buzz_event.proposal = self.name
+ buzz_event.insert()
+
+ self.status = "Event Created"
+
+ frappe.msgprint(
+ _("Pohodex Event Manager Event {0} created successfully.").format(
+ f'{buzz_event.title} '
+ )
+ )
diff --git a/event_manager/proposals/doctype/event_proposal/test_event_proposal.py b/event_manager/proposals/doctype/event_proposal/test_event_proposal.py
new file mode 100644
index 0000000..6eecb99
--- /dev/null
+++ b/event_manager/proposals/doctype/event_proposal/test_event_proposal.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventProposal(IntegrationTestCase):
+ """
+ Integration tests for EventProposal.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/proposals/doctype/proposal_speaker/__init__.py b/event_manager/proposals/doctype/proposal_speaker/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.json b/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.json
new file mode 100644
index 0000000..acd4c1a
--- /dev/null
+++ b/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.json
@@ -0,0 +1,55 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 12:07:26.497133",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "first_name",
+ "last_name",
+ "column_break_ouof",
+ "email"
+ ],
+ "fields": [
+ {
+ "fieldname": "first_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "First Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "last_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Last Name"
+ },
+ {
+ "fieldname": "column_break_ouof",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "email",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Email",
+ "options": "Email",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-07-26 12:12:56.836403",
+ "modified_by": "Administrator",
+ "module": "Proposals",
+ "name": "Proposal Speaker",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.py b/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.py
new file mode 100644
index 0000000..775bf48
--- /dev/null
+++ b/event_manager/proposals/doctype/proposal_speaker/proposal_speaker.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class ProposalSpeaker(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ email: DF.Data
+ first_name: DF.Data
+ last_name: DF.Data | None
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/proposals/doctype/sponsorship_deck_item/__init__.py b/event_manager/proposals/doctype/sponsorship_deck_item/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.json b/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.json
new file mode 100644
index 0000000..77cc542
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.json
@@ -0,0 +1,35 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-10-29 17:07:45.086186",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "file"
+ ],
+ "fields": [
+ {
+ "fieldname": "file",
+ "fieldtype": "Attach",
+ "in_list_view": 1,
+ "label": "File",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-10-29 17:07:59.277650",
+ "modified_by": "Administrator",
+ "module": "Proposals",
+ "name": "Sponsorship Deck Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.py b/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.py
new file mode 100644
index 0000000..3bca140
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_deck_item/sponsorship_deck_item.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class SponsorshipDeckItem(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ file: DF.Attach
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/proposals/doctype/sponsorship_enquiry/__init__.py b/event_manager/proposals/doctype/sponsorship_enquiry/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.js b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.js
new file mode 100644
index 0000000..e147c30
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.js
@@ -0,0 +1,22 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Sponsorship Enquiry", {
+ refresh(frm) {
+ if (!frm.doc.__islocal) {
+ if (frm.doc.status === "Approval Pending") {
+ frm.add_custom_button(__("Approve"), () => {
+ frm.set_value("status", "Payment Pending");
+ frm.save();
+ });
+ }
+
+ frm.add_custom_button(__("Create Sponsor"), () => {
+ frm.call("create_sponsor").then(() => {
+ frappe.show_alert(__("Sponsor Created!"));
+ frm.refresh();
+ });
+ });
+ }
+ },
+});
diff --git a/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.json b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.json
new file mode 100644
index 0000000..52a6cb2
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.json
@@ -0,0 +1,137 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 12:13:43.344063",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "event",
+ "company_name",
+ "company_logo",
+ "website",
+ "column_break_fhgg",
+ "status",
+ "tier",
+ "country",
+ "phone",
+ "section_break_additional",
+ "additional_fields"
+ ],
+ "fields": [
+ {
+ "default": "Approval Pending",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_preview": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "Approval Pending\nPayment Pending\nPaid\nWithdrawn"
+ },
+ {
+ "fieldname": "company_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Company Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "company_logo",
+ "fieldtype": "Attach Image",
+ "label": "Company Logo",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_fhgg",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "tier",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Tier",
+ "options": "Sponsorship Tier"
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "website",
+ "fieldtype": "Data",
+ "label": "Website",
+ "options": "URL"
+ },
+ {
+ "fieldname": "country",
+ "fieldtype": "Link",
+ "label": "Country",
+ "options": "Country"
+ },
+ {
+ "fieldname": "phone",
+ "fieldtype": "Phone",
+ "label": "Phone"
+ },
+ {
+ "fieldname": "section_break_additional",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "additional_fields",
+ "fieldtype": "Table",
+ "label": "Additional Fields",
+ "options": "Additional Field"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "company_logo",
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "link_doctype": "Event Sponsor",
+ "link_fieldname": "enquiry"
+ },
+ {
+ "link_doctype": "Event Payment",
+ "link_fieldname": "reference_docname"
+ }
+ ],
+ "modified": "2025-10-28 17:00:34.475703",
+ "modified_by": "Administrator",
+ "module": "Proposals",
+ "name": "Sponsorship Enquiry",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "if_owner": 1,
+ "read": 1,
+ "role": "Pohodex Event Manager User"
+ }
+ ],
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "company_name",
+ "track_seen": 1,
+ "track_views": 1
+}
diff --git a/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py
new file mode 100644
index 0000000..98342af
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.py
@@ -0,0 +1,137 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.email.doctype.email_template.email_template import get_email_template
+from frappe.model.document import Document
+from frappe.utils import get_url
+
+from event_manager.payments import mark_payment_as_received
+
+
+class SponsorshipEnquiry(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ company_logo: DF.AttachImage
+ company_name: DF.Data
+ country: DF.Link | None
+ event: DF.Link
+ phone: DF.Phone | None
+ status: DF.Literal["Approval Pending", "Payment Pending", "Paid", "Withdrawn"]
+ tier: DF.Link | None
+ website: DF.Data | None
+ # end: auto-generated types
+
+ def on_update(self):
+ if self.has_value_changed("status") and self.status == "Payment Pending":
+ try:
+ self.send_approval_notification()
+ except Exception:
+ frappe.log_error("Error sending Sponsorship Approval Notification")
+
+ def on_payment_authorized(self, payment_status: str):
+ if payment_status in ("Authorized", "Completed"):
+ mark_payment_as_received(self.doctype, self.name)
+ frappe.get_doc(
+ {
+ "doctype": "Event Sponsor",
+ "company_name": self.company_name,
+ "company_logo": self.company_logo,
+ "event": self.event,
+ "tier": self.tier,
+ "enquiry": self.name,
+ "website": self.website,
+ }
+ ).insert(ignore_permissions=True)
+ self.db_set("status", "Paid")
+
+ @frappe.whitelist()
+ def create_sponsor(self):
+ frappe.only_for("Event Manager")
+
+ if not self.tier:
+ frappe.throw(frappe._("Please select a sponsorship tier!"))
+
+ frappe.get_doc(
+ {
+ "doctype": "Event Sponsor",
+ "company_name": self.company_name,
+ "company_logo": self.company_logo,
+ "event": self.event,
+ "tier": self.tier,
+ "enquiry": self.name,
+ "website": self.website,
+ "country": self.country,
+ }
+ ).insert(ignore_permissions=True)
+
+ def after_insert(self):
+ try:
+ self.send_pitch_deck()
+ except Exception:
+ frappe.log_error("Error sending Sponsor Pitch Deck")
+
+ def send_pitch_deck(self, now=False):
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
+ settings = frappe.get_cached_doc("Pohodex Event Manager Settings")
+
+ # Check event-level toggle first, then fall back to global
+ if not event.auto_send_pitch_deck and not settings.auto_send_pitch_deck:
+ return
+
+ # Get template: event-level takes precedence, fall back to global
+ template_name = event.sponsor_deck_email_template or settings.default_sponsor_deck_email_template
+ if not template_name:
+ frappe.log_error("No sponsor deck email template configured", "Sponsorship Enquiry")
+ return
+
+ email_template = get_email_template(template_name, {"doc": self, "event": event})
+
+ subject = email_template.get("subject")
+ content = email_template.get("message")
+
+ # Get CC and Reply-To: event-level takes precedence
+ cc = event.sponsor_deck_cc or settings.default_sponsor_deck_cc
+ reply_to = event.sponsor_deck_reply_to or settings.default_sponsor_deck_reply_to
+
+ frappe.sendmail(
+ recipients=[self.owner],
+ subject=subject,
+ cc=cc,
+ reply_to=reply_to,
+ content=content,
+ reference_doctype=self.doctype,
+ reference_name=self.name,
+ now=now,
+ attachments=[{"file_url": attachment.file} for attachment in event.sponsor_deck_attachments],
+ )
+
+ def send_approval_notification(self):
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
+ host_name = event.host or "The Event Team"
+ dashboard_link = get_url(f"/dashboard/account/sponsorships/{self.name}")
+
+ subject = f"[Payment Pending] Your Sponsorship for {event.title} has been Approved!"
+ message = f"""
+ Dear {self.company_name},
+
+ We are pleased to inform you that your sponsorship enquiry for {event.title} has been approved.
+
+ You can now proceed to select a sponsorship tier and complete the payment by visiting your dashboard here .
+
+ {host_name}
+ """
+
+ frappe.sendmail(
+ recipients=[self.owner],
+ subject=subject,
+ message=message,
+ reference_doctype=self.doctype,
+ reference_name=self.name,
+ )
diff --git a/event_manager/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py b/event_manager/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py
new file mode 100644
index 0000000..7c483fd
--- /dev/null
+++ b/event_manager/proposals/doctype/sponsorship_enquiry/test_sponsorship_enquiry.py
@@ -0,0 +1,171 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+EXTRA_TEST_RECORD_DEPENDENCIES = []
+IGNORE_TEST_RECORD_DEPENDENCIES = []
+
+
+class TestSponsorshipEnquiryEmail(IntegrationTestCase):
+ """Tests for Sponsorship Enquiry pitch deck email with template fallback logic."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ cls.test_event.auto_send_pitch_deck = False
+ cls.test_event.sponsor_deck_email_template = None
+ cls.test_event.sponsor_deck_cc = None
+ cls.test_event.sponsor_deck_reply_to = None
+ cls.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.auto_send_pitch_deck = False
+ settings.default_sponsor_deck_email_template = None
+ settings.default_sponsor_deck_cc = None
+ settings.default_sponsor_deck_reply_to = None
+ settings.save()
+
+ def _create_enquiry(self):
+ return frappe.get_doc(
+ {
+ "doctype": "Sponsorship Enquiry",
+ "event": self.test_event.name,
+ "company_name": "Test Sponsor Co",
+ "company_logo": "/files/test-logo.png",
+ }
+ ).insert()
+
+ def _create_template(self, name, subject_prefix):
+ if frappe.db.exists("Email Template", name):
+ frappe.delete_doc("Email Template", name, force=True)
+ return frappe.get_doc(
+ {
+ "doctype": "Email Template",
+ "name": name,
+ "subject": f"{subject_prefix} - {{{{ event.title }}}}",
+ "response": f"{subject_prefix} content
",
+ }
+ ).insert()
+
+ def tearDown(self):
+ for e in frappe.get_all("Sponsorship Enquiry", {"company_name": "Test Sponsor Co"}, pluck="name"):
+ frappe.delete_doc("Sponsorship Enquiry", e, force=True)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.test_event.auto_send_pitch_deck = False
+ cls.test_event.sponsor_deck_email_template = None
+ cls.test_event.sponsor_deck_cc = None
+ cls.test_event.sponsor_deck_reply_to = None
+ cls.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.auto_send_pitch_deck = False
+ settings.default_sponsor_deck_email_template = None
+ settings.default_sponsor_deck_cc = None
+ settings.default_sponsor_deck_reply_to = None
+ settings.save()
+ super().tearDownClass()
+
+ @patch("frappe.sendmail")
+ def test_no_email_when_disabled(self, mock_sendmail):
+ self.test_event.auto_send_pitch_deck = False
+ self.test_event.save()
+
+ enquiry = self._create_enquiry()
+ enquiry.send_pitch_deck()
+
+ mock_sendmail.assert_not_called()
+
+ @patch("frappe.sendmail")
+ def test_uses_event_settings(self, mock_sendmail):
+ template = self._create_template("Event Sponsor Template", "EVENT")
+ try:
+ self.test_event.auto_send_pitch_deck = True
+ self.test_event.sponsor_deck_email_template = template.name
+ self.test_event.sponsor_deck_reply_to = "event@test.com"
+ self.test_event.sponsor_deck_cc = "event-cc@test.com"
+ self.test_event.save()
+
+ # after_insert hook triggers send_pitch_deck automatically
+ self._create_enquiry()
+
+ mock_sendmail.assert_called_once()
+ args = mock_sendmail.call_args[1]
+ self.assertIn("EVENT", args["subject"])
+ self.assertEqual(args["reply_to"], "event@test.com")
+ self.assertEqual(args["cc"], "event-cc@test.com")
+ finally:
+ self.test_event.auto_send_pitch_deck = False
+ self.test_event.sponsor_deck_email_template = None
+ self.test_event.sponsor_deck_reply_to = None
+ self.test_event.sponsor_deck_cc = None
+ self.test_event.save()
+ frappe.delete_doc("Email Template", template.name, force=True)
+
+ @patch("frappe.sendmail")
+ def test_falls_back_to_global_settings(self, mock_sendmail):
+ template = self._create_template("Global Sponsor Template", "GLOBAL")
+ try:
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.auto_send_pitch_deck = True
+ settings.default_sponsor_deck_email_template = template.name
+ settings.default_sponsor_deck_reply_to = "global@test.com"
+ settings.default_sponsor_deck_cc = "global-cc@test.com"
+ settings.save()
+
+ # after_insert hook triggers send_pitch_deck automatically
+ self._create_enquiry()
+
+ mock_sendmail.assert_called_once()
+ args = mock_sendmail.call_args[1]
+ self.assertIn("GLOBAL", args["subject"])
+ self.assertEqual(args["reply_to"], "global@test.com")
+ self.assertEqual(args["cc"], "global-cc@test.com")
+ finally:
+ settings.auto_send_pitch_deck = False
+ settings.default_sponsor_deck_email_template = None
+ settings.default_sponsor_deck_reply_to = None
+ settings.default_sponsor_deck_cc = None
+ settings.save()
+ frappe.delete_doc("Email Template", template.name, force=True)
+
+ @patch("frappe.sendmail")
+ def test_event_settings_take_precedence(self, mock_sendmail):
+ event_template = self._create_template("Event Template", "EVENT")
+ global_template = self._create_template("Global Template", "GLOBAL")
+ try:
+ self.test_event.auto_send_pitch_deck = True
+ self.test_event.sponsor_deck_email_template = event_template.name
+ self.test_event.sponsor_deck_reply_to = "event@test.com"
+ self.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.auto_send_pitch_deck = True
+ settings.default_sponsor_deck_email_template = global_template.name
+ settings.default_sponsor_deck_reply_to = "global@test.com"
+ settings.save()
+
+ # after_insert hook triggers send_pitch_deck automatically
+ self._create_enquiry()
+
+ mock_sendmail.assert_called_once()
+ args = mock_sendmail.call_args[1]
+ self.assertIn("EVENT", args["subject"])
+ self.assertEqual(args["reply_to"], "event@test.com")
+ finally:
+ self.test_event.auto_send_pitch_deck = False
+ self.test_event.sponsor_deck_email_template = None
+ self.test_event.sponsor_deck_reply_to = None
+ self.test_event.save()
+ settings.auto_send_pitch_deck = False
+ settings.default_sponsor_deck_email_template = None
+ settings.default_sponsor_deck_reply_to = None
+ settings.save()
+ frappe.delete_doc("Email Template", event_template.name, force=True)
+ frappe.delete_doc("Email Template", global_template.name, force=True)
diff --git a/event_manager/proposals/doctype/talk_proposal/__init__.py b/event_manager/proposals/doctype/talk_proposal/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/proposals/doctype/talk_proposal/talk_proposal.js b/event_manager/proposals/doctype/talk_proposal/talk_proposal.js
new file mode 100644
index 0000000..9f3af9d
--- /dev/null
+++ b/event_manager/proposals/doctype/talk_proposal/talk_proposal.js
@@ -0,0 +1,21 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Talk Proposal", {
+ refresh(frm) {
+ if (frm.doc.status != "Accepted") {
+ const btn = frm.add_custom_button(__("Accept and Create Talk"), () => {
+ frm.call({
+ method: "create_talk",
+ doc: frm.doc,
+ btn,
+ }).then(({ message: talk }) => {
+ frm.set_value("status", "Accepted");
+ frm.save();
+ frm.refresh();
+ frappe.set_route("Form", "Event Talk", talk.name);
+ });
+ });
+ }
+ },
+});
diff --git a/event_manager/proposals/doctype/talk_proposal/talk_proposal.json b/event_manager/proposals/doctype/talk_proposal/talk_proposal.json
new file mode 100644
index 0000000..7cbf979
--- /dev/null
+++ b/event_manager/proposals/doctype/talk_proposal/talk_proposal.json
@@ -0,0 +1,160 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 11:59:34.416282",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "submitted_by",
+ "column_break_esac",
+ "status",
+ "event",
+ "section_break_yqfb",
+ "description",
+ "column_break_kemm",
+ "speakers",
+ "section_break_ouiw",
+ "phone",
+ "section_break_additional",
+ "additional_fields"
+ ],
+ "fields": [
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "submitted_by",
+ "fieldtype": "Link",
+ "label": "Submitted By",
+ "options": "User"
+ },
+ {
+ "fieldname": "column_break_esac",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Review Pending",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "label": "Status",
+ "options": "Review Pending\nShortlisted\nAccepted\nRejected"
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_yqfb",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Description"
+ },
+ {
+ "fieldname": "column_break_kemm",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "speakers",
+ "fieldtype": "Table",
+ "label": "Speakers",
+ "options": "Proposal Speaker",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_ouiw",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "phone",
+ "fieldtype": "Phone",
+ "label": "Phone"
+ },
+ {
+ "fieldname": "section_break_additional",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "additional_fields",
+ "fieldtype": "Table",
+ "label": "Additional Fields",
+ "options": "Additional Field"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "link_doctype": "Event Talk",
+ "link_fieldname": "proposal"
+ }
+ ],
+ "modified": "2026-03-23 17:28:23.254661",
+ "modified_by": "Administrator",
+ "module": "Proposals",
+ "name": "Talk Proposal",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [
+ {
+ "color": "Green",
+ "title": "Accepted"
+ }
+ ],
+ "title_field": "title",
+ "track_changes": 1,
+ "track_seen": 1,
+ "track_views": 1
+}
diff --git a/event_manager/proposals/doctype/talk_proposal/talk_proposal.py b/event_manager/proposals/doctype/talk_proposal/talk_proposal.py
new file mode 100644
index 0000000..3ff687d
--- /dev/null
+++ b/event_manager/proposals/doctype/talk_proposal/talk_proposal.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+
+
+class TalkProposal(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.proposals.doctype.proposal_speaker.proposal_speaker import ProposalSpeaker
+ from event_manager.ticketing.doctype.additional_field.additional_field import AdditionalField
+
+ additional_fields: DF.Table[AdditionalField]
+ description: DF.TextEditor | None
+ event: DF.Link
+ phone: DF.Phone | None
+ speakers: DF.Table[ProposalSpeaker]
+ status: DF.Literal["Review Pending", "Shortlisted", "Accepted", "Rejected"]
+ submitted_by: DF.Link | None
+ title: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ if not self.submitted_by:
+ self.submitted_by = frappe.session.user
+
+ @frappe.whitelist()
+ def create_talk(self):
+ talk = get_mapped_doc("Talk Proposal", self.name, {"Talk Proposal": {"doctype": "Event Talk"}})
+
+ for speaker in self.speakers:
+ user = frappe.db.exists("User", speaker.email)
+ if not user:
+ user = (
+ frappe.get_doc(
+ {
+ "doctype": "User",
+ "first_name": speaker.first_name,
+ "last_name": speaker.last_name,
+ "email": speaker.email,
+ }
+ )
+ .insert()
+ .name
+ )
+
+ speaker_profile = frappe.db.exists("Speaker Profile", {"user": user})
+ if not speaker_profile:
+ speaker_profile = frappe.get_doc({"doctype": "Speaker Profile", "user": user}).insert().name
+
+ talk.append("speakers", {"speaker": speaker_profile})
+ return talk.save()
diff --git a/event_manager/proposals/doctype/talk_proposal/test_talk_proposal.py b/event_manager/proposals/doctype/talk_proposal/test_talk_proposal.py
new file mode 100644
index 0000000..051fdf2
--- /dev/null
+++ b/event_manager/proposals/doctype/talk_proposal/test_talk_proposal.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestTalkProposal(IntegrationTestCase):
+ """
+ Integration tests for TalkProposal.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/public/.gitkeep b/event_manager/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/public/images/buzz-logo-round.png b/event_manager/public/images/buzz-logo-round.png
new file mode 100644
index 0000000..e0942ef
Binary files /dev/null and b/event_manager/public/images/buzz-logo-round.png differ
diff --git a/event_manager/public/images/buzz-logo.png b/event_manager/public/images/buzz-logo.png
new file mode 100644
index 0000000..d7b4c5b
Binary files /dev/null and b/event_manager/public/images/buzz-logo.png differ
diff --git a/event_manager/public/images/buzz-logo.svg b/event_manager/public/images/buzz-logo.svg
new file mode 100644
index 0000000..4b0bbcd
--- /dev/null
+++ b/event_manager/public/images/buzz-logo.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/event_manager/tasks.py b/event_manager/tasks.py
new file mode 100644
index 0000000..5e4982f
--- /dev/null
+++ b/event_manager/tasks.py
@@ -0,0 +1,12 @@
+import frappe
+from frappe.utils import today
+
+
+def unpublish_ticket_types_after_last_date():
+ frappe.db.set_value(
+ "Event Ticket Type",
+ {"is_published": True, "auto_unpublish_after": ("<", today())},
+ "is_published",
+ False,
+ )
+ frappe.db.commit()
diff --git a/event_manager/templates/__init__.py b/event_manager/templates/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/templates/emails/ticket.html b/event_manager/templates/emails/ticket.html
new file mode 100644
index 0000000..9312ea3
--- /dev/null
+++ b/event_manager/templates/emails/ticket.html
@@ -0,0 +1,350 @@
+
+
+
+
+
+
+
+
+
+
+ Your ticket for {{ event_title }} is ready!
+
+
+
+
+
+
+
+
+
+
+
+ 🎟️ Your Ticket is Ready!
+
+
+ Get ready for an amazing experience
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ event_title }}
+
+
+
+
+
+
+ 📅 DATE & TIME
+
+
+ {{ frappe.format_date(event_doc.start_date) }}
+
+ {% if event_doc.start_date != event_doc.end_date %}
+
+ - {{ frappe.format_date(event_doc.end_date) }}
+
+ {% endif %}
+
+
+ {{ frappe.utils.format_time(event_doc.start_time, "hh:mm a") }}
+ {% if event_doc.end_time %}
+ - {{ frappe.utils.format_time(event_doc.end_time, "hh:mm a") }}
+ {% endif %}
+
+ {{ event_doc.time_zone or "" }}
+
+
+
+
+ 📍 VENUE
+
+
+ {{ event_doc.venue }}
+
+
+ {{ frappe.db.get_value("Event Venue", event_doc.venue, "address") }}
+
+
+
+
+
+
+
+
+
+
+
+ 👤 ATTENDEE
+
+
+ {{ doc.attendee_name }}
+
+
+
+
+ 🎫 TICKET TYPE
+
+
+ {{ frappe.db.get_value("Event Ticket Type", doc.ticket_type, "title") }}
+
+
+
+
+
+
+
+
+
+
+ 🔢 TICKET ID
+
+
+ {{ doc.name }}
+
+
+ {% if doc.add_ons %}
+
+
+ ✨ ADD-ONS
+
+
+ {% for add_on in doc.add_ons %}
+
+ •
+ {{ frappe.db.get_value("Ticket Add-on", add_on.add_on, "title") }} ({{ add_on.value }})
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Entry QR Code
+
+
+
+
+
+ Show this QR code at the venue entrance for quick check-in
+
+
+
+
+
+
+ {% set event_settings = frappe.get_cached_doc("Pohodex Event Manager Settings") %}
+ {% if event_settings.support_email %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
diff --git a/event_manager/templates/ics/ics.jinja2 b/event_manager/templates/ics/ics.jinja2
new file mode 100644
index 0000000..1b32510
--- /dev/null
+++ b/event_manager/templates/ics/ics.jinja2
@@ -0,0 +1,18 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Pohodex Event Manager Events//EN
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+BEGIN:VEVENT
+UID:{{uid}}@event_manager
+DTSTAMP:{{now}}
+DTSTART;TZID={{timezone}}:{{start}}
+DTEND;TZID={{timezone}}:{{end}}
+SUMMARY:{{title}}
+LOCATION:{{location}}
+{% if organizer_email %}ORGANIZER;CN={{organizer_name}}:mailto:{{organizer_email}}
+{% endif %}
+ATTENDEE;CN=Attendee;RSVP=TRUE:mailto:{{attendee_email}}
+DESCRIPTION:{{description}}
+END:VEVENT
+END:VCALENDAR
\ No newline at end of file
diff --git a/event_manager/templates/pages/__init__.py b/event_manager/templates/pages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/__init__.py b/event_manager/ticketing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/__init__.py b/event_manager/ticketing/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/additional_field/__init__.py b/event_manager/ticketing/doctype/additional_field/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/additional_field/additional_field.json b/event_manager/ticketing/doctype/additional_field/additional_field.json
new file mode 100644
index 0000000..aa8f83a
--- /dev/null
+++ b/event_manager/ticketing/doctype/additional_field/additional_field.json
@@ -0,0 +1,60 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-11-01 11:36:52.321319",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "label",
+ "fieldname",
+ "column_break_nptw",
+ "value",
+ "fieldtype"
+ ],
+ "fields": [
+ {
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Label"
+ },
+ {
+ "fieldname": "fieldname",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Fieldname",
+ "reqd": 1
+ },
+ {
+ "fieldname": "value",
+ "fieldtype": "Code",
+ "in_list_view": 1,
+ "label": "Value"
+ },
+ {
+ "fieldname": "column_break_nptw",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "fieldtype",
+ "fieldtype": "Data",
+ "label": "Fieldtype"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-11-01 11:38:22.581796",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Additional Field",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/additional_field/additional_field.py b/event_manager/ticketing/doctype/additional_field/additional_field.py
new file mode 100644
index 0000000..87587f2
--- /dev/null
+++ b/event_manager/ticketing/doctype/additional_field/additional_field.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class AdditionalField(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ fieldname: DF.Data
+ fieldtype: DF.Data | None
+ label: DF.Data | None
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ value: DF.Data
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/ticketing/doctype/attendee_ticket_add_on/__init__.py b/event_manager/ticketing/doctype/attendee_ticket_add_on/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.js b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.js
new file mode 100644
index 0000000..cae5fd8
--- /dev/null
+++ b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.js
@@ -0,0 +1,19 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Attendee Ticket Add-on", {
+// });
+
+frappe.ui.form.on("Ticket Add-on Value", {
+ add_on(frm, cdt, cdn) {
+ const doc = frappe.get_doc(cdt, cdn);
+ frappe.db.get_value("Ticket Add-on", doc.add_on, "options").then(({ message }) => {
+ if (message.options) {
+ const options = message.options.trim().split("\n");
+ frm.grids[0].grid.grid_rows_by_docname[cdn].on_grid_fields_dict.value.set_data(
+ options
+ );
+ }
+ });
+ },
+});
diff --git a/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.json b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.json
new file mode 100644
index 0000000..8829b60
--- /dev/null
+++ b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.json
@@ -0,0 +1,73 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 12:59:48.053719",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "attendee_name",
+ "section_break_rejz",
+ "add_ons"
+ ],
+ "fields": [
+ {
+ "fieldname": "add_ons",
+ "fieldtype": "Table",
+ "label": "Add ons",
+ "options": "Ticket Add-on Value"
+ },
+ {
+ "fieldname": "section_break_rejz",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fetch_from": "attendee.full_name",
+ "fieldname": "attendee_name",
+ "fieldtype": "Data",
+ "label": "Attendee Name",
+ "read_only": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-08-10 14:17:52.755675",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Attendee Ticket Add-on",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "read_only": 1,
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "attendee_name"
+}
diff --git a/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.py b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.py
new file mode 100644
index 0000000..d70e292
--- /dev/null
+++ b/event_manager/ticketing/doctype/attendee_ticket_add_on/attendee_ticket_add_on.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class AttendeeTicketAddon(Document):
+ pass
diff --git a/event_manager/ticketing/doctype/attendee_ticket_add_on/test_attendee_ticket_add_on.py b/event_manager/ticketing/doctype/attendee_ticket_add_on/test_attendee_ticket_add_on.py
new file mode 100644
index 0000000..73ff029
--- /dev/null
+++ b/event_manager/ticketing/doctype/attendee_ticket_add_on/test_attendee_ticket_add_on.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestAttendeeTicketAddon(IntegrationTestCase):
+ """
+ Integration tests for AttendeeTicketAddon.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/ticketing/doctype/buzz_coupon_code/__init__.py b/event_manager/ticketing/doctype/buzz_coupon_code/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.js b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.js
new file mode 100644
index 0000000..3b8c94b
--- /dev/null
+++ b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.js
@@ -0,0 +1,42 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Pohodex Event Manager Coupon Code", {
+ refresh(frm) {
+ frm.set_query("ticket_type", () => {
+ return {
+ filters: {
+ event: frm.doc.event,
+ },
+ };
+ });
+
+ frm.set_query("add_on", "free_add_ons", () => {
+ return {
+ filters: {
+ event: frm.doc.event,
+ },
+ };
+ });
+
+ frm.trigger("coupon_type");
+ frm.trigger("applies_to");
+ },
+
+ coupon_type(frm) {
+ if (frm.doc.coupon_type === "Free Tickets") {
+ frm.set_value("applies_to", "Event");
+ }
+ },
+
+ applies_to(frm) {
+ if (frm.doc.applies_to === "Event") {
+ frm.set_value("event_category", null);
+ } else if (frm.doc.applies_to === "Event Category") {
+ frm.set_value("event", null);
+ } else {
+ frm.set_value("event", null);
+ frm.set_value("event_category", null);
+ }
+ },
+});
diff --git a/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.json b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.json
new file mode 100644
index 0000000..bf450e5
--- /dev/null
+++ b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.json
@@ -0,0 +1,255 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "field:code",
+ "creation": "2025-12-30 19:40:20.803479",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "code",
+ "coupon_type",
+ "applies_to",
+ "column_break_hhol",
+ "is_active",
+ "event",
+ "event_category",
+ "section_break_nvvh",
+ "ticket_type",
+ "number_of_free_tickets",
+ "discount_type",
+ "discount_value",
+ "column_break_pbrj",
+ "maximum_discount_amount",
+ "minimum_order_value",
+ "validity_limits_section",
+ "valid_from",
+ "valid_till",
+ "column_break_elot",
+ "max_usage_count",
+ "max_usage_per_user",
+ "section_break_ygde",
+ "free_add_ons",
+ "usage_statistics_section",
+ "times_used",
+ "column_break_zogd",
+ "free_tickets_claimed"
+ ],
+ "fields": [
+ {
+ "description": "Leave empty to auto-generate",
+ "fieldname": "code",
+ "fieldtype": "Data",
+ "label": "Code",
+ "set_only_once": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "coupon_type",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Coupon Type",
+ "options": "Free Tickets\nDiscount",
+ "reqd": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "is_active",
+ "fieldtype": "Check",
+ "label": "Is Active"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Free Tickets' || doc.applies_to == 'Event'",
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "label": "Event",
+ "mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets' || doc.applies_to == 'Event'",
+ "options": "Pohodex Event Manager Event"
+ },
+ {
+ "depends_on": "eval:doc.applies_to == 'Event Category'",
+ "fieldname": "event_category",
+ "fieldtype": "Link",
+ "label": "Event Category",
+ "mandatory_depends_on": "eval:doc.applies_to == 'Event Category'",
+ "options": "Event Category"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "fieldname": "ticket_type",
+ "fieldtype": "Link",
+ "label": "Ticket Type",
+ "mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "options": "Event Ticket Type"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "fieldname": "number_of_free_tickets",
+ "fieldtype": "Int",
+ "label": "Number of Free Tickets",
+ "mandatory_depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "non_negative": 1
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "fieldname": "free_add_ons",
+ "fieldtype": "Table",
+ "label": "Free Add-ons",
+ "options": "Coupon Free Add-on"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount'",
+ "fieldname": "discount_type",
+ "fieldtype": "Select",
+ "label": "Discount Type",
+ "mandatory_depends_on": "eval:doc.coupon_type == 'Discount'",
+ "options": "Percentage\nFlat Amount"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount'",
+ "fieldname": "discount_value",
+ "fieldtype": "Float",
+ "label": "Discount Value",
+ "mandatory_depends_on": "eval:doc.coupon_type == 'Discount'",
+ "non_negative": 1
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount'",
+ "description": "0 is unlimited",
+ "fieldname": "max_usage_count",
+ "fieldtype": "Int",
+ "label": "Max Usage Count",
+ "non_negative": 1
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount'",
+ "fieldname": "times_used",
+ "fieldtype": "Int",
+ "is_virtual": 1,
+ "label": "Times Used",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Free Tickets'",
+ "fieldname": "free_tickets_claimed",
+ "fieldtype": "Int",
+ "is_virtual": 1,
+ "label": "Free Tickets Claimed",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_hhol",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_nvvh",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_pbrj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount' && doc.discount_type == 'Percentage'",
+ "fieldname": "maximum_discount_amount",
+ "fieldtype": "Float",
+ "label": "Maximum Discount Amount",
+ "non_negative": 1
+ },
+ {
+ "fieldname": "section_break_ygde",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "eval:doc.coupon_type == 'Discount'",
+ "fieldname": "minimum_order_value",
+ "fieldtype": "Float",
+ "label": "Minimum Order Value",
+ "non_negative": 1
+ },
+ {
+ "fieldname": "validity_limits_section",
+ "fieldtype": "Section Break",
+ "label": "Validity & Limits"
+ },
+ {
+ "description": "Coupon active from this date (leave empty for immediate)",
+ "fieldname": "valid_from",
+ "fieldtype": "Date",
+ "label": "Valid From"
+ },
+ {
+ "description": "Coupon expires after this date (leave empty for no expiry)",
+ "fieldname": "valid_till",
+ "fieldtype": "Date",
+ "label": "Valid Till"
+ },
+ {
+ "fieldname": "column_break_elot",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "description": "0 = unlimited",
+ "fieldname": "max_usage_per_user",
+ "fieldtype": "Int",
+ "label": "Max Usage Per User",
+ "non_negative": 1
+ },
+ {
+ "fieldname": "usage_statistics_section",
+ "fieldtype": "Section Break",
+ "label": "Usage Statistics"
+ },
+ {
+ "fieldname": "column_break_zogd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "applies_to",
+ "fieldtype": "Select",
+ "label": "Applies To",
+ "options": "\nEvent\nEvent Category",
+ "read_only_depends_on": "eval:doc.coupon_type == 'Free Tickets'"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-01-12 10:38:02.737227",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Pohodex Event Manager Coupon Code",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.py b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.py
new file mode 100644
index 0000000..a2fa826
--- /dev/null
+++ b/event_manager/ticketing/doctype/buzz_coupon_code/buzz_coupon_code.py
@@ -0,0 +1,165 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class BuzzCouponCode(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.ticketing.doctype.coupon_free_add_on.coupon_free_add_on import CouponFreeAddon
+
+ applies_to: DF.Literal["", "Event", "Event Category"]
+ code: DF.Data | None
+ coupon_type: DF.Literal["Free Tickets", "Discount"]
+ discount_type: DF.Literal["Percentage", "Flat Amount"]
+ discount_value: DF.Float
+ event: DF.Link | None
+ event_category: DF.Link | None
+ free_add_ons: DF.Table[CouponFreeAddon]
+ is_active: DF.Check
+ max_usage_count: DF.Int
+ max_usage_per_user: DF.Int
+ maximum_discount_amount: DF.Float
+ minimum_order_value: DF.Float
+ number_of_free_tickets: DF.Int
+ ticket_type: DF.Link | None
+ valid_from: DF.Date | None
+ valid_till: DF.Date | None
+ # end: auto-generated types
+
+ def autoname(self):
+ if not self.code:
+ self.code = frappe.generate_hash(length=8).upper()
+
+ def validate(self):
+ self.validate_discount_value()
+ self.validate_scope()
+ self.validate_free_tickets_event()
+ self.validate_validity_dates()
+
+ def validate_validity_dates(self):
+ if self.valid_from and self.valid_till:
+ if self.valid_from > self.valid_till:
+ frappe.throw(_("Valid From cannot be after Valid Till"))
+
+ def validate_discount_value(self):
+ if self.coupon_type == "Discount":
+ if self.discount_value <= 0:
+ frappe.throw(_("Discount value must be greater than 0"))
+ if self.discount_type == "Percentage" and self.discount_value > 100:
+ frappe.throw(_("Percentage discount cannot exceed 100%"))
+
+ def validate_scope(self):
+ if self.applies_to == "Event":
+ self.event_category = None
+ elif self.applies_to == "Event Category":
+ self.event = None
+ else:
+ self.event = None
+ self.event_category = None
+
+ def validate_free_tickets_event(self):
+ if self.coupon_type == "Free Tickets":
+ if self.applies_to != "Event":
+ frappe.throw(_("Free Tickets coupon must be restricted to an Event"))
+ if not self.event:
+ frappe.throw(_("Event is required for Free Tickets coupon"))
+ if not self.ticket_type:
+ frappe.throw(_("Ticket Type is required for Free Tickets coupon"))
+ if self.number_of_free_tickets <= 0:
+ frappe.throw(_("Number of free tickets must be greater than 0"))
+
+ def is_valid_for_event(self, event_name):
+ if not self.is_active:
+ return False, _("Coupon is not active")
+
+ is_valid, msg = self.is_within_validity_period()
+ if not is_valid:
+ return False, msg
+
+ if not self.applies_to:
+ return True, ""
+
+ if self.applies_to == "Event":
+ if str(self.event) != str(event_name):
+ return False, _("Coupon is not valid for this event")
+ return True, ""
+
+ if self.applies_to == "Event Category":
+ event_category = frappe.get_cached_value("Pohodex Event Manager Event", event_name, "category")
+ if not event_category or str(event_category) != str(self.event_category):
+ return False, _("Coupon is not valid for this event category")
+ return True, ""
+
+ def is_usage_available(self):
+ if self.max_usage_count > 0:
+ if self.times_used >= self.max_usage_count:
+ return False, _("Coupon usage limit reached")
+ return True, ""
+
+ def is_min_order_met(self, order_amount):
+ if self.minimum_order_value > 0:
+ if order_amount < self.minimum_order_value:
+ gap = self.minimum_order_value - order_amount
+ return False, _("Add {0} more to use this coupon (min order {1})").format(
+ gap, self.minimum_order_value
+ )
+ return True, ""
+
+ def is_within_validity_period(self):
+ today = frappe.utils.getdate()
+
+ if self.valid_from and today < frappe.utils.getdate(self.valid_from):
+ return False, _("Coupon is not yet active (starts {0})").format(self.valid_from)
+
+ if self.valid_till and today > frappe.utils.getdate(self.valid_till):
+ return False, _("Coupon expired on {0}").format(self.valid_till)
+
+ return True, ""
+
+ def is_user_limit_reached(self, user=None):
+ if not self.max_usage_per_user:
+ return False, ""
+
+ user = user or frappe.session.user
+ user_usage = frappe.db.count(
+ "Event Booking", {"coupon_code": self.name, "user": user, "docstatus": 1}
+ )
+
+ if user_usage >= self.max_usage_per_user:
+ return True, _("You have reached the maximum usage limit for this coupon")
+
+ return False, ""
+
+ @property
+ def times_used(self):
+ return frappe.db.count("Event Booking", {"coupon_code": self.name, "docstatus": 1})
+
+ @property
+ def free_tickets_claimed(self):
+ """Calculate total attendees from all submitted bookings using this coupon"""
+ from frappe.query_builder.functions import Count
+
+ EventBooking = frappe.qb.DocType("Event Booking")
+ EventBookingAttendee = frappe.qb.DocType("Event Booking Attendee")
+
+ count = (
+ frappe.qb.from_(EventBookingAttendee)
+ .join(EventBooking)
+ .on(EventBooking.name == EventBookingAttendee.parent)
+ .where(EventBooking.coupon_code == self.name)
+ .where(EventBooking.docstatus == 1)
+ .where(EventBookingAttendee.ticket_type == self.ticket_type)
+ .select(Count(EventBookingAttendee.name))
+ ).run()[0][0]
+
+ return count or 0
diff --git a/event_manager/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py b/event_manager/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py
new file mode 100644
index 0000000..b485d31
--- /dev/null
+++ b/event_manager/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py
@@ -0,0 +1,1338 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+EXTRA_TEST_RECORD_DEPENDENCIES = []
+IGNORE_TEST_RECORD_DEPENDENCIES = []
+
+
+class IntegrationTestBuzzCouponCode(IntegrationTestCase):
+ """
+ Integration tests for BuzzCouponCode.
+ Use this class for testing interactions between multiple components.
+ """
+
+ def setUp(self):
+ """Set up test fixtures."""
+ self.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self.test_event.apply_tax = False
+ self.test_event.save()
+
+ # Create test ticket type
+ self.test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": self.test_event.name,
+ "title": "Coupon Test Ticket",
+ "price": 500,
+ "is_published": True,
+ }
+ ).insert()
+
+ # Create test add-on
+ self.test_add_on = frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": self.test_event.name,
+ "title": f"Test T-Shirt {frappe.generate_hash(length=6)}",
+ "price": 200,
+ }
+ ).insert()
+
+ # ==================== DISCOUNT COUPON TESTS ====================
+
+ def test_percentage_discount_applies_correctly(self):
+ """Test that percentage discount is calculated correctly."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 20,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Jane Doe",
+ "email": "jane@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 2 tickets x 500 = 1000, 20% discount = 200
+ self.assertEqual(booking.net_amount, 1000)
+ self.assertEqual(booking.discount_amount, 200)
+ self.assertEqual(booking.total_amount, 800)
+
+ def test_flat_discount_applies_correctly(self):
+ """Test that flat amount discount is calculated correctly."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Flat Amount",
+ "discount_value": 300,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 1 ticket x 500 = 500, flat 300 discount
+ self.assertEqual(booking.net_amount, 500)
+ self.assertEqual(booking.discount_amount, 300)
+ self.assertEqual(booking.total_amount, 200)
+
+ def test_flat_discount_does_not_exceed_total(self):
+ """Test that flat discount is capped at net amount."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Flat Amount",
+ "discount_value": 1000, # More than ticket price
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # Discount should be capped at 500 (net amount), not 1000
+ self.assertEqual(booking.net_amount, 500)
+ self.assertEqual(booking.discount_amount, 500)
+ self.assertEqual(booking.total_amount, 0)
+
+ def test_discount_coupon_usage_limit_enforced(self):
+ """Test that discount coupon usage limit is enforced."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "max_usage_count": 2,
+ "is_active": True,
+ }
+ ).insert()
+
+ # First booking - should succeed
+ booking1 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User 1",
+ "email": "user1@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking1.submit()
+
+ # Second booking - should succeed
+ booking2 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User 2",
+ "email": "user2@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking2.submit()
+
+ # Third booking - should fail (usage limit reached)
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User 3",
+ "email": "user3@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ def test_discount_coupon_unlimited_usage(self):
+ """Test that coupon with max_usage_count=0 has unlimited usage."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 5,
+ "max_usage_count": 0, # Unlimited
+ "is_active": True,
+ }
+ ).insert()
+
+ # Create 5 bookings - all should succeed
+ for i in range(5):
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": f"User {i}",
+ "email": f"user{i}@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking.submit()
+
+ # Verify all 5 bookings were created
+ self.assertEqual(coupon.times_used, 5)
+
+ # ==================== MAX DISCOUNT CAP TESTS ====================
+
+ def test_percentage_discount_capped_at_max(self):
+ """Test that percentage discount is capped at maximum_discount_amount."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 50,
+ "maximum_discount_amount": 500,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Jane Doe",
+ "email": "jane@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Bob Smith",
+ "email": "bob@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Alice Brown",
+ "email": "alice@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 4 tickets x 500 = 2000, 50% = 1000, but capped at 500
+ self.assertEqual(booking.net_amount, 2000)
+ self.assertEqual(booking.discount_amount, 500)
+ self.assertEqual(booking.total_amount, 1500)
+
+ # ==================== MINIMUM ORDER VALUE TESTS ====================
+
+ def test_min_order_value_enforced(self):
+ """Test that coupon is rejected when order is below minimum_order_value."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 20,
+ "minimum_order_value": 1000,
+ "is_active": True,
+ }
+ ).insert()
+
+ # Order of 500 (1 ticket) is below minimum of 1000
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ def test_percentage_with_cap_and_min_order(self):
+ """Test coupon with both maximum_discount_amount and minimum_order_value."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 50,
+ "maximum_discount_amount": 300,
+ "minimum_order_value": 500,
+ "is_active": True,
+ }
+ ).insert()
+
+ # Order of 500 (1 ticket) meets minimum, 50% = 250, below cap
+ booking1 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(booking1.net_amount, 500)
+ self.assertEqual(booking1.discount_amount, 250)
+ self.assertEqual(booking1.total_amount, 250)
+
+ # Order of 1000 (2 tickets), 50% = 500, capped at 300
+ booking2 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Jane Doe",
+ "email": "jane@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Bob Smith",
+ "email": "bob@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(booking2.net_amount, 1000)
+ self.assertEqual(booking2.discount_amount, 300)
+ self.assertEqual(booking2.total_amount, 700)
+
+ # ==================== FREE TICKETS COUPON TESTS ====================
+
+ def test_free_tickets_applied_correctly(self):
+ """Test that free tickets coupon makes tickets free."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 2,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Speaker 1",
+ "email": "speaker1@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Speaker 2",
+ "email": "speaker2@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # Both tickets should be free
+ self.assertEqual(booking.net_amount, 1000)
+ self.assertEqual(booking.discount_amount, 1000)
+ self.assertEqual(booking.total_amount, 0)
+
+ def test_partial_free_tickets(self):
+ """Test that only N tickets are free when booking more than free limit."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 2,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Person 1",
+ "email": "person1@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Person 2",
+ "email": "person2@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Person 3",
+ "email": "person3@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 3 tickets x 500 = 1500, 2 free = 1000 discount
+ self.assertEqual(booking.net_amount, 1500)
+ self.assertEqual(booking.discount_amount, 1000)
+ self.assertEqual(booking.total_amount, 500)
+
+ def test_partial_free_tickets_with_paid_addon(self):
+ """Test 1 free ticket + 2 attendees + paid add-on."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 1,
+ "is_active": True,
+ # No free_add_ons - T-Shirt should be paid
+ }
+ ).insert()
+
+ # Create attendee add-on (paid, not in free list)
+ attendee_add_on = frappe.get_doc(
+ {
+ "doctype": "Attendee Ticket Add-on",
+ "add_ons": [{"add_on": self.test_add_on.name, "value": "XL"}],
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Person 1",
+ "email": "person1@test.com",
+ "add_ons": attendee_add_on.name,
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Person 2",
+ "email": "person2@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 2 tickets x 500 = 1000, 1 add-on x 200 = 200
+ # Net = 1200
+ # Only 1 ticket free = 500 discount
+ # Total = 700 (1 paid ticket + 1 paid add-on)
+ self.assertEqual(booking.net_amount, 1200)
+ self.assertEqual(booking.discount_amount, 500)
+ self.assertEqual(booking.total_amount, 700)
+
+ def test_free_tickets_tracking_across_bookings(self):
+ """Test that free tickets are tracked across multiple bookings."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 5,
+ "is_active": True,
+ }
+ ).insert()
+
+ # First booking: claim 2 free tickets
+ booking1 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User A1",
+ "email": "usera1@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User A2",
+ "email": "usera2@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking1.submit()
+
+ # Verify 2 claimed
+ self.assertEqual(coupon.free_tickets_claimed, 2)
+
+ # Second booking: claim 2 more (4 total, 1 remaining)
+ booking2 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User B1",
+ "email": "userb1@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User B2",
+ "email": "userb2@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking2.submit()
+
+ # Verify 4 claimed
+ self.assertEqual(coupon.free_tickets_claimed, 4)
+
+ # Third booking: try to claim 3, but only 1 remaining
+ booking3 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User C1",
+ "email": "userc1@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User C2",
+ "email": "userc2@test.com",
+ },
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User C3",
+ "email": "userc3@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # 3 tickets x 500 = 1500, only 1 free remaining = 500 discount
+ self.assertEqual(booking3.discount_amount, 500)
+ self.assertEqual(booking3.total_amount, 1000)
+
+ def test_free_tickets_with_free_addons(self):
+ """Test that free add-ons are discounted for free ticket holders."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 1,
+ "free_add_ons": [{"add_on": self.test_add_on.name}],
+ "is_active": True,
+ }
+ ).insert()
+
+ # Create attendee add-on
+ attendee_add_on = frappe.get_doc(
+ {
+ "doctype": "Attendee Ticket Add-on",
+ "add_ons": [{"add_on": self.test_add_on.name, "value": "XL"}],
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Speaker",
+ "email": "speaker@test.com",
+ "add_ons": attendee_add_on.name,
+ },
+ ],
+ }
+ ).insert()
+
+ # Ticket (500) + Add-on (200) = 700, both free
+ self.assertEqual(booking.net_amount, 700)
+ self.assertEqual(booking.discount_amount, 700)
+ self.assertEqual(booking.total_amount, 0)
+
+ # ==================== VALIDATION TESTS ====================
+
+ def test_coupon_event_scope_validation(self):
+ """Test that coupon scoped to event rejects other events."""
+ # Get category and host from test event
+ category = self.test_event.category
+ host = self.test_event.host
+
+ other_event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Other Event",
+ "route": "other-event-test",
+ "start_date": "2025-12-31",
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ "category": category,
+ "host": host,
+ }
+ ).insert()
+
+ other_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": other_event.name,
+ "title": "Other Ticket",
+ "price": 100,
+ "is_published": True,
+ }
+ ).insert()
+
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "applies_to": "Event",
+ "event": self.test_event.name, # Scoped to test_event
+ "is_active": True,
+ }
+ ).insert()
+
+ # Try to use on other event - should fail
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": other_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": other_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ def test_coupon_category_scope_validation(self):
+ """Test that coupon scoped to event_category applies to events in that category."""
+ # Create a different category
+ other_category = frappe.get_doc(
+ {
+ "doctype": "Event Category",
+ "name": "Other Test Category",
+ "slug": "other-test-category",
+ }
+ ).insert()
+
+ # Create coupon scoped to test_event's category
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "applies_to": "Event Category",
+ "event_category": self.test_event.category, # Scoped to category
+ "is_active": True,
+ }
+ ).insert()
+
+ # Should work for event in same category
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+ self.assertGreater(booking.discount_amount, 0)
+
+ # Create event in different category
+ other_event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Other Category Event",
+ "route": "other-category-event-test",
+ "start_date": "2025-12-31",
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ "category": other_category.name,
+ "host": self.test_event.host,
+ }
+ ).insert()
+
+ other_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": other_event.name,
+ "title": "Other Category Ticket",
+ "price": 100,
+ "is_published": True,
+ }
+ ).insert()
+
+ # Should fail for event in different category
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": other_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": other_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ def test_coupon_global_scope(self):
+ """Test that coupon with no event/event_category applies to all events."""
+ # Create coupon with no event or category (global scope)
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 15,
+ "is_active": True,
+ # No event or event_category - should apply globally
+ }
+ ).insert()
+
+ # Should work for test_event
+ booking1 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "User 1",
+ "email": "user1@test.com",
+ },
+ ],
+ }
+ ).insert()
+ self.assertGreater(booking1.discount_amount, 0)
+
+ # Create another event
+ other_event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Another Event",
+ "route": "another-event-global-test",
+ "start_date": "2025-12-31",
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ "category": self.test_event.category,
+ "host": self.test_event.host,
+ }
+ ).insert()
+
+ other_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": other_event.name,
+ "title": "Another Event Ticket",
+ "price": 200,
+ "is_published": True,
+ }
+ ).insert()
+
+ # Should also work for other event (global scope)
+ booking2 = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": other_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": other_ticket_type.name,
+ "first_name": "User 2",
+ "email": "user2@test.com",
+ },
+ ],
+ }
+ ).insert()
+ self.assertGreater(booking2.discount_amount, 0)
+
+ def test_inactive_coupon_rejected(self):
+ """Test that inactive coupon is rejected."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "is_active": False, # Inactive
+ }
+ ).insert()
+
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ def test_free_tickets_requires_event(self):
+ """Test that Free Tickets coupon requires event."""
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 5,
+ "is_active": True,
+ # No event - should fail
+ }
+ ).insert()
+
+ def test_free_tickets_requires_specific_event_restriction(self):
+ """Test that Free Tickets coupon requires applies_to='Event'."""
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event Category",
+ "event_category": self.test_event.category,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 2,
+ "is_active": True,
+ }
+ ).insert()
+
+ def test_free_tickets_rejects_all_events(self):
+ """Test that Free Tickets coupon cannot use applies_to=''."""
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Free Tickets",
+ "applies_to": "",
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 2,
+ "is_active": True,
+ }
+ ).insert()
+
+ def test_specific_event_clears_event_category(self):
+ """Test that applies_to='Event' clears event_category field."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "event_category": self.test_event.category, # Should be cleared
+ "is_active": True,
+ }
+ ).insert()
+
+ self.assertIsNone(coupon.event_category)
+
+ def test_event_category_clears_event(self):
+ """Test that applies_to='Event Category' clears event field."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "applies_to": "Event Category",
+ "event": self.test_event.name, # Should be cleared
+ "event_category": self.test_event.category,
+ "is_active": True,
+ }
+ ).insert()
+
+ self.assertIsNone(coupon.event)
+
+ def test_all_events_clears_both_scope_fields(self):
+ """Test that applies_to='' clears both event and event_category."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "applies_to": "",
+ "event": self.test_event.name, # Should be cleared
+ "event_category": self.test_event.category, # Should be cleared
+ "is_active": True,
+ }
+ ).insert()
+
+ self.assertIsNone(coupon.event)
+ self.assertIsNone(coupon.event_category)
+
+ def test_percentage_discount_cannot_exceed_100(self):
+ """Test that percentage discount cannot exceed 100%."""
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 150, # More than 100%
+ "is_active": True,
+ }
+ ).insert()
+
+ def test_buzz_coupon_code_auto_generated(self):
+ """Test that coupon code is auto-generated if not provided."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "is_active": True,
+ # No code provided
+ }
+ ).insert()
+
+ self.assertIsNotNone(coupon.code)
+ self.assertEqual(len(coupon.code), 8)
+
+ def test_coupon_tracked_in_booking(self):
+ """Test that coupon code is tracked in booking document."""
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "TESTTRACK",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # Coupon should be tracked in booking
+ self.assertEqual(booking.coupon_code, "TESTTRACK")
+
+
+class TestValidateCouponAPI(IntegrationTestCase):
+ """Test the validate_coupon API endpoint."""
+
+ def setUp(self):
+ self.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self.test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": self.test_event.name,
+ "title": "API Test Ticket",
+ "price": 100,
+ "is_published": True,
+ }
+ ).insert()
+
+ def test_validate_coupon_returns_discount_info(self):
+ """Test that validate_coupon returns correct discount info."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "APITEST",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 25,
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("APITEST", str(self.test_event.name))
+
+ self.assertTrue(result["valid"])
+ self.assertEqual(result["coupon_type"], "Discount")
+ self.assertEqual(result["discount_type"], "Percentage")
+ self.assertEqual(result["discount_value"], 25)
+
+ def test_validate_coupon_returns_max_and_min_values(self):
+ """Test that validate_coupon returns max_discount_amount and min_order_value."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "MAXMIN",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 30,
+ "maximum_discount_amount": 500,
+ "minimum_order_value": 200,
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("MAXMIN", str(self.test_event.name))
+
+ self.assertTrue(result["valid"])
+ self.assertEqual(result["max_discount_amount"], 500)
+ self.assertEqual(result["min_order_value"], 200)
+
+ def test_validate_coupon_returns_free_tickets_info(self):
+ """Test that validate_coupon returns correct free tickets info."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "FREEAPI",
+ "coupon_type": "Free Tickets",
+ "applies_to": "Event",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "number_of_free_tickets": 3,
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("FREEAPI", str(self.test_event.name))
+
+ self.assertTrue(result["valid"])
+ self.assertEqual(result["coupon_type"], "Free Tickets")
+ self.assertEqual(result["remaining_tickets"], 3)
+
+ def test_validate_coupon_invalid_code(self):
+ """Test that invalid coupon code returns error."""
+ from event_manager.api import validate_coupon
+
+ result = validate_coupon("INVALIDCODE", str(self.test_event.name))
+
+ self.assertFalse(result["valid"])
+ self.assertIn("error", result)
+
+ # ==================== VALIDITY PERIOD TESTS ====================
+
+ def test_coupon_not_yet_active(self):
+ """Test that coupon with future valid_from is rejected."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "FUTURESTART",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "valid_from": frappe.utils.add_days(frappe.utils.today(), 1),
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("FUTURESTART", str(self.test_event.name))
+
+ self.assertFalse(result["valid"])
+ self.assertIn("not yet active", result["error"].lower())
+
+ def test_expired_coupon_rejected(self):
+ """Test that expired coupon is rejected."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "EXPIRED",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "valid_till": frappe.utils.add_days(frappe.utils.today(), -1),
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("EXPIRED", str(self.test_event.name))
+
+ self.assertFalse(result["valid"])
+ self.assertIn("expired", result["error"].lower())
+
+ def test_coupon_within_validity_period(self):
+ """Test that coupon within valid date range works."""
+ from event_manager.api import validate_coupon
+
+ frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "VALIDPERIOD",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "valid_from": frappe.utils.add_days(frappe.utils.today(), -1),
+ "valid_till": frappe.utils.add_days(frappe.utils.today(), 7),
+ "is_active": True,
+ }
+ ).insert()
+
+ result = validate_coupon("VALIDPERIOD", str(self.test_event.name))
+
+ self.assertTrue(result["valid"])
+
+ # ==================== PER-USER LIMIT TESTS ====================
+
+ def test_max_usage_per_user_enforced(self):
+ """Test that user cannot exceed per-user usage limit."""
+ from event_manager.api import validate_coupon
+
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "PERUSER",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "max_usage_per_user": 1,
+ "is_active": True,
+ }
+ ).insert()
+
+ # Create and submit a booking with this coupon
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": frappe.session.user,
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Test User",
+ "email": "test@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking.submit()
+
+ # Second attempt should fail
+ result = validate_coupon("PERUSER", str(self.test_event.name))
+
+ self.assertFalse(result["valid"])
+ self.assertIn("maximum usage limit", result["error"].lower())
+
+ def test_per_user_limit_does_not_affect_other_users(self):
+ """Test that per-user limit doesn't block other users."""
+ from event_manager.api import validate_coupon
+
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": "PERUSEROTHER",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "max_usage_per_user": 1,
+ "is_active": True,
+ }
+ ).insert()
+
+ # Create a test user if not exists
+ if not frappe.db.exists("User", "testuser_b@example.com"):
+ frappe.get_doc(
+ {
+ "doctype": "User",
+ "email": "testuser_b@example.com",
+ "first_name": "Test User B",
+ "send_welcome_email": 0,
+ }
+ ).insert(ignore_permissions=True)
+
+ # User A (Administrator) uses the coupon
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {
+ "ticket_type": self.test_ticket_type.name,
+ "first_name": "Admin User",
+ "email": "admin@test.com",
+ },
+ ],
+ }
+ ).insert()
+ booking.submit()
+
+ # Switch to User B
+ frappe.set_user("testuser_b@example.com")
+
+ # User B should still be able to use the coupon
+ result = validate_coupon("PERUSEROTHER", str(self.test_event.name))
+
+ # Switch back to Administrator
+ frappe.set_user("Administrator")
+
+ self.assertTrue(result["valid"])
diff --git a/event_manager/ticketing/doctype/coupon_free_add_on/__init__.py b/event_manager/ticketing/doctype/coupon_free_add_on/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.json b/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.json
new file mode 100644
index 0000000..124a6d0
--- /dev/null
+++ b/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.json
@@ -0,0 +1,36 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-12-30 19:46:58.116937",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "add_on"
+ ],
+ "fields": [
+ {
+ "fieldname": "add_on",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Add-on",
+ "options": "Ticket Add-on",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-12-30 19:47:35.920567",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Coupon Free Add-on",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.py b/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.py
new file mode 100644
index 0000000..c2cd033
--- /dev/null
+++ b/event_manager/ticketing/doctype/coupon_free_add_on/coupon_free_add_on.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class CouponFreeAddon(Document):
+ pass
diff --git a/event_manager/ticketing/doctype/event_booking/__init__.py b/event_manager/ticketing/doctype/event_booking/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/event_booking/event_booking.js b/event_manager/ticketing/doctype/event_booking/event_booking.js
new file mode 100644
index 0000000..25a49be
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking/event_booking.js
@@ -0,0 +1,33 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Event Booking", {
+ refresh(frm) {
+ frm.set_query("ticket_type", "attendees", (doc, cdt, cdn) => {
+ return {
+ filters: {
+ event: doc.event,
+ },
+ };
+ });
+
+ // Add Approve/Reject buttons for pending bookings
+ if (frappe.user.has_role("Event Manager") && frm.doc.status === "Approval Pending") {
+ frm.add_custom_button(__("Approve and Submit"), function () {
+ frappe.confirm("Are you sure you want to approve this booking?", function () {
+ frm.call("approve_booking").then(() => {
+ frm.refresh();
+ });
+ });
+ });
+
+ frm.add_custom_button(__("Reject"), function () {
+ frappe.confirm("Are you sure you want to reject this booking?", function () {
+ frm.call("reject_booking").then(() => {
+ frm.refresh();
+ });
+ });
+ });
+ }
+ },
+});
diff --git a/event_manager/ticketing/doctype/event_booking/event_booking.json b/event_manager/ticketing/doctype/event_booking/event_booking.json
new file mode 100644
index 0000000..d3ee635
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking/event_booking.json
@@ -0,0 +1,334 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "naming_series:",
+ "creation": "2025-07-22 19:39:32.418375",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "event",
+ "column_break_cjxu",
+ "user",
+ "naming_series",
+ "section_break_status",
+ "payment_status",
+ "column_break_status",
+ "status",
+ "payment_method_section",
+ "payment_method",
+ "column_break_payment_method",
+ "offline_payment_method",
+ "section_break_xvkp",
+ "attendees",
+ "section_break_suav",
+ "additional_fields",
+ "section_break_tsys",
+ "net_amount",
+ "tax_percentage",
+ "tax_label",
+ "tax_amount",
+ "column_break_naeh",
+ "total_amount",
+ "currency",
+ "coupon_code",
+ "discount_amount",
+ "billing_details_section",
+ "invoice_requested",
+ "tax_id",
+ "column_break_billing",
+ "billing_address",
+ "section_break_sdfp",
+ "amended_from",
+ "marketing_tab",
+ "utm_parameters"
+ ],
+ "fields": [
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Event Booking",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "section_break_sdfp",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "section_break_xvkp",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "attendees",
+ "fieldtype": "Table",
+ "label": "Attendees",
+ "options": "Event Booking Attendee",
+ "reqd": 1
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_cjxu",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "label": "User",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_tsys",
+ "fieldtype": "Section Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "total_amount",
+ "fieldtype": "Currency",
+ "label": "Total Amount",
+ "non_negative": 1,
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_naeh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "net_amount",
+ "fieldtype": "Currency",
+ "label": "Net Amount",
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "tax_percentage",
+ "fieldtype": "Percent",
+ "label": "Tax Percentage",
+ "read_only": 1
+ },
+ {
+ "fieldname": "tax_label",
+ "fieldtype": "Data",
+ "label": "Tax Label",
+ "read_only": 1
+ },
+ {
+ "fieldname": "tax_amount",
+ "fieldtype": "Currency",
+ "label": "Tax Amount",
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_suav",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "additional_fields",
+ "fieldtype": "Table",
+ "label": "Additional Fields",
+ "options": "Additional Field"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Naming Series",
+ "options": "B.###"
+ },
+ {
+ "fieldname": "marketing_tab",
+ "fieldtype": "Tab Break",
+ "label": "Marketing"
+ },
+ {
+ "fieldname": "utm_parameters",
+ "fieldtype": "Table",
+ "label": "UTM Parameters",
+ "options": "UTM Parameter",
+ "read_only": 1
+ },
+ {
+ "fieldname": "coupon_code",
+ "fieldtype": "Link",
+ "label": "Coupon Code",
+ "options": "Pohodex Event Manager Coupon Code"
+ },
+ {
+ "fieldname": "discount_amount",
+ "fieldtype": "Currency",
+ "label": "Discount Amount",
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "billing_details_section",
+ "fieldtype": "Section Break",
+ "label": "Billing Details"
+ },
+ {
+ "depends_on": "eval:doc.invoice_requested==1",
+ "fieldname": "tax_id",
+ "fieldtype": "Data",
+ "label": "Tax ID"
+ },
+ {
+ "fieldname": "column_break_billing",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.invoice_requested==1",
+ "fieldname": "billing_address",
+ "fieldtype": "Small Text",
+ "label": "Billing Address",
+ "mandatory_depends_on": "eval:doc.invoice_requested==1"
+ },
+ {
+ "fieldname": "section_break_status",
+ "fieldtype": "Section Break",
+ "label": "Status"
+ },
+ {
+ "default": "Unpaid",
+ "fieldname": "payment_status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Payment Status",
+ "options": "Unpaid\nPaid\nVerification Pending",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_status",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Approval Pending",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "Confirmed\nApproval Pending\nApproved\nRejected",
+ "read_only": 1
+ },
+ {
+ "fieldname": "payment_method_section",
+ "fieldtype": "Section Break",
+ "label": "Payment Method"
+ },
+ {
+ "fieldname": "payment_method",
+ "fieldtype": "Data",
+ "label": "Payment Method",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_payment_method",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.payment_method === 'Offline'",
+ "fieldname": "offline_payment_method",
+ "fieldtype": "Data",
+ "label": "Offline Payment Method",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "invoice_requested",
+ "fieldtype": "Check",
+ "label": "Invoice Requested"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [
+ {
+ "link_doctype": "Event Ticket",
+ "link_fieldname": "booking"
+ },
+ {
+ "link_doctype": "Event Payment",
+ "link_fieldname": "reference_docname"
+ }
+ ],
+ "modified": "2026-05-03 07:18:23.131237",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Booking",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "select": 1,
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "search_fields": "event,total_amount,currency",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "user"
+}
diff --git a/event_manager/ticketing/doctype/event_booking/event_booking.py b/event_manager/ticketing/doctype/event_booking/event_booking.py
new file mode 100644
index 0000000..0693e32
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking/event_booking.py
@@ -0,0 +1,352 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+from event_manager.api import OFFLINE_PAYMENT_METHOD
+from event_manager.payments import mark_payment_as_received
+
+
+class EventBooking(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.events.doctype.utm_parameter.utm_parameter import UTMParameter
+ from event_manager.ticketing.doctype.additional_field.additional_field import AdditionalField
+ from event_manager.ticketing.doctype.event_booking_attendee.event_booking_attendee import EventBookingAttendee
+
+ additional_fields: DF.Table[AdditionalField]
+ amended_from: DF.Link | None
+ attendees: DF.Table[EventBookingAttendee]
+ billing_address: DF.SmallText | None
+ coupon_code: DF.Link | None
+ currency: DF.Link
+ discount_amount: DF.Currency
+ event: DF.Link
+ invoice_requested: DF.Check
+ naming_series: DF.Literal["B.###"]
+ net_amount: DF.Currency
+ offline_payment_method: DF.Data | None
+ payment_method: DF.Data | None
+ payment_status: DF.Literal["Unpaid", "Paid", "Verification Pending"]
+ status: DF.Literal["Confirmed", "Approval Pending", "Approved", "Rejected"]
+ tax_amount: DF.Currency
+ tax_id: DF.Data | None
+ tax_label: DF.Data | None
+ tax_percentage: DF.Percent
+ total_amount: DF.Currency
+ user: DF.Link
+ utm_parameters: DF.Table[UTMParameter]
+ # end: auto-generated types
+
+ def validate(self):
+ self.validate_ticket_availability()
+ self.fetch_amounts_from_ticket_types()
+ self.set_currency()
+ self.set_total()
+ self.apply_coupon_if_applicable()
+ self.apply_taxes_if_applicable()
+
+ def before_submit(self):
+ """Set status before submit based on payment method."""
+ # Skip if already approved (submission triggered by approve_booking)
+ if self.status == "Approved":
+ return
+
+ if self.total_amount == 0:
+ self.payment_status = "Paid"
+ self.status = "Confirmed"
+ return
+
+ if self.payment_method == OFFLINE_PAYMENT_METHOD:
+ frappe.throw(
+ _(
+ "This booking requires offline payment verification. Please use the Approve or Reject button instead."
+ )
+ )
+ elif self.payment_status != "Paid":
+ self.payment_status = "Unpaid"
+ self.status = "Approval Pending"
+
+ def set_currency(self):
+ self.currency = self.attendees[0].currency
+
+ def set_total(self):
+ self.net_amount = 0
+ for attendee in self.attendees:
+ self.net_amount += attendee.amount
+ if attendee.add_ons:
+ attendee.add_on_total = attendee.get_add_on_total()
+ attendee.number_of_add_ons = attendee.get_number_of_add_ons()
+ self.net_amount += attendee.add_on_total
+ self.total_amount = self.net_amount
+
+ def apply_taxes_if_applicable(self):
+ """Apply tax based on event-level tax configuration."""
+ self.tax_percentage = 0
+ self.tax_amount = 0
+ self.tax_label = None
+
+ event = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
+ if not event.apply_tax:
+ return
+
+ self.tax_label = event.tax_label or "Tax"
+ self.tax_percentage = event.tax_percentage or 0
+
+ if self.tax_percentage > 0:
+ if event.tax_inclusive:
+ # Tax is included in the price — back-calculate the tax component
+ self.tax_amount = round(
+ self.total_amount * self.tax_percentage / (100 + self.tax_percentage), 2
+ )
+ else:
+ # Tax is added on top of the price
+ self.tax_amount = self.total_amount * (self.tax_percentage / 100)
+ self.total_amount += self.tax_amount
+
+ def validate_ticket_availability(self):
+ num_tickets_by_type = {}
+ for attendee in self.attendees:
+ if attendee.ticket_type not in num_tickets_by_type:
+ num_tickets_by_type[attendee.ticket_type] = 0
+ num_tickets_by_type[attendee.ticket_type] += 1
+
+ for ticket_type, num_tickets in num_tickets_by_type.items():
+ ticket_type_doc = frappe.get_cached_doc("Event Ticket Type", ticket_type)
+ if not ticket_type_doc.is_published:
+ frappe.throw(frappe._(f"{ticket_type_doc.title} tickets no longer available!"))
+
+ if not ticket_type_doc.are_tickets_available(num_tickets):
+ frappe.throw(
+ frappe._(
+ f"Only {ticket_type_doc.remaining_tickets} tickets available for {ticket_type_doc.title}, you are trying to book {num_tickets}!"
+ )
+ )
+
+ def fetch_amounts_from_ticket_types(self):
+ for attendee in self.attendees:
+ price, currency = frappe.get_cached_value(
+ "Event Ticket Type", attendee.ticket_type, ["price", "currency"]
+ )
+ # Always set price from ticket type - coupon will discount later
+ attendee.amount = price
+ if not attendee.currency:
+ attendee.currency = currency
+
+ def on_submit(self):
+ self.validate_coupon_availability()
+ self.generate_tickets()
+
+ def validate_coupon_availability(self):
+ """Re-validate coupon with lock to prevent race condition."""
+ if not self.coupon_code:
+ return
+
+ # Lock coupon row to prevent concurrent over-allocation
+ coupon = frappe.get_doc("Pohodex Event Manager Coupon Code", self.coupon_code, for_update=True)
+
+ if coupon.coupon_type == "Free Tickets":
+ # Count claimed tickets excluding current booking (since it's already docstatus=1 during on_submit)
+ claimed = self.get_free_tickets_claimed_excluding_self(coupon)
+ remaining = coupon.number_of_free_tickets - claimed
+
+ # Count only attendees that were actually discounted (amount == 0)
+ # This supports partial allocation where user books more tickets than remaining free
+ coupon_ticket_type = str(coupon.ticket_type) if coupon.ticket_type else ""
+ tickets_discounted = len(
+ [a for a in self.attendees if str(a.ticket_type) == coupon_ticket_type and a.amount == 0]
+ )
+
+ if remaining < tickets_discounted:
+ frappe.throw(_("Only {0} free tickets remaining").format(remaining))
+
+ def get_free_tickets_claimed_excluding_self(self, coupon):
+ """Get free tickets claimed excluding current booking."""
+ from frappe.query_builder.functions import Count
+
+ EventBooking = frappe.qb.DocType("Event Booking")
+ EventBookingAttendee = frappe.qb.DocType("Event Booking Attendee")
+
+ count = (
+ frappe.qb.from_(EventBookingAttendee)
+ .join(EventBooking)
+ .on(EventBooking.name == EventBookingAttendee.parent)
+ .where(EventBooking.coupon_code == coupon.name)
+ .where(EventBooking.docstatus == 1)
+ .where(EventBooking.name != self.name)
+ .where(EventBookingAttendee.ticket_type == coupon.ticket_type)
+ .select(Count(EventBookingAttendee.name))
+ ).run()[0][0]
+
+ return count or 0
+
+ def generate_tickets(self):
+ for attendee in self.attendees:
+ ticket = frappe.new_doc("Event Ticket")
+ ticket.event = self.event
+ ticket.booking = self.name
+ ticket.ticket_type = attendee.ticket_type
+ ticket.first_name = attendee.first_name
+ ticket.last_name = attendee.last_name
+ ticket.attendee_email = attendee.email
+
+ if attendee.add_ons:
+ add_ons_list = frappe.get_cached_doc("Attendee Ticket Add-on", attendee.add_ons).add_ons
+ ticket.add_ons = add_ons_list
+
+ # Add custom fields from attendee to ticket
+ if attendee.custom_fields:
+ custom_fields_data = attendee.custom_fields
+ if isinstance(custom_fields_data, str):
+ try:
+ custom_fields_data = json.loads(custom_fields_data)
+ except (json.JSONDecodeError, TypeError):
+ custom_fields_data = {}
+
+ # Get custom field definitions for this event to get proper labels and types
+ custom_field_defs = frappe.db.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": self.event, "enabled": 1, "applied_to": "Ticket"},
+ fields=["fieldname", "label", "fieldtype"],
+ )
+ custom_field_map = {cf["fieldname"]: cf for cf in custom_field_defs}
+
+ for field_name, field_value in custom_fields_data.items():
+ if field_value and field_name in custom_field_map:
+ field_def = custom_field_map[field_name]
+ ticket.append(
+ "additional_fields",
+ {
+ "fieldname": field_name,
+ "value": str(field_value),
+ "label": field_def["label"],
+ "fieldtype": field_def["fieldtype"],
+ },
+ )
+
+ ticket.flags.ignore_permissions = 1
+ ticket.insert().submit()
+
+ def on_payment_authorized(self, payment_status: str):
+ if payment_status in ("Authorized", "Completed"):
+ # payment success, submit the booking
+ self.payment_status = "Paid"
+ self.status = "Confirmed"
+ self.update_payment_record()
+
+ def update_payment_record(self):
+ try:
+ mark_payment_as_received(self.doctype, self.name)
+ self.flags.ignore_permissions = 1
+ self.submit()
+ except Exception:
+ frappe.log_error(frappe.get_traceback(), _("Booking Failed"))
+ frappe.throw(frappe._("Booking Failed! Please contact support."))
+
+ def on_cancel(self):
+ self.ignore_linked_doctypes = ["Ticket Cancellation Request"]
+ self.cancel_all_tickets()
+
+ def cancel_all_tickets(self):
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": self.name}, pluck="name")
+ for ticket in tickets:
+ frappe.get_cached_doc("Event Ticket", ticket).cancel()
+
+ @frappe.whitelist()
+ def approve_booking(self):
+ """Approve the booking and submit it to generate tickets."""
+ frappe.only_for("Event Manager")
+
+ self.status = "Approved"
+ if self.payment_status == "Verification Pending":
+ self.payment_status = "Paid"
+
+ self.flags.ignore_permissions = True
+ self.submit()
+ frappe.msgprint(_("Booking has been approved!"))
+
+ @frappe.whitelist()
+ def reject_booking(self):
+ """Reject and discard the booking."""
+ frappe.only_for("Event Manager")
+
+ self.flags.ignore_permissions = True
+ self.discard()
+ self.db_set("status", "Rejected")
+ frappe.msgprint(_("Booking has been rejected!"))
+
+ def apply_coupon_if_applicable(self):
+ self.discount_amount = 0
+
+ if not self.coupon_code:
+ return
+
+ coupon = frappe.get_cached_doc("Pohodex Event Manager Coupon Code", self.coupon_code)
+
+ is_valid, error_msg = coupon.is_valid_for_event(self.event)
+ if not is_valid:
+ frappe.throw(error_msg)
+
+ is_available, error_msg = coupon.is_usage_available()
+ if not is_available:
+ frappe.throw(error_msg)
+
+ is_limited, error_msg = coupon.is_user_limit_reached(user=self.user)
+ if is_limited:
+ frappe.throw(error_msg)
+
+ if coupon.coupon_type == "Discount":
+ is_met, error_msg = coupon.is_min_order_met(self.net_amount)
+ if not is_met:
+ frappe.throw(error_msg)
+ if coupon.discount_type == "Percentage":
+ calculated_discount = self.net_amount * (coupon.discount_value / 100)
+ if coupon.maximum_discount_amount > 0:
+ self.discount_amount = min(calculated_discount, coupon.maximum_discount_amount)
+ else:
+ self.discount_amount = calculated_discount
+ else:
+ self.discount_amount = min(coupon.discount_value, self.net_amount)
+
+ self.total_amount = self.net_amount - self.discount_amount
+
+ # Free Tickets - only discount attendees with matching ticket type
+ elif coupon.coupon_type == "Free Tickets":
+ remaining = coupon.number_of_free_tickets - coupon.free_tickets_claimed
+ free_add_on_names = [row.add_on for row in coupon.free_add_ons]
+
+ # Only discount attendees with matching ticket type
+ # Use str() to handle int/string type mismatch in document names
+ coupon_ticket_type = str(coupon.ticket_type) if coupon.ticket_type else ""
+ discounted = 0
+ for attendee in self.attendees:
+ if discounted >= remaining:
+ break
+ attendee_ticket_type = str(attendee.ticket_type) if attendee.ticket_type else ""
+ if attendee_ticket_type != coupon_ticket_type:
+ continue
+
+ self.discount_amount += attendee.amount
+ attendee.amount = 0
+ discounted += 1
+
+ # Discount free add-ons for this attendee
+ if attendee.add_ons and free_add_on_names:
+ add_on_doc = frappe.get_cached_doc("Attendee Ticket Add-on", attendee.add_ons)
+ for add_on_row in add_on_doc.add_ons:
+ if add_on_row.add_on in free_add_on_names:
+ self.discount_amount += add_on_row.price
+
+ if discounted == 0:
+ frappe.throw(_("No attendees with eligible ticket type for this coupon"))
+
+ self.total_amount = self.net_amount - self.discount_amount
diff --git a/event_manager/ticketing/doctype/event_booking/test_event_booking.py b/event_manager/ticketing/doctype/event_booking/test_event_booking.py
new file mode 100644
index 0000000..f860759
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking/test_event_booking.py
@@ -0,0 +1,1093 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+TEST_ADD_ON_PRICE = 100
+TEST_VIP_TICKET_TYPE_PRICE = 500
+
+
+class IntegrationTestEventBooking(IntegrationTestCase):
+ """
+ Integration tests for EventBooking.
+ Use this class for testing interactions between multiple components.
+ """
+
+ def test_total_calculation_without_taxes(self):
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ # Disable tax at event level
+ test_event.apply_tax = False
+ test_event.save()
+
+ test_ticket_add_on = frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": test_event.name,
+ "title": "T-Shirt",
+ "price": TEST_ADD_ON_PRICE,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "VIP",
+ "price": TEST_VIP_TICKET_TYPE_PRICE,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"},
+ ],
+ }
+ ).insert()
+
+ # without add ons
+ self.assertEqual(test_booking.total_amount, 1000)
+
+ test_attendee_add_on = frappe.get_doc(
+ {
+ "doctype": "Attendee Ticket Add-on",
+ "add_ons": [{"add_on": test_ticket_add_on.name, "value": "XL"}],
+ }
+ ).insert()
+
+ test_booking.attendees[0].add_ons = test_attendee_add_on.name
+ test_booking.save()
+
+ # with one add-on
+ self.assertEqual(test_booking.attendees[0].number_of_add_ons, 1)
+ self.assertEqual(test_booking.attendees[0].add_on_total, TEST_ADD_ON_PRICE)
+ self.assertEqual(test_booking.net_amount, 1100)
+ self.assertEqual(test_booking.total_amount, 1100)
+
+ def test_total_calculation_with_taxes(self):
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ # Enable tax at event level (exclusive)
+ test_event.apply_tax = True
+ test_event.tax_inclusive = False
+ test_event.tax_label = "GST"
+ test_event.tax_percentage = 18
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "VIP",
+ "price": TEST_VIP_TICKET_TYPE_PRICE,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"},
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(test_booking.net_amount, 1000)
+ self.assertEqual(test_booking.tax_label, "GST")
+ self.assertEqual(test_booking.tax_percentage, 18)
+ self.assertEqual(test_booking.tax_amount, 180)
+ self.assertEqual(test_booking.total_amount, 1180)
+
+ def test_total_calculation_with_custom_tax_label(self):
+ """Test that custom tax labels (e.g., VAT) work correctly."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ # Enable tax with custom VAT label (exclusive)
+ test_event.apply_tax = True
+ test_event.tax_inclusive = False
+ test_event.tax_label = "VAT"
+ test_event.tax_percentage = 20
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Standard",
+ "price": 100,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(test_booking.tax_label, "VAT")
+ self.assertEqual(test_booking.tax_percentage, 20)
+ self.assertEqual(test_booking.tax_amount, 20)
+ self.assertEqual(test_booking.total_amount, 120)
+
+ def test_tax_inclusive_calculation(self):
+ """Test that tax-inclusive prices back-calculate tax without increasing total."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ # Enable tax-inclusive pricing
+ test_event.apply_tax = True
+ test_event.tax_inclusive = True
+ test_event.tax_label = "GST"
+ test_event.tax_percentage = 18
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "VIP",
+ "price": TEST_VIP_TICKET_TYPE_PRICE,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"},
+ ],
+ }
+ ).insert()
+
+ # net_amount = 2 * 500 = 1000
+ self.assertEqual(test_booking.net_amount, 1000)
+ self.assertEqual(test_booking.tax_label, "GST")
+ self.assertEqual(test_booking.tax_percentage, 18)
+ # tax_amount = 1000 * 18 / 118 = 152.54 (rounded to 2 decimals)
+ self.assertAlmostEqual(test_booking.tax_amount, round(1000 * 18 / 118, 2), places=2)
+ # total stays the same — tax is included in the price
+ self.assertEqual(test_booking.total_amount, 1000)
+
+ def test_tax_inclusive_with_single_ticket(self):
+ """Test tax-inclusive with a single ticket at a round price."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_event.apply_tax = True
+ test_event.tax_inclusive = True
+ test_event.tax_label = "VAT"
+ test_event.tax_percentage = 20
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Standard",
+ "price": 120,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ ],
+ }
+ ).insert()
+
+ # Price is 120 inclusive of 20% VAT
+ # tax_amount = 120 * 20 / 120 = 20
+ self.assertEqual(test_booking.net_amount, 120)
+ self.assertEqual(test_booking.tax_amount, 20)
+ self.assertEqual(test_booking.total_amount, 120)
+
+ def test_tax_exclusive_still_works(self):
+ """Ensure that when tax_inclusive is False, tax is still added on top."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_event.apply_tax = True
+ test_event.tax_inclusive = False
+ test_event.tax_label = "GST"
+ test_event.tax_percentage = 18
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "VIP",
+ "price": TEST_VIP_TICKET_TYPE_PRICE,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ {"ticket_type": test_ticket_type.name, "first_name": "Jenny", "email": "jenny@email.com"},
+ ],
+ }
+ ).insert()
+
+ # Exclusive: tax added on top
+ self.assertEqual(test_booking.net_amount, 1000)
+ self.assertEqual(test_booking.tax_amount, 180)
+ self.assertEqual(test_booking.total_amount, 1180)
+
+ def test_prevents_booking_if_tickets_unavailable(self):
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_vip_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "VIP",
+ "price": 500,
+ "is_published": True,
+ "max_tickets_available": 2,
+ }
+ ).insert()
+
+ test_normal_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Normal",
+ "price": 500,
+ "is_published": True,
+ }
+ ).insert()
+
+ # VIP Ticket 1
+ frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "ticket_type": test_vip_ticket_type.name,
+ "attendee_name": "John Doe",
+ "attendee_email": "john@email.com",
+ }
+ ).insert().submit()
+
+ # VIP Ticket 2 with Normal Ticket 1
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "user": frappe.session.user,
+ "event": test_event.name,
+ "attendees": [
+ {
+ "first_name": "John",
+ "last_name": "Doe",
+ "ticket_type": test_vip_ticket_type.name,
+ "email": "john@email.com",
+ },
+ {
+ "first_name": "Jenny",
+ "last_name": "Doe",
+ "ticket_type": test_normal_ticket_type.name,
+ "email": "jenny@email.com",
+ },
+ ],
+ }
+ ).insert().submit()
+
+ # VIP Ticket 3 with Normal Ticket 2
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "user": frappe.session.user,
+ "event": test_event.name,
+ "attendees": [
+ {
+ "first_name": "John",
+ "last_name": "Doe",
+ "ticket_type": test_vip_ticket_type.name,
+ "email": "john@email.com",
+ },
+ {
+ "first_name": "John",
+ "last_name": "Doe",
+ "ticket_type": test_normal_ticket_type.name,
+ "email": "john@email.com",
+ },
+ ],
+ }
+ ).insert()
+
+ # Unpublish normal ticket type
+ test_normal_ticket_type.is_published = False
+ test_normal_ticket_type.save()
+
+ # Booking with unpublished ticket type
+ with self.assertRaises(frappe.ValidationError):
+ frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "user": frappe.session.user,
+ "event": test_event.name,
+ "attendees": [
+ {
+ "first_name": "John",
+ "last_name": "Doe",
+ "ticket_type": test_normal_ticket_type.name,
+ "email": "john@email.com",
+ }
+ ],
+ }
+ ).insert()
+
+ def test_utm_parameters_are_saved(self):
+ """Test that UTM parameters are correctly saved with bookings."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Standard",
+ "price": 100,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ ],
+ "utm_parameters": [
+ {"utm_name": "utm_source", "value": "google"},
+ {"utm_name": "utm_medium", "value": "cpc"},
+ {"utm_name": "utm_campaign", "value": "summer_sale"},
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(len(test_booking.utm_parameters), 3)
+ self.assertEqual(test_booking.utm_parameters[0].utm_name, "utm_source")
+ self.assertEqual(test_booking.utm_parameters[0].value, "google")
+ self.assertEqual(test_booking.utm_parameters[1].utm_name, "utm_medium")
+ self.assertEqual(test_booking.utm_parameters[1].value, "cpc")
+ self.assertEqual(test_booking.utm_parameters[2].utm_name, "utm_campaign")
+ self.assertEqual(test_booking.utm_parameters[2].value, "summer_sale")
+
+ def test_booking_without_utm_parameters(self):
+ """Test that bookings work correctly without UTM parameters."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Standard",
+ "price": 100,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(len(test_booking.utm_parameters), 0)
+
+ def test_custom_utm_parameters(self):
+ """Test that custom UTM parameters (beyond standard ones) are saved."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Standard",
+ "price": 100,
+ }
+ ).insert()
+
+ test_booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "John", "email": "john@email.com"},
+ ],
+ "utm_parameters": [
+ {"utm_name": "utm_source", "value": "newsletter"},
+ {"utm_name": "utm_custom_param", "value": "special_offer"},
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(len(test_booking.utm_parameters), 2)
+ # Check custom utm parameter is saved
+ custom_param = next(
+ (p for p in test_booking.utm_parameters if p.utm_name == "utm_custom_param"), None
+ )
+ self.assertIsNotNone(custom_param)
+ self.assertEqual(custom_param.value, "special_offer")
+
+
+class TestProcessBookingAPI(IntegrationTestCase):
+ """Test the process_booking API endpoint for UTM parameter handling."""
+
+ def test_process_booking_with_utm_parameters(self):
+ """Test that process_booking API correctly saves UTM parameters."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "API Test Ticket",
+ "price": 0, # Free ticket to avoid payment flow
+ "is_published": True,
+ }
+ ).insert()
+
+ # Disable tax at event level
+ test_event.apply_tax = False
+ test_event.save()
+
+ attendees = [
+ {
+ "first_name": "API Test User",
+ "email": "apitest@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ]
+
+ utm_parameters = [
+ {"utm_name": "utm_source", "value": "facebook"},
+ {"utm_name": "utm_medium", "value": "social"},
+ {"utm_name": "utm_campaign", "value": "winter_promo"},
+ {"utm_name": "utm_content", "value": "banner_ad"},
+ {"utm_name": "utm_term", "value": "event tickets"},
+ ]
+
+ result = process_booking(
+ attendees=attendees,
+ event=str(test_event.name),
+ utm_parameters=utm_parameters,
+ )
+
+ # Verify booking was created
+ self.assertIn("booking_name", result)
+
+ # Fetch the booking and verify UTM parameters
+ booking = frappe.get_doc("Event Booking", result["booking_name"])
+ self.assertEqual(len(booking.utm_parameters), 5)
+
+ # Verify each UTM parameter
+ utm_dict = {p.utm_name: p.value for p in booking.utm_parameters}
+ self.assertEqual(utm_dict["utm_source"], "facebook")
+ self.assertEqual(utm_dict["utm_medium"], "social")
+ self.assertEqual(utm_dict["utm_campaign"], "winter_promo")
+ self.assertEqual(utm_dict["utm_content"], "banner_ad")
+ self.assertEqual(utm_dict["utm_term"], "event tickets")
+
+ # ==================== Offline Payment Tests ====================
+
+ def _cleanup_offline_methods(self, event_name):
+ """Remove all Offline Payment Method records for the given event."""
+ frappe.db.delete("Offline Payment Method", {"event": event_name})
+
+ def test_offline_booking_cannot_be_submitted_directly(self):
+ """Test that offline bookings cannot be submitted directly — must use approve/reject."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Offline Ticket",
+ "price": 500,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ "payment_method": "Offline",
+ }
+ ).insert()
+
+ with self.assertRaises(frappe.ValidationError):
+ booking.submit()
+
+ def test_approve_offline_booking(self):
+ """Test approving an offline booking submits it and generates tickets."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Approval Test Ticket",
+ "price": 500,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ "payment_method": "Offline",
+ "status": "Approval Pending",
+ "payment_status": "Verification Pending",
+ }
+ ).insert()
+
+ # Booking should be in draft with no tickets
+ self.assertEqual(booking.docstatus, 0)
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 0)
+
+ booking.approve_booking()
+ booking.reload()
+
+ # After approval, booking should be submitted with tickets generated
+ self.assertEqual(booking.docstatus, 1)
+ self.assertEqual(booking.status, "Approved")
+ self.assertEqual(booking.payment_status, "Paid")
+
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 1)
+
+ def test_reject_offline_booking(self):
+ """Test rejecting an offline booking keeps it in draft with no tickets."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Rejection Test Ticket",
+ "price": 500,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ "payment_method": "Offline",
+ "status": "Approval Pending",
+ "payment_status": "Verification Pending",
+ }
+ ).insert()
+
+ self.assertEqual(booking.docstatus, 0)
+
+ booking.reject_booking()
+ booking.reload()
+
+ # Discarded (docstatus=2) and marked as Rejected
+ self.assertEqual(booking.docstatus, 2)
+ self.assertEqual(booking.status, "Rejected")
+
+ # No tickets should be generated
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 0)
+
+ def test_offline_with_coupon_code(self):
+ """Test offline payment with coupon code discount."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Coupon Test Ticket",
+ "price": 500,
+ }
+ ).insert()
+
+ coupon = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Coupon Code",
+ "code": f"OFFLINE10-{frappe.generate_hash(length=4)}",
+ "coupon_type": "Discount",
+ "discount_type": "Percentage",
+ "discount_value": 10,
+ "is_active": True,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "coupon_code": coupon.name,
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ "payment_method": "Offline",
+ }
+ ).insert()
+
+ self.assertEqual(booking.net_amount, 500)
+ self.assertEqual(booking.discount_amount, 50)
+ self.assertEqual(booking.total_amount, 450)
+
+ def test_offline_with_tax(self):
+ """Test offline payment with tax calculation."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_event.apply_tax = True
+ test_event.tax_percentage = 18
+ test_event.tax_label = "GST"
+ test_event.save()
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Tax Test Ticket",
+ "price": 500,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ "payment_method": "Offline",
+ }
+ ).insert()
+
+ self.assertEqual(booking.net_amount, 500)
+ self.assertEqual(booking.tax_percentage, 18)
+ self.assertEqual(booking.tax_amount, 90)
+ self.assertEqual(booking.total_amount, 590)
+
+ def test_offline_booking_requires_payment_method_field(self):
+ """Test that offline booking requires payment_method field."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self._cleanup_offline_methods(test_event.name)
+
+ frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Payment Method Test",
+ "price": 500,
+ }
+ ).insert()
+
+ # Booking without payment_method field should default to normal flow
+ booking_without_method = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test", "email": "test@test.com"}
+ ],
+ }
+ ).insert()
+
+ booking_without_method.submit()
+ # Without payment_method field, it should go to normal payment flow
+ self.assertEqual(booking_without_method.payment_status, "Unpaid")
+
+ # Booking with payment_method = "Offline" should block direct submission
+ booking_with_method = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {"ticket_type": test_ticket_type.name, "first_name": "Test2", "email": "test2@test.com"}
+ ],
+ "payment_method": "Offline",
+ }
+ ).insert()
+
+ with self.assertRaises(frappe.ValidationError):
+ booking_with_method.submit()
+
+ def test_process_booking_offline_stays_in_draft(self):
+ """Test that offline bookings via process_booking stay in draft with no tickets."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_event.apply_tax = False
+ test_event.is_published = True
+ test_event.save()
+ self._cleanup_offline_methods(test_event.name)
+
+ offline_method = frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Offline Draft Test",
+ "price": 500,
+ "is_published": True,
+ }
+ ).insert()
+
+ result = process_booking(
+ attendees=[
+ {
+ "first_name": "Offline User",
+ "email": "offline@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ],
+ event=str(test_event.name),
+ is_offline=True,
+ offline_payment_method=str(offline_method.name),
+ )
+
+ self.assertIn("booking_name", result)
+ self.assertTrue(result.get("offline_payment"))
+
+ booking = frappe.get_doc("Event Booking", result["booking_name"])
+
+ # Booking must be in draft (not submitted)
+ self.assertEqual(booking.docstatus, 0)
+ self.assertEqual(booking.status, "Approval Pending")
+ self.assertEqual(booking.payment_status, "Verification Pending")
+
+ # No tickets should exist
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 0)
+
+ def test_process_booking_offline_generates_tickets_on_approval(self):
+ """Test that approving an offline booking created via API generates tickets."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_event.apply_tax = False
+ test_event.is_published = True
+ test_event.save()
+ self._cleanup_offline_methods(test_event.name)
+
+ offline_method = frappe.get_doc(
+ {
+ "doctype": "Offline Payment Method",
+ "title": "Bank Transfer",
+ "event": test_event.name,
+ "enabled": 1,
+ }
+ ).insert()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Offline Approval Test",
+ "price": 500,
+ "is_published": True,
+ }
+ ).insert()
+
+ result = process_booking(
+ attendees=[
+ {
+ "first_name": "Approval User",
+ "email": "approval@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ],
+ event=str(test_event.name),
+ is_offline=True,
+ offline_payment_method=str(offline_method.name),
+ )
+
+ booking = frappe.get_doc("Event Booking", result["booking_name"])
+
+ # No tickets before approval
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 0)
+
+ # Approve the booking
+ booking.approve_booking()
+ booking.reload()
+
+ # After approval: submitted, approved, tickets generated
+ self.assertEqual(booking.docstatus, 1)
+ self.assertEqual(booking.status, "Approved")
+ self.assertEqual(booking.payment_status, "Paid")
+
+ tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking.name})
+ self.assertEqual(len(tickets), 1)
+
+ def test_process_booking_without_utm_parameters(self):
+ """Test that process_booking API works without UTM parameters."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_event.is_published = True
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "API Test Ticket No UTM",
+ "price": 0,
+ "is_published": True,
+ }
+ ).insert()
+
+ # Disable tax at event level
+ test_event.apply_tax = False
+ test_event.save()
+
+ attendees = [
+ {
+ "first_name": "No UTM User",
+ "email": "noutm@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ]
+
+ result = process_booking(
+ attendees=attendees,
+ event=str(test_event.name),
+ utm_parameters=None,
+ )
+
+ self.assertIn("booking_name", result)
+
+ booking = frappe.get_doc("Event Booking", result["booking_name"])
+ self.assertEqual(len(booking.utm_parameters), 0)
+
+ def test_process_booking_with_empty_utm_parameters(self):
+ """Test that process_booking API handles empty UTM list."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "API Test Ticket Empty UTM",
+ "price": 0,
+ "is_published": True,
+ }
+ ).insert()
+
+ # Disable tax at event level
+ test_event.apply_tax = False
+ test_event.is_published = True
+ test_event.save()
+
+ attendees = [
+ {
+ "first_name": "Empty UTM User",
+ "email": "emptyutm@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ]
+
+ result = process_booking(
+ attendees=attendees,
+ event=str(test_event.name),
+ utm_parameters=[],
+ )
+
+ self.assertIn("booking_name", result)
+
+ booking = frappe.get_doc("Event Booking", result["booking_name"])
+ self.assertEqual(len(booking.utm_parameters), 0)
+
+ def test_process_booking_failed_for_unpublished_event(self):
+ """Booking must fail when Pohodex Event Manager Event is not published."""
+ from event_manager.api import process_booking
+
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ test_event.is_published = 0
+ test_event.save()
+
+ test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Unpublished Test Ticket",
+ "price": 0,
+ "is_published": True,
+ }
+ ).insert()
+
+ attendees = [
+ {
+ "first_name": "Failed User",
+ "email": "failed@email.com",
+ "ticket_type": str(test_ticket_type.name),
+ "add_ons": [],
+ }
+ ]
+
+ with self.assertRaises(frappe.ValidationError) as ctx:
+ process_booking(
+ attendees=attendees,
+ event=str(test_event.name),
+ )
+
+ self.assertIn("Event is not live", str(ctx.exception))
+
+ def test_free_event_booking_auto_confirms(self):
+ """Test that free event bookings (total_amount = 0) automatically confirm on submit."""
+ test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ free_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": test_event.name,
+ "title": "Free Ticket",
+ "price": 0,
+ }
+ ).insert()
+
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": test_event.name,
+ "user": frappe.session.user,
+ "attendees": [
+ {
+ "first_name": "Free User",
+ "email": "free@email.com",
+ "ticket_type": free_ticket_type.name,
+ }
+ ],
+ }
+ ).insert()
+
+ booking.submit()
+
+ self.assertEqual(booking.status, "Confirmed", "Free booking should auto-confirm")
+ self.assertEqual(booking.payment_status, "Paid", "Free booking should be marked as Paid")
+ self.assertEqual(booking.total_amount, 0, "Free booking should have zero total")
diff --git a/event_manager/ticketing/doctype/event_booking/test_guest_booking.py b/event_manager/ticketing/doctype/event_booking/test_guest_booking.py
new file mode 100644
index 0000000..8ea74fe
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking/test_guest_booking.py
@@ -0,0 +1,207 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+import uuid
+from base64 import b32encode
+
+import frappe
+import pyotp
+from frappe.tests import IntegrationTestCase
+
+from event_manager.api import process_booking
+
+
+class TestGuestBooking(IntegrationTestCase):
+ def setUp(self):
+ frappe.set_user("Administrator")
+ self.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ self.event_name = str(self.test_event.name)
+ self.original_allow_guest = self.test_event.allow_guest_booking
+ self.original_verification = self.test_event.guest_verification_method
+ self.test_event.allow_guest_booking = True
+ self.test_event.guest_verification_method = "None"
+ self.test_event.is_published = True
+ self.test_event.save()
+ self.ticket_type = str(self._get_or_create_free_ticket_type())
+
+ def tearDown(self):
+ frappe.set_user("Administrator")
+ self.test_event.reload()
+ self.test_event.allow_guest_booking = self.original_allow_guest
+ self.test_event.guest_verification_method = self.original_verification
+ self.test_event.is_published = False
+ self.test_event.save()
+
+ # --- helpers ---
+
+ def _generate_test_email(self):
+ return f"testguest-{uuid.uuid4().hex[:8]}@example.com"
+
+ def _cleanup_test_user(self, email):
+ frappe.set_user("Administrator")
+ if frappe.db.exists("User", email):
+ frappe.delete_doc("User", email, force=True)
+
+ def _get_or_create_free_ticket_type(self):
+ existing = frappe.db.get_value(
+ "Event Ticket Type",
+ {"event": self.test_event.name, "price": 0},
+ "name",
+ )
+ if existing:
+ return existing
+ return (
+ frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": self.test_event.name,
+ "title": "Free (Test)",
+ "price": 0,
+ }
+ )
+ .insert()
+ .name
+ )
+
+ def _make_attendees(self, email):
+ return [{"ticket_type": self.ticket_type, "first_name": "Test", "last_name": "Guest", "email": email}]
+
+ # --- tests ---
+
+ def test_guest_booking_without_otp(self):
+ """Full happy path: guest books with verification='None', booking + user created."""
+ email = self._generate_test_email()
+ try:
+ frappe.set_user("Guest")
+ result = process_booking(
+ attendees=self._make_attendees(email),
+ event=self.event_name,
+ guest_email=email,
+ guest_full_name="Test Guest",
+ )
+ self.assertIn("booking_name", result)
+ self.assertTrue(frappe.db.exists("Event Booking", result["booking_name"]))
+ self.assertTrue(frappe.db.exists("User", email))
+ self.assertIn("Pohodex Event Manager User", frappe.get_roles(email))
+ finally:
+ self._cleanup_test_user(email)
+
+ def test_guest_booking_with_otp(self):
+ """OTP happy path: cache OTP, pass it in, booking succeeds, OTP cache cleared."""
+ email = self._generate_test_email()
+ self.test_event.guest_verification_method = "Email OTP"
+ self.test_event.save()
+ try:
+ # Simulate OTP generation (same as send_guest_booking_otp)
+ otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
+ otp_code = pyotp.HOTP(otp_secret).at(0)
+ frappe.cache.set_value(
+ f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600
+ )
+
+ frappe.set_user("Guest")
+ result = process_booking(
+ attendees=self._make_attendees(email),
+ event=self.event_name,
+ guest_email=email,
+ guest_full_name="Test Guest",
+ otp=str(otp_code),
+ )
+ self.assertIn("booking_name", result)
+ # OTP cache should be cleared after successful verification
+ self.assertIsNone(frappe.cache.get_value(f"guest_booking_otp:email:{email.lower().strip()}"))
+ finally:
+ self._cleanup_test_user(email)
+
+ def test_guest_booking_rejected_when_disabled(self):
+ """Security gate: allow_guest_booking=False raises AuthenticationError."""
+ self.test_event.allow_guest_booking = False
+ self.test_event.save()
+
+ frappe.set_user("Guest")
+ with self.assertRaises(frappe.AuthenticationError):
+ process_booking(
+ attendees=self._make_attendees("nobody@example.com"),
+ event=self.event_name,
+ guest_email="nobody@example.com",
+ guest_full_name="Nobody",
+ )
+
+ def test_invalid_otp_rejected(self):
+ """Wrong OTP code raises ValidationError."""
+ email = self._generate_test_email()
+ self.test_event.guest_verification_method = "Email OTP"
+ self.test_event.save()
+
+ otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
+ frappe.cache.set_value(
+ f"guest_booking_otp:email:{email.lower().strip()}", otp_secret, expires_in_sec=600
+ )
+
+ frappe.set_user("Guest")
+ with self.assertRaises(frappe.ValidationError):
+ process_booking(
+ attendees=self._make_attendees(email),
+ event=self.event_name,
+ guest_email=email,
+ guest_full_name="Test Guest",
+ otp="000000",
+ )
+
+ def test_guest_booking_requires_email(self):
+ """Missing email raises ValidationError."""
+ frappe.set_user("Guest")
+ with self.assertRaises(frappe.ValidationError):
+ process_booking(
+ attendees=[
+ {
+ "ticket_type": self.ticket_type,
+ "first_name": "Test",
+ "last_name": "Guest",
+ "email": "t@e.com",
+ }
+ ],
+ event=self.event_name,
+ guest_email="",
+ guest_full_name="Test Guest",
+ )
+
+ def test_brute_force_lockout(self):
+ """Repeated wrong OTPs locks out subsequent attempts.
+
+ LoginAttemptTracker(max_consecutive_login_attempts=5) uses strict >
+ comparison, so lockout triggers after count exceeds the threshold.
+ """
+ email = self._generate_test_email()
+ self.test_event.guest_verification_method = "Email OTP"
+ self.test_event.save()
+
+ otp_secret = b32encode(b"TESTSECRET").decode("utf-8")
+ cache_key = f"guest_booking_otp:email:{email.lower().strip()}"
+
+ frappe.set_user("Guest")
+
+ # Exhaust allowed attempts (Frappe's tracker uses > comparison,
+ # so we need max_consecutive_login_attempts + 1 failures to trigger lockout)
+ for _ in range(6):
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+ with self.assertRaises(frappe.ValidationError):
+ process_booking(
+ attendees=self._make_attendees(email),
+ event=self.event_name,
+ guest_email=email,
+ guest_full_name="Test Guest",
+ otp="000000",
+ )
+
+ # Next attempt should hit "Too many failed attempts"
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+ with self.assertRaises(frappe.ValidationError) as ctx:
+ process_booking(
+ attendees=self._make_attendees(email),
+ event=self.event_name,
+ guest_email=email,
+ guest_full_name="Test Guest",
+ otp="000000",
+ )
+ self.assertIn("Too many failed attempts", str(ctx.exception))
diff --git a/event_manager/ticketing/doctype/event_booking_attendee/__init__.py b/event_manager/ticketing/doctype/event_booking_attendee/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.json b/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.json
new file mode 100644
index 0000000..2ca79a5
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.json
@@ -0,0 +1,121 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-22 19:41:54.837428",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "first_name",
+ "last_name",
+ "full_name",
+ "ticket_type",
+ "custom_fields",
+ "column_break_xmfr",
+ "email",
+ "amount",
+ "currency",
+ "add_ons",
+ "number_of_add_ons",
+ "add_on_total"
+ ],
+ "fields": [
+ {
+ "fieldname": "first_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "First Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "last_name",
+ "fieldtype": "Data",
+ "label": "Last Name"
+ },
+ {
+ "fieldname": "full_name",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Full Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_xmfr",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "email",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Email",
+ "options": "Email",
+ "reqd": 1
+ },
+ {
+ "fieldname": "ticket_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Ticket Type",
+ "options": "Event Ticket Type",
+ "reqd": 1
+ },
+ {
+ "default": "INR",
+ "fetch_from": "ticket_type.currency",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "ticket_type.price",
+ "fetch_if_empty": 1,
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "fieldname": "add_ons",
+ "fieldtype": "Link",
+ "label": "Add Ons",
+ "options": "Attendee Ticket Add-on"
+ },
+ {
+ "fieldname": "add_on_total",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Add On Total",
+ "read_only": 1
+ },
+ {
+ "fieldname": "number_of_add_ons",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Number of Add Ons",
+ "read_only": 1
+ },
+ {
+ "fieldname": "custom_fields",
+ "fieldtype": "JSON",
+ "label": "Custom Fields"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-11-01 11:33:49.166876",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Booking Attendee",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.py b/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.py
new file mode 100644
index 0000000..bf02e41
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_booking_attendee/event_booking_attendee.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventBookingAttendee(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ add_on_total: DF.Currency
+ add_ons: DF.Link | None
+ amount: DF.Currency
+ currency: DF.Link
+ custom_fields: DF.JSON | None
+ email: DF.Data
+ first_name: DF.Data
+ full_name: DF.Data
+ last_name: DF.Data | None
+ number_of_add_ons: DF.Int
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ ticket_type: DF.Link
+ # end: auto-generated types
+
+ def before_validate(self):
+ # Backward compat: split full_name into first/last if first_name not provided
+ if not self.first_name and self.full_name:
+ name_parts = self.full_name.strip().split(" ", 1)
+ self.first_name = name_parts[0]
+ if not self.last_name and len(name_parts) > 1:
+ self.last_name = name_parts[1]
+
+ def before_save(self):
+ self.full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
+
+ def get_add_on_total(self):
+ if not self.add_ons:
+ return 0
+ doc = frappe.get_cached_doc("Attendee Ticket Add-on", self.add_ons)
+ add_ons = doc.add_ons
+ return sum(r.price for r in add_ons)
+
+ def get_number_of_add_ons(self):
+ return len(frappe.get_cached_doc("Attendee Ticket Add-on", self.add_ons).add_ons)
diff --git a/event_manager/ticketing/doctype/event_payment/__init__.py b/event_manager/ticketing/doctype/event_payment/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/event_payment/event_payment.js b/event_manager/ticketing/doctype/event_payment/event_payment.js
new file mode 100644
index 0000000..0cf3899
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_payment/event_payment.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Payment", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/ticketing/doctype/event_payment/event_payment.json b/event_manager/ticketing/doctype/event_payment/event_payment.json
new file mode 100644
index 0000000..0cb46c0
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_payment/event_payment.json
@@ -0,0 +1,137 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2025-07-31 19:51:13.433686",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "reference_doctype",
+ "reference_docname",
+ "column_break_guir",
+ "amount",
+ "currency",
+ "payment_received",
+ "section_break_mcjj",
+ "payment_gateway",
+ "column_break_oauu",
+ "payment_id",
+ "order_id"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "payment_received",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Payment Received"
+ },
+ {
+ "fieldname": "reference_doctype",
+ "fieldtype": "Link",
+ "label": "Reference DocType",
+ "options": "DocType"
+ },
+ {
+ "fieldname": "column_break_guir",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "reference_docname",
+ "fieldtype": "Dynamic Link",
+ "label": "Reference Name",
+ "options": "reference_doctype"
+ },
+ {
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "label": "Amount",
+ "options": "currency"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_mcjj",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "order_id",
+ "fieldtype": "Data",
+ "label": "Order ID"
+ },
+ {
+ "fieldname": "column_break_oauu",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "payment_id",
+ "fieldtype": "Data",
+ "label": "Payment ID"
+ },
+ {
+ "fieldname": "payment_gateway",
+ "fieldtype": "Link",
+ "label": "Payment Gateway",
+ "options": "Payment Gateway"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-12-06 06:42:53.095763",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Payment",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "user",
+ "track_changes": 1,
+ "track_seen": 1,
+ "track_views": 1
+}
diff --git a/event_manager/ticketing/doctype/event_payment/event_payment.py b/event_manager/ticketing/doctype/event_payment/event_payment.py
new file mode 100644
index 0000000..200ed0a
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_payment/event_payment.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EventPayment(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amount: DF.Currency
+ currency: DF.Link | None
+ name: DF.Int | None
+ order_id: DF.Data | None
+ payment_gateway: DF.Link | None
+ payment_id: DF.Data | None
+ payment_received: DF.Check
+ reference_docname: DF.DynamicLink | None
+ reference_doctype: DF.Link | None
+ user: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/ticketing/doctype/event_payment/test_event_payment.py b/event_manager/ticketing/doctype/event_payment/test_event_payment.py
new file mode 100644
index 0000000..281156c
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_payment/test_event_payment.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventPayment(IntegrationTestCase):
+ """
+ Integration tests for EventPayment.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/ticketing/doctype/event_ticket/__init__.py b/event_manager/ticketing/doctype/event_ticket/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/event_ticket/event_ticket.js b/event_manager/ticketing/doctype/event_ticket/event_ticket.js
new file mode 100644
index 0000000..3173a5f
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket/event_ticket.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Ticket", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/ticketing/doctype/event_ticket/event_ticket.json b/event_manager/ticketing/doctype/event_ticket/event_ticket.json
new file mode 100644
index 0000000..f5ac95e
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket/event_ticket.json
@@ -0,0 +1,200 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "hash",
+ "creation": "2025-07-22 19:49:30.385500",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "first_name",
+ "last_name",
+ "attendee_name",
+ "event",
+ "booking",
+ "coupon_used",
+ "column_break_sqmr",
+ "attendee_email",
+ "ticket_type",
+ "qr_code",
+ "section_break_ijdn",
+ "additional_fields",
+ "section_break_cgvb",
+ "add_ons",
+ "section_break_yzvi",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 1,
+ "fieldname": "first_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "First Name",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "last_name",
+ "fieldtype": "Data",
+ "label": "Last Name"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "attendee_name",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "in_standard_filter": 1,
+ "label": "Attendee Name",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "ticket_type.event",
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event"
+ },
+ {
+ "fieldname": "booking",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Booking",
+ "options": "Event Booking"
+ },
+ {
+ "fieldname": "column_break_sqmr",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ticket_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Ticket Type",
+ "options": "Event Ticket Type",
+ "reqd": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Event Ticket",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "section_break_yzvi",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "qr_code",
+ "fieldtype": "Attach Image",
+ "label": "QR Code",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "add_ons",
+ "fieldtype": "Table",
+ "label": "Add Ons",
+ "options": "Ticket Add-on Value"
+ },
+ {
+ "fieldname": "section_break_cgvb",
+ "fieldtype": "Section Break"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "attendee_email",
+ "fieldtype": "Data",
+ "label": "Attendee Email",
+ "options": "Email",
+ "reqd": 1
+ },
+ {
+ "fieldname": "coupon_used",
+ "fieldtype": "Link",
+ "label": "Coupon Used ",
+ "options": "Bulk Ticket Coupon"
+ },
+ {
+ "fieldname": "section_break_ijdn",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "additional_fields",
+ "fieldtype": "Table",
+ "label": "Additional Fields",
+ "options": "Additional Field"
+ }
+ ],
+ "grid_page_length": 50,
+ "image_field": "qr_code",
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [
+ {
+ "link_doctype": "Event Check In",
+ "link_fieldname": "ticket"
+ }
+ ],
+ "modified": "2025-12-30 13:25:40.498434",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Ticket",
+ "naming_rule": "Random",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "if_owner": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "search_fields": "event,ticket_type",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "attendee_name"
+}
diff --git a/event_manager/ticketing/doctype/event_ticket/event_ticket.py b/event_manager/ticketing/doctype/event_ticket/event_ticket.py
new file mode 100644
index 0000000..f460378
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket/event_ticket.py
@@ -0,0 +1,185 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.core.api.user_invitation import invite_by_email
+from frappe.model.document import Document
+
+from event_manager.utils import generate_ics_file, generate_qr_code_file, only_if_app_installed
+
+
+class EventTicket(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.ticketing.doctype.additional_field.additional_field import AdditionalField
+ from event_manager.ticketing.doctype.ticket_add_on_value.ticket_add_on_value import TicketAddonValue
+
+ add_ons: DF.Table[TicketAddonValue]
+ additional_fields: DF.Table[AdditionalField]
+ amended_from: DF.Link | None
+ attendee_email: DF.Data
+ attendee_name: DF.Data
+ booking: DF.Link | None
+ coupon_used: DF.Link | None
+ event: DF.Link | None
+ first_name: DF.Data
+ last_name: DF.Data | None
+ qr_code: DF.AttachImage | None
+ ticket_type: DF.Link
+ # end: auto-generated types
+
+ def before_validate(self):
+ # Backward compat: split attendee_name into first/last if first_name not provided
+ if not self.first_name and self.attendee_name:
+ name_parts = self.attendee_name.strip().split(" ", 1)
+ self.first_name = name_parts[0]
+ if not self.last_name and len(name_parts) > 1:
+ self.last_name = name_parts[1]
+
+ def validate(self):
+ self.attendee_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
+
+ def before_submit(self):
+ self.validate_coupon_usage()
+ self.generate_qr_code()
+
+ def on_submit(self):
+ try:
+ self.send_ticket_email()
+ except Exception as e:
+ frappe.log_error("Error sending ticket email: " + str(e))
+
+ # TODO: bring back after we have templates
+ # try:
+ # self.send_user_invitation()
+ # except Exception as e:
+ # frappe.log_error("Error sending user invitation: " + str(e))
+ self.create_zoom_registration_if_applicable()
+
+ @only_if_app_installed("zoom_integration")
+ def create_zoom_registration_if_applicable(self):
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
+
+ if event_doc.zoom_webinar:
+ doc = {
+ "doctype": "Zoom Webinar Registration",
+ "webinar": event_doc.zoom_webinar,
+ "email": self.attendee_email,
+ "first_name": self.first_name,
+ "last_name": self.last_name or "-",
+ }
+ registration = frappe.get_doc(doc).insert(ignore_permissions=True)
+
+ try:
+ registration.submit()
+ # Store the registration reference on the ticket
+ self.db_set("zoom_webinar_registration", registration.name)
+ except Exception:
+ frappe.log_error("Failed to create registration on Zoom")
+
+ def send_user_invitation(self):
+ invite_by_email(
+ emails=self.attendee_email,
+ roles=["Pohodex Event Manager User"],
+ redirect_to_path="/dashboard/account/tickets",
+ app_name="event_manager",
+ )
+
+ def send_ticket_email(self, now: bool = False):
+ send_ticket_email = frappe.get_cached_value("Pohodex Event Manager Event", self.event, "send_ticket_email")
+
+ if not send_ticket_email:
+ return
+
+ event_title, ticket_template, ticket_print_format, venue = frappe.get_cached_value(
+ "Pohodex Event Manager Event", self.event, ["title", "ticket_email_template", "ticket_print_format", "venue"]
+ )
+
+ # Fallback to global setting if event-level not set
+ if not ticket_template:
+ ticket_template = frappe.db.get_single_value("Pohodex Event Manager Settings", "default_ticket_email_template")
+
+ subject = frappe._("Your ticket to {0} 🎟️").format(event_title)
+ event_doc = frappe.get_cached_doc("Pohodex Event Manager Event", self.event)
+ args = {
+ "doc": self,
+ "event_doc": event_doc,
+ "event_title": event_title,
+ "venue": venue,
+ }
+
+ if ticket_template:
+ from frappe.email.doctype.email_template.email_template import get_email_template
+
+ email_template = get_email_template(ticket_template, args)
+ subject = email_template.get("subject")
+ content = email_template.get("message")
+
+ attachments = []
+
+ if event_doc.attach_email_ticket:
+ attachments.append(
+ {
+ "print_format_attachment": 1,
+ "doctype": self.doctype,
+ "name": self.name,
+ "print_format": ticket_print_format or "Standard Ticket",
+ }
+ )
+
+ if event_doc.attach_calendar_invite:
+ ics_content = generate_ics_file(event_doc, self.attendee_email)
+ attachments.append(
+ {
+ "fname": f"{event_doc.title}.ics",
+ "fcontent": ics_content,
+ }
+ )
+
+ frappe.sendmail(
+ recipients=[self.attendee_email],
+ subject=subject,
+ content=content if ticket_template else None,
+ template="ticket" if not ticket_template else None,
+ args=args,
+ reference_doctype=self.doctype,
+ reference_name=self.name,
+ now=now,
+ attachments=attachments,
+ )
+
+ def validate_coupon_usage(self):
+ if not self.coupon_used:
+ return
+
+ coupon = frappe.get_cached_doc("Bulk Ticket Coupon", self.coupon_used)
+ if coupon.is_used_up():
+ frappe.throw(frappe._("Coupon has been already used up maximum number of times!"))
+
+ def generate_qr_code(self):
+ self.qr_code = generate_qr_code_file(
+ doc=self,
+ data=self.name,
+ file_prefix="ticket-qr-code",
+ )
+
+ def on_cancel(self):
+ self.ignore_linked_doctypes = ["Event Booking", "Ticket Cancellation Request"]
+ self.send_cancellation_email()
+
+ def send_cancellation_email(self):
+ event_title = frappe.get_cached_value("Pohodex Event Manager Event", self.event, "title")
+ frappe.sendmail(
+ recipients=self.attendee_email,
+ subject=f"Your ticket to {event_title} is cancelled.",
+ message=f"Hi {self.attendee_name}, your ticket has been cancelled successfully. Sad to see you go.",
+ header=[("Ticket Cancelled"), "red"],
+ delayed=False,
+ retry=2,
+ )
diff --git a/event_manager/ticketing/doctype/event_ticket/test_event_ticket.py b/event_manager/ticketing/doctype/event_ticket/test_event_ticket.py
new file mode 100644
index 0000000..2b5e935
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket/test_event_ticket.py
@@ -0,0 +1,176 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+from unittest.mock import patch
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+from event_manager.utils import generate_qr_code_file, make_qr_image
+
+EXTRA_TEST_RECORD_DEPENDENCIES = []
+IGNORE_TEST_RECORD_DEPENDENCIES = ["Bulk Ticket Coupon"]
+
+
+class TestEventTicketEmail(IntegrationTestCase):
+ """Tests for Event Ticket email sending with template fallback logic."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+ cls.test_event.ticket_email_template = None
+ cls.test_event.save()
+
+ # Clear global settings
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.default_ticket_email_template = None
+ settings.save()
+
+ def setUp(self):
+ self.test_ticket_type = frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": self.test_event.name,
+ "title": "Email Test Ticket",
+ "price": 100,
+ }
+ ).insert()
+
+ self.test_ticket = frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "event": self.test_event.name,
+ "ticket_type": self.test_ticket_type.name,
+ "attendee_name": "Test Attendee",
+ "attendee_email": "test@example.com",
+ }
+ ).insert()
+
+ def tearDown(self):
+ frappe.delete_doc("Event Ticket", self.test_ticket.name, force=True)
+ frappe.delete_doc("Event Ticket Type", self.test_ticket_type.name, force=True)
+
+ def _create_template(self, name, subject_prefix):
+ if frappe.db.exists("Email Template", name):
+ frappe.delete_doc("Email Template", name, force=True)
+ return frappe.get_doc(
+ {
+ "doctype": "Email Template",
+ "name": name,
+ "subject": f"{subject_prefix} - {{{{ event_title }}}}",
+ "response": f"{subject_prefix} content
",
+ }
+ ).insert()
+
+ @patch("frappe.sendmail")
+ def test_uses_event_template_when_set(self, mock_sendmail):
+ template = self._create_template("Event Ticket Template", "EVENT")
+ try:
+ self.test_event.ticket_email_template = template.name
+ self.test_event.save()
+
+ self.test_ticket.send_ticket_email(now=True)
+
+ mock_sendmail.assert_called_once()
+ self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"])
+ finally:
+ self.test_event.ticket_email_template = None
+ self.test_event.save()
+ frappe.delete_doc("Email Template", template.name, force=True)
+
+ @patch("frappe.sendmail")
+ def test_falls_back_to_global_template(self, mock_sendmail):
+ template = self._create_template("Global Ticket Template", "GLOBAL")
+ try:
+ self.test_event.ticket_email_template = None
+ self.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.default_ticket_email_template = template.name
+ settings.save()
+
+ self.test_ticket.send_ticket_email(now=True)
+
+ mock_sendmail.assert_called_once()
+ self.assertIn("GLOBAL", mock_sendmail.call_args[1]["subject"])
+ finally:
+ settings.default_ticket_email_template = None
+ settings.save()
+ frappe.delete_doc("Email Template", template.name, force=True)
+
+ @patch("frappe.sendmail")
+ def test_event_template_takes_precedence(self, mock_sendmail):
+ event_template = self._create_template("Event Template", "EVENT")
+ global_template = self._create_template("Global Template", "GLOBAL")
+ try:
+ self.test_event.ticket_email_template = event_template.name
+ self.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.default_ticket_email_template = global_template.name
+ settings.save()
+
+ self.test_ticket.send_ticket_email(now=True)
+
+ mock_sendmail.assert_called_once()
+ self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"])
+ self.assertNotIn("GLOBAL", mock_sendmail.call_args[1]["subject"])
+ finally:
+ self.test_event.ticket_email_template = None
+ self.test_event.save()
+ settings.default_ticket_email_template = None
+ settings.save()
+ frappe.delete_doc("Email Template", event_template.name, force=True)
+ frappe.delete_doc("Email Template", global_template.name, force=True)
+
+ @patch("frappe.sendmail")
+ def test_uses_inline_template_when_none_configured(self, mock_sendmail):
+ self.test_event.ticket_email_template = None
+ self.test_event.save()
+
+ settings = frappe.get_doc("Pohodex Event Manager Settings")
+ settings.default_ticket_email_template = None
+ settings.save()
+
+ self.test_ticket.send_ticket_email(now=True)
+
+ mock_sendmail.assert_called_once()
+ self.assertEqual(mock_sendmail.call_args[1]["template"], "ticket")
+
+
+class TestQRCodeGeneration(IntegrationTestCase):
+ """Tests for QR code generation utility."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.test_event = frappe.get_doc("Pohodex Event Manager Event", {"route": "test-route"})
+
+ def test_make_qr_image_returns_png_bytes(self):
+ """QR image generation should return valid PNG bytes."""
+ result = make_qr_image("test-data-123")
+
+ self.assertIsInstance(result, bytes)
+ # PNG magic bytes
+ self.assertTrue(result.startswith(b"\x89PNG"))
+
+ def test_generate_qr_code_file_creates_attachment(self):
+ """QR code file should be created and attached to document."""
+ file_url = generate_qr_code_file(
+ doc=self.test_event,
+ data="test-qr-data",
+ field_name="qr_code",
+ file_prefix="test-qr",
+ )
+
+ self.assertIsNotNone(file_url)
+ self.assertTrue(file_url.endswith(".png"))
+
+ # Verify file exists in File doctype
+ file_doc = frappe.get_doc("File", {"file_url": file_url})
+ self.assertEqual(file_doc.attached_to_doctype, "Pohodex Event Manager Event")
+ self.assertEqual(str(file_doc.attached_to_name), str(self.test_event.name))
+
+ # Cleanup
+ file_doc.delete()
diff --git a/event_manager/ticketing/doctype/event_ticket_type/__init__.py b/event_manager/ticketing/doctype/event_ticket_type/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.js b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.js
new file mode 100644
index 0000000..f19b15e
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Event Ticket Type", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.json b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.json
new file mode 100644
index 0000000..4e8ae3c
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.json
@@ -0,0 +1,161 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "autoincrement",
+ "creation": "2025-07-22 19:49:59.242064",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "title",
+ "price",
+ "currency",
+ "column_break_lrao",
+ "event",
+ "is_published",
+ "auto_unpublish_after",
+ "max_tickets_available",
+ "stats_section",
+ "tickets_sold",
+ "column_break_ygut",
+ "remaining_tickets"
+ ],
+ "fields": [
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "description": "VIP, Early Bird, etc.",
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_lrao",
+ "fieldtype": "Column Break"
+ },
+ {
+ "allow_in_quick_entry": 1,
+ "default": "0",
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency",
+ "reqd": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "is_published",
+ "fieldtype": "Check",
+ "label": "Is Published?"
+ },
+ {
+ "description": "Leave it 0 for no limit",
+ "fieldname": "max_tickets_available",
+ "fieldtype": "Int",
+ "label": "Max Tickets Available",
+ "non_negative": 1
+ },
+ {
+ "depends_on": "eval:doc.is_published",
+ "description": "For Early Bird, etc.",
+ "fieldname": "auto_unpublish_after",
+ "fieldtype": "Date",
+ "label": "Auto Unpublish After"
+ },
+ {
+ "fieldname": "stats_section",
+ "fieldtype": "Section Break",
+ "label": "Stats"
+ },
+ {
+ "description": "-1 if no limit defined above",
+ "fieldname": "remaining_tickets",
+ "fieldtype": "Int",
+ "is_virtual": 1,
+ "label": "Remaining Tickets"
+ },
+ {
+ "fieldname": "tickets_sold",
+ "fieldtype": "Int",
+ "is_virtual": 1,
+ "label": "Tickets Sold"
+ },
+ {
+ "fieldname": "column_break_ygut",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [
+ {
+ "link_doctype": "Event Ticket",
+ "link_fieldname": "ticket_type"
+ }
+ ],
+ "modified": "2025-10-28 16:17:49.999214",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Ticket Type",
+ "naming_rule": "Autoincrement",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "search_fields": "event,price,currency",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title"
+}
diff --git a/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.py b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.py
new file mode 100644
index 0000000..4df8392
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket_type/event_ticket_type.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class EventTicketType(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ auto_unpublish_after: DF.Date | None
+ currency: DF.Link
+ event: DF.Link
+ is_published: DF.Check
+ max_tickets_available: DF.Int
+ name: DF.Int | None
+ price: DF.Currency
+ title: DF.Data
+ # end: auto-generated types
+
+ def are_tickets_available(self, num_tickets: int) -> bool:
+ if self.remaining_tickets != -1 and self.remaining_tickets < num_tickets:
+ return False
+ return True
+
+ @property
+ def tickets_sold(self) -> int:
+ """Returns the number of tickets sold for this ticket type."""
+ return frappe.db.count("Event Ticket", {"ticket_type": self.name, "docstatus": 1})
+
+ @property
+ def remaining_tickets(self) -> int:
+ """Returns -1 if no limit, otherwise the number of remaining tickets."""
+ if not self.max_tickets_available:
+ return -1
+ return self.max_tickets_available - self.tickets_sold
diff --git a/event_manager/ticketing/doctype/event_ticket_type/test_event_ticket_type.py b/event_manager/ticketing/doctype/event_ticket_type/test_event_ticket_type.py
new file mode 100644
index 0000000..511e81b
--- /dev/null
+++ b/event_manager/ticketing/doctype/event_ticket_type/test_event_ticket_type.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestEventTicketType(IntegrationTestCase):
+ """
+ Integration tests for EventTicketType.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/ticketing/doctype/ticket_add_on/__init__.py b/event_manager/ticketing/doctype/ticket_add_on/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/ticket_add_on/test_ticket_add_on.py b/event_manager/ticketing/doctype/ticket_add_on/test_ticket_add_on.py
new file mode 100644
index 0000000..821247e
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on/test_ticket_add_on.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestTicketAddon(IntegrationTestCase):
+ """
+ Integration tests for TicketAddon.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.js b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.js
new file mode 100644
index 0000000..68424d9
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Ticket Add-on", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.json b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.json
new file mode 100644
index 0000000..8ce135a
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.json
@@ -0,0 +1,128 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 12:49:04.542973",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "enabled",
+ "title",
+ "event",
+ "price",
+ "description",
+ "column_break_lbak",
+ "currency",
+ "user_selects_option",
+ "options"
+ ],
+ "fields": [
+ {
+ "default": "0",
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "fieldname": "column_break_lbak",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "INR",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency"
+ },
+ {
+ "fieldname": "event",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Event",
+ "options": "Pohodex Event Manager Event",
+ "reqd": 1
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title",
+ "reqd": 1
+ },
+ {
+ "fieldname": "options",
+ "fieldtype": "Small Text",
+ "label": "Options",
+ "mandatory_depends_on": "eval:doc.user_selects_option==true"
+ },
+ {
+ "default": "0",
+ "fieldname": "user_selects_option",
+ "fieldtype": "Check",
+ "label": "User Selects Option?"
+ },
+ {
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "label": "Description"
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled?"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2025-11-08 15:25:00.872093",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Ticket Add-on",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Pohodex Event Manager User",
+ "select": 1,
+ "share": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "show_title_field_in_link": 1,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "title",
+ "track_changes": 1
+}
diff --git a/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.py b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.py
new file mode 100644
index 0000000..a0d2cd3
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on/ticket_add_on.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+
+
+class TicketAddon(Document):
+ def validate(self):
+ self.validate_duplicate_title()
+
+ def validate_duplicate_title(self):
+ if frappe.db.exists(
+ self.doctype,
+ {
+ "event": self.event,
+ "title": self.title,
+ "name": ["!=", self.name],
+ },
+ ):
+ frappe.throw(_("Add-on {0} already exists for this event").format(self.title))
diff --git a/event_manager/ticketing/doctype/ticket_add_on_value/__init__.py b/event_manager/ticketing/doctype/ticket_add_on_value/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.json b/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.json
new file mode 100644
index 0000000..bda14a8
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.json
@@ -0,0 +1,70 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-26 13:01:07.476312",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "add_on",
+ "value",
+ "column_break_jbbk",
+ "price",
+ "currency"
+ ],
+ "fields": [
+ {
+ "fieldname": "add_on",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Add on",
+ "options": "Ticket Add-on",
+ "reqd": 1
+ },
+ {
+ "fieldname": "value",
+ "fieldtype": "Autocomplete",
+ "in_list_view": 1,
+ "label": "Value",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_jbbk",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "add_on.price",
+ "fetch_if_empty": 1,
+ "fieldname": "price",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Price",
+ "non_negative": 1,
+ "options": "currency"
+ },
+ {
+ "default": "INR",
+ "fetch_from": "add_on.currency",
+ "fetch_if_empty": 1,
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Currency",
+ "options": "Currency"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-08-02 11:36:25.853051",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Ticket Add-on Value",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.py b/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.py
new file mode 100644
index 0000000..853824a
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_add_on_value/ticket_add_on_value.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class TicketAddonValue(Document):
+ pass
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_item/__init__.py b/event_manager/ticketing/doctype/ticket_cancellation_item/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.json b/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.json
new file mode 100644
index 0000000..e9de821
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.json
@@ -0,0 +1,35 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-08-20 15:21:33.899513",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "ticket"
+ ],
+ "fields": [
+ {
+ "fieldname": "ticket",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Ticket",
+ "options": "Event Ticket",
+ "reqd": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-08-20 15:21:50.432135",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Ticket Cancellation Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.py b/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.py
new file mode 100644
index 0000000..4a76fd0
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_item/ticket_cancellation_item.py
@@ -0,0 +1,23 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class TicketCancellationItem(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ ticket: DF.Link
+ # end: auto-generated types
+
+ pass
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_request/__init__.py b/event_manager/ticketing/doctype/ticket_cancellation_request/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_request/test_ticket_cancellation_request.py b/event_manager/ticketing/doctype/ticket_cancellation_request/test_ticket_cancellation_request.py
new file mode 100644
index 0000000..304b817
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_request/test_ticket_cancellation_request.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestTicketCancellationRequest(IntegrationTestCase):
+ """
+ Integration tests for TicketCancellationRequest.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.js b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.js
new file mode 100644
index 0000000..e0c8574
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Ticket Cancellation Request", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.json b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.json
new file mode 100644
index 0000000..9fb7e82
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.json
@@ -0,0 +1,109 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-08-20 15:20:06.899417",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "booking",
+ "cancel_full_booking",
+ "tickets",
+ "column_break_fiii",
+ "status",
+ "section_break_cykg",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "booking",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Booking",
+ "options": "Event Booking",
+ "reqd": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "cancel_full_booking",
+ "fieldtype": "Check",
+ "label": "Cancel Full Booking?"
+ },
+ {
+ "depends_on": "eval:doc.cancel_full_booking==0",
+ "fieldname": "tickets",
+ "fieldtype": "Table",
+ "label": "Tickets",
+ "mandatory_depends_on": "eval:doc.cancel_full_booking==0",
+ "options": "Ticket Cancellation Item"
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Ticket Cancellation Request",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "column_break_fiii",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "In Review",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "options": "In Review\nAccepted\nRejected"
+ },
+ {
+ "fieldname": "section_break_cykg",
+ "fieldtype": "Section Break"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2025-11-11 14:24:24.302960",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Ticket Cancellation Request",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Event Manager",
+ "select": 1,
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.py b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.py
new file mode 100644
index 0000000..90c0e93
--- /dev/null
+++ b/event_manager/ticketing/doctype/ticket_cancellation_request/ticket_cancellation_request.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class TicketCancellationRequest(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from event_manager.ticketing.doctype.ticket_cancellation_item.ticket_cancellation_item import (
+ TicketCancellationItem,
+ )
+
+ amended_from: DF.Link | None
+ booking: DF.Link
+ cancel_full_booking: DF.Check
+ status: DF.Literal["In Review", "Accepted", "Rejected"]
+ tickets: DF.Table[TicketCancellationItem]
+ # end: auto-generated types
+
+ def on_submit(self):
+ if self.status != "Accepted":
+ frappe.throw(frappe._("You must accept the request in order to submit it!"))
+
+ if self.cancel_full_booking:
+ frappe.get_cached_doc("Event Booking", self.booking).cancel()
+ else:
+ # cancel individual tickets
+ for ticket_item in self.tickets:
+ frappe.get_cached_doc("Event Ticket", ticket_item.ticket).cancel()
diff --git a/event_manager/ticketing/notification/__init__.py b/event_manager/ticketing/notification/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/notification/ticket_email_notification/ticket_email_notification.py b/event_manager/ticketing/notification/ticket_email_notification/ticket_email_notification.py
new file mode 100644
index 0000000..1d652b0
--- /dev/null
+++ b/event_manager/ticketing/notification/ticket_email_notification/ticket_email_notification.py
@@ -0,0 +1 @@
+# Ticket Email Notification
diff --git a/event_manager/ticketing/print_format/__init__.py b/event_manager/ticketing/print_format/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/print_format/standard_ticket/__init__.py b/event_manager/ticketing/print_format/standard_ticket/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/print_format/standard_ticket/standard_ticket.json b/event_manager/ticketing/print_format/standard_ticket/standard_ticket.json
new file mode 100644
index 0000000..27a72d7
--- /dev/null
+++ b/event_manager/ticketing/print_format/standard_ticket/standard_ticket.json
@@ -0,0 +1,33 @@
+{
+ "absolute_value": 0,
+ "align_labels_right": 0,
+ "creation": "2025-09-07 17:59:03.058811",
+ "custom_format": 1,
+ "default_print_language": "en",
+ "disabled": 0,
+ "doc_type": "Event Ticket",
+ "docstatus": 0,
+ "doctype": "Print Format",
+ "font_size": 14,
+ "html": "\n\n\n \n \n\n\n \n \n \n
\n
\n Attendee \n {{ doc.attendee_name }} \n
\n \n
\n Email \n {{ doc.attendee_email }} \n
\n \n \n
\n Ticket Type \n {{ frappe.db.get_value(\"Event Ticket Type\", doc.ticket_type, \"title\") }} \n
\n \n
\n Booking Ref \n {{ doc.booking }} \n
\n \n
\n Booking Date \n {{ frappe.format_date(doc.creation) }} \n
\n \n {% if doc.add_ons %}\n
\n
Add-ons \n {% for addon in doc.add_ons %}\n
\n {{ frappe.db.get_value(\"Ticket Add-on\", addon.add_on, \"title\") }} \n {{ addon.value }} \n
\n {% endfor %}\n
\n {% endif %}\n \n
\n
\n {% if doc.qr_code %}\n
\n {% else %}\n
\n QR Code\n
\n {% endif %}\n
\n
Scan for Entry
\n
\n
\n \n \n
\n\n",
+ "idx": 0,
+ "line_breaks": 0,
+ "margin_bottom": 15.0,
+ "margin_left": 15.0,
+ "margin_right": 15.0,
+ "margin_top": 15.0,
+ "modified": "2026-01-19 12:56:24.773201",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Standard Ticket",
+ "owner": "Administrator",
+ "page_number": "Hide",
+ "pdf_generator": "wkhtmltopdf",
+ "print_format_builder": 0,
+ "print_format_builder_beta": 0,
+ "print_format_for": "DocType",
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 0,
+ "standard": "Yes"
+}
diff --git a/event_manager/ticketing/report/__init__.py b/event_manager/ticketing/report/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/report/detailed_event_registrations/__init__.py b/event_manager/ticketing/report/detailed_event_registrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.js b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.js
new file mode 100644
index 0000000..65a624c
--- /dev/null
+++ b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.js
@@ -0,0 +1,14 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["Detailed Event Registrations"] = {
+ filters: [
+ {
+ fieldname: "event",
+ label: __("Event"),
+ fieldtype: "Link",
+ options: "Pohodex Event Manager Event",
+ reqd: 1,
+ },
+ ],
+};
diff --git a/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.json b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.json
new file mode 100644
index 0000000..cfbdd52
--- /dev/null
+++ b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.json
@@ -0,0 +1,28 @@
+{
+ "add_total_row": 0,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2025-12-30 13:50:53.801487",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": null,
+ "modified": "2025-12-30 13:50:58.181897",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Detailed Event Registrations",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Event Ticket",
+ "report_name": "Detailed Event Registrations",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Event Manager"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.py b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.py
new file mode 100644
index 0000000..eec7d3b
--- /dev/null
+++ b/event_manager/ticketing/report/detailed_event_registrations/detailed_event_registrations.py
@@ -0,0 +1,318 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+
+def execute(filters=None):
+ if not filters:
+ filters = {}
+
+ if not filters.get("event"):
+ return [], []
+
+ columns = get_columns(filters)
+ data = get_data(filters, columns)
+ return columns, data
+
+
+def get_columns(filters):
+ event = filters.get("event")
+
+ # Fixed columns
+ columns = [
+ {
+ "fieldname": "ticket_id",
+ "label": _("Ticket ID"),
+ "fieldtype": "Link",
+ "options": "Event Ticket",
+ "width": 120,
+ },
+ {
+ "fieldname": "attendee_name",
+ "label": _("Attendee Name"),
+ "fieldtype": "Data",
+ "width": 150,
+ },
+ {
+ "fieldname": "attendee_email",
+ "label": _("Attendee Email"),
+ "fieldtype": "Data",
+ "width": 180,
+ },
+ {
+ "fieldname": "booking_id",
+ "label": _("Booking ID"),
+ "fieldtype": "Link",
+ "options": "Event Booking",
+ "width": 120,
+ },
+ {
+ "fieldname": "ticket_type",
+ "label": _("Ticket Type"),
+ "fieldtype": "Data",
+ "width": 120,
+ },
+ {
+ "fieldname": "booking_user",
+ "label": _("Booking User"),
+ "fieldtype": "Link",
+ "options": "User",
+ "width": 150,
+ },
+ {
+ "fieldname": "booked_at",
+ "label": _("Booked At"),
+ "fieldtype": "Datetime",
+ "width": 160,
+ },
+ ]
+
+ # Dynamic custom field columns
+ custom_fields = get_custom_fields_for_event(event)
+ for cf in custom_fields:
+ columns.append(
+ {
+ "fieldname": f"cf_{cf.fieldname}",
+ "label": _(cf.label),
+ "fieldtype": "Data",
+ "width": 180,
+ }
+ )
+
+ # Dynamic add-on columns
+ add_ons = get_add_ons_for_event(event)
+ for addon in add_ons:
+ columns.append(
+ {
+ "fieldname": f"addon_{addon.name}",
+ "label": _(addon.title),
+ "fieldtype": "Data",
+ "width": 150,
+ }
+ )
+
+ # Dynamic UTM parameter columns
+ utm_params = get_utm_params_for_event(event)
+ for utm in utm_params:
+ columns.append(
+ {
+ "fieldname": f"utm_{utm}",
+ "label": _(utm.replace("_", " ").title()),
+ "fieldtype": "Data",
+ "width": 150,
+ }
+ )
+
+ return columns
+
+
+def get_data(filters, columns):
+ event = filters.get("event")
+
+ # Get all submitted tickets for the event
+ tickets = frappe.get_all(
+ "Event Ticket",
+ filters={"event": event, "docstatus": 1},
+ fields=["name", "attendee_name", "attendee_email", "booking", "ticket_type", "creation"],
+ )
+
+ if not tickets:
+ return []
+
+ # Get ticket type titles
+ ticket_type_map = get_ticket_type_map(event)
+
+ # Get booking details
+ booking_ids = list(set([t.booking for t in tickets if t.booking]))
+ booking_map = get_booking_map(booking_ids)
+
+ # Get custom fields configuration
+ custom_fields = get_custom_fields_for_event(event)
+ custom_field_names = [cf.fieldname for cf in custom_fields]
+
+ # Get add-ons configuration
+ add_ons = get_add_ons_for_event(event)
+ add_on_names = [addon.name for addon in add_ons]
+
+ # Get UTM params
+ utm_params = get_utm_params_for_event(event)
+
+ # Get ticket additional fields
+ ticket_ids = [t.name for t in tickets]
+ ticket_additional_fields = get_ticket_additional_fields(ticket_ids)
+
+ # Get booking additional fields
+ booking_additional_fields = get_booking_additional_fields(booking_ids)
+
+ # Get ticket add-ons
+ ticket_add_ons = get_ticket_add_ons(ticket_ids)
+
+ # Get booking UTM parameters
+ booking_utm_params = get_booking_utm_params(booking_ids)
+
+ # Build data rows
+ data = []
+ for ticket in tickets:
+ row = {
+ "ticket_id": ticket.name,
+ "attendee_name": ticket.attendee_name,
+ "attendee_email": ticket.attendee_email,
+ "booking_id": ticket.booking,
+ "ticket_type": ticket_type_map.get(str(ticket.ticket_type), ticket.ticket_type),
+ "booking_user": booking_map.get(ticket.booking, {}).get("user", ""),
+ "booked_at": ticket.creation,
+ }
+
+ # Add custom field values (ticket takes priority over booking)
+ for cf_name in custom_field_names:
+ ticket_cf_value = ticket_additional_fields.get(ticket.name, {}).get(cf_name)
+ booking_cf_value = booking_additional_fields.get(ticket.booking, {}).get(cf_name)
+ row[f"cf_{cf_name}"] = ticket_cf_value or booking_cf_value or ""
+
+ # Add add-on values
+ for addon_name in add_on_names:
+ addon_value = ticket_add_ons.get(ticket.name, {}).get(addon_name)
+ row[f"addon_{addon_name}"] = addon_value or ""
+
+ # Add UTM parameter values
+ for utm in utm_params:
+ utm_value = booking_utm_params.get(ticket.booking, {}).get(utm)
+ row[f"utm_{utm}"] = utm_value or ""
+
+ data.append(row)
+
+ return data
+
+
+def get_custom_fields_for_event(event):
+ return frappe.get_all(
+ "Pohodex Event Manager Custom Field",
+ filters={"event": event, "enabled": 1},
+ fields=["fieldname", "label", "applied_to"],
+ order_by="order asc",
+ )
+
+
+def get_add_ons_for_event(event):
+ return frappe.get_all(
+ "Ticket Add-on",
+ filters={"event": event, "enabled": 1},
+ fields=["name", "title"],
+ order_by="creation asc",
+ )
+
+
+def get_utm_params_for_event(event):
+ # Get distinct UTM parameter names from bookings for this event
+ utm_data = frappe.db.sql(
+ """
+ SELECT DISTINCT up.utm_name
+ FROM `tabUTM Parameter` up
+ INNER JOIN `tabEvent Booking` eb ON up.parent = eb.name
+ WHERE eb.event = %s AND eb.docstatus = 1
+ ORDER BY up.utm_name
+ """,
+ (event,),
+ as_dict=True,
+ )
+ return [u.utm_name for u in utm_data]
+
+
+def get_ticket_type_map(event):
+ ticket_types = frappe.get_all(
+ "Event Ticket Type",
+ filters={"event": event},
+ fields=["name", "title"],
+ )
+ # Use string keys to handle type mismatches (autoincrement IDs can be int or str)
+ return {str(tt.name): tt.title for tt in ticket_types}
+
+
+def get_booking_map(booking_ids):
+ if not booking_ids:
+ return {}
+
+ bookings = frappe.get_all(
+ "Event Booking",
+ filters={"name": ["in", booking_ids]},
+ fields=["name", "user"],
+ )
+ return {b.name: b for b in bookings}
+
+
+def get_ticket_additional_fields(ticket_ids):
+ if not ticket_ids:
+ return {}
+
+ additional_fields = frappe.get_all(
+ "Additional Field",
+ filters={"parent": ["in", ticket_ids], "parenttype": "Event Ticket"},
+ fields=["parent", "fieldname", "value"],
+ )
+
+ result = {}
+ for af in additional_fields:
+ if af.parent not in result:
+ result[af.parent] = {}
+ result[af.parent][af.fieldname] = af.value
+
+ return result
+
+
+def get_booking_additional_fields(booking_ids):
+ if not booking_ids:
+ return {}
+
+ additional_fields = frappe.get_all(
+ "Additional Field",
+ filters={"parent": ["in", booking_ids], "parenttype": "Event Booking"},
+ fields=["parent", "fieldname", "value"],
+ )
+
+ result = {}
+ for af in additional_fields:
+ if af.parent not in result:
+ result[af.parent] = {}
+ result[af.parent][af.fieldname] = af.value
+
+ return result
+
+
+def get_ticket_add_ons(ticket_ids):
+ if not ticket_ids:
+ return {}
+
+ add_ons = frappe.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ["in", ticket_ids], "parenttype": "Event Ticket"},
+ fields=["parent", "add_on", "value"],
+ )
+
+ result = {}
+ for ao in add_ons:
+ if ao.parent not in result:
+ result[ao.parent] = {}
+ result[ao.parent][ao.add_on] = ao.value
+
+ return result
+
+
+def get_booking_utm_params(booking_ids):
+ if not booking_ids:
+ return {}
+
+ utm_params = frappe.get_all(
+ "UTM Parameter",
+ filters={"parent": ["in", booking_ids], "parenttype": "Event Booking"},
+ fields=["parent", "utm_name", "value"],
+ )
+
+ result = {}
+ for up in utm_params:
+ if up.parent not in result:
+ result[up.parent] = {}
+ result[up.parent][up.utm_name] = up.value
+
+ return result
diff --git a/event_manager/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py b/event_manager/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py
new file mode 100644
index 0000000..388e012
--- /dev/null
+++ b/event_manager/ticketing/report/detailed_event_registrations/test_detailed_event_registrations.py
@@ -0,0 +1,828 @@
+# Copyright (c) 2025, BWH Studios and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests import IntegrationTestCase
+
+from event_manager.ticketing.report.detailed_event_registrations.detailed_event_registrations import (
+ execute,
+ get_add_ons_for_event,
+ get_booking_additional_fields,
+ get_booking_map,
+ get_booking_utm_params,
+ get_columns,
+ get_custom_fields_for_event,
+ get_data,
+ get_ticket_add_ons,
+ get_ticket_additional_fields,
+ get_ticket_type_map,
+ get_utm_params_for_event,
+)
+
+
+class TestDetailedEventRegistrationsReport(IntegrationTestCase):
+ """Integration tests for the Detailed Event Registrations report."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.test_event = cls._create_test_event()
+ cls.test_ticket_type = cls._create_test_ticket_type(cls.test_event)
+ cls.test_ticket_type_vip = cls._create_test_ticket_type(cls.test_event, title="VIP", price=500)
+
+ @classmethod
+ def _create_test_event(cls):
+ """Create a test event for the report tests."""
+ # Check if test event already exists
+ if frappe.db.exists("Pohodex Event Manager Event", {"route": "test-report-event"}):
+ return frappe.get_doc("Pohodex Event Manager Event", {"route": "test-report-event"})
+
+ # Create required dependencies
+ if not frappe.db.exists("Event Category", "Test Category"):
+ frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert()
+
+ if not frappe.db.exists("Event Host", "Test Host"):
+ frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert()
+
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Test Report Event",
+ "route": "test-report-event",
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ "medium": "Online",
+ "apply_tax": False,
+ }
+ ).insert()
+ return event
+
+ @classmethod
+ def _create_test_ticket_type(cls, event, title="Standard", price=100):
+ """Create a test ticket type."""
+ return frappe.get_doc(
+ {
+ "doctype": "Event Ticket Type",
+ "event": event.name,
+ "title": title,
+ "price": price,
+ "is_published": True,
+ }
+ ).insert()
+
+ def _create_booking_with_tickets(
+ self,
+ attendees_data=None,
+ utm_parameters=None,
+ additional_fields=None,
+ submit=True,
+ ):
+ """Helper to create a booking with tickets."""
+ if attendees_data is None:
+ attendees_data = [
+ {
+ "first_name": "John Doe",
+ "email": "john@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ]
+
+ booking_data = {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "attendees": attendees_data,
+ }
+
+ if utm_parameters:
+ booking_data["utm_parameters"] = utm_parameters
+
+ if additional_fields:
+ booking_data["additional_fields"] = additional_fields
+
+ booking = frappe.get_doc(booking_data).insert()
+
+ if submit:
+ booking.submit()
+
+ return booking
+
+ def _create_custom_field(self, label, fieldname, applied_to="Ticket", fieldtype="Data"):
+ """Helper to create a custom field for the event."""
+ return frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Custom Field",
+ "event": self.test_event.name,
+ "label": label,
+ "fieldname": fieldname,
+ "fieldtype": fieldtype,
+ "applied_to": applied_to,
+ "enabled": 1,
+ "order": 1,
+ }
+ ).insert()
+
+ def _create_ticket_add_on(self, title, price=50, user_selects_option=True, options="S\nM\nL\nXL"):
+ """Helper to create a ticket add-on for the event."""
+ return frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": self.test_event.name,
+ "title": title,
+ "price": price,
+ "enabled": 1,
+ "user_selects_option": user_selects_option,
+ "options": options if user_selects_option else None,
+ }
+ ).insert()
+
+ def _get_ticket_for_booking(self, booking_name, attendee_email=None):
+ """Helper to get a ticket from a booking."""
+ filters = {"booking": booking_name}
+ if attendee_email:
+ filters["attendee_email"] = attendee_email
+ ticket_names = frappe.get_all("Event Ticket", filters=filters, pluck="name")
+ if ticket_names:
+ return frappe.get_doc("Event Ticket", ticket_names[0])
+ return None
+
+ # ==================== Test execute function ====================
+
+ def test_execute_returns_empty_without_filters(self):
+ """Test that execute returns empty results without filters."""
+ columns, data = execute(None)
+ self.assertEqual(columns, [])
+ self.assertEqual(data, [])
+
+ def test_execute_returns_empty_without_event_filter(self):
+ """Test that execute returns empty results without event filter."""
+ columns, data = execute({})
+ self.assertEqual(columns, [])
+ self.assertEqual(data, [])
+
+ def test_execute_returns_columns_and_data_with_event_filter(self):
+ """Test that execute returns proper columns and data with event filter."""
+ # Create a submitted booking
+ self._create_booking_with_tickets()
+
+ columns, data = execute({"event": self.test_event.name})
+
+ self.assertIsInstance(columns, list)
+ self.assertIsInstance(data, list)
+ self.assertGreater(len(columns), 0)
+ self.assertGreater(len(data), 0)
+
+ # ==================== Test get_columns function ====================
+
+ def test_get_columns_returns_fixed_columns(self):
+ """Test that get_columns returns the required fixed columns."""
+ columns = get_columns({"event": self.test_event.name})
+
+ fieldnames = [col["fieldname"] for col in columns]
+ expected_fieldnames = [
+ "ticket_id",
+ "attendee_name",
+ "attendee_email",
+ "booking_id",
+ "ticket_type",
+ "booking_user",
+ ]
+
+ for expected in expected_fieldnames:
+ self.assertIn(expected, fieldnames)
+
+ def test_get_columns_includes_custom_field_columns(self):
+ """Test that custom field columns are included."""
+ # Create a custom field
+ custom_field = self._create_custom_field("Company Name", "company_name")
+
+ columns = get_columns({"event": self.test_event.name})
+ fieldnames = [col["fieldname"] for col in columns]
+
+ self.assertIn("cf_company_name", fieldnames)
+
+ # Clean up
+ custom_field.delete()
+
+ def test_get_columns_includes_add_on_columns(self):
+ """Test that add-on columns are included."""
+ # Create a ticket add-on
+ add_on = self._create_ticket_add_on("T-Shirt Size")
+
+ columns = get_columns({"event": self.test_event.name})
+ fieldnames = [col["fieldname"] for col in columns]
+
+ self.assertIn(f"addon_{add_on.name}", fieldnames)
+
+ # Clean up
+ add_on.delete()
+
+ def test_get_columns_includes_utm_columns(self):
+ """Test that UTM parameter columns are included."""
+ # Create a booking with UTM parameters
+ self._create_booking_with_tickets(
+ utm_parameters=[
+ {"utm_name": "utm_source", "value": "google"},
+ {"utm_name": "utm_medium", "value": "cpc"},
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ fieldnames = [col["fieldname"] for col in columns]
+
+ self.assertIn("utm_utm_source", fieldnames)
+ self.assertIn("utm_utm_medium", fieldnames)
+
+ # ==================== Test get_data function ====================
+
+ def test_get_data_returns_only_submitted_tickets(self):
+ """Test that only submitted tickets are returned."""
+ # Create a draft booking (not submitted)
+ self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Draft User",
+ "email": "draft@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ],
+ submit=False,
+ )
+
+ # Create a submitted booking
+ self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Submitted User",
+ "email": "submitted@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ],
+ submit=True,
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Check that draft user is not in data
+ attendee_names = [row["attendee_name"] for row in data]
+ self.assertNotIn("Draft User", attendee_names)
+ self.assertIn("Submitted User", attendee_names)
+
+ def test_get_data_includes_correct_ticket_info(self):
+ """Test that ticket information is correctly included."""
+ booking = self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Test Attendee",
+ "email": "testattendee@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our test attendee
+ test_row = next((row for row in data if row["attendee_name"] == "Test Attendee"), None)
+
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row["attendee_email"], "testattendee@test.com")
+ self.assertEqual(test_row["booking_id"], booking.name)
+ # Check ticket type is the title from the ticket type we created
+ self.assertEqual(test_row["ticket_type"], self.test_ticket_type.title)
+ self.assertEqual(test_row["booking_user"], "Administrator")
+
+ def test_get_data_includes_custom_field_values_from_ticket(self):
+ """Test that custom field values from tickets are included."""
+ # Create a custom field
+ custom_field = self._create_custom_field(
+ "Dietary Preference", "dietary_preference", applied_to="Ticket"
+ )
+
+ # Create a standalone ticket with additional fields (not via booking)
+ ticket = frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "ticket_type": self.test_ticket_type.name,
+ "attendee_name": "Dietary Test User",
+ "attendee_email": "dietary@test.com",
+ "additional_fields": [
+ {
+ "fieldname": "dietary_preference",
+ "label": "Dietary Preference",
+ "value": "Vegetarian",
+ }
+ ],
+ }
+ ).insert()
+ ticket.submit()
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our ticket
+ test_row = next((row for row in data if row["ticket_id"] == ticket.name), None)
+
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row["cf_dietary_preference"], "Vegetarian")
+
+ # Clean up
+ custom_field.delete()
+
+ def test_get_data_custom_field_ticket_priority_over_booking(self):
+ """Test that ticket-level custom field values take priority over booking-level."""
+ # Create a custom field
+ custom_field = self._create_custom_field("Organization", "organization", applied_to="Ticket")
+
+ # Create a standalone ticket with both booking and ticket level custom fields
+ # Since we can't easily add ticket-level fields after submission, we use direct insertion
+ # First create the ticket with ticket-level field
+ ticket = frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "ticket_type": self.test_ticket_type.name,
+ "attendee_name": "Priority Test User",
+ "attendee_email": "priority@test.com",
+ "additional_fields": [
+ {
+ "fieldname": "organization",
+ "label": "Organization",
+ "value": "Ticket Org",
+ }
+ ],
+ }
+ ).insert()
+
+ # Create a booking with organization field and link the ticket
+ booking = frappe.get_doc(
+ {
+ "doctype": "Event Booking",
+ "event": self.test_event.name,
+ "user": "Administrator",
+ "attendees": [
+ {
+ "first_name": "Priority Test User",
+ "email": "priority@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ],
+ "additional_fields": [
+ {
+ "fieldname": "organization",
+ "label": "Organization",
+ "value": "Booking Org",
+ }
+ ],
+ }
+ ).insert()
+
+ # Update the ticket to link to this booking
+ ticket.booking = booking.name
+ ticket.save()
+
+ # Submit the ticket
+ ticket.submit()
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our ticket
+ test_row = next((row for row in data if row["ticket_id"] == ticket.name), None)
+
+ self.assertIsNotNone(test_row)
+ # Ticket value should take priority
+ self.assertEqual(test_row["cf_organization"], "Ticket Org")
+
+ # Clean up
+ custom_field.delete()
+
+ def test_get_data_falls_back_to_booking_custom_field(self):
+ """Test that booking-level custom field is used when ticket doesn't have it."""
+ # Create a custom field
+ custom_field = self._create_custom_field("Company", "company", applied_to="Booking")
+
+ # Create a booking with additional fields (no ticket-level fields)
+ booking = self._create_booking_with_tickets(
+ additional_fields=[
+ {
+ "fieldname": "company",
+ "label": "Company",
+ "value": "Acme Inc",
+ }
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our ticket
+ ticket = self._get_ticket_for_booking(booking.name)
+ self.assertIsNotNone(ticket, "Ticket should be created with booking")
+ test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
+
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row["cf_company"], "Acme Inc")
+
+ # Clean up
+ custom_field.delete()
+
+ def test_get_data_includes_add_on_values(self):
+ """Test that add-on values are correctly included."""
+ # Create a ticket add-on
+ add_on = self._create_ticket_add_on("T-Shirt Size")
+
+ # Create attendee add-on doc
+ attendee_add_on = frappe.get_doc(
+ {
+ "doctype": "Attendee Ticket Add-on",
+ "add_ons": [{"add_on": add_on.name, "value": "XL"}],
+ }
+ ).insert()
+
+ # Create a booking with the add-on
+ booking = self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "AddOn User",
+ "email": "addon@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ "add_ons": attendee_add_on.name,
+ }
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our ticket
+ ticket = self._get_ticket_for_booking(booking.name)
+ self.assertIsNotNone(ticket, "Ticket should be created with booking")
+ test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
+
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row[f"addon_{add_on.name}"], "XL")
+
+ # Clean up
+ add_on.delete()
+
+ def test_get_data_includes_utm_values(self):
+ """Test that UTM parameter values are correctly included."""
+ booking = self._create_booking_with_tickets(
+ utm_parameters=[
+ {"utm_name": "utm_source", "value": "facebook"},
+ {"utm_name": "utm_campaign", "value": "summer_promo"},
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find the row for our ticket
+ ticket = self._get_ticket_for_booking(booking.name)
+ self.assertIsNotNone(ticket, "Ticket should be created with booking")
+ test_row = next((row for row in data if row["ticket_id"] == ticket.name), None) # type: ignore
+
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row["utm_utm_source"], "facebook")
+ self.assertEqual(test_row["utm_utm_campaign"], "summer_promo")
+
+ def test_get_data_handles_multiple_tickets_per_booking(self):
+ """Test that multiple tickets per booking are handled correctly."""
+ booking = self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Attendee One",
+ "email": "one@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ },
+ {
+ "first_name": "Attendee Two",
+ "email": "two@test.com",
+ "ticket_type": self.test_ticket_type_vip.name,
+ },
+ ]
+ )
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Find rows for both attendees
+ attendee_names = [row["attendee_name"] for row in data]
+ self.assertIn("Attendee One", attendee_names)
+ self.assertIn("Attendee Two", attendee_names)
+
+ # Check ticket types
+ attendee_one_row = next((row for row in data if row["attendee_name"] == "Attendee One"), None)
+ attendee_two_row = next((row for row in data if row["attendee_name"] == "Attendee Two"), None)
+
+ self.assertIsNotNone(attendee_one_row)
+ self.assertIsNotNone(attendee_two_row)
+ self.assertEqual(attendee_one_row["ticket_type"], self.test_ticket_type.title)
+ self.assertEqual(attendee_two_row["ticket_type"], self.test_ticket_type_vip.title)
+ self.assertEqual(attendee_one_row["booking_id"], booking.name)
+ self.assertEqual(attendee_two_row["booking_id"], booking.name)
+
+ # ==================== Test helper functions ====================
+
+ def test_get_custom_fields_for_event(self):
+ """Test get_custom_fields_for_event returns correct fields."""
+ cf1 = self._create_custom_field("Field One", "field_one")
+ cf2 = self._create_custom_field("Field Two", "field_two")
+
+ # Create a disabled field that should not be returned
+ cf3 = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Custom Field",
+ "event": self.test_event.name,
+ "label": "Disabled Field",
+ "fieldname": "disabled_field",
+ "fieldtype": "Data",
+ "applied_to": "Ticket",
+ "enabled": 0,
+ "order": 1,
+ }
+ ).insert()
+
+ result = get_custom_fields_for_event(self.test_event.name)
+
+ fieldnames = [r.fieldname for r in result]
+ self.assertIn("field_one", fieldnames)
+ self.assertIn("field_two", fieldnames)
+ self.assertNotIn("disabled_field", fieldnames)
+
+ # Clean up
+ cf1.delete()
+ cf2.delete()
+ cf3.delete()
+
+ def test_get_add_ons_for_event(self):
+ """Test get_add_ons_for_event returns correct add-ons."""
+ ao1 = self._create_ticket_add_on("Add-on One")
+ ao2 = self._create_ticket_add_on("Add-on Two")
+
+ # Create a disabled add-on
+ ao3 = frappe.get_doc(
+ {
+ "doctype": "Ticket Add-on",
+ "event": self.test_event.name,
+ "title": "Disabled Add-on",
+ "price": 50,
+ "enabled": 0,
+ }
+ ).insert()
+
+ result = get_add_ons_for_event(self.test_event.name)
+
+ titles = [r.title for r in result]
+ self.assertIn("Add-on One", titles)
+ self.assertIn("Add-on Two", titles)
+ self.assertNotIn("Disabled Add-on", titles)
+
+ # Clean up
+ ao1.delete()
+ ao2.delete()
+ ao3.delete()
+
+ def test_get_utm_params_for_event(self):
+ """Test get_utm_params_for_event returns distinct UTM names."""
+ # Create bookings with different UTM params
+ self._create_booking_with_tickets(
+ utm_parameters=[
+ {"utm_name": "utm_source", "value": "google"},
+ {"utm_name": "utm_medium", "value": "cpc"},
+ ]
+ )
+
+ self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Another User",
+ "email": "another@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ }
+ ],
+ utm_parameters=[
+ {"utm_name": "utm_source", "value": "facebook"}, # Duplicate name, different value
+ {"utm_name": "utm_campaign", "value": "winter"},
+ ],
+ )
+
+ result = get_utm_params_for_event(self.test_event.name)
+
+ # Should have 3 distinct UTM names
+ self.assertIn("utm_source", result)
+ self.assertIn("utm_medium", result)
+ self.assertIn("utm_campaign", result)
+ self.assertEqual(len(set(result)), len(result)) # All unique
+
+ def test_get_ticket_type_map(self):
+ """Test get_ticket_type_map returns correct mapping."""
+ result = get_ticket_type_map(self.test_event.name)
+
+ # Keys are strings due to autoincrement ID handling
+ self.assertIn(str(self.test_ticket_type.name), result)
+ self.assertEqual(result[str(self.test_ticket_type.name)], "Standard")
+ self.assertIn(str(self.test_ticket_type_vip.name), result)
+ self.assertEqual(result[str(self.test_ticket_type_vip.name)], "VIP")
+
+ def test_get_booking_map(self):
+ """Test get_booking_map returns correct mapping."""
+ booking = self._create_booking_with_tickets()
+
+ result = get_booking_map([booking.name])
+
+ self.assertIn(booking.name, result)
+ self.assertEqual(result[booking.name]["user"], "Administrator")
+
+ def test_get_booking_map_empty_list(self):
+ """Test get_booking_map handles empty list."""
+ result = get_booking_map([])
+ self.assertEqual(result, {})
+
+ def test_get_ticket_additional_fields(self):
+ """Test get_ticket_additional_fields returns correct data."""
+ # Create a standalone ticket with additional fields
+ ticket = frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "ticket_type": self.test_ticket_type.name,
+ "attendee_name": "Additional Fields Test",
+ "attendee_email": "addfields@test.com",
+ "additional_fields": [
+ {
+ "fieldname": "test_field",
+ "label": "Test Field",
+ "value": "Test Value",
+ }
+ ],
+ }
+ ).insert()
+
+ result = get_ticket_additional_fields([ticket.name])
+
+ self.assertIn(ticket.name, result)
+ self.assertEqual(result[ticket.name]["test_field"], "Test Value")
+
+ def test_get_ticket_additional_fields_empty_list(self):
+ """Test get_ticket_additional_fields handles empty list."""
+ result = get_ticket_additional_fields([])
+ self.assertEqual(result, {})
+
+ def test_get_booking_additional_fields(self):
+ """Test get_booking_additional_fields returns correct data."""
+ booking = self._create_booking_with_tickets(
+ additional_fields=[
+ {
+ "fieldname": "booking_field",
+ "label": "Booking Field",
+ "value": "Booking Value",
+ }
+ ]
+ )
+
+ result = get_booking_additional_fields([booking.name])
+
+ self.assertIn(booking.name, result)
+ self.assertEqual(result[booking.name]["booking_field"], "Booking Value")
+
+ def test_get_booking_additional_fields_empty_list(self):
+ """Test get_booking_additional_fields handles empty list."""
+ result = get_booking_additional_fields([])
+ self.assertEqual(result, {})
+
+ def test_get_ticket_add_ons(self):
+ """Test get_ticket_add_ons returns correct data."""
+ add_on = self._create_ticket_add_on("Test Add-on")
+
+ attendee_add_on = frappe.get_doc(
+ {
+ "doctype": "Attendee Ticket Add-on",
+ "add_ons": [{"add_on": add_on.name, "value": "Medium"}],
+ }
+ ).insert()
+
+ booking = self._create_booking_with_tickets(
+ attendees_data=[
+ {
+ "first_name": "Add-on Test",
+ "email": "addontest@test.com",
+ "ticket_type": self.test_ticket_type.name,
+ "add_ons": attendee_add_on.name,
+ }
+ ]
+ )
+
+ ticket = self._get_ticket_for_booking(booking.name)
+ self.assertIsNotNone(ticket, "Ticket should be created with booking")
+ result = get_ticket_add_ons([ticket.name]) # type: ignore
+
+ self.assertIn(ticket.name, result) # type: ignore
+ self.assertEqual(result[ticket.name][add_on.name], "Medium") # type: ignore
+
+ # Clean up
+ add_on.delete()
+
+ def test_get_ticket_add_ons_empty_list(self):
+ """Test get_ticket_add_ons handles empty list."""
+ result = get_ticket_add_ons([])
+ self.assertEqual(result, {})
+
+ def test_get_booking_utm_params(self):
+ """Test get_booking_utm_params returns correct data."""
+ booking = self._create_booking_with_tickets(
+ utm_parameters=[
+ {"utm_name": "utm_source", "value": "twitter"},
+ {"utm_name": "utm_medium", "value": "social"},
+ ]
+ )
+
+ result = get_booking_utm_params([booking.name])
+
+ self.assertIn(booking.name, result)
+ self.assertEqual(result[booking.name]["utm_source"], "twitter")
+ self.assertEqual(result[booking.name]["utm_medium"], "social")
+
+ def test_get_booking_utm_params_empty_list(self):
+ """Test get_booking_utm_params handles empty list."""
+ result = get_booking_utm_params([])
+ self.assertEqual(result, {})
+
+ # ==================== Edge cases ====================
+
+ def test_report_with_no_tickets(self):
+ """Test report handles events with no tickets."""
+ # Create a new event with no tickets
+ event = frappe.get_doc(
+ {
+ "doctype": "Pohodex Event Manager Event",
+ "title": "Empty Event",
+ "route": "empty-event-" + frappe.generate_hash(length=6),
+ "category": "Test Category",
+ "host": "Test Host",
+ "start_date": frappe.utils.today(),
+ "start_time": "10:00:00",
+ "end_time": "18:00:00",
+ "medium": "Online",
+ }
+ ).insert()
+
+ columns, data = execute({"event": event.name})
+
+ self.assertIsInstance(columns, list)
+ self.assertEqual(data, [])
+ # Note: Not cleaning up the event as it may have linked dependencies in test DB
+
+ def test_report_with_missing_booking(self):
+ """Test report handles tickets without booking reference gracefully."""
+ # Create a standalone ticket without booking
+ ticket = frappe.get_doc(
+ {
+ "doctype": "Event Ticket",
+ "ticket_type": self.test_ticket_type.name,
+ "attendee_name": "Standalone User",
+ "attendee_email": "standalone@test.com",
+ }
+ ).insert()
+ ticket.submit()
+
+ columns = get_columns({"event": self.test_event.name})
+ data = get_data({"event": self.test_event.name}, columns)
+
+ # Should include the ticket even without booking
+ test_row = next((row for row in data if row["attendee_name"] == "Standalone User"), None)
+ self.assertIsNotNone(test_row)
+ self.assertEqual(test_row["booking_user"], "")
+
+ def test_report_column_ordering(self):
+ """Test that columns are in the expected order."""
+ # Create all types of dynamic columns
+ custom_field = self._create_custom_field("Custom Col", "custom_col")
+ add_on = self._create_ticket_add_on("Add-on Col")
+ self._create_booking_with_tickets(utm_parameters=[{"utm_name": "utm_test", "value": "test"}])
+
+ columns = get_columns({"event": self.test_event.name})
+ fieldnames = [col["fieldname"] for col in columns]
+
+ # Fixed columns should come first
+ fixed_end_index = fieldnames.index("booking_user")
+
+ # Custom fields should come after fixed columns
+ cf_index = fieldnames.index("cf_custom_col")
+ self.assertGreater(cf_index, fixed_end_index)
+
+ # Add-ons should come after custom fields
+ addon_index = fieldnames.index(f"addon_{add_on.name}")
+ self.assertGreater(addon_index, cf_index)
+
+ # UTM params should come last
+ utm_index = fieldnames.index("utm_utm_test")
+ self.assertGreater(utm_index, addon_index)
+
+ # Clean up
+ custom_field.delete()
+ add_on.delete()
diff --git a/event_manager/ticketing/report/event_add_ons_overview/__init__.py b/event_manager/ticketing/report/event_add_ons_overview/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.js b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.js
new file mode 100644
index 0000000..02538f6
--- /dev/null
+++ b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.js
@@ -0,0 +1,25 @@
+// Copyright (c) 2025, BWH Studios and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["Event Add-Ons Overview"] = {
+ filters: [
+ {
+ fieldname: "event",
+ label: __("Event"),
+ fieldtype: "Link",
+ options: "Pohodex Event Manager Event",
+ reqd: 1,
+ },
+ {
+ fieldname: "add_on_type",
+ label: __("Add-On Type"),
+ fieldtype: "Link",
+ options: "Ticket Add-on",
+ },
+ {
+ fieldname: "add_on_value",
+ label: __("Add-On Value"),
+ fieldtype: "Data",
+ },
+ ],
+};
diff --git a/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.json b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.json
new file mode 100644
index 0000000..f09019a
--- /dev/null
+++ b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.json
@@ -0,0 +1,34 @@
+{
+ "add_total_row": 0,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2025-11-06 10:58:02.520355",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letterhead": null,
+ "modified": "2025-11-06 10:58:02.520355",
+ "modified_by": "Administrator",
+ "module": "Ticketing",
+ "name": "Event Add-Ons Overview",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Event Ticket",
+ "report_name": "Event Add-Ons Overview",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Event Manager"
+ },
+ {
+ "role": "Pohodex Event Manager User"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.py b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.py
new file mode 100644
index 0000000..747359a
--- /dev/null
+++ b/event_manager/ticketing/report/event_add_ons_overview/event_add_ons_overview.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2025, BWH Studios and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+
+
+def execute(filters: dict | None = None):
+ """Return columns and data for the report.
+
+ This is the main entry point for the report. It accepts the filters as a
+ dictionary and should return columns and data. It is called by the framework
+ every time the report is refreshed or a filter is updated.
+ """
+ columns = get_columns()
+ data = get_data(filters)
+
+ return columns, data
+
+
+def get_columns() -> list[dict]:
+ """Return columns for the report.
+
+ One field definition per column, just like a DocType field definition.
+ """
+ return [
+ {
+ "label": _("Attendee Name"),
+ "fieldname": "attendee_name",
+ "fieldtype": "Data",
+ },
+ {"label": _("Attendee Email"), "fieldname": "attendee_email", "fieldtype": "Data", "width": 200},
+ {"label": _("Add-On"), "fieldname": "add_on", "fieldtype": "Data", "width": 150},
+ {"label": _("Value"), "fieldname": "value", "fieldtype": "Data", "width": 150},
+ {
+ "label": _("Ticket"),
+ "fieldname": "ticket",
+ "fieldtype": "Link",
+ "options": "Event Ticket",
+ "width": 150,
+ },
+ ]
+
+
+def get_data(filters=None) -> list[dict]:
+ """Return data for the report.
+
+ The report data is a list of rows, with each row being a list of cell values.
+ """
+ tav = frappe.qb.DocType("Ticket Add-on Value")
+ ticket = frappe.qb.DocType("Event Ticket")
+ ticket_add_on = frappe.qb.DocType("Ticket Add-on")
+
+ if not filters:
+ filters = {}
+
+ query = (
+ frappe.qb.from_(tav)
+ .join(ticket)
+ .on(tav.parent == ticket.name)
+ .join(ticket_add_on)
+ .on(tav.add_on == ticket_add_on.name)
+ .select(
+ ticket.attendee_name,
+ ticket.attendee_email,
+ ticket.name.as_("ticket"),
+ ticket_add_on.title.as_("add_on"),
+ tav.value,
+ )
+ .where(ticket.event == filters.get("event"))
+ .where(ticket.docstatus == 1)
+ )
+
+ if filters.get("add_on_type"):
+ query = query.where(ticket_add_on.name == filters.get("add_on_type"))
+
+ if filters.get("add_on_value"):
+ # like operator for partial matching
+ query = query.where(tav.value.like(f"%{filters.get('add_on_value')}%"))
+
+ return query.run(as_dict=True)
diff --git a/event_manager/uninstall.py b/event_manager/uninstall.py
new file mode 100644
index 0000000..61cc120
--- /dev/null
+++ b/event_manager/uninstall.py
@@ -0,0 +1,15 @@
+from event_manager.install import ZOOM_INTEGRATION_CUSTOM_FIELDS
+from event_manager.utils import delete_custom_fields
+
+
+def before_uninstall():
+ delete_custom_fields_for_zoom_integration()
+
+
+def before_app_uninstall(app_name):
+ if app_name == "zoom_integration":
+ delete_custom_fields_for_zoom_integration()
+
+
+def delete_custom_fields_for_zoom_integration():
+ delete_custom_fields(ZOOM_INTEGRATION_CUSTOM_FIELDS)
diff --git a/event_manager/utils.py b/event_manager/utils.py
new file mode 100644
index 0000000..7631a39
--- /dev/null
+++ b/event_manager/utils.py
@@ -0,0 +1,190 @@
+import functools
+from collections.abc import Callable
+
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+
+def is_app_installed(app_name: str) -> bool:
+ """Check if a specified app is installed."""
+ return app_name in frappe.get_installed_apps()
+
+
+def only_if_app_installed(app_name: str, raise_exception: bool = False) -> Callable:
+ """
+ Decorator to check if a specified app is installed before running the function.
+
+ :param app_name: The name of the app to check for installation.
+ :param raise_exception: If True, raises an exception if the app is not installed.
+ If False, the function silently returns None.
+ :return: The decorated function.
+ """
+
+ def decorator(func: Callable) -> Callable:
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ installed_apps = frappe.get_installed_apps()
+ if app_name not in installed_apps:
+ if raise_exception:
+ frappe.throw(
+ frappe._("This feature requires the {0} app to be installed.").format(app_name)
+ )
+ return None
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+def add_event_manager_user_role(doc, event=None):
+ doc.add_roles("Pohodex Event Manager User")
+
+
+# https://github.com/resilient-tech/india-compliance/blob/f259e9d1408a1cbb85c91146df3b5baa72e5fafb/india_compliance/utils/custom_fields.py
+def make_custom_fields(custom_fields, module_name, *args, **kwargs):
+ for _doctypes, fields in custom_fields.items():
+ if isinstance(fields, dict):
+ fields = (fields,)
+
+ for field in fields:
+ field["module"] = module_name
+
+ return create_custom_fields(custom_fields, *args, **kwargs)
+
+
+# https://github.com/resilient-tech/india-compliance/blob/f259e9d1408a1cbb85c91146df3b5baa72e5fafb/india_compliance/utils/custom_fields.py
+def get_custom_fields_creator(module_name):
+ return functools.partial(make_custom_fields, module_name=module_name)
+
+
+# https://github.com/resilient-tech/india-compliance/blob/f259e9d1408a1cbb85c91146df3b5baa72e5fafb/india_compliance/utils/custom_fields.py#L54C1-L77C48
+def delete_custom_fields(custom_fields):
+ """
+ :param custom_fields: a dict like `{'Sales Invoice': [{fieldname: 'test', ...}]}`
+ """
+
+ for doctypes, fields in custom_fields.items():
+ if isinstance(fields, dict):
+ # only one field
+ fields = [fields]
+
+ if isinstance(doctypes, str):
+ # only one doctype
+ doctypes = (doctypes,)
+
+ for doctype in doctypes:
+ frappe.db.delete(
+ "Custom Field",
+ {
+ "fieldname": ("in", [field["fieldname"] for field in fields]),
+ "dt": doctype,
+ },
+ )
+
+ frappe.clear_cache(doctype=doctype)
+
+
+def make_qr_image(data: str) -> bytes:
+ """
+ Generate QR code image bytes from data string.
+
+ :param data: The data to encode in the QR code
+ :return: PNG image as bytes
+ """
+ import io
+
+ import qrcode
+ from qrcode.image.styledpil import StyledPilImage
+ from qrcode.image.styles.moduledrawers.pil import HorizontalBarsDrawer
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_H,
+ box_size=10,
+ border=4,
+ )
+ qr.add_data(data)
+ qr.make(fit=True)
+
+ img = qr.make_image(image_factory=StyledPilImage, module_drawer=HorizontalBarsDrawer())
+ output = io.BytesIO()
+ img.save(output, format="PNG")
+ return output.getvalue()
+
+
+def generate_qr_code_file(doc, data: str, field_name: str = "qr_code", file_prefix: str = "qr-code") -> str:
+ """
+ Generate QR code image and attach as File to a document.
+
+ :param doc: The Frappe document to attach the QR code to
+ :param data: The data to encode in the QR code
+ :param field_name: The field name to attach the file to (default: "qr_code")
+ :param file_prefix: Prefix for the file name (default: "qr-code")
+ :return: The file URL of the created QR code image
+ """
+ qr_data = make_qr_image(data)
+ qr_code_file = frappe.get_doc(
+ {
+ "doctype": "File",
+ "content": qr_data,
+ "attached_to_doctype": doc.doctype,
+ "attached_to_name": doc.name,
+ "attached_to_field": field_name,
+ "file_name": f"{file_prefix}-{doc.name}.png",
+ }
+ ).save(ignore_permissions=True)
+ return qr_code_file.file_url
+
+
+def build_event_datetimes(event_doc):
+ from datetime import datetime, timedelta
+
+ from frappe.utils import get_time, getdate
+
+ start_date = getdate(event_doc.start_date)
+ start_time = get_time(event_doc.start_time)
+
+ start_datetime = datetime.combine(start_date, start_time)
+
+ end_date = getdate(event_doc.end_date) if event_doc.end_date else start_date
+
+ if event_doc.end_time:
+ end_time = get_time(event_doc.end_time)
+ end_datetime = datetime.combine(end_date, end_time)
+ else:
+ end_datetime = start_datetime + timedelta(hours=1)
+
+ return start_datetime, end_datetime
+
+
+def generate_ics_file(event_doc, attendee_email: str):
+ from uuid import uuid4
+
+ from frappe.utils import now_datetime
+
+ start_dt, end_dt = build_event_datetimes(event_doc)
+ organizer_name = event_doc.host or event_doc.title
+ organizer_email = frappe.db.get_value(
+ "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "email_id"
+ )
+
+ venue_address = ""
+ if event_doc.venue:
+ venue_address = frappe.db.get_value("Event Venue", event_doc.venue, "address") or ""
+
+ context = {
+ "uid": uuid4(),
+ "now": now_datetime().strftime("%Y%m%dT%H%M%S"),
+ "timezone": event_doc.time_zone,
+ "start": start_dt.strftime("%Y%m%dT%H%M%S"),
+ "end": end_dt.strftime("%Y%m%dT%H%M%S"),
+ "title": event_doc.title,
+ "location": venue_address,
+ "attendee_email": attendee_email,
+ "description": f"Your ticket for {event_doc.title}",
+ "organizer_name": organizer_name,
+ "organizer_email": organizer_email,
+ }
+
+ return frappe.render_template("templates/ics/ics.jinja2", context, is_path=True)
diff --git a/event_manager/workspace_sidebar/buzz.json b/event_manager/workspace_sidebar/buzz.json
new file mode 100644
index 0000000..b728f0b
--- /dev/null
+++ b/event_manager/workspace_sidebar/buzz.json
@@ -0,0 +1,99 @@
+{
+ "app": "event_manager",
+ "creation": "2026-03-05 19:10:56.507483",
+ "docstatus": 0,
+ "doctype": "Workspace Sidebar",
+ "header_icon": "calendar-check",
+ "idx": 0,
+ "items": [
+ {
+ "child": 0,
+ "collapsible": 1,
+ "icon": "calendar-check",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Events",
+ "link_to": "Pohodex Event Manager Event",
+ "link_type": "DocType",
+ "show_arrow": 0,
+ "type": "Link"
+ },
+ {
+ "child": 0,
+ "collapsible": 1,
+ "icon": "ticket-check",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Tickets",
+ "link_to": "Event Ticket",
+ "link_type": "DocType",
+ "show_arrow": 0,
+ "type": "Link"
+ },
+ {
+ "child": 0,
+ "collapsible": 1,
+ "icon": "file-spreadsheet",
+ "indent": 1,
+ "keep_closed": 0,
+ "label": "Reports",
+ "link_type": "DocType",
+ "show_arrow": 0,
+ "type": "Section Break"
+ },
+ {
+ "child": 1,
+ "collapsible": 1,
+ "icon": "book-check",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Registrations",
+ "link_to": "Detailed Event Registrations",
+ "link_type": "Report",
+ "show_arrow": 0,
+ "type": "Link"
+ },
+ {
+ "child": 1,
+ "collapsible": 1,
+ "icon": "badge-dollar-sign",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Payments",
+ "link_to": "Event Payment",
+ "link_type": "DocType",
+ "show_arrow": 0,
+ "type": "Link"
+ },
+ {
+ "child": 0,
+ "collapsible": 1,
+ "icon": "list",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Tools",
+ "link_type": "DocType",
+ "show_arrow": 0,
+ "type": "Section Break"
+ },
+ {
+ "child": 1,
+ "collapsible": 1,
+ "icon": "scan-qr-code",
+ "indent": 0,
+ "keep_closed": 0,
+ "label": "Check In Scanner",
+ "link_type": "URL",
+ "show_arrow": 0,
+ "type": "Link",
+ "url": "/dashboard/check-in"
+ }
+ ],
+ "modified": "2026-03-14 06:54:28.414089",
+ "modified_by": "Administrator",
+ "module": "Events",
+ "name": "Pohodex Event Manager",
+ "owner": "Administrator",
+ "standard": 1,
+ "title": "Pohodex Event Manager"
+}
diff --git a/event_manager/www/.gitkeep b/event_manager/www/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/event_manager/www/dashboard.py b/event_manager/www/dashboard.py
new file mode 100644
index 0000000..cc1564a
--- /dev/null
+++ b/event_manager/www/dashboard.py
@@ -0,0 +1,34 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.utils import get_system_timezone
+
+no_cache = 1
+
+
+def get_context():
+ csrf_token = frappe.sessions.get_csrf_token()
+ frappe.db.commit()
+ context = frappe._dict()
+ context.boot = get_boot()
+ context.boot.csrf_token = csrf_token
+ return context
+
+
+@frappe.whitelist(methods=["POST"], allow_guest=True)
+def get_context_for_dev():
+ if not frappe.conf.developer_mode:
+ frappe.throw("This method is only meant for developer mode")
+ return get_boot()
+
+
+def get_boot():
+ return frappe._dict(
+ {
+ "frappe_version": frappe.__version__,
+ "site_name": frappe.local.site,
+ "read_only_mode": frappe.flags.read_only,
+ "system_timezone": get_system_timezone(),
+ }
+ )
diff --git a/license.txt b/license.txt
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/license.txt
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..84998aa
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "event_manager-frappe-app",
+ "version": "1.0.0",
+ "description": "Event Management App built on Frappe",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "postinstall": "cd dashboard && yarn install",
+ "dev": "cd dashboard && yarn dev",
+ "build": "cd dashboard && yarn build",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "test:e2e:headed": "playwright test --headed",
+ "test:e2e:debug": "playwright test --debug"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/BuildWithHussain/event_manager.git"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "bugs": {
+ "url": "https://github.com/BuildWithHussain/event_manager/issues"
+ },
+ "homepage": "https://github.com/BuildWithHussain/event_manager#readme",
+ "devDependencies": {
+ "@playwright/test": "^1.57.0",
+ "@types/node": "^25.0.8"
+ }
+}
diff --git a/plans/Completed/custom-forms.md b/plans/Completed/custom-forms.md
new file mode 100644
index 0000000..a36e952
--- /dev/null
+++ b/plans/Completed/custom-forms.md
@@ -0,0 +1,183 @@
+# Custom Forms Feature - Implementation Plan
+
+## Context
+
+Currently, Talk Proposals and Sponsorship Enquiries use Frappe Web Forms, and Event Feedback has a minimal DocType with no frontend form. We want to replace these with dashboard-based Vue forms using a shared `BaseCustomEventForm.vue` that renders doctype fields + Pohodex Event Manager Custom Fields, with a common success state. This removes the dependency on Frappe Web Forms and gives us full control over UX.
+
+## Architecture
+
+```
+Routes:
+ /dashboard/events/:eventRoute/feedback → FeedbackForm.vue
+ /dashboard/events/:eventRoute/propose-talk → ProposeTalkForm.vue
+ /dashboard/events/:eventRoute/enquire-sponsorship → EnquireSponsorshipForm.vue
+
+Components:
+ FeedbackForm.vue ──────────┐
+ ProposeTalkForm.vue ────────┼──► BaseCustomEventForm.vue
+ EnquireSponsorshipForm.vue ─┘ │
+ ├── CustomFieldInput.vue (reused)
+ ├── CustomFieldsSection.vue (reused)
+ └── FormSuccess.vue (new, inline success state)
+```
+
+---
+
+## Phase 1: Backend DocType Schema Updates
+
+### 1.1 Update Event Feedback DocType
+**File:** `event_manager/events/doctype/event_feedback/event_feedback.json`
+- Add only `additional_fields` (Table → Additional Field) — keep the DocType minimal for now
+- Add permission: `Pohodex Event Manager User` role with `create: 1, read: 1` (if_owner)
+
+### 1.2 Add `additional_fields` to Talk Proposal & Sponsorship Enquiry
+**Files:**
+- `event_manager/proposals/doctype/talk_proposal/talk_proposal.json` — add `additional_fields` (Table → Additional Field)
+- `event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.json` — add `additional_fields` (Table → Additional Field)
+
+Reuses existing `Additional Field` child table (`event_manager/ticketing/doctype/additional_field/`).
+
+### 1.3 Update Pohodex Event Manager Custom Field `applied_to` options
+**File:** `event_manager/event_manager/doctype/buzz_custom_field/buzz_custom_field.json`
+- Add options: `Event Feedback`, `Talk Proposal`, `Sponsorship Enquiry`
+
+### 1.4 Run `bench migrate`
+
+---
+
+## Phase 2: Backend API Endpoints
+
+**File:** `event_manager/api.py`
+
+### 2.1 `get_custom_form_data(event_route, form_type)`
+- Whitelist check: `form_type` must be in `CUSTOM_FORM_CONFIG` dict
+- Resolve event from `event_route`
+- Check deadline (`talk_proposals_close_at`, `sponsorship_proposals_close_at`, none for feedback, show a nice closed message / banner if the form is closed)
+- Return `form_fields` (from doctype meta, excluding internal fields like `status`, `submitted_by`, `owner`, `event`), `custom_fields` (Pohodex Event Manager Custom Fields for this event + `applied_to`), `event` details, `closed` flag
+- we should also check if event itself is published or not, if not 404
+
+### 2.2 `submit_custom_form(event_route, form_type, data, custom_fields_data)`
+- Whitelist check on `form_type`
+- Auto-set: `event` from route, `submitted_by` from session user (for Talk Proposal)
+- Validate submitted fields against allowed fieldnames
+- Create doc, append `additional_fields` rows for custom field values
+- Return `{name, doctype}`
+
+Config dict:
+```python
+CUSTOM_FORM_CONFIG = {
+ "Event Feedback": {
+ "applied_to": "Event Feedback",
+ "exclude_fields": {"name", "owner", "creation", "modified", "modified_by",
+ "docstatus", "idx", "additional_fields", "event"},
+ "auto_set": {"event": "from_route"},
+ "deadline_field": None,
+ },
+ "Talk Proposal": {
+ "applied_to": "Talk Proposal",
+ "exclude_fields": {"name", "owner", "creation", "modified", "modified_by",
+ "docstatus", "idx", "submitted_by", "status",
+ "additional_fields", "event"},
+ "auto_set": {"event": "from_route", "submitted_by": "session_user"},
+ "deadline_field": "talk_proposals_close_at",
+ },
+ "Sponsorship Enquiry": {
+ "applied_to": "Sponsorship Enquiry",
+ "exclude_fields": {"name", "owner", "creation", "modified", "modified_by",
+ "docstatus", "idx", "status", "additional_fields", "event"},
+ "auto_set": {"event": "from_route"},
+ "deadline_field": "sponsorship_proposals_close_at",
+ },
+}
+```
+
+---
+
+## Phase 3: Frontend
+
+### 3.1 Add routes in `dashboard/src/router.ts`
+Three new routes with `props: true`, marked `isPublic` but depending on weather the event has guest booking enabled or not, it should handle that
+
+### 3.2 Create `BaseCustomEventForm.vue`
+**File:** `dashboard/src/components/BaseCustomEventForm.vue`
+
+**Props:** `eventRoute`, `formType`, `title`, `successTitle`, `successMessage`
+
+**Behavior:**
+1. Fetch form data via `createResource` → `event_manager.api.get_custom_form_data`
+2. If `closed`, show "submissions closed" message
+3. Render standard fields using `CustomFieldInput.vue` (normalized to same shape)
+4. Render Pohodex Event Manager Custom Fields via `CustomFieldsSection.vue`
+5. Submit via `event_manager.api.submit_custom_form`
+6. On success, show inline success state (no separate page)
+
+**Special field type handling** (in BaseCustomEventForm, not CustomFieldInput):
+- `Text Editor` → frappe-ui `TextEditor`
+- `Attach Image` → frappe-ui `FileUploader` (upload first, set URL on doc)
+- `Link` → `Autocomplete` with debounced search on linked doctype (check apps/crm on how it renders link field n the CRM frontend, you might also find other types of fields being rendered in the Field.vue of CRM)
+- `Table` (e.g. speakers) → List view component showing added rows + "Add" button. Clicking "Add" opens a dialog with the child table's fields. On dialog submit, row is appended. No inline editing — edit button to open the dialog again.
+
+### 3.3 Create wrapper page components
+- `dashboard/src/pages/FeedbackForm.vue` — passes `form-type="Event Feedback"`, default success strings
+- `dashboard/src/pages/ProposeTalkForm.vue` — passes `form-type="Talk Proposal"`, default success strings
+- `dashboard/src/pages/EnquireSponsorshipForm.vue` — passes `form-type="Sponsorship Enquiry"`, default success strings
+
+Each is ~15 lines: just a template with `BaseCustomEventForm` and `defineProps`.
+Default success strings are fallbacks — event-level configured messages take priority (see 3.5).
+
+### 3.4 Extend `CustomFieldInput.vue` (if needed)
+**File:** `dashboard/src/components/CustomFieldInput.vue`
+- May need minor extensions for `Rating` fieldtype
+- Complex types (Table) handled directly in `BaseCustomEventForm.vue`
+- Text Editor, Attach Image, Link, should be added to custom field input
+- in the backend the value column should be changed to `Code` type to support various values like this
+- The attachments should be attached to the document being created, the custom field should have the path (check File doctype) to the file
+
+### 3.5 Configurable success messages per event
+**DocType change:** Add 3 markdown fields to `Pohodex Event Manager Event` DocType:
+- `feedback_success_message` (Markdown)
+- `proposal_success_message` (Markdown)
+- `sponsorship_success_message` (Markdown)
+
+**API:** `get_custom_form_data` returns the relevant success message field for the form type.
+**Frontend:** `BaseCustomEventForm.vue` uses event-configured message if set, otherwise falls back to wrapper's default props.
+
+---
+
+## Files Summary
+
+### New files (6)
+| File | Purpose |
+|------|---------|
+| `dashboard/src/components/BaseCustomEventForm.vue` | Shared form renderer |
+| `dashboard/src/pages/FeedbackForm.vue` | Feedback wrapper |
+| `dashboard/src/pages/ProposeTalkForm.vue` | Talk proposal wrapper |
+| `dashboard/src/pages/EnquireSponsorshipForm.vue` | Sponsorship wrapper |
+
+### Modified files (7)
+| File | Change |
+|------|--------|
+| `event_manager/events/doctype/event_feedback/event_feedback.json` | Add additional_fields + permissions |
+| `event_manager/proposals/doctype/talk_proposal/talk_proposal.json` | Add additional_fields table |
+| `event_manager/proposals/doctype/sponsorship_enquiry/sponsorship_enquiry.json` | Add additional_fields table |
+| `event_manager/event_manager/doctype/buzz_custom_field/buzz_custom_field.json` | Add 3 new applied_to options |
+| `event_manager/events/doctype/buzz_event/buzz_event.json` | Add 3 success message markdown fields |
+| `event_manager/api.py` | Add `get_custom_form_data` + `submit_custom_form` |
+| `dashboard/src/router.ts` | Add 3 routes |
+
+---
+
+## Implementation Order
+1. DocType schema changes + `bench migrate`
+2. API endpoints in `event_manager/api.py`
+3. `BaseCustomEventForm.vue`
+4. Three wrapper pages + router routes
+5. Test each form end-to-end
+
+## Verification
+- Create a Pohodex Event Manager Event with a route
+- Add Pohodex Event Manager Custom Fields for each `applied_to` type
+- Navigate to each form URL, verify fields render (standard + custom)
+- Submit each form, verify doc created with correct data + additional_fields
+- Test deadline enforcement (set `talk_proposals_close_at` to past, verify form shows closed)
+- Test without login → should redirect to login page
diff --git a/plans/Completed/dynamic-custom-forms.md b/plans/Completed/dynamic-custom-forms.md
new file mode 100644
index 0000000..6ad2415
--- /dev/null
+++ b/plans/Completed/dynamic-custom-forms.md
@@ -0,0 +1,154 @@
+# Plan: Dynamic Custom Forms via Child Table
+
+## Context
+
+Currently, custom forms are hardcoded to 3 types (Event Feedback, Talk Proposal, Sponsorship Enquiry) via `CUSTOM_FORM_CONFIG` dict in `event_manager/api.py`. Each form type requires a toggle field, deadline field, success message field on Pohodex Event Manager Event, a hardcoded config entry, and a dedicated Vue wrapper page + route. This doesn't scale. We want users to attach **any DocType** as a publishable form on their event via a child table.
+
+**Decision:** Migrate all 3 existing forms into the child table system (no dual system). Allow any DocType — no validation on event/additional_fields fields existing.
+
+---
+
+## Changes
+
+### 1. New Child Table DocType: `Pohodex Event Manager Event Form`
+
+**New directory:** `event_manager/events/doctype/buzz_event_form/`
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `form_doctype` | Link (DocType) | Which DocType to render as a form |
+| `publish` | Check | Whether the form is live |
+| `auto_close_at` | Datetime | Auto-close submissions after this time |
+| `route` | Data, reqd, in_list_view | URL slug (e.g. `feedback`, `propose-talk`) |
+| `success_message` | Markdown Editor | Shown after successful submission |
+
+- `istable: 1`, parent is Pohodex Event Manager Event
+- Controller validates `route` uniqueness within the event
+
+### 2. Update Pohodex Event Manager Event DocType
+
+**File:** `event_manager/events/doctype/buzz_event/buzz_event.json`
+
+- Add `custom_forms` child table field (Table → Pohodex Event Manager Event Form) in the Forms section
+- **Remove** the now-redundant fields:
+ - `accept_event_feedback`, `accept_talk_proposals`, `accept_sponsorship_enquiries` (toggle checks)
+ - `talk_proposals_close_at`, `sponsorship_proposals_close_at` (deadlines — `registrations_close_at` stays, it's for ticketing)
+ - `feedback_success_message`, `proposal_success_message`, `sponsorship_success_message` (success messages)
+- Keep `auto_closures_section` and `registrations_close_at` (those are for ticket registration, not forms)
+
+### 3. Update Pohodex Event Manager Custom Field `applied_to`
+
+**File:** `event_manager/event_manager/doctype/buzz_custom_field/buzz_custom_field.json`
+
+- Add `Custom Form` option to `applied_to` Select:
+ `"Booking\nTicket\nOffline Payment Form\nCustom Form"`
+- Remove the 3 hardcoded options: `Event Feedback`, `Talk Proposal`, `Sponsorship Enquiry`
+- Add new field `custom_form_doctype` (Link → DocType):
+ - `depends_on`: `eval:doc.applied_to === 'Custom Form'`
+ - `mandatory_depends_on`: `eval:doc.applied_to === 'Custom Form'`
+ - Label: "Custom Form DocType"
+- When filtering custom fields in the API, match on `applied_to == "Custom Form"` AND `custom_form_doctype == `
+
+### 4. Backend API Changes
+
+**File:** `event_manager/api.py`
+
+**4a. Replace `CUSTOM_FORM_CONFIG` with a universal `STANDARD_EXCLUDE_FIELDS` set.**
+
+```python
+STANDARD_EXCLUDE_FIELDS = {
+ "name", "owner", "creation", "modified", "modified_by",
+ "docstatus", "idx", "additional_fields", "event",
+ "section_break_additional",
+}
+```
+
+This covers meta/internal fields that should never appear on a public form. `get_form_fields` already handles hidden, read-only, and layout fields. Per-form config (deadline, success message, route, enabled) now comes from the child table row.
+
+**4b. Update `validate_custom_form(event_route, form_route)`**
+- Change signature: accept `form_route` (the slug) instead of `form_type` (DocType name)
+- Look up event by route, then find matching row in `event_doc.custom_forms` where `route == form_route` and `publish == 1`
+- Check event is published
+- Return the matched child table row + event_doc
+
+**4c. Update `get_custom_form_data(event_route, form_route)`**
+- Get the child row via validate
+- `form_type` = `row.form_doctype` (the DocType name)
+- Build exclude_fields dynamically: standard meta fields (`name`, `owner`, `creation`, `modified`, `modified_by`, `docstatus`, `idx`) + `additional_fields` + `event` + any Section/Column break containing `additional`
+- Deadline: check `row.auto_close_at` directly
+- Custom fields: filter `Pohodex Event Manager Custom Field` where `applied_to == "Custom Form"` AND `custom_form_doctype == form_type` AND `event == event_doc.name` AND `enabled == 1`
+- Success message: from `row.success_message`
+- Auto-set: if DocType has an `event` field → auto-set from route; if it has `submitted_by` → auto-set from session user
+
+**4d. Update `submit_custom_form(event_route, form_route, data, custom_fields_data)`**
+- Same flow but config comes from child table row
+- DocType to create = `row.form_doctype`
+- Auto-detect `event` and `submitted_by` fields from DocType meta
+
+**4e. New API: `get_event_forms(event_route)`**
+- Returns list of published forms: `[{route, doctype, label (from DocType meta)}]`
+- Useful for frontend to build navigation/links to available forms
+
+**4f. Update `get_form_fields`**
+- Make the exclude set a parameter with sensible defaults (the standard fields)
+
+### 5. Frontend Changes
+
+**File:** `dashboard/src/router.ts`
+
+- Remove the 3 hardcoded form routes (`/events/:eventRoute/feedback`, etc.)
+- Add single dynamic route: `/events/:eventRoute/forms/:formRoute` → `CustomFormPage.vue`
+ - `meta: { isPublic: true }`, `props: true`
+
+**New file:** `dashboard/src/pages/CustomFormPage.vue`
+- Props: `eventRoute`, `formRoute`
+- Calls `get_custom_form_data(event_route, form_route)` — the API now accepts route slug
+- Derives title from DocType label (returned by API)
+- Passes to `BaseCustomEventForm` (which needs minor update to accept `formRoute` instead of `formType`)
+
+**File:** `dashboard/src/components/BaseCustomEventForm.vue`
+- Update props: accept `formRoute` (route slug) instead of `formType` (DocType name)
+- Update resource calls to pass `form_route` instead of `form_type`
+- Everything else stays the same — it already renders fields generically
+
+**Delete:**
+- `dashboard/src/pages/FeedbackForm.vue`
+- `dashboard/src/pages/ProposeTalkForm.vue`
+- `dashboard/src/pages/EnquireSponsorshipForm.vue`
+
+---
+
+## Files Summary
+
+| File | Action | Change |
+|------|--------|--------|
+| `event_manager/events/doctype/buzz_event_form/` | **New** | Child table DocType (4 files) |
+| `event_manager/events/doctype/buzz_event/buzz_event.json` | Modify | Add `custom_forms` table, remove 8 form-related fields |
+| `event_manager/event_manager/doctype/buzz_custom_field/buzz_custom_field.json` | Modify | Replace 3 hardcoded options with "Custom Form" + add `custom_form_doctype` Link |
+| `event_manager/api.py` | Modify | Remove CUSTOM_FORM_CONFIG, rewrite validate/get/submit to use child table, add `get_event_forms` |
+| `dashboard/src/router.ts` | Modify | Replace 3 routes with 1 dynamic route |
+| `dashboard/src/pages/CustomFormPage.vue` | **New** | Single dynamic form wrapper |
+| `dashboard/src/components/BaseCustomEventForm.vue` | Modify | Accept `formRoute` instead of `formType` |
+| `dashboard/src/pages/FeedbackForm.vue` | **Delete** | No longer needed |
+| `dashboard/src/pages/ProposeTalkForm.vue` | **Delete** | No longer needed |
+| `dashboard/src/pages/EnquireSponsorshipForm.vue` | **Delete** | No longer needed |
+
+## Implementation Order
+
+1. Create `Pohodex Event Manager Event Form` child table DocType
+2. Update Pohodex Event Manager Event DocType (add child table, remove old fields) + `bench migrate`
+3. Update Pohodex Event Manager Custom Field (new option + link field) + `bench migrate`
+4. Rewrite `event_manager/api.py` — remove hardcoded config, dynamic resolution from child table
+5. Frontend: new route + `CustomFormPage.vue` + update `BaseCustomEventForm.vue` + delete old wrappers
+6. Test end-to-end
+
+## Verification
+
+1. On a Pohodex Event Manager Event, add "Event Feedback" to `custom_forms` with route=`feedback`, publish=1
+2. Add "Talk Proposal" with route=`propose-talk`, publish=1, auto_close_at=future
+3. Navigate to `/dashboard/events/{eventRoute}/forms/feedback` — verify form renders
+4. Submit feedback → verify doc created with correct event link
+5. Add Pohodex Event Manager Custom Fields with `applied_to="Custom Form"`, `custom_form_doctype="Event Feedback"` → verify they appear on the form
+6. Set `auto_close_at` to past → verify form shows closed message
+7. Create a brand new DocType, add it to custom_forms → verify it works as a form
+8. Unpublish a form → verify 404
diff --git a/plans/Completed/event-proposal-form.md b/plans/Completed/event-proposal-form.md
new file mode 100644
index 0000000..90e50e9
--- /dev/null
+++ b/plans/Completed/event-proposal-form.md
@@ -0,0 +1,108 @@
+# Plan: Event Proposal Public Form
+
+## Context
+
+Event Proposal DocType exists but is only accessible via the Frappe desk. We want a public-facing form at `/dashboard/event-proposal` that anyone can submit, controlled by a toggle in Pohodex Event Manager Settings.
+
+---
+
+## Changes
+
+### 1. Pohodex Event Manager Settings DocType
+
+**File:** `event_manager/events/doctype/buzz_settings/buzz_settings.json`
+
+Add a new **Proposals** Tab Break (before Communications tab) with:
+
+| Field | Type | Notes |
+|-------|------|-------|
+| `proposals_tab` | Tab Break | Label: "Proposals" |
+| `event_proposals_section` | Section Break | Label: "Event Proposals" |
+| `accept_event_proposals` | Check | Label: "Accept Event Proposals", default 0 |
+| `allow_guest_event_proposals` | Check | Label: "Allow Guest Submission", depends_on: `eval:doc.accept_event_proposals`, default 0 |
+| `event_proposal_success_message` | Markdown Editor | depends_on: `eval:doc.accept_event_proposals`, Label: "Success Message" |
+
+### 2. Backend API
+
+**File:** `event_manager/api/forms.py`
+
+**2a. Add `EVENT_PROPOSAL_EXCLUDE_FIELDS`**
+
+```python
+EVENT_PROPOSAL_EXCLUDE_FIELDS = STANDARD_EXCLUDE_FIELDS | {
+ "naming_series",
+ "amended_from",
+ "host",
+}
+```
+
+`status` and `submitted_by` are already in `STANDARD_EXCLUDE_FIELDS`.
+
+**2b. `get_event_proposal_form_data()` — whitelist, allow_guest**
+
+- Read `Pohodex Event Manager Settings` — if `accept_event_proposals` is falsy, throw DoesNotExistError
+- If guest not allowed and user is Guest, throw AuthenticationError
+- Call `get_form_fields("Event Proposal", EVENT_PROPOSAL_EXCLUDE_FIELDS)`
+- Read success message from settings
+- Return `{form_fields, form_title, success_title, success_message, closed}`
+
+**2c. `submit_event_proposal(data)` — whitelist, allow_guest**
+
+- Same settings + guest check
+- Parse data via `frappe.parse_json`
+- Build doc from allowed fields only (filter through `get_form_fields`)
+- `frappe.get_doc(doc_data).insert(ignore_permissions=True)`
+
+### 3. Frontend Route
+
+**File:** `dashboard/src/router.ts`
+
+Add route:
+```typescript
+{
+ path: "/event-proposal",
+ name: "event-proposal",
+ meta: { isPublic: true },
+ component: () => import("@/pages/EventProposalForm.vue"),
+}
+```
+
+### 4. New Page: EventProposalForm.vue
+
+**File:** `dashboard/src/pages/EventProposalForm.vue`
+
+Slimmed-down version of BaseCustomEventForm:
+- No `eventRoute`/`formRoute` props
+- No `EventDetailsHeader`
+- No `CustomFieldsSection`
+- Calls `get_event_proposal_form_data` on mount
+- Submits to `submit_event_proposal`
+- Reuses `CustomFieldInput` for field rendering
+- Reuses table dialog pattern for Table fields
+- Shows success/closed/error states same as BaseCustomEventForm
+
+---
+
+## Files Summary
+
+| File | Action | Change |
+|------|--------|--------|
+| `event_manager/events/doctype/buzz_settings/buzz_settings.json` | Modify | Add Proposals tab with toggle + success message |
+| `event_manager/api/forms.py` | Modify | Add 2 endpoints + exclude set |
+| `dashboard/src/router.ts` | Modify | Add 1 route |
+| `dashboard/src/pages/EventProposalForm.vue` | New | Public form page |
+
+## Implementation Order
+
+1. Update Pohodex Event Manager Settings JSON + `bench migrate`
+2. Add API endpoints in `event_manager/api/forms.py`
+3. Add route + create `EventProposalForm.vue`
+4. Test end-to-end
+
+## Verification
+
+1. Enable "Accept Event Proposals" in Pohodex Event Manager Settings
+2. Navigate to `/dashboard/event-proposal` — form renders with Event Proposal fields (minus status, naming_series, amended_from, host)
+3. Submit form — Event Proposal doc created with status "Received"
+4. Disable toggle — form shows not found
+5. Check success message renders after submission
diff --git a/plans/Completed/login-modal.md b/plans/Completed/login-modal.md
new file mode 100644
index 0000000..aa94cda
--- /dev/null
+++ b/plans/Completed/login-modal.md
@@ -0,0 +1,60 @@
+# Plan: In-App Login Modal
+
+## Status: Implemented ✓
+
+## Goal
+Replace the redirect to Frappe's `/login` page with an in-app modal dialog that supports all login features. Users stay on the current page (booking, custom form, etc.) and authenticate without leaving context.
+
+## What Was Done
+
+### New Files Created
+1. **`event_manager/api/auth.py`** — `get_login_context()` whitelisted API (allow_guest) returning login settings (disable_signup, disable_user_pass_login, login_with_email_link), Google OAuth URL, and login_banner from Pohodex Event Manager Settings
+2. **`dashboard/src/components/LoginDialog.vue`** — Multi-view modal with 4 views: login, signup, forgot-password, email-link. Uses native HTML `