From fe71b831821668204cb014f5e6f023dc9b1accba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 07:50:44 +0000 Subject: [PATCH 1/3] feat: sync coupon code with URL query param So users can share a booking link like ?coupon=FOO and have it auto-applied, or see the applied coupon reflected back in the URL. --- dashboard/src/components/BookingForm.vue | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 642dd76..66da480 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -822,6 +822,13 @@ onMounted(async () => { attendees.value = [newAttendee]; } + + // Pre-fill and auto-apply coupon from ?coupon= query param + const couponFromQuery = route.query.coupon; + if (typeof couponFromQuery === "string" && couponFromQuery.trim() && !couponApplied.value) { + couponCode.value = couponFromQuery.trim(); + await applyCoupon(); + } }); // Ensure existing attendees have proper add-on structure when availableAddOns changes @@ -1018,6 +1025,8 @@ async function applyCoupon() { }; // Info panel shows details - no toast needed } + + syncCouponToQuery(couponCode.value.trim()); } else { couponApplied.value = false; couponData.value = null; @@ -1030,6 +1039,21 @@ function removeCoupon() { couponApplied.value = false; couponData.value = null; couponError.value = ""; + syncCouponToQuery(null); +} + +function syncCouponToQuery(value) { + const currentQuery = route.query; + const currentCoupon = typeof currentQuery.coupon === "string" ? currentQuery.coupon : undefined; + if ((value || undefined) === currentCoupon) return; + + const nextQuery = { ...currentQuery }; + if (value) { + nextQuery.coupon = value; + } else { + delete nextQuery.coupon; + } + router.replace({ query: nextQuery }); } // --- FORM VALIDATION --- From 532f6a4382046bbb32595613e78faf02d0ce2c82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 08:19:12 +0000 Subject: [PATCH 2/3] refactor: use useRouteQuery for coupon URL sync Replaces manual router.replace glue with @vueuse/router's useRouteQuery so the applied coupon is a reactive ref that reads from and writes back to ?coupon=. --- dashboard/package.json | 1 + dashboard/src/components/BookingForm.vue | 28 +++++++----------------- dashboard/yarn.lock | 12 ++++++++++ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/dashboard/package.json b/dashboard/package.json index 13dbf8f..8b1f397 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -13,6 +13,7 @@ }, "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", diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 66da480..7a74d32 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -404,6 +404,7 @@ import { clearBookingCache } from "@/utils/index"; import { FormControl, createResource, toast } from "frappe-ui"; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; +import { useRouteQuery } from "@vueuse/router"; import LucideAlertCircle from "~icons/lucide/alert-circle"; import LucideCheck from "~icons/lucide/check"; import LucideCheckCircle from "~icons/lucide/check-circle"; @@ -518,7 +519,8 @@ const activeOfflineCustomFields = computed(() => { return selectedOfflineMethod.value.custom_fields || []; }); -// Coupon state +// Coupon state — `appliedCouponQuery` keeps the URL in sync with the applied coupon +const appliedCouponQuery = useRouteQuery("coupon", null); const couponCode = ref(""); const couponApplied = ref(false); const couponError = ref(""); @@ -824,9 +826,9 @@ onMounted(async () => { } // Pre-fill and auto-apply coupon from ?coupon= query param - const couponFromQuery = route.query.coupon; - if (typeof couponFromQuery === "string" && couponFromQuery.trim() && !couponApplied.value) { - couponCode.value = couponFromQuery.trim(); + const initialCoupon = appliedCouponQuery.value; + if (typeof initialCoupon === "string" && initialCoupon.trim() && !couponApplied.value) { + couponCode.value = initialCoupon.trim(); await applyCoupon(); } }); @@ -1026,7 +1028,7 @@ async function applyCoupon() { // Info panel shows details - no toast needed } - syncCouponToQuery(couponCode.value.trim()); + appliedCouponQuery.value = couponCode.value.trim(); } else { couponApplied.value = false; couponData.value = null; @@ -1039,21 +1041,7 @@ function removeCoupon() { couponApplied.value = false; couponData.value = null; couponError.value = ""; - syncCouponToQuery(null); -} - -function syncCouponToQuery(value) { - const currentQuery = route.query; - const currentCoupon = typeof currentQuery.coupon === "string" ? currentQuery.coupon : undefined; - if ((value || undefined) === currentCoupon) return; - - const nextQuery = { ...currentQuery }; - if (value) { - nextQuery.coupon = value; - } else { - delete nextQuery.coupon; - } - router.replace({ query: nextQuery }); + appliedCouponQuery.value = null; } // --- FORM VALIDATION --- diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 6f6dc30..7e09659 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -1147,6 +1147,13 @@ resolved "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.6.0.tgz" integrity sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ== +"@vueuse/router@^13.6.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-13.9.0.tgz#44235e6732a30b53d1c8e2ef13ce783fdd189ca6" + integrity sha512-7AYay8Pv/0fC4D0eygbIyZuLyVs+9D7dsnO5D8aqat9qcOz91v/XFWR667WE1+p+OkU0ib+FjQUdnTVBNoIw8g== + dependencies: + "@vueuse/shared" "13.9.0" + "@vueuse/shared@10.11.1", "@vueuse/shared@^10.5.0": version "10.11.1" resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz" @@ -1166,6 +1173,11 @@ resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-13.6.0.tgz" integrity sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg== +"@vueuse/shared@13.9.0": + version "13.9.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.9.0.tgz#7168b4ed647e625b05eb4e7e80fe8aabd00e3923" + integrity sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g== + acorn@^8.14.0, acorn@^8.14.1, acorn@^8.15.0: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" From c92af3e1b52ada191e56c63507d3b9112c212054 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 08:23:24 +0000 Subject: [PATCH 3/3] feat: normalize coupon code to uppercase on the frontend The applied card and the ?coupon= URL always show the canonical uppercase form, and the input field visually uppercases as the user types. --- dashboard/src/components/BookingForm.vue | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 7a74d32..13b0f8a 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -217,7 +217,7 @@ v-model="couponCode" :placeholder="__('Enter code')" :aria-label="__('Coupon code')" - class="flex-1" + class="flex-1 uppercase" @keyup.enter="applyCoupon" />