Initialize fork and rebrand app to event_manager
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -0,0 +1,50 @@
|
||||
# Frappe UI Starter
|
||||
|
||||
This template should help get you started developing custom frontend for Frappe
|
||||
apps with Vue 3 and the Frappe UI package.
|
||||
|
||||

|
||||
|
||||

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