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

This commit is contained in:
2026-05-11 09:56:57 +02:00
parent f82bb803ac
commit 786cbc724f
500 changed files with 41152 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
+50
View File
@@ -0,0 +1,50 @@
# Frappe UI Starter
This template should help get you started developing custom frontend for Frappe
apps with Vue 3 and the Frappe UI package.
![Auth](https://user-images.githubusercontent.com/34810212/236846289-ac31c292-81ea-4456-be65-95773a4049be.png)
![Home](https://user-images.githubusercontent.com/34810212/236846299-fd534e2b-1c06-4f01-a4f2-91a27547cd55.png)
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
the box. It also has basic authentication frontend.
## Docs
[Frappe UI Website](https://frappeui.com)
## Usage
This template is meant to be cloned inside an existing Frappe App. Assuming your
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
```
cd apps/todo
npx degit NagariaHussain/doppio_frappeui_starter frontend
cd frontend
yarn
yarn dev
```
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
```
"ignore_csrf": 1
```
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
## Resources
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
- [Vue Router](https://next.router.vuejs.org/guide/)
- [Frappe UI](https://github.com/frappe/frappe-ui)
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
- [Vite](https://vitejs.dev/guide/)
+14
View File
@@ -0,0 +1,14 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const LucideEdit: typeof import("~icons/lucide/edit")["default"]
const LucideMic: typeof import("~icons/lucide/mic")["default"]
const LucideRadio: typeof import("~icons/lucide/radio")["default"]
const LucideSettings: typeof import("~icons/lucide/settings")["default"]
const LucideUserPen: typeof import("~icons/lucide/user-pen")["default"]
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
}
}
+52
View File
@@ -0,0 +1,52 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AddOnPreferenceDialog: typeof import('./src/components/AddOnPreferenceDialog.vue')['default']
AttendeeFormControl: typeof import('./src/components/AttendeeFormControl.vue')['default']
BackButton: typeof import('./src/components/common/BackButton.vue')['default']
BaseCustomEventForm: typeof import('./src/components/BaseCustomEventForm.vue')['default']
BillingDetails: typeof import('./src/components/BillingDetails.vue')['default']
BookingEventInfo: typeof import('./src/components/BookingEventInfo.vue')['default']
BookingFinancialSummary: typeof import('./src/components/BookingFinancialSummary.vue')['default']
BookingForm: typeof import('./src/components/BookingForm.vue')['default']
BookingHeader: typeof import('./src/components/BookingHeader.vue')['default']
BookingSummary: typeof import('./src/components/BookingSummary.vue')['default']
BuzzLogo: typeof import('./src/components/common/BuzzLogo.vue')['default']
CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default']
CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default']
CustomFieldInput: typeof import('./src/components/CustomFieldInput.vue')['default']
CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default']
EventDetailsHeader: typeof import('./src/components/EventDetailsHeader.vue')['default']
EventSelector: typeof import('./src/components/EventSelector.vue')['default']
EventSponsorForm: typeof import('./src/components/EventSponsorForm.vue')['default']
LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default']
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
LoginRequired: typeof import('./src/components/LoginRequired.vue')['default']
Navbar: typeof import('./src/components/Navbar.vue')['default']
OfflinePaymentDialog: typeof import('./src/components/OfflinePaymentDialog.vue')['default']
PaymentGatewayDialog: typeof import('./src/components/PaymentGatewayDialog.vue')['default']
PhoneInput: typeof import('./src/components/PhoneInput.vue')['default']
ProfileView: typeof import('./src/components/ProfileView.vue')['default']
ProposalEditDialog: typeof import('./src/components/ProposalEditDialog.vue')['default']
QRCodeExpandDialog: typeof import('./src/components/QRCodeExpandDialog.vue')['default']
QRScanner: typeof import('./src/components/QRScanner.vue')['default']
RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SponsorLogoUploader: typeof import('./src/components/SponsorLogoUploader.vue')['default']
SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default']
SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default']
TicketCard: typeof import('./src/components/TicketCard.vue')['default']
TicketDetailsModal: typeof import('./src/components/TicketDetailsModal.vue')['default']
TicketsSection: typeof import('./src/components/TicketsSection.vue')['default']
TicketTransferDialog: typeof import('./src/components/TicketTransferDialog.vue')['default']
TransferTicketDialog: typeof import('./src/components/TransferTicketDialog.vue')['default']
}
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pohodex Event Manager Dashboard</title>
</head>
<body class="bg-surface-white">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frappe-ui-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/event_manager/dashboard/ && yarn copy-html-entry",
"preview": "vite preview",
"lint": "biome check --write .",
"typecheck": "./typecheck.sh",
"copy-html-entry": "cp ../event_manager/public/dashboard/index.html ../event_manager/www/dashboard.html"
},
"dependencies": {
"@vueuse/core": "^13.6.0",
"@vueuse/router": "^13.6.0",
"canvas-confetti": "^1.9.3",
"feather-icons": "^4.29.2",
"frappe-ui": "^0.1.257",
"socket.io-client": "^4.7.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.2.0",
"@vitejs/plugin-vue": "^5.1.4",
"autoprefixer": "^10.4.2",
"html5-qrcode": "^2.3.8",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.15",
"typescript": "^5.9.3",
"unplugin-auto-import": "0.18.6",
"vite": "^5.4.10",
"vue-tsc": "^3.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import LoginDialog from "@/components/LoginDialog.vue";
import { FrappeUIProvider, setConfig } from "frappe-ui";
import Layout from "./layouts/Layout.vue";
setConfig("systemTimezone", window.timezone?.system || null);
setConfig("localTimezone", window.timezone?.user || null);
</script>
<template>
<FrappeUIProvider>
<Layout>
<router-view />
</Layout>
<LoginDialog />
</FrappeUIProvider>
</template>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+152
View File
@@ -0,0 +1,152 @@
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
url("Inter-Thin.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 100;
font-display: swap;
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
url("Inter-ThinItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLight.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 200;
font-display: swap;
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
url("Inter-Light.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 300;
font-display: swap;
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
url("Inter-LightItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
url("Inter-Regular.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
url("Inter-Italic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
url("Inter-Medium.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
url("Inter-MediumItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
url("Inter-SemiBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
url("Inter-Bold.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-BoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBold.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
url("Inter-Black.woff?v=3.12") format("woff");
}
@font-face {
font-family: "Inter";
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
url("Inter-BlackItalic.woff?v=3.12") format("woff");
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,152 @@
<template>
<Dialog v-model="show" :options="dialogOptions">
<template #body-content>
<div class="space-y-4">
<p class="text-ink-gray-8">
Update your add-on preferences for <strong>{{ ticket.attendee_name }}</strong>
</p>
<div v-if="addOnsWithOptions.length === 0" class="text-center py-4">
<p class="text-ink-gray-6">No customizable add-ons found for this ticket.</p>
</div>
<div v-else class="space-y-4">
<div v-for="addon in addOnsWithOptions" :key="addon.id" class="space-y-2">
<label class="block text-sm font-medium text-ink-gray-8">
{{ __(addon.title) }}
</label>
<p class="text-xs text-ink-gray-6 mb-2">Current: {{ addon.value }}</p>
<FormControl
type="select"
:options="addon.selectOptions"
v-model="preferences[addon.id]"
:placeholder="`Select ${addon.title.toLowerCase()}`"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="flex space-x-2">
<Button
variant="solid"
:loading="savePreferences.loading"
:disabled="!hasChanges || addOnsWithOptions.length === 0"
@click="handleSave"
>
Save Preferences
</Button>
<Button variant="outline" @click="close">Cancel</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog, FormControl, createResource, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
ticket: {
type: Object,
required: true,
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const show = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const preferences = ref({});
// Filter add-ons that have selectable options
const addOnsWithOptions = computed(() => {
if (!props.ticket?.add_ons) return [];
return props.ticket.add_ons
.filter((addon) => addon.options && addon.options.length > 0)
.map((addon) => ({
...addon,
selectOptions: addon.options.map((option) => ({
label: __(option),
value: option,
})),
}));
});
// Check if user has made any changes
const hasChanges = computed(() => {
return addOnsWithOptions.value.some((addon) => {
const currentValue = preferences.value[addon.id];
return currentValue && currentValue !== addon.value;
});
});
const dialogOptions = {
title: "Update Add-on Preferences",
size: "lg",
};
// Initialize preferences when dialog opens
watch(
() => props.modelValue,
(newValue) => {
if (newValue && addOnsWithOptions.value.length > 0) {
preferences.value = {};
for (const addon of addOnsWithOptions.value) {
preferences.value[addon.id] = addon.value;
}
}
},
{ immediate: true }
);
const savePreferences = createResource({
url: "buzz.api.change_add_on_preference",
onSuccess: () => {
toast.success("Add-on preferences updated successfully!");
emit("success");
show.value = false;
},
onError: (error) => {
// Check if this is the specific error about change window closing
if (error?.message?.includes("change window has closed")) {
toast.error(
"Add-on changes are not allowed at this time - the change window has closed as the event is approaching."
);
} else {
toast.error("Failed to update preferences");
}
console.error("Error updating add-on preferences:", error);
},
});
const handleSave = async () => {
const changes = addOnsWithOptions.value.filter((addon) => {
const newValue = preferences.value[addon.id];
return newValue && newValue !== addon.value;
});
if (changes.length === 0) {
toast.warning("No changes to save");
return;
}
// Save each changed preference
for (const addon of changes) {
const newValue = preferences.value[addon.id];
await savePreferences.submit({
add_on_id: addon.id,
new_value: newValue,
});
}
};
</script>
@@ -0,0 +1,216 @@
<!-- AttendeeCard.vue -->
<template>
<div
class="bg-surface-white border border-outline-gray-3 rounded-xl p-4 md:p-6 mb-6 shadow-sm relative"
>
<!-- Remove Button -->
<div class="flex justify-between items-start mb-4 border-b pb-2">
<h4 class="text-lg font-semibold text-ink-gray-9">
{{ __("Attendee") }} #{{ index + 1 }}
</h4>
<Tooltip :text="__('Remove Attendee')" :hover-delay="0.5">
<Button
v-if="showRemove"
@click="$emit('remove')"
type="button"
theme="red"
icon="x"
/>
</Tooltip>
</div>
<!-- Name, Email and Custom Fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 items-end">
<FormControl
v-model="attendee.first_name"
:label="__('First Name')"
:placeholder="__('Enter first name')"
required
type="text"
/>
<FormControl
v-model="attendee.last_name"
:label="__('Last Name')"
:placeholder="__('Enter last name')"
:required="eventDetails.category === 'Webinars'"
type="text"
/>
<FormControl
v-model="attendee.email"
:label="__('Email')"
:placeholder="__('Enter email address')"
required
type="email"
/>
<!-- Ticket Type -->
<!-- Show selector only if there are multiple ticket types -->
<FormControl
v-if="
availableTicketTypes.length > 1 &&
!(eventDetails.category == 'Webinars' && eventDetails.free_webinar)
"
v-model="attendee.ticket_type"
:label="__('Ticket Type')"
type="select"
:options="
availableTicketTypes.map((tt) => ({
label: `${__(tt.title)} (${formatPriceOrFree(tt.price, tt.currency)})`,
value: String(tt.name),
}))
"
/>
<!-- Custom Fields for Tickets integrated with basic fields -->
<template v-if="customFields.length > 0">
<CustomFieldInput
v-for="field in customFields"
:key="field.fieldname"
:field="field"
:model-value="getCustomFieldValue(field.fieldname)"
@update:model-value="updateCustomFieldValue(field.fieldname, $event)"
/>
</template>
</div>
<!-- Add-ons -->
<div v-if="availableAddOns.length > 0">
<hr class="my-4" />
<div v-for="addOn in availableAddOns" :key="addOn.name" class="mb-4">
<div class="flex flex-col gap-3">
<FormControl
type="checkbox"
:model-value="getAddOnSelected(addOn.name)"
@update:model-value="updateAddOnSelection(addOn.name, $event)"
:id="`add_on_${addOn.name}_${index}`"
:label="__(addOn.title)"
/>
<div class="text-ink-gray-5 text-sm/4" v-if="addOn.description">
<p>
{{ __(addOn.description) }}
</p>
</div>
</div>
<div
v-if="addOn.user_selects_option && getAddOnSelected(addOn.name)"
class="mt-2 ml-6"
>
<FormControl
:model-value="getAddOnOption(addOn.name)"
@update:model-value="updateAddOnOption(addOn.name, $event)"
type="select"
:options="
addOn.options.map((option) => ({ label: __(option), value: option }))
"
size="sm"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { getFieldDefaultValue } from "@/composables/useCustomFields";
import { formatPriceOrFree } from "@/utils/currency";
import { Tooltip } from "frappe-ui";
import CustomFieldInput from "./CustomFieldInput.vue";
const props = defineProps({
attendee: { type: Object, required: true },
index: { type: Number, required: true },
availableTicketTypes: { type: Array, required: true },
availableAddOns: { type: Array, required: true },
customFields: { type: Array, default: () => [] },
showRemove: { type: Boolean, default: false },
eventDetails: {
type: Object,
required: false,
default: () => ({}),
},
});
defineEmits(["remove"]);
// Helper methods to safely access add-on properties
const ensureAddOnExists = (addOnName) => {
if (!props.attendee.add_ons) {
props.attendee.add_ons = {};
}
if (!props.attendee.add_ons[addOnName]) {
const addOn = props.availableAddOns.find((a) => a.name === addOnName);
props.attendee.add_ons[addOnName] = {
selected: false,
option: addOn?.options ? addOn.options[0] || null : null,
};
}
};
const getAddOnSelected = (addOnName) => {
ensureAddOnExists(addOnName);
return props.attendee.add_ons[addOnName].selected;
};
const getAddOnOption = (addOnName) => {
ensureAddOnExists(addOnName);
return props.attendee.add_ons[addOnName].option;
};
const updateAddOnSelection = (addOnName, selected) => {
ensureAddOnExists(addOnName);
props.attendee.add_ons[addOnName].selected = selected;
// If selecting an add-on and it has options, ensure the first option is selected
if (selected) {
const addOn = props.availableAddOns.find((a) => a.name === addOnName);
if (
addOn?.options &&
addOn.options.length > 0 &&
!props.attendee.add_ons[addOnName].option
) {
props.attendee.add_ons[addOnName].option = addOn.options[0];
}
}
};
const updateAddOnOption = (addOnName, option) => {
ensureAddOnExists(addOnName);
props.attendee.add_ons[addOnName].option = option;
};
// Custom fields helper methods
const ensureCustomFieldsExists = () => {
if (!props.attendee.custom_fields) {
props.attendee.custom_fields = {};
}
};
const getCustomFieldValue = (fieldname) => {
ensureCustomFieldsExists();
const currentValue = props.attendee.custom_fields[fieldname];
// Apply default for fields that don't have values yet
if (!currentValue && currentValue !== "") {
const field = props.customFields.find((f) => f.fieldname === fieldname);
if (field) {
const defaultValue = getFieldDefaultValue(field);
if (defaultValue) {
updateCustomFieldValue(fieldname, defaultValue);
return defaultValue;
}
}
}
return currentValue || "";
};
const updateCustomFieldValue = (fieldname, value) => {
ensureCustomFieldsExists();
props.attendee.custom_fields[fieldname] = value;
};
</script>
@@ -0,0 +1,322 @@
<template>
<div>
<div class="w-8 mx-auto" v-if="formDataResource.loading">
<Spinner />
</div>
<div v-else-if="submitted" class="text-center">
<div class="bg-surface-green-1 border border-outline-green-1 rounded-lg p-8">
<LucideCheckCircle class="w-16 h-16 text-ink-green-2 mx-auto mb-4" />
<h2 class="text-ink-green-3 font-semibold text-xl mb-2">
{{ formData.success_title }}
</h2>
<div
v-if="renderedSuccessMessage"
class="prose prose-sm max-w-none text-ink-green-2"
v-html="renderedSuccessMessage"
></div>
<p v-else class="text-ink-green-2">
{{ __("Your submission has been received.") }}
</p>
</div>
</div>
<LoginRequired
v-else-if="loginRequired"
:message="__('Please log in to submit this form.')"
/>
<div v-else-if="formData?.closed" class="text-center">
<div class="bg-surface-amber-1 border border-outline-amber-1 rounded-lg p-8">
<LucideAlertCircle class="w-16 h-16 text-ink-amber-3 mx-auto mb-4" />
<h2 class="text-ink-amber-3 font-semibold text-xl mb-2">
{{ formData.closed_title }}
</h2>
<p class="text-ink-amber-2">
{{ formData.closed_message }}
</p>
</div>
</div>
<div v-else-if="formData">
<EventDetailsHeader :event-details="formData.event" />
<form
class="bg-surface-white border border-outline-gray-1 rounded-lg p-6"
@submit.prevent="handleSubmit"
>
<h1 class="text-ink-gray-9 font-bold text-2xl mb-6">
{{ formData.form_title }}
</h1>
<div class="space-y-4">
<template v-for="field in formData.form_fields" :key="field.fieldname">
<div v-if="field.fieldtype === 'Table'" class="space-y-2">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
</label>
<div v-if="tableData[field.fieldname]?.length" class="space-y-2">
<div
v-for="(row, idx) in tableData[field.fieldname]"
:key="idx"
class="flex items-center justify-between border rounded-md px-3 py-2"
>
<span class="text-sm text-ink-gray-7">
{{ getTableRowSummary(row) }}
</span>
<div class="flex gap-1">
<Button
variant="ghost"
size="sm"
@click="editTableRow(field, idx)"
>
{{ __("Edit") }}
</Button>
<Button
variant="ghost"
size="sm"
@click="removeTableRow(field.fieldname, idx)"
>
{{ __("Remove") }}
</Button>
</div>
</div>
</div>
<Button variant="outline" size="sm" @click="addTableRow(field)">
{{ __("Add {0}", [__(field.label)]) }}
</Button>
</div>
<CustomFieldInput
v-else
:field="normalizeField(field)"
:model-value="formValues[field.fieldname]"
@update:model-value="formValues[field.fieldname] = $event"
/>
</template>
<CustomFieldsSection
v-if="formData.custom_fields?.length"
:custom-fields="formData.custom_fields"
v-model="customFieldValues"
:show-title="false"
/>
</div>
<Button
variant="solid"
size="lg"
class="w-full mt-6"
:loading="submitResource.loading"
type="submit"
>
{{ __("Submit") }}
</Button>
</form>
</div>
<div v-else-if="loadError" class="text-center">
<div class="bg-surface-amber-1 border border-outline-amber-1 rounded-lg p-8">
<LucideAlertCircle class="w-16 h-16 text-ink-amber-3 mx-auto mb-4" />
<h2 class="text-ink-amber-3 font-semibold text-xl mb-2">
{{ __("Not Found") }}
</h2>
<p class="text-ink-amber-2">
{{ loadError }}
</p>
</div>
</div>
<Dialog v-model="tableDialog.open" :options="{ title: tableDialog.title }">
<template #body-content>
<form @submit.prevent="saveTableRow">
<div class="space-y-4">
<template
v-for="childField in tableDialog.fields"
:key="childField.fieldname"
>
<CustomFieldInput
:field="normalizeField(childField)"
:model-value="tableDialog.rowData[childField.fieldname]"
@update:model-value="
tableDialog.rowData[childField.fieldname] = $event
"
/>
</template>
</div>
<Button variant="solid" type="submit" class="mt-4">
{{ tableDialog.editIndex !== null ? __("Update") : __("Add") }}
</Button>
</form>
</template>
</Dialog>
</div>
</template>
<script setup>
import CustomFieldInput from "@/components/CustomFieldInput.vue";
import CustomFieldsSection from "@/components/CustomFieldsSection.vue";
import EventDetailsHeader from "@/components/EventDetailsHeader.vue";
import LoginRequired from "@/components/LoginRequired.vue";
import { Button, Dialog, Spinner, createResource, toast } from "frappe-ui";
import { marked } from "marked";
import { computed, reactive, ref } from "vue";
import LucideAlertCircle from "~icons/lucide/alert-circle";
import LucideCheckCircle from "~icons/lucide/check-circle";
const props = defineProps({
eventRoute: {
type: String,
required: true,
},
formRoute: {
type: String,
required: true,
},
});
const formData = ref(null);
const formValues = reactive({});
const customFieldValues = ref({});
const submitted = ref(false);
const loginRequired = ref(false);
const loadError = ref(null);
const tableData = reactive({});
const tableDialog = reactive({
open: false,
title: "",
fieldname: "",
fields: [],
rowData: {},
editIndex: null,
});
const renderedSuccessMessage = computed(() => {
const msg = formData.value?.success_message;
if (!msg) return "";
return marked(msg);
});
function normalizeField(field) {
return {
fieldname: field.fieldname,
fieldtype: field.fieldtype,
label: field.label,
options: field.options,
mandatory: field.reqd || field.mandatory,
placeholder: field.placeholder || "",
default_value: field.default || field.default_value,
link_options: field.link_options,
};
}
function getTableRowSummary(row) {
const values = Object.values(row).filter((v) => v && typeof v === "string");
return values.slice(0, 3).join(" — ") || __("(empty)");
}
function addTableRow(field) {
if (!tableData[field.fieldname]) tableData[field.fieldname] = [];
tableDialog.open = true;
tableDialog.title = __("Add {0}", [__(field.label)]);
tableDialog.fieldname = field.fieldname;
tableDialog.fields = field.child_fields || [];
tableDialog.rowData = {};
tableDialog.editIndex = null;
}
function editTableRow(field, idx) {
tableDialog.open = true;
tableDialog.title = __("Edit {0}", [__(field.label)]);
tableDialog.fieldname = field.fieldname;
tableDialog.fields = field.child_fields || [];
tableDialog.rowData = { ...tableData[field.fieldname][idx] };
tableDialog.editIndex = idx;
}
function removeTableRow(fieldname, idx) {
tableData[fieldname].splice(idx, 1);
}
function saveTableRow() {
const fieldname = tableDialog.fieldname;
if (!tableData[fieldname]) tableData[fieldname] = [];
if (tableDialog.editIndex !== null) {
tableData[fieldname][tableDialog.editIndex] = { ...tableDialog.rowData };
} else {
tableData[fieldname].push({ ...tableDialog.rowData });
}
tableDialog.open = false;
}
const formDataResource = createResource({
url: "buzz.api.forms.get_custom_form_data",
params: {
event_route: props.eventRoute,
form_route: props.formRoute,
},
auto: true,
onSuccess: (data) => {
formData.value = data;
for (const field of data.form_fields || []) {
if (field.default) {
formValues[field.fieldname] = field.default;
}
}
},
onError: (err) => {
if (err.exc_type === "AuthenticationError") {
loginRequired.value = true;
return;
}
loadError.value = err.messages?.[0] || __("Form not found");
},
});
const submitResource = createResource({
url: "buzz.api.forms.submit_custom_form",
onSuccess: () => {
submitted.value = true;
},
onError: (err) => {
const msg = err.messages?.[0] || __("Failed to submit form");
toast.error(msg.replace(/<[^>]*>/g, ""));
},
});
function handleSubmit() {
for (const field of formData.value.form_fields || []) {
if (field.fieldtype === "Table" && field.reqd && !tableData[field.fieldname]?.length) {
toast.error(__("Please add at least one {0}", [__(field.label)]));
return;
}
}
for (const field of formData.value.custom_fields || []) {
if (!field.mandatory) continue;
const val = customFieldValues.value[field.fieldname];
const isEmpty = !val || val === "0" || val === 0;
if (isEmpty) {
toast.error(__("{0} is required", [__(field.label)]));
return;
}
}
const data = { ...formValues };
for (const [fieldname, rows] of Object.entries(tableData)) {
if (rows.length) {
data[fieldname] = rows;
}
}
submitResource.submit({
event_route: props.eventRoute,
form_route: props.formRoute,
data,
custom_fields_data: customFieldValues.value,
});
}
</script>
@@ -0,0 +1,67 @@
<template>
<div
class="bg-surface-white border border-outline-gray-3 rounded-xl p-4 md:p-6 mb-6 shadow-sm"
>
<h3 class="text-base font-medium text-ink-gray-8 border-b pb-2 mb-4">
{{ __("Billing Details") }}
</h3>
<div class="flex flex-col gap-4">
<FormControl
type="checkbox"
:model-value="invoiceRequested"
@update:model-value="$emit('update:invoiceRequested', $event)"
:label="__('Do you need an invoice?')"
/>
<template v-if="invoiceRequested">
<FormControl
:model-value="taxId"
@update:model-value="$emit('update:taxId', $event)"
type="text"
:label="taxIdLabel"
:placeholder="__('Enter {0}', [taxIdLabel])"
/>
<div class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __("Billing Address") }}
<span class="text-ink-red-4">*</span>
</label>
<Textarea
:model-value="billingAddress"
@update:model-value="$emit('update:billingAddress', $event)"
:placeholder="__('Enter billing address')"
:required="true"
variant="outline"
/>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { FormControl, Textarea } from "frappe-ui";
const props = defineProps({
invoiceRequested: {
type: Boolean,
default: false,
},
taxId: {
type: String,
default: "",
},
billingAddress: {
type: String,
default: "",
},
taxLabel: {
type: String,
default: "Tax",
},
});
defineEmits(["update:invoiceRequested", "update:taxId", "update:billingAddress"]);
const taxIdLabel = computed(() => __(props.taxLabel));
</script>
@@ -0,0 +1,99 @@
<template>
<div class="bg-surface-cards border border-outline-gray-1 rounded-lg p-6">
<div class="mb-8 flex items-center justify-between">
<h3 class="text-lg font-semibold text-ink-gray-9">{{ event.title }}</h3>
<Button
:link="`/events/${event.route}`"
icon-left="external-link"
variant="subtle"
size="sm"
>{{ __("Visit Event Page") }}
</Button>
</div>
<div class="space-y-4">
<!-- Start Date & Time -->
<div>
<div class="flex items-center text-ink-gray-6 mb-1">
<LucideCalendarDays class="w-4 h-4 mr-2 flex-shrink-0" />
<span class="text-sm font-medium">{{ __("Start Date") }}</span>
</div>
<p class="text-ink-gray-9 font-medium">
{{ formatEventDateTime(event.start_date, event.start_time) }}
</p>
</div>
<!-- End Date & Time -->
<div v-if="event.end_date">
<div class="flex items-center text-ink-gray-6 mb-1">
<LucideCalendarDays class="w-4 h-4 mr-2 flex-shrink-0" />
<span class="text-sm font-medium">{{ __("End Date") }}</span>
</div>
<p class="text-ink-gray-9 font-medium">
{{ formatEventDateTime(event.end_date, event.end_time) }}
</p>
</div>
<!-- Venue -->
<div v-if="venue">
<div class="flex items-center text-ink-gray-6 mb-1">
<LucideMapPin class="w-4 h-4 mr-2 flex-shrink-0" />
<span class="text-sm font-medium">{{ __("Venue") }}</span>
</div>
<p class="text-ink-gray-9 font-medium">{{ venue.name }}</p>
<p v-if="venue.address" class="text-sm text-ink-gray-6 mt-1">
{{ venue.address }}
</p>
</div>
<!-- Event Description -->
<div v-if="event.short_description" class="pt-2 border-t border-outline-gray-1">
<p class="text-sm text-ink-gray-6">{{ event.short_description }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { dayjsLocal } from "frappe-ui";
import LucideCalendarDays from "~icons/lucide/calendar-days";
import LucideMapPin from "~icons/lucide/map-pin";
defineProps({
event: {
type: Object,
required: true,
validator: (value) => {
return (
typeof value.title === "string" &&
value.start_date &&
typeof value.route === "string"
);
},
},
venue: {
type: Object,
default: null,
},
});
// Helper function to format date and time together (matching TicketDetails.vue)
const formatEventDateTime = (date, time) => {
if (!date) return "";
// Create a date object from the date string
const dateObj = dayjsLocal(date);
// If time is provided, combine it with the date
if (time) {
// Parse the time (format: "HH:mm:ss")
const [hours, minutes] = time.split(":");
const dateTimeObj = dateObj.hour(Number.parseInt(hours)).minute(Number.parseInt(minutes));
return dateTimeObj.format("MMMM DD, YYYY [at] h:mm A");
}
// If no time, just show the date
return dateObj.format("MMMM DD, YYYY");
};
</script>
@@ -0,0 +1,135 @@
<template>
<div class="bg-surface-cards border border-outline-gray-1 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-ink-gray-9">{{ __("Payment Summary") }}</h3>
<Badge
v-if="(booking.total_amount || 0) > 0"
variant="subtle"
:theme="paymentBadge.theme"
size="sm"
>
<template #prefix>
<component :is="paymentBadge.icon" class="w-3 h-3" />
</template>
{{ paymentBadge.label }}
</Badge>
</div>
<div class="space-y-3">
<!-- Net Amount (hide when tax-inclusive and no discount) -->
<div
v-if="!isTaxInclusive || hasDiscount"
class="flex justify-between items-center text-ink-gray-7"
>
<span>{{ __("Subtotal") }}</span>
<span class="font-medium">{{
formatPrice(booking.net_amount || 0, booking.currency || "INR")
}}</span>
</div>
<!-- Coupon Code -->
<div
v-if="booking.coupon_code"
class="flex justify-between items-center text-ink-gray-7"
>
<span>{{ __("Coupon") }}</span>
<span class="font-medium text-green-600">{{ booking.coupon_code }}</span>
</div>
<!-- Discount -->
<div v-if="hasDiscount" class="flex justify-between items-center text-green-600">
<span>{{ __("Discount") }}</span>
<span class="font-medium"
>-{{ formatPrice(booking.discount_amount, booking.currency || "INR") }}</span
>
</div>
<!-- Tax Information (exclusive only) -->
<div
v-if="hasTax && !isTaxInclusive"
class="flex justify-between items-center text-ink-gray-7"
>
<span
>{{ __(booking.tax_label || "Tax") }} ({{
booking.tax_percentage || 0
}}%)</span
>
<span class="font-medium">{{
formatPrice(booking.tax_amount || 0, booking.currency || "INR")
}}</span>
</div>
<!-- Divider -->
<hr class="border-outline-gray-1" />
<!-- Total Amount -->
<div class="flex justify-between items-center text-lg font-semibold text-ink-gray-9">
<span>{{ isPaid ? __("Total Paid") : __("Total") }}</span>
<span :class="isPaid ? 'text-ink-green-2' : 'text-ink-gray-9'">{{
formatPrice(booking.total_amount || 0, booking.currency || "INR")
}}</span>
</div>
<!-- Tax-inclusive note -->
<div v-if="hasTax && isTaxInclusive" class="text-sm text-ink-gray-5 text-right mt-3">
{{
__("Inclusive of {0} {1} ({2}%)", [
formatPrice(booking.tax_amount || 0, booking.currency || "INR"),
__(booking.tax_label || "Tax"),
booking.tax_percentage || 0,
])
}}
</div>
</div>
</div>
</template>
<script setup>
import { formatPrice } from "@/utils/currency";
import { Badge } from "frappe-ui";
import { computed } from "vue";
import LucideCheck from "~icons/lucide/check";
import LucideClock from "~icons/lucide/clock";
import LucideX from "~icons/lucide/x";
const props = defineProps({
booking: {
type: Object,
required: true,
validator: (value) => {
return typeof value === "object" && value !== null;
},
},
});
const hasTax = computed(() => {
return Boolean(props.booking.tax_amount && props.booking.tax_amount > 0);
});
const hasDiscount = computed(() => {
return (props.booking.discount_amount || 0) > 0;
});
const isPaid = computed(() => props.booking.payment_status === "Paid");
const paymentBadge = computed(() => {
const status = props.booking.payment_status;
if (status === "Paid") {
return { label: __("Paid"), theme: "green", icon: LucideCheck };
} else if (status === "Verification Pending") {
return {
label: __("Verification Pending"),
theme: "orange",
icon: LucideClock,
};
}
return { label: __(status || "Unpaid"), theme: "red", icon: LucideX };
});
const isTaxInclusive = computed(() => {
// Tax-inclusive: total_amount equals net_amount minus discount (tax not added on top)
if (!hasTax.value) return false;
const expected = (props.booking.net_amount || 0) - (props.booking.discount_amount || 0);
return Math.abs(props.booking.total_amount - expected) < 0.01;
});
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
<template>
<div class="mb-6">
<BackButton :to="{ name: 'bookings-list' }" :label="__('Back to Bookings')" />
</div>
<h2 class="text-ink-gray-9 font-semibold text-lg mb-3">
{{ __("Booking Details") }}
<span class="text-ink-gray-5 font-mono">(#{{ bookingId }})</span>
</h2>
</template>
<script setup>
import BackButton from "./common/BackButton.vue";
defineProps({
bookingId: {
type: String,
required: true,
},
});
</script>
+233
View File
@@ -0,0 +1,233 @@
<!-- BookingSummary.vue -->
<template>
<div class="bg-surface-gray-1 border border-outline-gray-1 rounded-lg p-4">
<h2 class="text-xl font-bold text-ink-gray-9 mb-4">{{ __("Booking Summary") }}</h2>
<!-- Tickets Section -->
<div v-if="Object.keys(summary.tickets).length" class="mb-4">
<h3 class="text-lg font-semibold text-ink-gray-8 mb-2">{{ __("Tickets") }}</h3>
<div
v-for="(ticket, name) in summary.tickets"
:key="name"
class="flex justify-between items-start text-ink-gray-7 mb-2"
>
<div class="flex flex-col">
<span>{{ __(ticket.title) }}</span>
<span
v-if="freeTicketType === name && freeTicketCount > 0"
class="text-sm text-ink-gray-5"
>
{{ Math.min(freeTicketCount, ticket.count) }} x
<span class="line-through">{{
formatPriceOrFree(ticket.price, ticket.currency)
}}</span>
{{ __("Free")
}}{{
ticket.count > freeTicketCount
? `, ${ticket.count - freeTicketCount} x ${formatPriceOrFree(
ticket.price,
ticket.currency
)}`
: ""
}}
</span>
<span v-else-if="netAmount > 0" class="text-sm text-ink-gray-5">
{{ ticket.count }} x {{ formatPriceOrFree(ticket.price, ticket.currency) }}
</span>
<span v-else class="text-sm text-ink-gray-5">x {{ ticket.count }}</span>
</div>
<span v-if="freeTicketType === name && freeTicketCount > 0" class="font-medium">
{{
ticket.count <= freeTicketCount
? __("Free")
: formatPriceOrFree(
(ticket.count - freeTicketCount) * ticket.price,
ticket.currency
)
}}
</span>
<span v-else-if="netAmount > 0" class="font-medium">{{
formatPriceOrFree(ticket.amount, ticket.currency)
}}</span>
</div>
</div>
<!-- Add-ons Section -->
<div v-if="Object.keys(summary.add_ons).length" class="mb-4">
<h3 class="text-lg font-semibold text-ink-gray-8 mb-2">{{ __("Add-ons") }}</h3>
<div
v-for="(addOn, name) in summary.add_ons"
:key="name"
class="flex justify-between items-start text-ink-gray-7 mb-2"
>
<div class="flex flex-col">
<span>{{ __(addOn.title) }}</span>
<span v-if="freeAddOnCounts[name] > 0" class="text-sm text-ink-gray-5">
{{ Math.min(freeAddOnCounts[name], addOn.count) }} x
<span class="line-through">{{
formatPriceOrFree(addOn.price, addOn.currency)
}}</span>
{{ __("Free")
}}{{
addOn.count > freeAddOnCounts[name]
? `, ${addOn.count - freeAddOnCounts[name]} x ${formatPriceOrFree(
addOn.price,
addOn.currency
)}`
: ""
}}
</span>
<span v-else-if="netAmount > 0" class="text-sm text-ink-gray-5">
{{ addOn.count }} x {{ formatPriceOrFree(addOn.price, addOn.currency) }}
</span>
<span v-else class="text-sm text-ink-gray-5">x {{ addOn.count }}</span>
</div>
<span v-if="freeAddOnCounts[name] > 0" class="font-medium">
{{
addOn.count <= freeAddOnCounts[name]
? __("Free")
: formatPriceOrFree(
(addOn.count - freeAddOnCounts[name]) * addOn.price,
addOn.currency
)
}}
</span>
<span v-else-if="netAmount > 0" class="font-medium">{{
formatPriceOrFree(addOn.amount, addOn.currency)
}}</span>
</div>
</div>
<!-- Show pricing summary if total > 0 OR coupon made it free -->
<template v-if="total > 0 || (couponApplied && netAmount > 0)">
<hr class="my-4 border-t border-outline-gray-1" />
<!-- Subtotal (hide when tax-inclusive and no discount, since it equals total) -->
<div
v-if="!taxInclusive || (couponApplied && discountAmount > 0)"
class="flex justify-between items-center text-ink-gray-7 mb-2"
>
<span>{{ __("Subtotal") }}</span>
<span class="font-medium">{{ formatPriceOrFree(netAmount, totalCurrency) }}</span>
</div>
<!-- Discount Section -->
<div
v-if="couponApplied && discountAmount > 0"
class="flex justify-between items-center text-green-600 mb-2"
>
<span>{{
couponType === "Free Tickets" ? __("Free Tickets") : __("Discount")
}}</span>
<span class="font-medium"
>-{{ formatPriceOrFree(discountAmount, totalCurrency) }}</span
>
</div>
<!-- Tax Section (exclusive only shown as line item added to total) -->
<div
v-if="shouldApplyTax && !taxInclusive"
class="flex justify-between items-center text-ink-gray-7 mb-2"
>
<span>{{ __(taxLabel) }} ({{ taxPercentage }}%)</span>
<span class="font-medium">{{ formatPriceOrFree(taxAmount, totalCurrency) }}</span>
</div>
<!-- Final Total Section -->
<hr v-if="shouldApplyTax" class="my-2 border-t border-outline-gray-1" />
<div class="flex justify-between items-center text-xl font-bold text-ink-gray-9">
<h3>{{ __("Total") }}</h3>
<span>{{ formatPriceOrFree(total, totalCurrency) }}</span>
</div>
<!-- Tax-inclusive note (shown below total) -->
<div
v-if="shouldApplyTax && taxInclusive"
class="text-sm text-ink-gray-5 text-right mt-3"
>
{{
__("Inclusive of {0} {1} ({2}%)", [
formatPriceOrFree(taxAmount, totalCurrency),
__(taxLabel),
taxPercentage,
])
}}
</div>
</template>
<!-- Free event message -->
<template v-else>
<hr class="my-2 border-t border-outline-gray-1" />
<div class="text-center pt-2">
<div class="text-xl font-bold text-green-600">{{ __("Free Event") }}</div>
</div>
</template>
</div>
</template>
<script setup>
import { formatPriceOrFree } from "@/utils/currency";
defineProps({
summary: {
type: Object,
required: true,
},
netAmount: {
type: Number,
required: true,
},
discountAmount: {
type: Number,
default: 0,
},
couponApplied: {
type: Boolean,
default: false,
},
couponType: {
type: String,
default: "",
},
freeAddOnCounts: {
type: Object,
default: () => ({}),
},
freeTicketType: {
type: String,
default: "",
},
freeTicketCount: {
type: Number,
default: 0,
},
taxAmount: {
type: Number,
default: 0,
},
taxPercentage: {
type: Number,
default: 0,
},
taxLabel: {
type: String,
default: "Tax",
},
taxInclusive: {
type: Boolean,
default: false,
},
shouldApplyTax: {
type: Boolean,
default: false,
},
total: {
type: Number,
required: true,
},
totalCurrency: {
type: String,
default: "INR",
},
});
</script>
@@ -0,0 +1,321 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Request Ticket Cancellation'),
size: '3xl',
}"
>
<template #body-content>
<div class="space-y-6">
<p class="text-ink-gray-7">
{{
__(
"Select the tickets you would like to cancel. Please note that cancellation requests are subject to approval and refund policies."
)
}}
</p>
<!-- Info about excluded tickets -->
<div
v-if="cancelledTickets.length > 0 || cancellationRequestedTickets.length > 0"
class="p-4 bg-surface-blue-1 border border-outline-blue-1 rounded-lg"
>
<p class="text-sm text-ink-blue-2">
<span v-if="cancelledTickets.length > 0">
{{ pluralize(cancelledTickets.length, __("ticket")) }}
{{ __("already cancelled") }}.
</span>
<span
v-if="
cancelledTickets.length > 0 &&
cancellationRequestedTickets.length > 0
"
>
<br />
</span>
<span v-if="cancellationRequestedTickets.length > 0">
{{ pluralize(cancellationRequestedTickets.length, __("ticket")) }}
{{ __("already have pending cancellation requests") }}.
</span>
</p>
</div>
<!-- Select All Option -->
<div
v-if="availableTickets.length > 0"
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
:class="{
'border-outline-gray-4 bg-surface-gray-2': isAllSelected,
}"
@click="toggleSelectAll"
>
<div class="flex items-center space-x-3">
<input
type="checkbox"
:checked="isAllSelected"
@change="toggleSelectAll"
class="h-4 w-4 text-ink-gray-6 border-outline-gray-1 rounded focus:ring-ink-gray-5"
/>
<div>
<h3 class="font-semibold text-ink-gray-9">
{{ __("Select All Available Tickets") }}
</h3>
<p class="text-sm text-ink-gray-6">
{{ __("Cancel all") }}
{{ pluralize(availableTickets.length, __("remaining ticket")) }}
</p>
</div>
</div>
</div>
<!-- Individual Ticket Selection -->
<div class="space-y-4">
<h4 class="font-medium text-ink-gray-8">
{{ __("Or select individual tickets:") }}
</h4>
<div v-if="availableTickets.length === 0" class="text-center py-8">
<p class="text-ink-gray-5">
{{
__(
"No tickets available for cancellation. All tickets are either already cancelled or have pending cancellation requests."
)
}}
</p>
</div>
<div v-else class="space-y-3 max-h-64 overflow-y-auto">
<div
v-for="ticket in availableTickets"
:key="ticket.name"
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
:class="{
'border-outline-gray-4 bg-surface-gray-2':
selectedTickets.includes(ticket.name),
}"
@click="toggleTicketSelection(ticket.name)"
>
<div class="flex items-start space-x-3">
<input
type="checkbox"
:checked="selectedTickets.includes(ticket.name)"
@change="toggleTicketSelection(ticket.name)"
class="h-4 w-4 text-ink-gray-6 border-outline-gray-1 rounded focus:ring-ink-gray-5 mt-1"
/>
<div class="flex-1">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-ink-gray-9">
{{ ticket.attendee_name }}
</h3>
<p class="text-sm text-ink-gray-6">
{{ ticket.attendee_email }}
</p>
<p class="text-sm text-ink-gray-5">
{{ ticket.ticket_type }}
</p>
</div>
</div>
<!-- Add-ons if any -->
<div
v-if="ticket.add_ons && ticket.add_ons.length > 0"
class="mt-2 pt-2 border-t border-outline-gray-1"
>
<p class="text-xs text-ink-gray-5 mb-1">
{{ __("Add-ons:") }}
</p>
<div class="flex flex-wrap gap-1">
<span
v-for="addon in ticket.add_ons"
:key="addon.name"
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-surface-gray-1 text-ink-gray-7"
>
{{ addon.title }}: {{ addon.value }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Warning if no tickets selected -->
<div v-if="selectedTickets.length === 0" class="text-center py-4">
<p class="text-ink-red-3 text-sm">
{{ __("Please select at least one ticket to cancel.") }}
</p>
</div>
<!-- Summary -->
<div
v-if="selectedTickets.length > 0"
class="p-4 bg-surface-blue-1 border border-outline-blue-1 rounded-lg"
>
<div class="flex items-center justify-between">
<div>
<h4 class="font-semibold text-ink-blue-2">
{{ __("Cancellation Summary") }}
</h4>
<p class="text-ink-blue-2">
{{ pluralize(selectedTickets.length, __("ticket")) }}
{{ __("selected for cancellation") }}
<span v-if="isAllSelected" class="font-medium">{{
__("(Full booking)")
}}</span>
</p>
</div>
<div class="text-right">
<p class="text-sm text-ink-blue-2">{{ __("Request Type") }}</p>
<p class="font-medium text-ink-blue-2">
{{
isAllSelected
? __("Full Cancellation")
: __("Partial Cancellation")
}}
</p>
</div>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end space-x-3">
<Button variant="ghost" @click="closeDialog" :loading="submitting">
{{ __("Cancel") }}
</Button>
<Button
variant="solid"
@click="submitCancellationRequest"
:disabled="selectedTickets.length === 0"
:loading="submitting"
>
{{ __("Submit Cancellation Request") }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { pluralize } from "@/utils/pluralize";
import { Button, Dialog, createResource, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
tickets: {
type: Array,
required: true,
},
bookingId: {
type: String,
required: true,
},
cancellationRequestedTickets: {
type: Array,
default: () => [],
},
cancelledTickets: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const show = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
// Filter out tickets that are already cancelled or have pending cancellation request
const availableTickets = computed(() => {
return props.tickets.filter(
(ticket) =>
!props.cancelledTickets.includes(ticket.name) &&
!props.cancellationRequestedTickets.includes(ticket.name)
);
});
const selectedTickets = ref([]);
const submitting = ref(false);
const isAllSelected = computed({
get: () =>
selectedTickets.value.length === availableTickets.value.length &&
availableTickets.value.length > 0,
set: (val) => {
if (val) {
selectedTickets.value = availableTickets.value.map((ticket) => ticket.name);
} else {
selectedTickets.value = [];
}
},
});
const toggleSelectAll = () => {
isAllSelected.value = !isAllSelected.value;
};
const toggleTicketSelection = (ticketId) => {
const index = selectedTickets.value.indexOf(ticketId);
if (index > -1) {
selectedTickets.value.splice(index, 1);
} else {
selectedTickets.value.push(ticketId);
}
};
const closeDialog = () => {
show.value = false;
selectedTickets.value = [];
};
const createCancellationRequest = createResource({
url: "buzz.api.create_cancellation_request",
onSuccess: (data) => {
submitting.value = false;
const ticketCount = selectedTickets.value.length;
const isFullCancellation = isAllSelected.value;
toast.success(
isFullCancellation
? __("Full booking cancellation request submitted successfully!")
: `${__("Cancellation request submitted for")} ${pluralize(
ticketCount,
__("ticket")
)}!`
);
emit("success", data);
closeDialog();
},
onError: (error) => {
submitting.value = false;
toast.error(
error?.messages?.[0] || __("Failed to submit cancellation request. Please try again.")
);
},
});
const submitCancellationRequest = () => {
if (selectedTickets.value.length === 0) return;
submitting.value = true;
createCancellationRequest.submit({
booking_id: props.bookingId,
ticket_ids: selectedTickets.value,
});
};
// Reset selected tickets when dialog closes
watch(show, (newVal) => {
if (!newVal) {
selectedTickets.value = [];
}
});
</script>
@@ -0,0 +1,41 @@
<template>
<div v-if="cancellationRequest" class="mb-6">
<div class="bg-surface-blue-1 border border-outline-blue-1 rounded-lg p-4">
<div class="flex items-center">
<LucideInfo class="w-5 h-5 text-ink-blue-2 mr-3" />
<div>
<h3 class="text-ink-blue-3 font-semibold">
{{ __("Cancellation Requested") }}
</h3>
<p class="text-ink-blue-2">
<span v-if="cancellationRequest.cancel_full_booking">
{{ __("Full booking cancellation has been requested.") }}
</span>
<span v-else>
{{
__("Partial cancellation has been requested for selected tickets.")
}}
</span>
{{ __("Request submitted on") }}
{{ formatDate(cancellationRequest.creation) }}.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import LucideInfo from "~icons/lucide/info";
defineProps({
cancellationRequest: {
type: Object,
default: null,
},
});
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
</script>
@@ -0,0 +1,227 @@
<template>
<div v-if="isDateField(field.fieldtype)" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<DatePicker
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="getFieldPlaceholder(field)"
/>
</div>
<div v-else-if="isDateTimeField(field.fieldtype)" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<DateTimePicker
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="getFieldPlaceholder(field)"
/>
</div>
<div v-else-if="field.fieldtype === 'Time'" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<TimePicker
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="getFieldPlaceholder(field)"
/>
</div>
<div v-else-if="field.fieldtype === 'Multi Select'" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<MultiSelect
:options="multiSelectOptions"
v-model="multiSelectProxy"
:placeholder="getFieldPlaceholder(field)"
/>
</div>
<FormControl
v-else-if="field.fieldtype === 'Check'"
type="checkbox"
:model-value="checkboxValue"
@update:model-value="$emit('update:modelValue', $event ? 1 : 0)"
:label="__(field.label)"
/>
<PhoneInput
v-else-if="field.fieldtype === 'Phone'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:label="field.label"
:required="field.mandatory"
:placeholder="getFieldPlaceholder(field)"
/>
<FormControl
v-else-if="field.fieldtype === 'Link'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:label="__(field.label)"
type="select"
:options="linkFieldOptions"
:required="field.mandatory"
:placeholder="getFieldPlaceholder(field)"
/>
<div v-else-if="isTextareaField(field.fieldtype)" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<Textarea
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:placeholder="getFieldPlaceholder(field)"
:required="field.mandatory"
variant="outline"
/>
</div>
<div v-else-if="field.fieldtype === 'Rating'" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<Rating
:model-value="Math.round((modelValue || 0) * 5)"
@update:model-value="$emit('update:modelValue', $event / 5)"
/>
</div>
<div v-else-if="field.fieldtype === 'Attach Image'" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<div v-if="modelValue" class="flex items-center gap-2">
<img :src="modelValue" class="h-16 w-16 rounded object-cover border" />
<Button variant="ghost" size="sm" @click="$emit('update:modelValue', '')">
{{ __("Remove") }}
</Button>
</div>
<FileUploader
v-else
@success="(file) => $emit('update:modelValue', file.file_url)"
:validateFile="validateImageFile"
>
<template #default="{ openFileSelector }">
<Button variant="outline" @click="openFileSelector">
{{ __("Upload Image") }}
</Button>
</template>
</FileUploader>
</div>
<div v-else-if="field.fieldtype === 'Attach'" class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-4">*</span>
</label>
<div v-if="modelValue" class="flex items-center gap-2">
<a
:href="modelValue"
target="_blank"
class="text-sm text-ink-blue-3 underline truncate max-w-xs"
>
{{ modelValue.split("/").pop() }}
</a>
<Button variant="ghost" size="sm" @click="$emit('update:modelValue', '')">
{{ __("Remove") }}
</Button>
</div>
<FileUploader v-else @success="(file) => $emit('update:modelValue', file.file_url)">
<template #default="{ openFileSelector }">
<Button variant="outline" @click="openFileSelector">
{{ __("Upload File") }}
</Button>
</template>
</FileUploader>
</div>
<FormControl
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:label="__(field.label)"
:type="getFormControlType(field.fieldtype, field.options)"
:options="getFieldOptions(field)"
:required="field.mandatory"
:placeholder="getFieldPlaceholder(field)"
/>
</template>
<script setup>
import PhoneInput from "@/components/PhoneInput.vue";
import {
getFieldOptions,
getFieldPlaceholder,
getFormControlType,
isDateField,
isDateTimeField,
isTextareaField,
} from "@/composables/useCustomFields";
import {
Button,
DatePicker,
DateTimePicker,
FileUploader,
TimePicker,
MultiSelect,
Rating,
Textarea,
} from "frappe-ui";
import { computed } from "vue";
const props = defineProps({
field: {
type: Object,
required: true,
},
});
const model = defineModel();
const multiSelectOptions = computed(() => getFieldOptions(props.field));
const checkboxValue = computed(() => model.value === 1 || model.value === "1");
const multiSelectProxy = computed({
get() {
if (!model.value) return [];
return Array.isArray(model.value) ? model.value : String(model.value).split(",");
},
set(val) {
if (!val || val.length === 0) {
model.value = "";
} else {
const values = val.map((item) => item.value || item);
model.value = values.join(",");
}
},
});
const linkFieldOptions = computed(() => {
if (!props.field.link_options) return [];
return props.field.link_options.map((name) => ({
label: name,
value: name,
}));
});
function validateImageFile(file) {
const validTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
if (!validTypes.includes(file.type)) {
return __("Please upload a valid image file (JPEG, PNG, GIF, WebP, SVG)");
}
}
</script>
@@ -0,0 +1,71 @@
<template>
<div v-if="customFields.length > 0" class="space-y-4">
<h5 v-if="showTitle" class="text-base font-medium text-ink-gray-8 border-b pb-2">
{{ __(title) || __("Additional Information") }}
</h5>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<CustomFieldInput
v-for="field in customFields"
:key="field.fieldname"
:field="field"
:model-value="getFieldValue(field.fieldname)"
@update:model-value="updateFieldValue(field.fieldname, $event)"
/>
</div>
</div>
</template>
<script setup>
import { getFieldDefaultValue } from "@/composables/useCustomFields";
import CustomFieldInput from "./CustomFieldInput.vue";
const props = defineProps({
customFields: {
type: Array,
default: () => [],
},
modelValue: {
type: Object,
default: () => ({}),
},
title: {
type: String,
default: "",
},
showTitle: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue"]);
// Get field value from model
const getFieldValue = (fieldname) => {
const currentValue = props.modelValue[fieldname];
// If field already has a value, return it
if (currentValue !== undefined && currentValue !== null && currentValue !== "") {
return currentValue;
}
// Apply default value if available
const field = props.customFields.find((f) => f.fieldname === fieldname);
if (field) {
const defaultValue = getFieldDefaultValue(field);
if (defaultValue) {
updateFieldValue(fieldname, defaultValue);
return defaultValue;
}
}
return "";
};
// Update field value in model
const updateFieldValue = (fieldname, value) => {
const updatedValue = { ...props.modelValue, [fieldname]: value };
emit("update:modelValue", updatedValue);
};
</script>
@@ -0,0 +1,143 @@
<!-- EventDetailsHeader.vue -->
<template>
<div v-if="eventDetails" class="mb-8">
<!-- Banner Image -->
<div
v-if="eventDetails.banner_image"
class="relative w-full h-48 md:h-64 lg:h-80 rounded-lg overflow-hidden mb-6"
>
<img
:src="eventDetails.banner_image"
:alt="eventDetails.title"
class="w-full h-auto object-cover contrast-100 brightness-100"
/>
</div>
<!-- Event Info without banner -->
<div v-else class="mb-6">
<h1 class="text-2xl md:text-3xl lg:text-4xl font-bold text-ink-gray-9 mb-4">
{{ __(eventDetails.title) }}
</h1>
</div>
<!-- Event Details -->
<div class="bg-surface-gray-1 rounded-lg p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 text-sm">
<!-- Date -->
<div v-if="eventDetails.start_date" class="flex flex-col items-start gap-3">
<div class="flex items-center gap-2">
<LucideCalendar class="h-4 w-4 text-ink-gray-6" />
<p class="text-ink-gray-6 text-base">{{ __("Date") }}</p>
</div>
<div>
<p class="font-medium text-ink-gray-8">
{{ formatEventDates(eventDetails.start_date, eventDetails.end_date) }}
</p>
</div>
</div>
<!-- Time -->
<div v-if="eventDetails.start_time" class="flex flex-col items-start gap-3">
<div class="flex gap-2 items-center">
<LucideClock class="h-4 w-4 text-ink-gray-6" />
<p class="text-ink-gray-6 text-base">{{ __("Time") }}</p>
</div>
<div>
<p class="font-medium text-ink-gray-8">
{{ formatEventTime(eventDetails.start_time, eventDetails.end_time) }}
<span v-if="eventDetails.time_zone"
>({{ eventDetails.time_zone }})</span
>
</p>
</div>
</div>
<!-- Venue -->
<div v-if="eventDetails.venue" class="flex flex-col items-start gap-3">
<div class="flex items-center gap-2">
<LucideMapPin class="h-4 w-4 text-ink-gray-6" />
<p class="text-ink-gray-6 text-base">{{ __("Venue") }}</p>
</div>
<div>
<p class="font-medium text-ink-gray-8">{{ eventDetails.venue }}</p>
</div>
</div>
<div
v-else-if="eventDetails.medium === 'Online'"
class="flex flex-col items-start gap-3"
>
<div class="flex items-center gap-2">
<LucideMapPin class="h-4 w-4 text-ink-gray-6" />
<p class="text-ink-gray-6 text-base">{{ __("Venue") }}</p>
</div>
<div>
<p class="font-medium text-ink-gray-8">{{ __("Online") }}</p>
</div>
</div>
</div>
<!-- Description -->
<div
v-if="eventDetails.short_description"
class="mt-4 pt-4 border-t border-outline-gray-2"
>
<p class="text-ink-gray-7 leading-relaxed">
{{ __(eventDetails.short_description) }}
</p>
</div>
</div>
</div>
</template>
<script setup>
import { dayjs, dayjsLocal } from "frappe-ui";
import LucideCalendar from "~icons/lucide/calendar";
import LucideClock from "~icons/lucide/clock";
import LucideMapPin from "~icons/lucide/map-pin";
const props = defineProps({
eventDetails: {
type: Object,
default: () => ({}),
},
});
// --- UTILITY FUNCTIONS ---
const formatEventDates = (startDate, endDate) => {
if (!startDate) return "";
const start = dayjsLocal(startDate);
const startFormatted = start.format("ddd, MMM D, YYYY");
if (!endDate || startDate === endDate) {
return startFormatted;
}
const end = dayjsLocal(endDate);
const endFormatted = end.format("ddd, MMM D, YYYY");
return `${startFormatted} - ${endFormatted}`;
};
const formatEventTime = (startTime, endTime) => {
if (!startTime) return "";
// Create a date object for today with the given time
const startDateTime = dayjsLocal()
.hour(Number.parseInt(startTime.split(":")[0]))
.minute(Number.parseInt(startTime.split(":")[1]));
const startFormatted = startDateTime.format("h:mm A");
if (!endTime) {
return startFormatted;
}
const endDateTime = dayjs()
.hour(Number.parseInt(endTime.split(":")[0]))
.minute(Number.parseInt(endTime.split(":")[1]));
const endFormatted = endDateTime.format("h:mm A");
return `${startFormatted} - ${endFormatted}`;
};
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="mb-6 size-full">
<!-- Header - only show when there are events -->
<h2
v-if="eventsResource.data?.length > 0"
class="text-lg font-semibold mb-4 text-gray-900 dark:text-white"
>
{{ __("Select Event") }}
</h2>
<!-- Loading State -->
<div v-if="eventsResource.loading" class="min-h-[50vh] flex justify-center items-center">
<div class="flex flex-col items-center gap-2">
<Spinner class="w-6 h-6" />
<div class="flex flex-col items-center">
<p class="text-gray-600 dark:text-gray-400">{{ __("Loading events...") }}</p>
<p class="text-gray-600 dark:text-gray-400">
{{ __("Please wait while we load the events...") }}
</p>
</div>
</div>
</div>
<!-- Events List View -->
<ListView
v-else
:columns="columns"
:rows="eventsResource.data || []"
row-key="name"
:options="{
selectable: false,
showTooltip: true,
onRowClick: handleEventSelect,
emptyState: {
title: __('No Events Available'),
description: __(
'There are currently no active events available for check-in. Events may be scheduled for later dates or may need to be published.'
),
button: {
label: __('Refresh Events'),
variant: 'solid',
onClick: () => eventsResource.fetch(),
},
},
}"
/>
</div>
</template>
<script setup>
import { ListView, Spinner, createListResource, dayjsLocal } from "frappe-ui";
defineProps({
selectedEvent: {
type: Object,
default: null,
},
});
const emit = defineEmits(["select"]);
const columns = [
{ label: __("Event"), key: "title", width: 1.5 },
{ label: __("Starts At"), key: "starts_at" },
{ label: __("Ends At"), key: "ends_at" },
];
const formatTimestamp = (date, time) => {
let formattedDate = "";
let formattedTime = "";
if (date || time) {
const dateTimeStr = date ? `${date}${time ? "T" + time : "T00:00:00"}` : undefined;
const parsed = dayjsLocal(dateTimeStr);
if (parsed.isValid()) {
formattedDate = parsed.format("MMM DD, YYYY");
formattedTime = parsed.format("h:mm A");
}
}
if (!formattedDate && !formattedTime) return "No date specified";
if (formattedDate && !formattedTime) return formattedDate;
if (!formattedDate && formattedTime) return formattedTime;
return `${formattedDate} ${formattedTime}`;
};
const eventsResource = createListResource({
doctype: "Buzz Event",
fields: ["name", "title", "start_date", "start_time", "end_date", "end_time"],
order_by: "start_date desc",
filters: {
is_published: 1,
end_date: [">=", dayjsLocal().format("YYYY-MM-DD")],
},
auto: true,
transform(data) {
return data.map((event) => ({
...event,
starts_at: formatTimestamp(event.start_date, event.start_time),
ends_at: formatTimestamp(event.end_date, event.end_time),
}));
},
});
const handleEventSelect = (event) => {
emit("select", event);
};
</script>
@@ -0,0 +1,30 @@
<template>
<Dropdown :options="languageOptions">
<template #default="{ open }">
<Button variant="ghost" size="md" :loading="isSwitching">
<LucideLanguages class="w-4 h-4" />
</Button>
</template>
</Dropdown>
</template>
<script setup>
import { useLanguage } from "@/composables/useLanguage";
import { Button, Dropdown } from "frappe-ui";
import { computed } from "vue";
import LucideLanguages from "~icons/lucide/languages";
const { availableLanguages, currentLanguage, changeLanguage, isSwitching } = useLanguage();
const languageOptions = computed(() => {
if (!availableLanguages.data || availableLanguages.data.length === 0) {
return [];
}
return availableLanguages.data.map((lang) => ({
label: lang.language_name || lang.name,
icon: currentLanguage.value === lang.language_code ? "check" : undefined,
onClick: () => changeLanguage(lang.language_code),
}));
});
</script>
+419
View File
@@ -0,0 +1,419 @@
<template>
<Dialog v-model="is_open" :options="{ size: 'md' }" @after-leave="resetState">
<template #body-title>
<h3 class="text-xl font-semibold text-ink-gray-9">
{{ view_title }}
</h3>
</template>
<template #body-content>
<div
v-if="login_context?.login_banner"
class="rounded-md bg-surface-gray-2 p-3 prose prose-sm max-w-none mb-6"
v-html="login_context.login_banner"
/>
<div
v-if="error_message"
class="mb-4 rounded-md bg-surface-red-2 p-3 text-sm text-ink-red-3"
>
{{ error_message }}
</div>
<div
v-if="success_message"
class="mb-4 rounded-md bg-surface-green-2 p-3 text-sm text-ink-green-3"
>
{{ success_message }}
</div>
<form v-if="current_view === 'login'" class="space-y-4" @submit.prevent="handleLogin">
<template v-if="!login_context?.disable_user_pass_login">
<FormControl
type="email"
:label="__('Email')"
:placeholder="__('Enter your email')"
v-model="form.email"
required
@keydown.enter="focusPassword"
/>
<FormControl
ref="password_input"
type="password"
:label="__('Password')"
:placeholder="__('Enter your password')"
v-model="form.password"
required
/>
<div class="flex justify-end">
<button
type="button"
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
@click="switchView('forgot-password')"
>
{{ __("Forgot Password?") }}
</button>
</div>
<Button
variant="solid"
class="w-full"
type="submit"
:loading="session.login.loading"
>
{{ __("Login") }}
</Button>
</template>
<template v-if="has_social_logins || login_context?.login_with_email_link">
<div class="relative flex items-center justify-center">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-outline-gray-2" />
</div>
<span class="relative bg-surface-modal px-2 text-sm text-ink-gray-4">
{{ __("or") }}
</span>
</div>
<SocialLoginButtons :provider_logins="login_context?.provider_logins" />
<template v-if="login_context?.login_with_email_link">
<Button
variant="subtle"
class="w-full"
type="button"
@click="switchView('email-link')"
>
{{ __("Login with Email Link") }}
</Button>
</template>
</template>
<div
v-if="!login_context?.disable_signup"
class="text-center text-sm text-ink-gray-5"
>
{{ __("Don't have an account?") }}
<button
type="button"
class="font-medium text-ink-gray-7 hover:text-ink-gray-9"
@click="switchView('signup')"
>
{{ __("Sign up") }}
</button>
</div>
</form>
<form
v-else-if="current_view === 'signup'"
class="space-y-4"
@submit.prevent="handleSignup"
>
<FormControl
type="text"
:label="__('Full Name')"
:placeholder="__('Enter your full name')"
v-model="form.full_name"
required
/>
<FormControl
type="email"
:label="__('Email')"
:placeholder="__('Enter your email')"
v-model="form.email"
required
/>
<Button
variant="solid"
class="w-full"
type="submit"
:loading="signup_resource.loading"
>
{{ __("Sign Up") }}
</Button>
<template v-if="has_social_logins">
<div class="relative flex items-center justify-center">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-outline-gray-2" />
</div>
<span class="relative bg-surface-modal px-2 text-sm text-ink-gray-4">
{{ __("or") }}
</span>
</div>
<SocialLoginButtons :provider_logins="login_context?.provider_logins" />
</template>
<div class="text-center text-sm text-ink-gray-5">
{{ __("Already have an account?") }}
<button
type="button"
class="font-medium text-ink-gray-7 hover:text-ink-gray-9"
@click="switchView('login')"
>
{{ __("Login") }}
</button>
</div>
</form>
<form
v-else-if="current_view === 'forgot-password'"
class="space-y-4"
@submit.prevent="handleForgotPassword"
>
<p class="text-sm text-ink-gray-5">
{{
__(
"Enter your email address and we'll send you a link to reset your password."
)
}}
</p>
<FormControl
type="email"
:label="__('Email')"
:placeholder="__('Enter your email')"
v-model="form.email"
required
/>
<Button
variant="solid"
class="w-full"
type="submit"
:loading="forgot_password_resource.loading"
>
{{ __("Reset Password") }}
</Button>
<div class="text-center">
<button
type="button"
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
@click="switchView('login')"
>
{{ __("Back to Login") }}
</button>
</div>
</form>
<form
v-else-if="current_view === 'email-link'"
class="space-y-4"
@submit.prevent="handleEmailLink"
>
<p class="text-sm text-ink-gray-5">
{{ __("We'll send you a one-time login link to your email address.") }}
</p>
<FormControl
type="email"
:label="__('Email')"
:placeholder="__('Enter your email')"
v-model="form.email"
required
/>
<Button
variant="solid"
class="w-full"
type="submit"
:loading="email_link_resource.loading"
>
{{ __("Send Login Link") }}
</Button>
<div class="text-center">
<button
type="button"
class="text-sm text-ink-gray-5 hover:text-ink-gray-7"
@click="switchView('login')"
>
{{ __("Back to Login") }}
</button>
</div>
</form>
</template>
</Dialog>
</template>
<script setup>
import { useLoginDialog } from "@/composables/useLoginDialog";
import { session } from "@/data/session";
import { userResource } from "@/data/user";
import { Button, Dialog, FormControl, createResource } from "frappe-ui";
import { computed, defineComponent, h, ref, watch } from "vue";
const { is_open, close, on_success_callback } = useLoginDialog();
const current_view = ref("login");
const error_message = ref("");
const success_message = ref("");
const password_input = ref(null);
const form = ref({
email: "",
password: "",
full_name: "",
});
const view_title = computed(() => {
const titles = {
login: __("Login to Continue"),
signup: __("Create Account"),
"forgot-password": __("Forgot Password"),
"email-link": __("Login with Email Link"),
};
return titles[current_view.value] || __("Login");
});
const login_context_resource = createResource({
url: "buzz.api.auth.get_login_context",
params: { redirect_to: window.location.href },
auto: true,
});
const login_context = computed(() => login_context_resource.data);
const has_social_logins = computed(() => login_context.value?.provider_logins?.length > 0);
const SocialLoginButtons = defineComponent({
props: { provider_logins: Array },
setup(props) {
return () =>
(props.provider_logins || []).map((provider) =>
h(
Button,
{
variant: "subtle",
class: "w-full",
type: "button",
onClick: () => {
window.location.href = provider.auth_url;
},
},
{
...(provider.icon
? {
prefix: () =>
h("img", {
src: provider.icon,
class: "h-4 w-4",
alt: provider.provider_name,
}),
}
: {}),
default: () => __("Continue with {0}", [provider.provider_name]),
}
)
);
},
});
function focusPassword() {
password_input.value?.$el?.querySelector("input")?.focus();
}
function switchView(view) {
current_view.value = view;
error_message.value = "";
success_message.value = "";
}
function resetState() {
current_view.value = "login";
error_message.value = "";
success_message.value = "";
form.value = { email: "", password: "", full_name: "" };
}
function handleLogin() {
error_message.value = "";
session.login.submit(
{ email: form.value.email, password: form.value.password },
{
onSuccess() {
userResource.reload();
session.user =
session.login.data?.user || document.cookie.match(/user_id=([^;]+)/)?.[1];
close();
if (on_success_callback.value) {
on_success_callback.value();
}
},
onError(error) {
error_message.value = error.messages?.[0] || __("Invalid email or password.");
},
}
);
}
const signup_resource = createResource({
url: "frappe.core.doctype.user.user.sign_up",
});
function handleSignup() {
error_message.value = "";
signup_resource.submit(
{
email: form.value.email,
full_name: form.value.full_name,
redirect_to: window.location.pathname,
},
{
onSuccess(data) {
if (data && data[0] === 1) {
success_message.value = __("Please check your email to verify your account.");
} else if (data && data[1]) {
success_message.value = data[1];
} else {
success_message.value = __("Please check your email to verify your account.");
}
},
onError(error) {
error_message.value =
error.messages?.[0] || __("Something went wrong. Please try again.");
},
}
);
}
const forgot_password_resource = createResource({
url: "frappe.core.doctype.user.user.reset_password",
});
function handleForgotPassword() {
error_message.value = "";
forgot_password_resource.submit(
{ user: form.value.email },
{
onSuccess() {
success_message.value = __("Password reset link has been sent to your email.");
},
onError(error) {
error_message.value =
error.messages?.[0] || __("Something went wrong. Please try again.");
},
}
);
}
const email_link_resource = createResource({
url: "frappe.www.login.send_login_link",
});
function handleEmailLink() {
error_message.value = "";
email_link_resource.submit(
{ email: form.value.email },
{
onSuccess() {
success_message.value = __("Login link has been sent to your email.");
},
onError(error) {
error_message.value =
error.messages?.[0] || __("Something went wrong. Please try again.");
},
}
);
}
watch(is_open, (value) => {
if (value) {
login_context_resource.fetch({ redirect_to: window.location.href });
}
});
</script>
@@ -0,0 +1,27 @@
<template>
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="text-center max-w-md">
<h2 class="text-xl font-semibold text-ink-gray-8 mb-2">
{{ __("Login Required") }}
</h2>
<p class="text-ink-gray-6 mb-6">
{{ __(message) }}
</p>
<Button variant="solid" size="lg" @click="openLogin">{{ __("Log In") }}</Button>
</div>
</div>
</template>
<script setup>
import { useLoginDialog } from "@/composables/useLoginDialog";
import { Button } from "frappe-ui";
defineProps({
message: {
type: String,
default: "Please log in to continue.",
},
});
const { open: openLogin } = useLoginDialog();
</script>
+67
View File
@@ -0,0 +1,67 @@
<template>
<div class="border-b">
<nav class="flex items-center justify-between gap-4 p-4 max-w-4xl mx-auto">
<a href="/">
<img
class="h-6 contrast-100 brightness-100 invert-[0.8] dark:invert-0"
v-if="userResource?.data?.brand_image"
:src="userResource.data.brand_image"
/>
<BuzzLogo v-else class="w-9 h-7 text-ink-gray-9" />
</a>
<div class="flex items-center gap-2">
<Button variant="ghost" size="md" @click="toggleTheme">
<LucideSun v-if="userTheme === 'dark'" class="w-4 h-4" />
<LucideMoon v-else class="w-4 h-4" />
</Button>
<LanguageSwitcher />
<Button
v-if="session.isLoggedIn"
:loading="session.logout.loading"
@click="session.logout.submit"
icon-right="log-out"
variant="ghost"
size="md"
>
{{ __("Log Out") }}
</Button>
<Button
v-else
@click="openLoginDialog"
icon-right="log-in"
variant="ghost"
size="md"
>
{{ __("Log In") }}
</Button>
</div>
</nav>
</div>
</template>
<script setup>
import { userResource } from "@/data/user";
import LucideMoon from "~icons/lucide/moon";
import LucideSun from "~icons/lucide/sun";
import { session } from "../data/session";
import LanguageSwitcher from "./LanguageSwitcher.vue";
import BuzzLogo from "./common/BuzzLogo.vue";
import { useLoginDialog } from "@/composables/useLoginDialog";
import { useStorage } from "@vueuse/core";
import { onMounted } from "vue";
const { open: openLoginDialog } = useLoginDialog();
const userTheme = useStorage("user-theme", "dark");
onMounted(() => {
document.documentElement.setAttribute("data-theme", userTheme.value);
});
function toggleTheme() {
const currentTheme = userTheme.value;
const newTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
userTheme.value = newTheme;
}
</script>
@@ -0,0 +1,182 @@
<template>
<Dialog v-model:open="isOpen" :options="{ size: 'md' }">
<template #body>
<div class="p-4">
<!-- Title (shows custom label if set, otherwise default) -->
<h3 class="text-lg font-semibold mb-4 text-ink-gray-9">
{{ offlineSettings.label }}
</h3>
<div class="space-y-4">
<!-- Amount -->
<div class="text-center p-3 bg-surface-gray-1 rounded">
<div class="text-xl font-bold text-ink-gray-9">
{{ formatCurrency(amount, currency) }}
</div>
</div>
<!-- Payment Details (HTML Content) -->
<div
v-if="offlineSettings.payment_details"
class="prose-sm [&>:first-child]:mt-0 bg-surface-gray-1 border border-outline-gray-1 rounded p-3 text-ink-gray-9"
v-html="offlineSettings.payment_details"
></div>
<!-- Custom Fields -->
<CustomFieldsSection
v-if="offlineCustomFields.length > 0"
:custom-fields="offlineCustomFields"
v-model="customFieldsData"
:show-title="false"
/>
<!-- Upload Proof -->
<div v-if="offlineSettings.collect_payment_proof">
<label class="block text-sm font-medium text-ink-gray-8 mb-2"
>{{ __("Proof of Payment") }} *</label
>
<FileUploader
ref="fileUploaderRef"
v-model="paymentProof"
:file-types="['image/*']"
@success="onFileUpload"
>
<template #default="{ openFileSelector, uploading, progress }">
<div
v-if="paymentProof"
class="flex items-center gap-1.5 text-sm text-ink-green-2"
>
<LucideCheckCircle class="h-4 w-4 flex-shrink-0" />
<span class="truncate">{{
paymentProof.file_name || paymentProof.name
}}</span>
<button
type="button"
class="ml-auto p-1 rounded hover:bg-surface-gray-2 text-ink-gray-5 hover:text-ink-gray-8"
:title="__('Replace')"
@click="openFileSelector"
>
<LucideRefreshCw class="h-3.5 w-3.5" />
</button>
</div>
<Button
v-else
@click="openFileSelector"
:loading="uploading"
variant="outline"
>
{{
uploading
? __("Uploading {0}%", [progress])
: __("Upload File")
}}
</Button>
</template>
</FileUploader>
</div>
</div>
<div class="flex gap-2 mt-4">
<Button variant="outline" class="flex-1" @click="$emit('cancel')">
{{ __("Cancel") }}
</Button>
<Button
variant="solid"
class="flex-1"
@click="submitOfflinePayment"
:loading="loading"
:disabled="isSubmitDisabled"
>
{{ __("Submit") }}
</Button>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog, FileUploader, toast } from "frappe-ui";
import { computed, ref } from "vue";
import LucideCheckCircle from "~icons/lucide/check-circle";
import LucideRefreshCw from "~icons/lucide/refresh-cw";
import { formatCurrency } from "../utils/currency";
import CustomFieldsSection from "./CustomFieldsSection.vue";
const props = defineProps({
open: {
type: Boolean,
default: false,
},
amount: {
type: Number,
required: true,
},
currency: {
type: String,
default: "INR",
},
offlineSettings: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
customFields: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["update:open", "submit", "cancel"]);
const isOpen = computed({
get: () => props.open,
set: (value) => emit("update:open", value),
});
const paymentProof = ref(null);
const customFieldsData = ref({});
// Custom fields are now pre-filtered by method in BookingForm
const offlineCustomFields = computed(() => props.customFields);
// Check if submit should be disabled
const isSubmitDisabled = computed(() => {
// Check payment proof requirement
if (props.offlineSettings.collect_payment_proof && !paymentProof.value) {
return true;
}
// Check mandatory custom fields
for (const field of offlineCustomFields.value) {
if (
field.mandatory &&
(!customFieldsData.value[field.fieldname] ||
customFieldsData.value[field.fieldname] === "")
) {
return true;
}
}
return false;
});
const onFileUpload = (file) => {
paymentProof.value = file;
};
const submitOfflinePayment = () => {
if (isSubmitDisabled.value) {
toast.error(__("Please fill all required fields"));
return;
}
emit("submit", {
payment_proof: paymentProof.value,
custom_fields: customFieldsData.value,
});
};
</script>
@@ -0,0 +1,90 @@
<template>
<Dialog
v-model="isOpen"
:options="{
title: __('Select Payment Method'),
size: 'md',
}"
>
<template #body-content>
<div class="space-y-3">
<div
v-for="gateway in paymentGateways"
:key="gateway"
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
:class="{
'border-outline-gray-4 bg-surface-gray-2': selectedGateway === gateway,
}"
@click="selectedGateway = gateway"
>
<div class="flex items-center space-x-3">
<input
type="radio"
:checked="selectedGateway === gateway"
@change="selectedGateway = gateway"
class="text-ink-gray-6"
/>
<div>
<h3 class="font-semibold text-ink-gray-9">{{ gateway }}</h3>
</div>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex justify-end space-x-3">
<Button variant="ghost" @click="closeDialog">{{ __("Cancel") }}</Button>
<Button variant="solid" :disabled="!selectedGateway" @click="proceedToPayment">
{{ __("Proceed to Pay") }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
open: {
type: Boolean,
default: false,
},
paymentGateways: {
type: Array,
required: true,
},
});
const emit = defineEmits(["update:open", "gateway-selected"]);
const isOpen = computed({
get: () => props.open,
set: (val) => emit("update:open", val),
});
const selectedGateway = ref(null);
// Reset selection when dialog opens
watch(
() => props.open,
(newVal) => {
if (newVal) {
selectedGateway.value = null;
}
}
);
const closeDialog = () => {
isOpen.value = false;
selectedGateway.value = null;
};
const proceedToPayment = () => {
if (!selectedGateway.value) return;
emit("gateway-selected", selectedGateway.value);
closeDialog();
};
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<div class="space-y-1.5">
<label class="text-xs text-ink-gray-5 block">
{{ __(label) }}
<span v-if="required" class="text-ink-red-4">*</span>
</label>
<div class="flex gap-1.5">
<div class="w-24 shrink-0">
<Combobox
:model-value="null"
@update:model-value="onDialCodeChange"
:options="dialCodeOptions"
variant="outline"
:placeholder="shortDisplay"
/>
</div>
<TextInput
type="tel"
:model-value="localNumber"
@update:model-value="onNumberInput"
:placeholder="placeholder || __('Phone number')"
/>
</div>
</div>
</template>
<script setup>
import { Combobox, TextInput, createResource } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
label: { type: String, default: "Phone" },
placeholder: { type: String, default: "" },
required: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const dialCode = ref("+91");
const localNumber = ref("");
const dialCodesData = ref([]);
function getFlagEmoji(countryCode) {
if (!countryCode) return "";
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}
const shortDisplay = computed(() => {
const entry = dialCodesData.value.find((d) => d.dial_code === dialCode.value);
if (entry) return `${getFlagEmoji(entry.code)} ${entry.dial_code}`;
return dialCode.value;
});
const dialCodeOptions = computed(() =>
dialCodesData.value.map((d) => ({
label: `${getFlagEmoji(d.code)} ${d.dial_code}`,
value: d.dial_code,
}))
);
function parsePhone(value) {
if (!value) {
localNumber.value = "";
return;
}
const match = value.match(/^(\+\d{1,4})[\s-]?(.*)$/);
if (match) {
dialCode.value = match[1];
localNumber.value = match[2];
} else {
localNumber.value = value;
}
}
parsePhone(props.modelValue);
watch(
() => props.modelValue,
(val) => parsePhone(val)
);
function emitValue() {
if (!localNumber.value) {
emit("update:modelValue", "");
return;
}
emit("update:modelValue", `${dialCode.value} ${localNumber.value}`);
}
function onDialCodeChange(code) {
if (code) {
dialCode.value = code;
emitValue();
}
}
function onNumberInput(num) {
const digitsOnly = String(num).replace(/\D/g, "");
localNumber.value = digitsOnly;
emitValue();
}
createResource({
url: "buzz.api.forms.get_dial_codes",
auto: true,
onSuccess: (data) => {
dialCodesData.value = data;
},
});
</script>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div v-if="profile" class="flex w-full items-center justify-between mb-3 sm:mb-5">
<FileUploader
@success="(file) => updateImage(file.file_url)"
:validateFile="validateIsImageFile"
>
<template #default="{ openFileSelector, error: _error }">
<div class="flex items-center gap-4">
<div class="group relative !size-[66px]">
<Avatar
class="!size-16"
:image="profile.user_image"
:label="profile.full_name"
/>
<component
:is="profile.user_image ? Dropdown : 'div'"
v-bind="
profile.user_image
? {
options: [
{
icon: 'upload',
label: profile.user_image
? __('Change image')
: __('Upload image'),
onClick: openFileSelector,
},
{
icon: 'trash-2',
label: __('Remove image'),
onClick: () => updateImage(),
},
],
}
: { onClick: openFileSelector }
"
class="!absolute bottom-0 left-0 right-0"
>
<div
class="z-1 absolute bottom-0.5 left-0 right-0.5 flex h-9 cursor-pointer items-center justify-center rounded-b-full bg-black bg-opacity-40 pt-3 opacity-0 duration-300 ease-in-out group-hover:opacity-100"
style="
-webkit-clip-path: inset(12px 0 0 0);
clip-path: inset(12px 0 0 0);
"
>
<LucideCamera class="size-4 cursor-pointer text-white" />
</div>
</component>
</div>
<div class="flex flex-col gap-1">
<span class="text-2xl font-semibold text-ink-gray-8">
{{ profile.full_name }}
</span>
<span class="text-base text-ink-gray-7">
{{ profile.email }}
</span>
<ErrorMessage :message="__(_error)" />
</div>
</div>
</template>
</FileUploader>
</div>
</template>
<script setup>
import { validateIsImageFile } from "@/utils";
import { Avatar, Dropdown, FileUploader, createResource, toast } from "frappe-ui";
import { onMounted, ref } from "vue";
import LucideCamera from "~icons/lucide/camera";
import { userResource } from "../data/user";
const user = userResource.data || {};
const profile = ref({});
const error = ref("");
const setUser = createResource({
url: "frappe.client.set_value",
makeParams() {
return {
doctype: "User",
name: user.name,
fieldname: {
first_name: profile.value.first_name,
last_name: profile.value.last_name,
user_image: profile.value.user_image,
},
};
},
onSuccess: () => {
error.value = "";
toast.success(__("Profile updated successfully"));
},
onError: (err) => {
error.value = err.messages[0] || __("Failed to update profile");
},
});
function updateImage(fileUrl = "") {
profile.value.user_image = fileUrl;
setUser.submit();
}
onMounted(() => {
profile.value = { ...userResource.data };
});
</script>
@@ -0,0 +1,172 @@
<template>
<Dialog v-model="isOpen" :options="{ size: '3xl' }">
<template #body-title>
<h3 class="text-xl font-semibold text-ink-gray-9">
{{ eventTalkId ? __("Edit Talk") : __("Edit Proposal") }}
</h3>
</template>
<template #body-content>
<div class="space-y-4">
<FormControl
type="text"
:label="__('Title')"
:placeholder="__('Enter proposal title')"
v-model="editForm.title"
:required="true"
/>
<div>
<label class="block text-sm font-medium text-ink-gray-7 mb-2">
{{ __("Description") }}
</label>
<TextEditor
:fixedMenu="true"
:content="editForm.description"
:placeholder="__('Enter proposal description...')"
@change="(val) => (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"
/>
</div>
<FormControl
v-if="!eventTalkId"
type="tel"
:label="__('Phone (optional)')"
:placeholder="__('Enter phone number')"
v-model="editForm.phone"
/>
</div>
</template>
<template #actions="{ close }">
<div class="flex gap-2">
<Button
variant="solid"
@click="handleSave"
:loading="updateResource.loading"
:disabled="!editForm.title"
>
{{ __("Save Changes") }}
</Button>
<Button variant="outline" @click="close">
{{ __("Cancel") }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog, FormControl, TextEditor, createResource, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
open: {
type: Boolean,
default: false,
},
proposalId: {
type: String,
required: true,
},
eventTalkId: {
type: String,
default: null,
},
initialData: {
type: Object,
default: () => ({ title: "", description: "", phone: "" }),
},
});
const emit = defineEmits(["update:open", "updated"]);
const isOpen = computed({
get: () => props.open,
set: (value) => emit("update:open", value),
});
const editForm = ref({
title: "",
description: "",
phone: "",
});
// Update resource using frappe.client.set_value
const updateResource = createResource({
url: "frappe.client.set_value",
onSuccess: () => {
const message = props.eventTalkId
? __("Talk updated successfully")
: __("Proposal updated successfully");
toast.success(message);
isOpen.value = false;
emit("updated");
},
onError: (error) => {
const message = props.eventTalkId
? __("Failed to update talk")
: __("Failed to update proposal");
toast.error(error.messages?.[0] || message);
},
});
const handleSave = () => {
if (!editForm.value.title) {
toast.error(__("Title is required"));
return;
}
// If eventTalkId is provided, update the Event Talk doctype
// Otherwise, update the Talk Proposal doctype
if (props.eventTalkId) {
updateResource.submit({
doctype: "Event Talk",
name: props.eventTalkId,
fieldname: {
title: editForm.value.title,
description: editForm.value.description,
},
});
} else {
updateResource.submit({
doctype: "Talk Proposal",
name: props.proposalId,
fieldname: {
title: editForm.value.title,
description: editForm.value.description,
phone: editForm.value.phone || "",
},
});
}
};
// Initialize form with initial data when dialog opens
watch(
() => props.open,
(newValue) => {
if (newValue) {
editForm.value = {
title: props.initialData.title || "",
description: props.initialData.description || "",
phone: props.initialData.phone || "",
};
}
},
{ immediate: true }
);
// Also watch for initialData changes
watch(
() => props.initialData,
(newData) => {
if (props.open && newData) {
editForm.value = {
title: newData.title || "",
description: newData.description || "",
phone: newData.phone || "",
};
}
},
{ deep: true }
);
</script>
@@ -0,0 +1,26 @@
<template>
<Dialog v-model="isOpen" :options="{ title: __('QR Code'), size: 'lg' }">
<template #body-content>
<div class="flex justify-center">
<img :src="qrCodeSrc" :alt="altText" class="w-full max-w-sm" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from "frappe-ui";
const isOpen = defineModel({ type: Boolean, default: false });
defineProps({
qrCodeSrc: {
type: String,
required: true,
},
altText: {
type: String,
default: "QR Code",
},
});
</script>
+251
View File
@@ -0,0 +1,251 @@
<template>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-medium text-gray-900 dark:text-white">
{{ __("Scan Ticket QR Code") }}
</h3>
</div>
<!-- Scanner Container -->
<div class="relative">
<div
id="qr-reader"
class="w-full text-ink-gray-7"
:class="{ 'opacity-50': isProcessingTicket }"
></div>
<!-- Processing Overlay -->
<div
v-if="isProcessingTicket"
class="absolute inset-0 bg-white dark:bg-gray-800 bg-opacity-75 flex items-center justify-center"
>
<Spinner class="w-8 h-8" />
</div>
</div>
<!-- Scanner Controls -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
<div class="flex gap-2">
<Button
@click="startScanner"
v-if="!scannerActive"
variant="outline"
class="flex-1"
>
<template #prefix>
<LucideQrCode class="w-4 h-4" />
</template>
Start Scanner
</Button>
<Button
@click="stopScanner"
v-else
variant="outline"
class="flex-1"
icon-left="square"
>
{{ __("Stop Scanner") }}
</Button>
</div>
<!-- Manual Entry -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-600">
<div class="flex gap-2">
<TextInput
v-model="manualTicketId"
placeholder="Enter ticket ID manually"
class="flex-1"
:disabled="isProcessingTicket"
/>
<Button
@click="handleManualEntry"
:loading="isProcessingTicket"
:disabled="!manualTicketId.trim()"
>
{{ __("Check") }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useTicketValidation } from "@/composables/useTicketValidation";
import { Button, Spinner, TextInput, toast } from "frappe-ui";
import { Html5Qrcode } from "html5-qrcode";
import { onMounted, onUnmounted, ref } from "vue";
import LucideQrCode from "~icons/lucide/qr-code";
const { validateTicket, isProcessingTicket } = useTicketValidation();
const qrScanner = ref(null);
const scannerActive = ref(false);
const manualTicketId = ref("");
const lastScannedTicketId = ref(null);
const scanTimeout = ref(null);
const startScanner = async () => {
if (scannerActive.value) return;
try {
// Clear any existing scanner content first
const container = document.getElementById("qr-reader");
if (container) {
container.innerHTML = "";
}
qrScanner.value = new Html5Qrcode("qr-reader");
// Start scanning with the back camera (environment) as default
await qrScanner.value.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
onScanSuccess
);
scannerActive.value = true;
} catch (error) {
console.error("Failed to start scanner:", error);
toast.error(__("Failed to start camera. Please check permissions."));
}
};
const stopScanner = async () => {
if (!scannerActive.value || !qrScanner.value) return;
try {
await qrScanner.value.stop();
} catch (error) {
console.error("Failed to stop scanner:", error);
}
qrScanner.value = null;
scannerActive.value = false;
};
const onScanSuccess = (decodedText) => {
// Extract ticket ID from QR code
const ticketId = extractTicketId(decodedText);
if (!ticketId) {
toast.error(__("Invalid QR code format"));
return;
}
// Prevent duplicate scans of the same ticket within 2 seconds
if (lastScannedTicketId.value === ticketId) {
return;
}
if (scanTimeout.value) {
clearTimeout(scanTimeout.value);
}
lastScannedTicketId.value = ticketId;
validateTicket(ticketId);
scanTimeout.value = setTimeout(() => {
lastScannedTicketId.value = null;
}, 2000);
};
const extractTicketId = (qrData) => {
// If QR contains just the ticket ID
if (qrData.match(/^[A-Z0-9\-]+$/)) {
return qrData;
}
// If QR contains a URL with ticket ID
const urlMatch = qrData.match(/ticket[\/=]([A-Z0-9\-]+)/i);
if (urlMatch) {
return urlMatch[1];
}
// Try to extract any alphanumeric string that looks like a ticket ID
const idMatch = qrData.match(/([A-Z0-9\-]{10,})/i);
if (idMatch) {
return idMatch[1];
}
return null;
};
const handleManualEntry = () => {
const ticketId = manualTicketId.value.trim();
if (!ticketId) return;
validateTicket(ticketId);
manualTicketId.value = "";
};
onMounted(() => {
// Automatically start the scanner when component mounts
startScanner();
});
onUnmounted(() => {
if (qrScanner.value) {
qrScanner.value
.stop()
.then(() => {
qrScanner.value.clear();
})
.catch((error) => {
console.error("Failed to cleanup scanner:", error);
})
.finally(() => {
qrScanner.value = null;
scannerActive.value = false;
});
} else {
qrScanner.value = null;
scannerActive.value = false;
}
if (scanTimeout.value) {
clearTimeout(scanTimeout.value);
scanTimeout.value = null;
}
});
defineExpose({
startScanner,
stopScanner,
});
</script>
<style scoped>
#qr-reader {
width: 100%;
}
:global(#qr-reader img[alt="Info icon"]) {
display: none !important;
}
:global(#qr-reader img[alt="Camera based scan"]) {
display: none !important;
}
/* Override html5-qrcode styles for better mobile experience */
:global(#qr-reader > div:first-child) {
border: none !important;
}
:global(#qr-reader video) {
border-radius: 0 !important;
}
/* Hide duplicate shaded regions - only show the first one */
:global(#qr-shaded-region:not(:first-of-type)) {
display: none !important;
}
/* Hide the dashboard UI since we're using our own controls */
:global(#qr-reader__dashboard) {
display: none !important;
}
</style>
@@ -0,0 +1,63 @@
<template>
<div>
<!-- Single consolidated restriction notice -->
<div v-if="hasRestrictions" class="mb-4">
<div class="bg-surface-amber-1 border border-outline-amber-1 rounded-lg p-4">
<div class="flex items-start">
<LucideTriangleAlert
class="w-5 h-5 text-ink-amber-2 mr-3 mt-0.5 flex-shrink-0"
/>
<div>
<p class="text-ink-amber-3 text-sm font-medium mb-2">
{{
__(
"Some options are no longer available as the event is approaching:"
)
}}
</p>
<ul class="text-ink-amber-3 text-sm space-y-1 list-disc list-inside">
<li v-if="!canRequestCancellation && !cancellationRequest">
{{ __("Ticket cancellation requests") }}
</li>
<li v-if="!canTransferTickets">{{ __("Ticket transfers") }}</li>
<li v-if="!canChangeAddOns">{{ __("Add-on preference changes") }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import LucideTriangleAlert from "~icons/lucide/triangle-alert";
const props = defineProps({
canRequestCancellation: {
type: Boolean,
default: false,
},
canTransferTickets: {
type: Boolean,
default: false,
},
canChangeAddOns: {
type: Boolean,
default: false,
},
cancellationRequest: {
type: Object,
default: null,
},
});
// Computed property to check if any restrictions exist
const hasRestrictions = computed(() => {
return (
(!props.canRequestCancellation && !props.cancellationRequest) ||
!props.canTransferTickets ||
!props.canChangeAddOns
);
});
</script>
@@ -0,0 +1,261 @@
<template>
<Dialog
v-model="isOpen"
:options="{
title: __('Select Sponsorship Tier'),
size: 'xl',
description: __('Choose your preferred sponsorship tier and proceed to payment'),
}"
>
<template #body-content>
<div v-if="!props.eventId" class="text-center py-8">
<p class="text-ink-gray-5">{{ __("Loading event information...") }}</p>
</div>
<div v-else-if="tiers.loading" class="flex justify-center py-8">
<Spinner />
</div>
<div v-else-if="tiers.data && tiers.data.length > 0" class="space-y-6">
<p
class="text-ink-gray-7"
v-html="
__(
'Select a sponsorship tier for <strong>{0}</strong> and proceed to payment.',
[eventTitle || __('this event')]
)
"
></p>
<!-- Tier Selection -->
<div class="space-y-3">
<div
v-for="tier in tiers.data"
:key="tier.name"
class="border border-outline-gray-2 rounded-lg p-4 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
:class="{
'border-outline-gray-4 bg-surface-gray-2':
selectedTier?.name === tier.name,
}"
@click="selectedTier = tier"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<input
type="radio"
:checked="selectedTier?.name === tier.name"
@change="selectedTier = tier"
class="text-ink-gray-6"
/>
<div>
<h3 class="font-semibold text-ink-gray-9">
{{ tier.title }}
</h3>
</div>
</div>
<div class="text-right">
<p class="font-bold text-lg text-ink-gray-9">
{{ formatCurrency(tier.price, tier.currency) }}
</p>
</div>
</div>
</div>
</div>
<!-- Payment Gateway Selection (only shown when tier is selected and multiple gateways exist) -->
<div v-if="selectedTier && hasMultipleGateways" class="space-y-3">
<h4 class="font-semibold text-ink-gray-8">
{{ __("Select Payment Method") }}
</h4>
<div class="flex flex-wrap gap-3">
<div
v-for="gateway in paymentGateways"
:key="gateway"
class="border border-outline-gray-2 rounded-lg px-4 py-3 cursor-pointer transition-all hover:border-outline-gray-3 hover:bg-surface-gray-1"
:class="{
'border-outline-gray-4 bg-surface-gray-2':
selectedGateway === gateway,
}"
@click="selectedGateway = gateway"
>
<div class="flex items-center space-x-2">
<input
type="radio"
:checked="selectedGateway === gateway"
@change="selectedGateway = gateway"
class="text-ink-gray-6"
/>
<span class="font-medium text-ink-gray-9">{{ gateway }}</span>
</div>
</div>
</div>
</div>
<!-- Selected Summary -->
<div
v-if="selectedTier"
class="p-4 bg-surface-green-1 border border-outline-green-1 rounded-lg"
>
<div class="flex items-center justify-between">
<div>
<h4 class="font-semibold text-ink-green-2">
{{ __("Selected Tier") }}
</h4>
<p class="text-ink-green-2">{{ selectedTier.title }}</p>
</div>
<div class="text-right">
<p class="text-sm text-ink-green-2">{{ __("Total Amount") }}</p>
<p class="font-bold text-xl text-ink-green-2">
{{ formatCurrency(selectedTier.price, selectedTier.currency) }}
</p>
</div>
</div>
</div>
</div>
<div v-else-if="tiers.error" class="text-center py-8">
<p class="text-ink-red-2">{{ __("Error loading sponsorship tiers") }}</p>
<p class="text-ink-gray-5 text-sm">{{ tiers.error }}</p>
</div>
<div v-else class="text-center py-8">
<p class="text-ink-gray-5">
{{ __("No sponsorship tiers available for this event") }}
</p>
</div>
</template>
<template #actions>
<div class="flex justify-end space-x-3">
<Button variant="ghost" @click="closeDialog">{{ __("Cancel") }}</Button>
<Button
variant="solid"
:disabled="!canProceed || paymentLink.loading"
:loading="paymentLink.loading"
@click="proceedToPayment"
>
{{ __("Proceed to Pay") }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { formatCurrency } from "@/utils/currency";
import { Button, Dialog, Spinner, createResource, useList } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
open: {
type: Boolean,
default: false,
},
enquiryId: {
type: String,
required: true,
},
eventId: {
type: String,
required: true,
},
eventTitle: {
type: String,
required: false,
},
});
const emit = defineEmits(["update:open", "payment-started"]);
const isOpen = ref(props.open);
const selectedTier = ref(null);
const selectedGateway = ref(null);
const paymentGateways = ref([]);
const hasMultipleGateways = computed(() => paymentGateways.value.length > 1);
const canProceed = computed(() => {
if (!selectedTier.value) return false;
// If multiple gateways, require gateway selection
if (hasMultipleGateways.value && !selectedGateway.value) return false;
return true;
});
// Watch for prop changes
watch(
() => props.open,
(newVal) => {
isOpen.value = newVal;
if (newVal && props.eventId) {
// Reset selection when dialog opens
selectedTier.value = null;
selectedGateway.value = null;
tiers.fetch();
fetchPaymentGateways();
}
}
);
watch(isOpen, (newVal) => {
emit("update:open", newVal);
});
// Resource to fetch sponsorship tiers
const tiers = useList({
doctype: "Sponsorship Tier",
filters: { event: props.eventId },
fields: ["name", "title", "price", "currency"],
orderBy: "price asc",
onError: console.error,
auto: false, // Don't auto-fetch, we'll fetch manually when dialog opens
});
// Fetch payment gateways for the event
const paymentGatewaysResource = createResource({
url: "buzz.api.get_event_payment_gateways",
onSuccess: (data) => {
paymentGateways.value = data || [];
},
onError: console.error,
});
function fetchPaymentGateways() {
paymentGatewaysResource.submit({
event: props.eventId,
});
}
// Resource to create payment link
const paymentLink = createResource({
url: "buzz.api.create_sponsorship_payment_link",
onSuccess: (paymentUrl) => {
emit("payment-started");
closeDialog();
// Redirect to payment page
window.location.href = paymentUrl;
},
onError: (error) => {
console.error("Payment link creation failed:", error);
// TODO: Show error toast
},
});
const closeDialog = () => {
isOpen.value = false;
selectedTier.value = null;
selectedGateway.value = null;
};
const proceedToPayment = () => {
if (!selectedTier.value) return;
// Use selected gateway or first available (for single gateway case)
const gateway = selectedGateway.value || paymentGateways.value[0] || null;
paymentLink.submit({
enquiry_id: props.enquiryId,
tier_id: selectedTier.value.name,
payment_gateway: gateway,
});
};
</script>
@@ -0,0 +1,51 @@
<template>
<Transition
name="success-message"
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-500 ease-in"
enter-from-class="opacity-0 transform -translate-y-4 scale-95"
enter-to-class="opacity-100 transform translate-y-0 scale-100"
leave-from-class="opacity-100 transform translate-y-0 scale-100"
leave-to-class="opacity-0 transform -translate-y-4 scale-95"
>
<div
v-if="show"
class="mb-6 bg-surface-green-1 border border-outline-green-1 rounded-lg p-4"
>
<div class="flex items-center">
<LucideCheckCircle class="w-6 h-6 text-ink-green-2 mr-3" />
<div>
<h3 class="text-ink-green-3 font-semibold">
{{ __("Payment Successful! 🎉") }}
</h3>
<p class="text-ink-green-2">
{{
isWebinar
? __(
"Your registration has been confirmed. You will receive an invite shortly."
)
: __(
"Your booking has been confirmed. Check your email for tickets and details."
)
}}
</p>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import LucideCheckCircle from "~icons/lucide/check-circle";
const props = defineProps({
show: {
type: Boolean,
default: false,
},
isWebinar: {
type: Boolean,
default: false,
},
});
</script>
+179
View File
@@ -0,0 +1,179 @@
<template>
<li class="shadow-md p-4 rounded-lg bg-surface-white border border-outline-gray-2 relative">
<!-- Status Badge -->
<div v-if="isCancelled || isCancellationRequested" class="absolute top-2 left-2">
<Badge
v-if="isCancelled"
variant="outline"
theme="red"
size="sm"
:label="__('Cancelled')"
/>
<Badge
v-else-if="isCancellationRequested"
variant="subtle"
theme="orange"
size="sm"
:label="__('Cancellation Requested')"
/>
</div>
<!-- Three-dot dropdown menu -->
<div class="absolute top-2 right-2">
<Dropdown :options="ticketActions" placement="left" v-if="ticketActions.length > 0">
<Button variant="ghost" icon="more-horizontal" size="sm" />
</Dropdown>
</div>
<div>
<h4
class="text-md font-semibold text-ink-gray-9"
:class="{ 'mt-6': isCancelled || isCancellationRequested }"
>
{{ ticket.attendee_name }}
</h4>
<p class="text-sm text-ink-gray-7">{{ __("Email") }}: {{ ticket.attendee_email }}</p>
<p
v-if="!['Default', 'Normal'].includes(ticket.ticket_type)"
class="text-sm text-ink-gray-7"
>
{{ __("Ticket Type") }}: {{ ticket.ticket_type }}
</p>
<!-- Add-ons Section -->
<div v-if="ticket.add_ons && ticket.add_ons.length > 0" class="mt-3">
<h5 class="text-sm font-medium text-ink-gray-8 mb-2">{{ __("Add-ons:") }}</h5>
<div class="space-y-3">
<div
v-for="addon in ticket.add_ons"
:key="addon.name"
class="bg-surface-gray-1 px-3 py-2 rounded text-xs"
>
<div class="font-medium text-ink-gray-8 mb-1">{{ addon.title }}</div>
<div v-if="addon.user_selects_option" class="text-ink-gray-7">
{{ addon.value }}
</div>
</div>
</div>
</div>
<div class="mt-3">
<img
:src="ticket.qr_code"
:alt="__('QR Code')"
:title="__('Click to enlarge')"
class="w-20 h-20 contrast-100 brightness-100 cursor-pointer hover:opacity-80 transition-opacity"
@click.stop="showQRExpanded = true"
/>
</div>
</div>
<!-- QR Code Expand Dialog -->
<QRCodeExpandDialog
v-model="showQRExpanded"
:qrCodeSrc="ticket.qr_code"
:altText="__('QR Code')"
/>
<!-- Ticket Transfer Dialog -->
<TicketTransferDialog
v-model="showTransferDialog"
:ticket="ticket"
@success="onTicketTransferSuccess"
/>
<!-- Add-on Preference Dialog -->
<AddOnPreferenceDialog
v-model="showPreferenceDialog"
:ticket="ticket"
@success="onPreferenceChangeSuccess"
/>
</li>
</template>
<script setup>
import { Badge, Button, Dropdown } from "frappe-ui";
import { computed, ref } from "vue";
import LucideEdit from "~icons/lucide/edit";
import LucideUserPen from "~icons/lucide/user-pen";
import AddOnPreferenceDialog from "./AddOnPreferenceDialog.vue";
import QRCodeExpandDialog from "./QRCodeExpandDialog.vue";
import TicketTransferDialog from "./TicketTransferDialog.vue";
const props = defineProps({
ticket: {
type: Object,
required: true,
},
canTransfer: {
type: Boolean,
default: false,
},
canChangeAddOns: {
type: Boolean,
default: false,
},
isCancellationRequested: {
type: Boolean,
default: false,
},
isCancelled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["transfer-success"]);
const showTransferDialog = ref(false);
const showPreferenceDialog = ref(false);
const showQRExpanded = ref(false);
// Check if ticket has customizable add-ons
const hasCustomizableAddOns = computed(() => {
return (
props.ticket?.add_ons?.some((addon) => addon.options && addon.options.length > 0) || false
);
});
const ticketActions = computed(() => {
const actions = [];
// Don't show any actions if ticket is cancelled or has a pending cancellation request
if (props.isCancelled || props.isCancellationRequested) {
return actions;
}
// Only show transfer action if transfers are allowed
if (props.canTransfer) {
actions.push({
label: __("Transfer Ticket"),
icon: LucideUserPen,
onClick: () => {
showTransferDialog.value = true;
},
});
}
// Only show preference action if add-on changes are allowed and ticket has customizable add-ons
if (props.canChangeAddOns && hasCustomizableAddOns.value) {
actions.push({
label: __("Change Add-on Preference"),
icon: LucideEdit,
onClick: () => {
showPreferenceDialog.value = true;
},
});
}
return actions;
});
const onTicketTransferSuccess = () => {
emit("transfer-success");
};
const onPreferenceChangeSuccess = () => {
emit("transfer-success");
};
</script>
@@ -0,0 +1,184 @@
<!-- TODO: This component needs a refactor -->
<template>
<Dialog v-model="showTicketModal">
<template #body-title>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ validationResult ? __("Valid Ticket") : __("Invalid Ticket") }}
</h3>
</template>
<template #body-content>
<!-- Success State -->
<div v-if="validationResult">
<div class="text-center mb-6">
<div
class="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4"
>
<LucideCheckCircle class="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h4 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ __("Valid Ticket") }}
</h4>
<p class="text-gray-600 dark:text-gray-400">{{ __("Ready for check-in") }}</p>
</div>
<div class="space-y-4 mb-6">
<div class="grid grid-cols-2 gap-4">
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Attendee") }}
</h3>
<p class="text-sm text-gray-900 dark:text-white">
{{ validationResult?.ticket?.attendee_name }}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Email") }}
</h3>
<p class="text-sm text-gray-900 dark:text-white">
{{ validationResult?.ticket?.attendee_email }}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Ticket Type") }}
</h3>
<p class="text-sm text-gray-900 dark:text-white">
{{ validationResult?.ticket?.ticket_type }}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Ticket ID") }}
</h3>
<p class="text-sm font-mono text-gray-900 dark:text-white">
{{ validationResult?.ticket?.id }}
</p>
</div>
</div>
<!-- Add-ons -->
<div
v-if="validationResult?.ticket?.add_ons?.length"
class="border-t border-gray-200 dark:border-gray-600 pt-4"
>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ __("Add-ons") }}
</h3>
<div class="space-y-2">
<div
v-for="addon in validationResult?.ticket?.add_ons"
:key="addon.add_on"
class="flex justify-between text-sm"
>
<span class="text-gray-900 dark:text-white"
>{{ __(addon.add_on_title || addon.add_on) }} ({{
formatPriceOrFree(addon.price, addon.currency)
}})</span
>
<span class="text-gray-600 dark:text-gray-400">{{
addon.value
}}</span>
</div>
</div>
</div>
<!-- Payment details -->
<div v-if="validationResult?.payment_details">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Payment Details") }}
</h3>
<div class="grid grid-cols-2 mt-3 text-sm text-gray-900 dark:text-white">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("ID") }}
</p>
<p>{{ validationResult?.payment_details?.name }}</p>
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ __("Amount Paid") }}
</p>
<p>
{{
formatPriceOrFree(
validationResult?.payment_details?.amount,
validationResult?.payment_details?.currency
)
}}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div v-else>
<div class="text-center mb-6">
<div
class="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4"
>
<LucideXCircle class="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h4 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ validationResult?.error || "Invalid Ticket" }}
</h4>
<p class="text-gray-600 dark:text-gray-400">
{{ validationResult?.message }}
</p>
</div>
</div>
</template>
<template #actions>
<div v-if="!validationResult?.ticket?.is_checked_in" class="flex gap-3 flex-col">
<Button @click="handleCheckIn" :loading="isCheckingIn" class="w-full">
<template #prefix>
<LucideUserCheck class="w-4 h-4" />
</template>
{{ __("Check In") }}
</Button>
<Button @click="handleModalClose" variant="outline" class="w-full">
{{ __("Cancel") }}
</Button>
</div>
<!-- TODO: I don't understand why this is here. -->
<div v-else>
<Button @click="handleModalClose" class="w-full" variant="outline">
{{ __("Close") }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { useTicketValidation } from "@/composables/useTicketValidation";
import { formatPriceOrFree } from "@/utils/currency";
import { Button, Dialog } from "frappe-ui";
import LucideCheckCircle from "~icons/lucide/check-circle";
import LucideUserCheck from "~icons/lucide/user-check";
import LucideXCircle from "~icons/lucide/x-circle";
const props = defineProps({
selectedEvent: {
type: Object,
default: null,
},
});
const { showTicketModal, isCheckingIn, validationResult, checkInTicket, closeModal } =
useTicketValidation();
const handleCheckIn = () => {
checkInTicket();
};
const handleModalClose = () => {
closeModal();
};
</script>
@@ -0,0 +1,126 @@
<template>
<Dialog v-model="isOpen">
<template #body-title>
<h3 class="text-xl font-semibold text-ink-gray-9">{{ __("Transfer Ticket") }}</h3>
</template>
<template #body-content>
<div class="space-y-4">
<p class="text-ink-gray-7">
{{
__(
"Transfer this ticket to a new attendee. The new attendee will receive the updated ticket information."
)
}}
</p>
<FormControl
type="text"
:label="__('First Name')"
:placeholder="__('Enter first name')"
v-model="transferForm.first_name"
:required="true"
/>
<FormControl
type="text"
:label="__('Last Name')"
:placeholder="__('Enter last name')"
v-model="transferForm.last_name"
/>
<FormControl
type="email"
:label="__('New Attendee Email')"
:placeholder="__('Enter email address')"
v-model="transferForm.email"
:required="true"
/>
</div>
</template>
<template #actions="{ close }">
<div class="flex gap-2">
<Button
variant="solid"
@click="handleTransferTicket"
:loading="transferResource.loading"
:disabled="!transferForm.first_name || !transferForm.email"
>
{{ __("Transfer Ticket") }}
</Button>
<Button variant="outline" @click="close"> {{ __("Cancel") }} </Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog, FormControl, createResource, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
ticket: {
type: Object,
default: null,
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const transferForm = ref({
first_name: "",
last_name: "",
email: "",
});
// Transfer ticket resource
const transferResource = createResource({
url: "buzz.api.transfer_ticket",
onSuccess: () => {
toast.success(__("Ticket transferred successfully!"));
isOpen.value = false;
resetTransferForm();
emit("success");
},
onError: (error) => {
toast.error(`${__("Failed to transfer ticket")}: ${error.message}`);
},
});
const handleTransferTicket = () => {
if (!props.ticket || !transferForm.value.first_name || !transferForm.value.email) {
toast.error(__("Please fill in all required fields"));
return;
}
transferResource.submit({
ticket_id: props.ticket.name,
new_first_name: transferForm.value.first_name,
new_last_name: transferForm.value.last_name || "",
new_email: transferForm.value.email,
});
};
const resetTransferForm = () => {
transferForm.value = {
first_name: "",
last_name: "",
email: "",
};
};
// Reset form when dialog is closed
watch(isOpen, (newValue) => {
if (!newValue) {
resetTransferForm();
}
});
</script>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="bg-surface-cards border border-outline-gray-1 rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-ink-gray-9">{{ __("Your Tickets") }}</h3>
<!-- Request Cancellation Button -->
<Button
v-if="showCancellationButton"
variant="subtle"
@click="$emit('request-cancellation')"
>
{{
cancellationRequest
? __("Request More Cancellations")
: __("Request Cancellation")
}}
</Button>
</div>
<!-- Tickets Grid -->
<ol class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TicketCard
v-for="ticket in tickets"
:key="ticket.name"
:ticket="ticket"
:can-transfer="canTransferTickets"
:can-change-add-ons="canChangeAddOns"
:is-cancellation-requested="isCancellationRequestedTicket(ticket.name)"
:is-cancelled="isCancelledTicket(ticket.name)"
@transfer-success="$emit('transfer-success')"
/>
</ol>
</div>
</template>
<script setup>
import { Button } from "frappe-ui";
import { computed } from "vue";
import TicketCard from "./TicketCard.vue";
const props = defineProps({
tickets: {
type: Array,
required: true,
},
canRequestCancellation: {
type: Boolean,
default: false,
},
canTransferTickets: {
type: Boolean,
default: false,
},
canChangeAddOns: {
type: Boolean,
default: false,
},
cancellationRequest: {
type: Object,
default: null,
},
cancellationRequestedTickets: {
type: Array,
default: () => [],
},
cancelledTickets: {
type: Array,
default: () => [],
},
});
defineEmits(["request-cancellation", "transfer-success"]);
// Check if there are any tickets that can still be cancelled
const hasTicketsAvailableForCancellation = computed(() => {
return props.tickets.some(
(ticket) =>
!props.cancelledTickets.includes(ticket.name) &&
!props.cancellationRequestedTickets.includes(ticket.name)
);
});
// Show cancellation button if:
// 1. Cancellation is allowed
// 2. Either there's no existing request OR there are still tickets available for cancellation
const showCancellationButton = computed(() => {
return (
props.canRequestCancellation &&
(!props.cancellationRequest || hasTicketsAvailableForCancellation.value)
);
});
const isCancellationRequestedTicket = (ticketId) => {
return props.cancellationRequestedTickets?.includes(ticketId) || false;
};
const isCancelledTicket = (ticketId) => {
return props.cancelledTickets?.includes(ticketId) || false;
};
</script>
@@ -0,0 +1,113 @@
<template>
<Dialog v-model="isOpen">
<template #body-title>
<h3 class="text-xl font-semibold text-ink-gray-9">Transfer Ticket</h3>
</template>
<template #body-content>
<div class="space-y-4">
<p class="text-ink-gray-7">
Transfer this ticket to a new attendee. The new attendee will receive the
updated ticket information.
</p>
<FormControl
type="text"
label="New Attendee Name"
placeholder="Enter full name"
v-model="transferForm.name"
:required="true"
/>
<FormControl
type="email"
label="New Attendee Email"
placeholder="Enter email address"
v-model="transferForm.email"
:required="true"
/>
</div>
</template>
<template #actions="{ close }">
<div class="flex gap-2">
<Button
variant="solid"
@click="handleTransferTicket"
:loading="transferResource.loading"
:disabled="!transferForm.name || !transferForm.email"
>
Transfer Ticket
</Button>
<Button variant="outline" @click="close"> Cancel </Button>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Button, Dialog, FormControl, createResource, toast } from "frappe-ui";
import { computed, ref, watch } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
ticket: {
type: Object,
default: null,
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const transferForm = ref({
name: "",
email: "",
});
// Transfer ticket resource
const transferResource = createResource({
url: "buzz.api.transfer_ticket",
onSuccess: () => {
toast.success("Ticket transferred successfully!");
isOpen.value = false;
resetTransferForm();
emit("success");
},
onError: (error) => {
toast.error(`Failed to transfer ticket: ${error.message}`);
},
});
const handleTransferTicket = () => {
if (!props.ticket || !transferForm.value.name || !transferForm.value.email) {
toast.error("Please fill in all required fields");
return;
}
transferResource.submit({
ticket_id: props.ticket.name,
new_name: transferForm.value.name,
new_email: transferForm.value.email,
});
};
const resetTransferForm = () => {
transferForm.value = {
name: "",
email: "",
};
};
// Reset form when dialog is closed
watch(isOpen, (newValue) => {
if (!newValue) {
resetTransferForm();
}
});
</script>
@@ -0,0 +1,20 @@
<template>
<Button variant="ghost" icon-left="chevron-left" :route="to" @click="$emit('click', $event)">
{{ label }}
</Button>
</template>
<script setup>
defineProps({
to: {
type: [String, Object],
default: null,
},
label: {
type: String,
default: "Back",
},
});
defineEmits(["click"]);
</script>
@@ -0,0 +1,20 @@
<template>
<svg fill="none" viewBox="0 0 36 28" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 26.7986V0.919846H3.87301C5.18748 0.919846 6.32005 1.15457 7.27069 1.62403C8.23308 2.08175 8.97247 2.80354 9.48887 3.78939C10.017 4.77525 10.2811 6.04865 10.2811 7.60959C10.2811 8.85364 10.1168 9.93926 9.78815 10.8664C9.47127 11.7936 8.84924 12.5506 7.92206 13.1374C8.86097 13.6538 9.53582 14.4754 9.94659 15.6021C10.3574 16.717 10.5628 18.008 10.5628 19.4751C10.5628 20.9187 10.328 22.1921 9.85857 23.2953C9.40085 24.3985 8.72014 25.2611 7.81644 25.8831C6.92447 26.4934 5.82125 26.7986 4.50677 26.7986H0ZM3.80259 23.0312C4.95276 23.0312 5.70975 22.6908 6.07358 22.0101C6.43741 21.3177 6.61932 20.3318 6.61932 19.0526C6.61932 17.7498 6.43741 16.764 6.07358 16.095C5.70975 15.4143 4.95276 15.0739 3.80259 15.0739V23.0312ZM3.80259 11.5882C4.8706 11.5882 5.60413 11.2948 6.00316 10.708C6.41394 10.1212 6.61932 9.25268 6.61932 8.10252C6.61932 6.94061 6.40807 6.08386 5.98556 5.53225C5.57478 4.9689 4.84713 4.68723 3.80259 4.68723V11.5882Z"
fill="currentColor"
></path>
<path
d="M13.9194 27.0803C13.3326 27.0803 12.8397 26.8925 12.4407 26.5169C12.0416 26.1413 11.7423 25.666 11.5428 25.0909C11.3433 24.5041 11.2436 23.9114 11.2436 23.3129V7.53917H14.7997V22.6087C14.7997 22.9725 14.8642 23.2366 14.9933 23.4009C15.1342 23.5535 15.3513 23.6298 15.6447 23.6298C15.9029 23.6298 16.12 23.5417 16.2961 23.3657C16.4839 23.1779 16.654 22.9608 16.8066 22.7143V7.53917H20.3627V26.7986H16.8066V25.179C16.3841 25.578 15.9088 25.9946 15.3806 26.4289C14.8525 26.8631 14.3654 27.0803 13.9194 27.0803Z"
fill="currentColor"
></path>
<path
d="M21.1461 26.7986V23.9114L26.0402 10.7784H21.1461V7.53917H29.8076V10.4263L24.9135 23.5593H29.8076V26.7986H21.1461Z"
fill="currentColor"
></path>
<path
d="M27.3385 26.7986V23.9114L32.2326 10.7784H28.1131V7.53917H36V10.4263L31.1059 23.5593H36V26.7986H27.3385Z"
fill="currentColor"
></path>
</svg>
</template>
@@ -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,
}
}
@@ -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 ""
}
+41
View File
@@ -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<string>
changeLanguage: (languageCode: string) => void
isSwitching: ComputedRef<boolean>
}
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),
}
}
@@ -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 }
}
@@ -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<boolean>
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,
}
}
@@ -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<boolean>
isCheckingIn: Ref<boolean>
validationResult: Ref<ValidationResult | null>
showTicketModal: Ref<boolean>
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<ValidationResult | null>(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
}
+46
View File
@@ -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),
})
+6
View File
@@ -0,0 +1,6 @@
import { createResource } from "frappe-ui"
export const userResource = createResource({
url: "event_manager.api.get_user_info",
cache: "User",
})
+28
View File
@@ -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
}
}
+7
View File
@@ -0,0 +1,7 @@
@import "./assets/Inter/inter.css";
@import "frappe-ui/style.css";
/* Fix tabs spacing */
[role="tablist"] {
gap: 1rem;
}

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