From 3f949b4e3515eba90f7dbb90d0f10bca9e36e586 Mon Sep 17 00:00:00 2001 From: Clara Date: Sun, 27 Oct 2024 15:05:13 +0000 Subject: [PATCH 1/9] feat: Fetch Booking Fees for Display (#688) * Initial work to fetch fees * Write some tests for the new miscCostsDisplay * Remove some console logging, fix an issue where production pages aren't reloaded after status change * Remove some unneccesary testing changes --- components/production/ProductionBanner.vue | 166 +++++++++++------- graphql/queries/MiscCosts.gql | 12 ++ .../productions/[productionSlug]/edit.vue | 7 +- .../productions/[productionSlug]/index.vue | 24 ++- .../[productionSlug]/performances/create.vue | 2 +- .../[productionSlug]/permissions.vue | 1 - .../production/ProductionBanner.spec.js | 101 ++++++++++- tests/unit/pages/Home.spec.js | 6 - tests/unit/support/fixtures/MiscCost.js | 1 - 9 files changed, 236 insertions(+), 84 deletions(-) create mode 100644 graphql/queries/MiscCosts.gql diff --git a/components/production/ProductionBanner.vue b/components/production/ProductionBanner.vue index b5c32f8f..31bfb829 100644 --- a/components/production/ProductionBanner.vue +++ b/components/production/ProductionBanner.vue @@ -64,8 +64,8 @@ - diff --git a/graphql/queries/MiscCosts.gql b/graphql/queries/MiscCosts.gql new file mode 100644 index 00000000..c54b5eb8 --- /dev/null +++ b/graphql/queries/MiscCosts.gql @@ -0,0 +1,12 @@ +query miscCosts { + miscCosts { + edges { + node { + name + description + percentage + value + } + } + } +} diff --git a/pages/administration/productions/[productionSlug]/edit.vue b/pages/administration/productions/[productionSlug]/edit.vue index a3c27bb8..6829020c 100644 --- a/pages/administration/productions/[productionSlug]/edit.vue +++ b/pages/administration/productions/[productionSlug]/edit.vue @@ -67,11 +67,12 @@ export default defineNuxtComponent({ const { data } = await this.$apollo.query({ query: AdminProductionEditQuery, variables: { - slug: this.production.slug - } + slug: await this.production.slug + }, + fetchPolicy: 'no-cache' }); this.production = data.production; - useRouter().replace( + useRouter().navigateTo( `/administration/productions/${this.production.slug}` ); successToast.fire({ title: 'Production Updated' }); diff --git a/pages/administration/productions/[productionSlug]/index.vue b/pages/administration/productions/[productionSlug]/index.vue index 2b32371c..adfd2752 100644 --- a/pages/administration/productions/[productionSlug]/index.vue +++ b/pages/administration/productions/[productionSlug]/index.vue @@ -206,16 +206,16 @@ export default defineNuxtComponent({ }, async asyncData() { // Execute query - const { data } = await useAsyncQuery({ + const { data } = await useDefaultApolloClient().query({ query: AdminProductionShowQuery, variables: { slug: useRoute().params.productionSlug }, - fetchPolicy: 'no-cache' + fetchPolicy: 'no-cache', + server: false }); - - const production = computed(() => data.value.production); - if (!production.value) { + const production = data.production; + if (!production) { throw createSafeError({ statusCode: 404, message: 'This production does not exist' @@ -243,7 +243,7 @@ export default defineNuxtComponent({ }; }, update: (data) => data.production.performances, - fetchPolicy: 'cache-and-network' + fetchPolicy: 'no-cache' } }, computed: { @@ -380,6 +380,16 @@ export default defineNuxtComponent({ }, 'setProductionStatus' ); + + const { data } = await useDefaultApolloClient().query({ + query: AdminProductionShowQuery, + variables: { + slug: useRoute().params.productionSlug + }, + fetchPolicy: 'no-cache' + }); + + this.production = data.production; } catch (e) { const errors = getValidationErrors(e); swal.fire({ @@ -390,7 +400,7 @@ export default defineNuxtComponent({ }); return; } - await refreshNuxtData(); + successToast.fire({ title: 'Status updated' }); } } diff --git a/pages/administration/productions/[productionSlug]/performances/create.vue b/pages/administration/productions/[productionSlug]/performances/create.vue index 346fb204..e8da58c6 100644 --- a/pages/administration/productions/[productionSlug]/performances/create.vue +++ b/pages/administration/productions/[productionSlug]/performances/create.vue @@ -4,7 +4,7 @@ Create - Cancel + Cancel { - await createWithPerformances([{}], { + await createWithPerformances([{}], [], { isBookable: false }); expect(fixTextSpacing(headerContainer.text())).not.to.contain( @@ -221,12 +221,12 @@ describe('ProductionBanner', function () { }); it('doesnt show buy tickets button when told to not be present', async () => { - await createWithPerformances([{}], {}, false); + await createWithPerformances([{}], [], {}, false); expect(headerContainer.find('button').exists()).to.be.false; }); it('doesnt show detailed data when told to not show', async () => { - await createWithPerformances([{}], {}, false, false); + await createWithPerformances([{}], [], {}, false, false); expect(headerContainer.text()).to.contain('Legally Ginger'); expect(fixTextSpacing(headerContainer.text())).to.contain('by STA'); @@ -245,8 +245,98 @@ describe('ProductionBanner', function () { ); }); + describe('Production Fee Display', function () { + it('doesnt display fees if none exist', async () => { + await createWithPerformances([]); + + expect(headerContainer.vm.miscCostsDisplay).to.equal(''); + expect(headerContainer.text()).to.not.contain('(exc. fees)'); + }); + + it('doesnt display if tickets are free', async () => { + await createWithPerformances( + [{}], + [ + { + id: 1, + name: 'Booking Fee', + description: 'Supports theatre maintainance and website', + percentage: 0.05 + } + ], + { + minSeatPrice: null + } + ); + + expect(headerContainer.text()).to.not.contain('(exc. fees)'); + }); + + const feeTestCases = [ + { + a: [0.05, null], + b: [null, null], + expected: '5%', + testMessage: 'percentage only' + }, + { + a: [null, 100], + b: [null, null], + expected: '£1', + testMessage: 'fixed fee only' + }, + { + a: [0.05, null], + b: [null, 100], + expected: '5% + £1', + testMessage: 'percentage and fixed fee, handling £s' + }, + { + a: [0.1, null], + b: [null, 25], + expected: '10% + 25p', + testMessage: 'percentage and fixed fee, handling pence' + }, + { + a: [null, 50], + b: [0.05, null], + expected: '5% + 50p', + testMessage: 'percentage and fixed fee, while order agnostic' + } + ]; + + it.each(feeTestCases)( + 'calculates correct fee with $testMessage', + async ({ a, b, expected }) => { + await createWithPerformances( + [{}], + [ + { + id: 1, + name: 'Theatre Improvement Levy', + description: 'Makes the STA stonks', + percentage: a[0], + value: a[1] + }, + { + id: 2, + name: 'Booking Fee', + description: 'Oh no Square charges us money', + percentage: b[0], + value: b[1] + } + ] + ); + + expect(headerContainer.vm.miscCostsDisplay).to.equal(expected); + expect(headerContainer.text()).to.contain('(exc. fees)'); + } + ); + }); + const createWithPerformances = async ( performances, + miscCostsData = [], productionOverrides, showBuyTicketsButton = true, showDetailedInfo = true @@ -262,6 +352,11 @@ describe('ProductionBanner', function () { production, showBuyTicketsButton, showDetailedInfo + }, + data() { + return { + miscCosts: miscCostsData + }; } }); }; diff --git a/tests/unit/pages/Home.spec.js b/tests/unit/pages/Home.spec.js index 6a7bc8a6..68e70251 100644 --- a/tests/unit/pages/Home.spec.js +++ b/tests/unit/pages/Home.spec.js @@ -117,12 +117,6 @@ describe('Home', function () { homepageComponent = await mountComponent(); await homepageComponent.vm.$nextTick(); - console.log(homepageComponent.vm.userBookingsResult.me?.bookings?.edges); - console.log( - homepageComponent.vm.userBookingsResult.me?.bookings?.edges[0].node - .performance.start - ); - expect(homepageComponent.findComponent(BookingHomepageOverview).exists()) .to.be.true; }); diff --git a/tests/unit/support/fixtures/MiscCost.js b/tests/unit/support/fixtures/MiscCost.js index 6ffd7d67..d6b12f02 100644 --- a/tests/unit/support/fixtures/MiscCost.js +++ b/tests/unit/support/fixtures/MiscCost.js @@ -4,7 +4,6 @@ export default (overrides = {}) => { id: 1, name: 'Booking Fee', description: 'Supports theatre maintainance and website', - percentage: 0.05, value: 5 }, overrides From 235fb7a99ba0c393f6cabe2d0fb864b5dcbe72eb Mon Sep 17 00:00:00 2001 From: Clara Date: Sun, 27 Oct 2024 16:24:58 +0000 Subject: [PATCH 2/9] repo: Create a Repository PR Template (#691) --- .github/pull_request_template.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..0773f18a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## PR Steps + +These steps are to ensure you correctly make a PR. _Please delete them before submitting the PR!_ + +1. Test and lint your code locally with `yarn pr` before opening a PR to save yourself time! +2. For almost all development, you should be targeting `develop` rather than `main`. Only releases target `main`! +3. Give your PR a descriptive name, and prefix that name with one of the following options: + - `fix:` for bug fixes + - `feat:` for new features + - `repo:` for repository fixes + - `deps:` for dependency updates + - If your PR does more than one of the above, focus on the primary purpose of your PR. E.g. if you have fixed small bugs in the process of implementing a feature, use `feat:` +4. Tag your PR! It makes it easier to know what's going on at a glance +5. Assign yourself to make clear you're working on it + +## Description + +A clear and concise description of the changes this PR makes. + +## PR Links + +- [Example](https://www.example.com) + +- Resolves # (issue) From cb985adf9ccc598137e275b3a2923932cc7e69ba Mon Sep 17 00:00:00 2001 From: qx22350 <55670585+zst-c@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:55:59 +0000 Subject: [PATCH 3/9] fix: Fix ticket scanner loading (#729) This fixes the fact that the box office ticket scanner, when loaded on mobile, would be stuck on the loading screen indefinitely. --- components/ui/Input/UiInputTicketScanner.client.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ui/Input/UiInputTicketScanner.client.vue b/components/ui/Input/UiInputTicketScanner.client.vue index 3103ae86..e2bedba2 100644 --- a/components/ui/Input/UiInputTicketScanner.client.vue +++ b/components/ui/Input/UiInputTicketScanner.client.vue @@ -2,8 +2,8 @@ @@ -56,7 +56,7 @@ function onTrackEvent(detectedCodes: any, ctx: any) { } } -async function onInit(promise: Promise) { +async function cameraOn(promise: Promise) { try { await promise; emit('ready'); @@ -90,7 +90,7 @@ async function onInit(promise: Promise) { emit('unable', errorMessage); } } -function onDecode(string: string) { +function onDetect(string: string) { new Audio('/audio/beep_single.mp3').play(); try { From c4b88683745cf7533e2b3e5ad8024c76ed527279 Mon Sep 17 00:00:00 2001 From: qx22350 <55670585+zst-c@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:56:35 +0000 Subject: [PATCH 4/9] fix: Add SameSite attribute to the cookie (#728) This implements a strict SameSite policy for our auth.ts cookies. --- store/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/store/auth.ts b/store/auth.ts index 32f10fd6..9f2ed33a 100644 --- a/store/auth.ts +++ b/store/auth.ts @@ -182,7 +182,8 @@ const useAuthStore = defineStore('auth', { // If the user wants to be remembered, we'll store a cookie to remember this preference if (remember) { cookie.set(runtimeConfig.public.auth.rememberKey, 'true', { - expires: remember ? rememberLengthDays : undefined + expires: remember ? rememberLengthDays : undefined, + sameSite: 'Strict' }); } else if ( remember === false && @@ -194,7 +195,8 @@ const useAuthStore = defineStore('auth', { // Finally, we'll set a cookie with the refresh token. If the user wants to be remembered, we'll make this expire in 1 year, otherwise it will expire with the session cookie.set(runtimeConfig.public.auth.refreshTokenKey, refreshToken, { - expires: this.isRemembering() ? rememberLengthDays : undefined + expires: this.isRemembering() ? rememberLengthDays : undefined, + sameSite: 'Strict' }); }, From 625051d7d7821a558d571041942d1cf75cbf69dd Mon Sep 17 00:00:00 2001 From: qx22350 <55670585+zst-c@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:14:26 +0000 Subject: [PATCH 5/9] Fix QR code scanner (#744) * Fix QR code scanner * Fix Linting * Fix failing tests * Remove remnant console.log --------- > # Description > > This fixes the issue with the QR code scanner, ever since the vue QR input scanner started returning objects rather than strings. See the documentation below for information. > > Also shows that we need new unit tests for this area! > ## PR Links > > * [Documentation](https://gruhn.github.io/vue-qrcode-reader/api/QrcodeCapture.html#decode) > > * Resolves ['Loading Scanner' Loading Screen Stuck in Box Office #721](https://github.com/BristolSTA/uobtheatre-web/issues/721) Co-authored-by: Clara --- classes/Ticket.ts | 6 +++--- components/ui/Input/UiInputTicketScanner.client.vue | 2 +- composables/useHardwareTicketScanner.ts | 2 +- pages/box-office/[performanceId]/index.vue | 2 +- tests/unit/classes/Ticket.spec.js | 8 +++++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/classes/Ticket.ts b/classes/Ticket.ts index 2a6a3e4d..fe53e391 100644 --- a/classes/Ticket.ts +++ b/classes/Ticket.ts @@ -54,10 +54,10 @@ export default class { return ticket; } - static dataFromQRCode(rawQRCode: string) { + static dataFromQRCode(detectedCodes: [string]) { try { - const result = JSON.parse(atob(rawQRCode)); - + const rawValue = JSON.parse(JSON.stringify(detectedCodes[0])).rawValue; + const result = JSON.parse(atob(rawValue)); return { bookingReference: result[0], ticketId: result[1] diff --git a/components/ui/Input/UiInputTicketScanner.client.vue b/components/ui/Input/UiInputTicketScanner.client.vue index e2bedba2..989ec18c 100644 --- a/components/ui/Input/UiInputTicketScanner.client.vue +++ b/components/ui/Input/UiInputTicketScanner.client.vue @@ -90,7 +90,7 @@ async function cameraOn(promise: Promise) { emit('unable', errorMessage); } } -function onDetect(string: string) { +function onDetect(string: [string]) { new Audio('/audio/beep_single.mp3').play(); try { diff --git a/composables/useHardwareTicketScanner.ts b/composables/useHardwareTicketScanner.ts index bf2ca0a5..425eae4d 100644 --- a/composables/useHardwareTicketScanner.ts +++ b/composables/useHardwareTicketScanner.ts @@ -12,7 +12,7 @@ export default function useHardwareTicketScanner() { if (!value) return (ticketDetails.value = undefined); try { - ticketDetails.value = Ticket.dataFromQRCode(value); + ticketDetails.value = Ticket.dataFromQRCode([value]); } catch (e) { if (e instanceof InvalidTicketQRCodeException) { return (isInvalid.value = true); diff --git a/pages/box-office/[performanceId]/index.vue b/pages/box-office/[performanceId]/index.vue index a4d23973..d378c6a3 100644 --- a/pages/box-office/[performanceId]/index.vue +++ b/pages/box-office/[performanceId]/index.vue @@ -149,7 +149,7 @@ watch(scannedCode, async (newValue) => { try { // Convert the scanned text into ticket data - const ticketDetails = Ticket.dataFromQRCode(newValue); + const ticketDetails = Ticket.dataFromQRCode([newValue]); // Do the scan action as appropriate let state = await handleTicketScan( diff --git a/tests/unit/classes/Ticket.spec.js b/tests/unit/classes/Ticket.spec.js index 58a94154..3f88c88d 100644 --- a/tests/unit/classes/Ticket.spec.js +++ b/tests/unit/classes/Ticket.spec.js @@ -103,7 +103,13 @@ describe('Ticket Class', () => { }); it('can get data from a QR code', () => { - expect(Ticket.dataFromQRCode('WyJhYmNkMTIzNCIsMl0')).to.include({ + expect( + Ticket.dataFromQRCode([ + { + rawValue: 'WyJhYmNkMTIzNCIsMl0=' + } + ]) + ).to.include({ bookingReference: 'abcd1234', ticketId: 2 }); From e320bb23a303a69cb35aad6ccb7f57ce711fcd6f Mon Sep 17 00:00:00 2001 From: Clara Date: Tue, 19 Nov 2024 11:21:11 +0000 Subject: [PATCH 6/9] feat: Maintenance banners to alert users of upcoming work (#565) * Mock up a draft maintenance warning banner * Allow the user to dismiss a banner alert, and store that dismissal as a cookie * Create a typeMap for different types of banner alerts * Include banner title in typeMap, distinguish upcoming/ongoing for maintenance * Start to fold in API data * Tweak the query staging for the banner * Continue some API work * Use new API filters, set cookies nicely * Should probably write a test * Oops * Significantly more testing work * Further test work * Finish testing, ready for review * Final restyling * Goodbye GitLens --- components/layout/LayoutMaintenanceBanner.vue | 182 ++++++++++++++++ components/ui/UiStaButton.vue | 11 +- graphql/queries/AllSiteMessages.gql | 24 +++ graphql/queries/UpcomingSiteMessages.gql | 34 +++ layouts/default.vue | 1 + tailwind.config.ts | 1 + .../layout/MaintenanceBanner.spec.js | 197 ++++++++++++++++++ .../production/ProductionBanner.spec.js | 5 + .../production/ProductionCastCredits.spec.js | 5 + .../ProductionDraftWarningBanner.spec.js | 5 + tests/unit/support/fixtures/SiteMessage.js | 30 +++ 11 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 components/layout/LayoutMaintenanceBanner.vue create mode 100644 graphql/queries/AllSiteMessages.gql create mode 100644 graphql/queries/UpcomingSiteMessages.gql create mode 100644 tests/unit/components/layout/MaintenanceBanner.spec.js create mode 100644 tests/unit/support/fixtures/SiteMessage.js diff --git a/components/layout/LayoutMaintenanceBanner.vue b/components/layout/LayoutMaintenanceBanner.vue new file mode 100644 index 00000000..e36b19a9 --- /dev/null +++ b/components/layout/LayoutMaintenanceBanner.vue @@ -0,0 +1,182 @@ + + + diff --git a/components/ui/UiStaButton.vue b/components/ui/UiStaButton.vue index b59a6ba9..dd3d8363 100644 --- a/components/ui/UiStaButton.vue +++ b/components/ui/UiStaButton.vue @@ -53,13 +53,18 @@ export default { classes() { const arr = []; arr.push(!this.small ? 'p-2' : 'p-1 text-sm'); + arr.push( + Array.isArray(this.colour) + ? `bg-${this.colour[0]}` + : `bg-sta-${this.colour}` + ); if (this.disabled) { - arr.push('cursor-not-allowed bg-gray-600'); + arr.push('cursor-not-allowed'); } else if (this.colour) { arr.push( Array.isArray(this.colour) - ? `bg-${this.colour[0]} hover:bg-${this.colour[1]}` - : `bg-sta-${this.colour} hover:bg-sta-${this.colour}-dark` + ? `hover:bg-${this.colour[1]}` + : `hover:bg-sta-${this.colour}-dark` ); } return arr; diff --git a/graphql/queries/AllSiteMessages.gql b/graphql/queries/AllSiteMessages.gql new file mode 100644 index 00000000..385546e9 --- /dev/null +++ b/graphql/queries/AllSiteMessages.gql @@ -0,0 +1,24 @@ +query allSiteMessages($afterCursor: String) { + siteMessages(first: 9, after: $afterCursor) { + edges { + node { + id + message + active + indefiniteOverride + displayStart + eventStart + eventEnd + eventDuration + creator { + id + firstName + lastName + } + type + dismissalPolicy + toDisplay + } + } + } +} diff --git a/graphql/queries/UpcomingSiteMessages.gql b/graphql/queries/UpcomingSiteMessages.gql new file mode 100644 index 00000000..a95689d5 --- /dev/null +++ b/graphql/queries/UpcomingSiteMessages.gql @@ -0,0 +1,34 @@ +query upcomingSiteMessages($now: DateTime, $afterCursor: String) { + siteMessages( + first: 5 + orderBy: "display_start" + after: $afterCursor + displayStart_Lte: $now + end_Gte: $now + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + message + active + indefiniteOverride + displayStart + eventStart + eventEnd + eventDuration + creator { + id + firstName + lastName + } + type + dismissalPolicy + toDisplay + } + } + } +} diff --git a/layouts/default.vue b/layouts/default.vue index f89e5b1d..340936aa 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,6 +1,7 @@