From dc2493e2ccecddb6f0d17803a781ed636c983eab Mon Sep 17 00:00:00 2001 From: Isa Herico Velasco <7840857+iisa@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:35:43 -0800 Subject: [PATCH] receipts view (#15) --- demo/index.html | 118 ++++++++++++ index.ts | 2 + package-lock.json | 17 +- package.json | 5 +- src/models/receipt.ts | 53 ++++++ src/monthly-giving-circle.ts | 86 ++++++++- src/presentational/iaux-button.ts | 93 ++++++++++ src/presentational/mgc-title.ts | 27 ++- src/receipts.ts | 237 ++++++++++++++++++++++++ src/welcome-message.ts | 1 + test/monthly-giving-circle.test.ts | 131 ++++++++++++- test/receipt-email-request-flow.test.ts | 72 +++++++ 12 files changed, 823 insertions(+), 19 deletions(-) create mode 100644 src/models/receipt.ts create mode 100644 src/presentational/iaux-button.ts create mode 100644 src/receipts.ts create mode 100644 test/receipt-email-request-flow.test.ts diff --git a/demo/index.html b/demo/index.html index 5fd6b5b..c442503 100644 --- a/demo/index.html +++ b/demo/index.html @@ -37,14 +37,132 @@
+ + + +

+
+ diff --git a/index.ts b/index.ts index e0bbf9b..b09f87a 100644 --- a/index.ts +++ b/index.ts @@ -1 +1,3 @@ export { MonthlyGivingCircle } from './src/monthly-giving-circle'; +export type { anUpdate } from './src/monthly-giving-circle'; +export { Receipt } from './src/models/receipt'; diff --git a/package-lock.json b/package-lock.json index e81bb58..218e5b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@internetarchive/donation-monthly-portal", - "version": "1.0.0", + "version": "0.0.0-receipts5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@internetarchive/donation-monthly-portal", - "version": "1.0.0", + "version": "0.0.0-receipts5", "license": "AGPL-3.0-only", "dependencies": { + "@internetarchive/iaux-notification-toast": "^0.0.0-alpha2", "@internetarchive/icon-donate": "^1.3.4", "lit": "^2.8.0" }, @@ -34,7 +35,7 @@ "madge": "^6.0.0", "prettier": "^2.7.1", "rimraf": "^5.0.0", - "sinon": "^17.0.0", + "sinon": "^17.0.1", "ts-lit-plugin": "^2.0.0", "tslib": "^2.7.0", "typescript": "^4.7.4", @@ -913,6 +914,15 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@internetarchive/iaux-notification-toast": { + "version": "0.0.0-alpha2", + "resolved": "https://registry.npmjs.org/@internetarchive/iaux-notification-toast/-/iaux-notification-toast-0.0.0-alpha2.tgz", + "integrity": "sha512-0wbHkP6xJmYE6uIreA0I2hMxC0aHOJzUjW+pFToXbBi8KzhNYvMucBL1jtMpV9HcXq7UsoS2wc0MHFc6lzcCJw==", + "license": "AGPL-3.0-only", + "dependencies": { + "lit": "^2.6.0" + } + }, "node_modules/@internetarchive/icon-donate": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@internetarchive/icon-donate/-/icon-donate-1.3.4.tgz", @@ -9294,6 +9304,7 @@ "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0", "@sinonjs/fake-timers": "^11.2.2", diff --git a/package.json b/package.json index e4e3a80..7932ca9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internetarchive/donation-monthly-portal", - "version": "1.0.0", + "version": "0.0.0-receipts10", "description": "The Internet Archive Monthly Portal", "license": "AGPL-3.0-only", "main": "dist/index.js", @@ -27,6 +27,7 @@ "ghpages:generate": "gh-pages -t -d ghpages -m \"Build for $(git log --pretty=format:\"%h %an %ai %s\" -n1) [skip ci]\"" }, "dependencies": { + "@internetarchive/iaux-notification-toast": "^0.0.0-alpha2", "@internetarchive/icon-donate": "^1.3.4", "lit": "^2.8.0" }, @@ -52,7 +53,7 @@ "madge": "^6.0.0", "prettier": "^2.7.1", "rimraf": "^5.0.0", - "sinon": "^17.0.0", + "sinon": "^17.0.1", "ts-lit-plugin": "^2.0.0", "tslib": "^2.7.0", "typescript": "^4.7.4", diff --git a/src/models/receipt.ts b/src/models/receipt.ts new file mode 100644 index 0000000..f79bf10 --- /dev/null +++ b/src/models/receipt.ts @@ -0,0 +1,53 @@ +type AReceipt = { + currency: string; + net_amount: number; + total_amount: number; + receive_date?: Date; + date: string; + isTest: boolean; + token: string; +}; +export class Receipt { + receipt: AReceipt; + + constructor(receipt: AReceipt) { + this.receipt = receipt; + } + + get amountFormatted(): string { + const value = this.receipt.total_amount ?? this.receipt.net_amount; + const currencyType = this.receipt.currency ?? 'CURR not found'; + if (value) { + return `${currencyType} ${this.currencySymbol}${value}`; + } + return "no amount found, can't find total_amount or net_amount"; + } + + get amount(): string { + return ( + `${this.receipt.total_amount}` ?? + `${this.receipt.net_amount}` ?? + "no amount found, can't find total_amount or net_amount" + ); + } + + get isTest(): boolean { + return this.receipt.isTest ?? false; + } + + get id(): string { + return this.receipt.token ?? 'no token found'; + } + + get date(): string { + return this.receipt.date ?? 'no date found'; + } + + get currencySymbol(): string { + if (this.receipt.currency === 'USD') { + return '$'; + } + + return ''; + } +} diff --git a/src/monthly-giving-circle.ts b/src/monthly-giving-circle.ts index 21ad3ee..a4e833c 100644 --- a/src/monthly-giving-circle.ts +++ b/src/monthly-giving-circle.ts @@ -1,22 +1,104 @@ /* eslint-disable no-debugger */ -import { LitElement, html } from 'lit'; +import { LitElement, html, TemplateResult, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import './welcome-message'; import './presentational/mgc-title'; +import './receipts'; +import type { IauxMgcReceipts } from './receipts'; +import './presentational/iaux-button'; + +export type anUpdate = { + message: string; + status: 'success' | 'fail'; + donationId: string; +}; @customElement('iaux-monthly-giving-circle') export class MonthlyGivingCircle extends LitElement { @property({ type: String }) patronName: string = ''; + @property({ type: Array }) receipts = []; + + @property({ type: Array }) updates: anUpdate[] = []; + + @property({ type: String, reflect: true }) viewToDisplay: + | 'welcome' + | 'receipts' = 'welcome'; + protected createRenderRoot() { return this; } + get receiptListElement(): IauxMgcReceipts { + return this.querySelector('iaux-mgc-receipts') as IauxMgcReceipts; + } + + updateReceived(update: anUpdate) { + this.receiptListElement.emailSent({ + id: update.donationId, + emailStatus: update.status, + }); + this.updates.unshift(update); + } + + get showReceiptsCTA(): TemplateResult { + return html` + { + this.viewToDisplay = 'receipts'; + this.dispatchEvent(new CustomEvent('ShowReceipts')); + }} + > + View recent donation history + + `; + } + protected render() { + if (this.viewToDisplay === 'receipts') { + return html` + + Recent donations + + { + this.viewToDisplay = 'welcome'; + this.dispatchEvent(new CustomEvent('ShowWelcome')); + this.updates = []; + await this.updateComplete; + }} + > + Back to account settings + + + + { + console.log('EmailReceiptRequest', event.detail); + this.dispatchEvent( + new CustomEvent('EmailReceiptRequest', { + detail: { ...event.detail }, + }) + ); + }} + > + `; + } + return html` - + + Monthly Giving Circle + ${this.receipts.length ? this.showReceiptsCTA : nothing} + `; } diff --git a/src/presentational/iaux-button.ts b/src/presentational/iaux-button.ts new file mode 100644 index 0000000..bbf62b2 --- /dev/null +++ b/src/presentational/iaux-button.ts @@ -0,0 +1,93 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; + +import '@internetarchive/icon-donate/icon-donate.js'; + +@customElement('iaux-button') +export class IauxButton extends LitElement { + @property({ type: Boolean, reflect: true }) isDisabled = false; + + // eslint-disable-next-line no-use-before-define + @property({ type: Object }) clickHandler?: (self: IauxButton) => void; + + @query('button') button!: HTMLButtonElement; + + render() { + return html` + + `; + } + + static styles = css` + :host { + display: inline-block; + height: var(--button-height, 30px); + } + + button { + border: none; + cursor: pointer; + line-height: normal; + border-radius: 0.4rem; + text-align: center; + vertical-align: middle; + display: inline-block; + padding: 0.6rem 1.2rem; + border: 1px solid transparent; + + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + user-select: none; + } + + :host(.transparent) button { + background-color: transparent; + } + + :host(.slim) button { + padding: 0; + } + + :host(.primary) button { + background-color: #194880; + border-color: #c5d1df; + } + + :host(.secondary) button { + background: #333; + } + + :host(.cancel) button { + background-color: #e51c26; + border-color: #f8c6c8; + } + + :host(.link) button { + color: #4b64ff; + border: none; + background: transparent; + display: flex; + align-items: var(--link-button-flex-align-items, flex-end); + padding: var(--link-button-padding, inherit); + height: inherit; + } + + :host([isdisabled]) button { + cursor: not-allowed; + opacity: 0.5; + color: #222 !important; + } + `; +} diff --git a/src/presentational/mgc-title.ts b/src/presentational/mgc-title.ts index 8ad170e..99a7654 100644 --- a/src/presentational/mgc-title.ts +++ b/src/presentational/mgc-title.ts @@ -9,10 +9,7 @@ export class MonthlyGivingCircle extends LitElement { get heart(): TemplateResult | typeof nothing { return this.titleStyle === 'heart' - ? html` -
- Monthly Giving Circle - ` + ? html`
` : nothing; } @@ -31,6 +28,21 @@ export class MonthlyGivingCircle extends LitElement { } static styles = css` + :host { + padding-bottom: 5px; + display: block; + } + + :host([titlestyle='default']) h2 { + justify-content: flex-start; + gap: 20px; + } + + :host([titlestyle='heart']) h2 .title-section { + width: 100%; + display: flex; + } + h2 { font-size: 1.5em; display: flex; @@ -39,13 +51,8 @@ export class MonthlyGivingCircle extends LitElement { align-content: center; margin: 0; justify-content: space-between; - gap: 10px; align-items: flex-end; - } - - h2 .title-section { - width: 100%; - display: flex; + line-height: normal; } h2 .icon-donate { diff --git a/src/receipts.ts b/src/receipts.ts new file mode 100644 index 0000000..717f2cf --- /dev/null +++ b/src/receipts.ts @@ -0,0 +1,237 @@ +/* eslint-disable no-console */ +import { LitElement, html, css, PropertyValues, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { Receipt } from './models/receipt'; + +import './presentational/iaux-button'; +import type { IauxButton } from './presentational/iaux-button'; + +type ReceiptEmailStatus = { + id: string; + emailStatus: 'success' | 'fail' | 'pending' | ''; +}; + +type receiptDispatcherMap = { + [id: string]: ReceiptEmailStatus; +}; + +@customElement('iaux-mgc-receipts') +export class IauxMgcReceipts extends LitElement { + @property({ type: Array }) receipts = []; + + @property({ type: Object }) + receiptDispatcher: receiptDispatcherMap | null = null; + + shouldUpdate(changed: PropertyValues) { + if (changed.has('receiptDispatcher')) { + return true; + } + return false; + } + + updated(changed: PropertyValues) { + if (changed.has('receipts')) { + this.updateReceiptSentMap(); + } + if (changed.has('receiptDispatcher')) { + console.log('receiptDispatcher UPDATED ---- ', this.receiptDispatcher); + } + } + + updateReceiptSentMap() { + if (!this.receipts.length) { + this.receiptDispatcher = null; + } else { + const receiptDispatcher: receiptDispatcherMap = {}; + this.receipts.forEach((receipt: Receipt) => { + receiptDispatcher[receipt.id] = { + id: receipt.id, + emailStatus: '', + }; + }); + this.receiptDispatcher = receiptDispatcher; + } + } + + emailReceipt(receipt: Receipt) { + this.dispatchEvent( + new CustomEvent('EmailReceiptRequest', { + detail: { + donation: receipt, + }, + }) + ); + } + + /** callback that confirms status of an receipt email request */ + async emailSent(receiptEmailed: ReceiptEmailStatus) { + const currStatusMap = this.receiptDispatcher; + this.receiptDispatcher = null; + await this.updateComplete; + const statusMap = { + ...currStatusMap, + } as receiptDispatcherMap; + const { id } = receiptEmailed; + statusMap[id] = receiptEmailed; + + this.receiptDispatcher = { ...statusMap }; + console.log( + 'RECEIPTS -- emailSent', + this.receiptDispatcher, + receiptEmailed + ); + + // re-enable email request button + const escapedId = CSS.escape(id); + const button = this.shadowRoot?.querySelector( + `#donation-${escapedId} iaux-button` + ) as IauxButton; + button.isDisabled = false; + } + + /* renderings */ + emailStatusMessageToDisplay(receiptSentStatus: ReceiptEmailStatus): string { + switch (receiptSentStatus.emailStatus) { + case 'success': + return '✓ Sent'; + case 'fail': + return '✖ Failed'; + default: + return ''; + } + } + + ctaButtonText(donation: Receipt, emailStatus?: ReceiptEmailStatus) { + if (emailStatus?.emailStatus === 'pending') { + return 'Sending...'; + } + + return 'Email receipt'; + } + + protected render() { + return html` +
+ + + + + + + ${this.receipts.length + ? this.receipts.map((donation: Receipt) => { + const emailStatus = this.receiptDispatcher?.[donation.id]; + + const emailUnavailable = emailStatus?.emailStatus === 'pending'; + const emailStatusToDisplay = + !emailStatus || !emailStatus.emailStatus + ? nothing + : html`${this.emailStatusMessageToDisplay(emailStatus)}`; + return html` + + + + + + `; + }) + : html`

No recent donations found

`} +
DateAmountAction
+
${donation.date}
+
+
+ ${donation.amountFormatted} +
+
+
+ { + const initialClick = !emailUnavailable; + if (initialClick) { + // eslint-disable-next-line no-param-reassign + iauxButton.isDisabled = true; + await iauxButton.updateComplete; + } + + if (emailUnavailable) return; + this.emailReceipt(donation); + if (this.receiptDispatcher) { + const statusMap = { + ...this.receiptDispatcher, + } as receiptDispatcherMap; + statusMap[donation.id].emailStatus = 'pending'; + this.receiptDispatcher = statusMap; + } + }} + > + ${this.ctaButtonText(donation, emailStatus)} + + ${emailStatusToDisplay} +
+
+
+ `; + } + + static styles = css` + table { + text-align: left; + table-layout: fixed; + min-width: 600px; + } + + button { + padding: 1rem 0; + } + + td { + padding: 0; + } + + th.date { + width: 55px; + } + th.amount { + width: 55px; + } + th.action { + width: 200px; + } + iaux-button-style { + display: inline-block; + } + + .request-receipt { + display: flex; + flex-wrap: nowrap; + align-content: center; + justify-content: flex-start; + align-items: center; + gap: 10px; + } + + .sent-status.success, + .sent-status.fail { + padding: 5px; + background: rgb(238, 253, 238); + width: 55px; + min-height: 20px; + } + .sent-status.success { + color: rgb(33, 149, 24); + border-left: 5px solid rgb(33, 149, 24); + } + .sent-status.fail { + color: #bb0505; + border-left: 5px solid #bb0505; + } + `; +} diff --git a/src/welcome-message.ts b/src/welcome-message.ts index 37e97ee..3915c9c 100644 --- a/src/welcome-message.ts +++ b/src/welcome-message.ts @@ -71,6 +71,7 @@ export class MGCWelcome extends LitElement { ul { list-style-type: disc; padding-left: 1rem; + margin-left: 1rem; } `; } diff --git a/test/monthly-giving-circle.test.ts b/test/monthly-giving-circle.test.ts index cc605f0..08dbb42 100644 --- a/test/monthly-giving-circle.test.ts +++ b/test/monthly-giving-circle.test.ts @@ -4,6 +4,7 @@ import { html, fixture, expect } from '@open-wc/testing'; import type { MonthlyGivingCircle } from '../src/monthly-giving-circle'; import '../src/monthly-giving-circle'; +import type { IauxButton } from '../src/presentational/iaux-button'; describe('IauxMonthlyGivingCircle', () => { it('displays welcome message on load', async () => { @@ -11,7 +12,133 @@ describe('IauxMonthlyGivingCircle', () => { html`` ); - // eslint-disable-next-line no-unused-expressions - expect(el.querySelector('iaux-mgc-welcome')).to.not.be.null; + expect(el.viewToDisplay).to.equal('welcome'); + + const titleEl = el.querySelector('iaux-mgc-title'); + expect(titleEl).to.not.be.null; + expect(titleEl!.getAttribute('titlestyle')).to.equal('heart'); + expect(titleEl!.children.length).to.equal(2); + expect((titleEl!.children[0] as HTMLElement).innerText).to.equal( + 'Monthly Giving Circle' + ); + + const welcomeEl = el.querySelector('iaux-mgc-welcome'); + const joinLink = welcomeEl!.shadowRoot?.querySelector( + 'a.join-mgc' + ) as HTMLAnchorElement; + expect(joinLink).to.not.be.null; + expect(joinLink.href).to.equal( + 'https://archive.org/donate/?amt=5&contrib_type=monthly&origin=iawww-usrsttng' + ); + }); + + describe('Receipts View - CTA & onclick display:', () => { + it('Displays receipts CTA when receipts are available', async () => { + const el = await fixture( + html`` + ); + + const titleEl = el.querySelector('iaux-mgc-title'); + const receiptsButton = titleEl!.querySelector( + 'iaux-button' + ) as IauxButton; + expect(receiptsButton).to.exist; + expect(receiptsButton!.innerText).to.equal( + 'View recent donation history' + ); + + el.receipts = []; + await el.updateComplete; + + const newTitleEl = el.querySelector('iaux-mgc-title'); + expect(newTitleEl).to.exist; + + const newReceiptsButton = newTitleEl!.querySelector('button'); + expect(newReceiptsButton).to.not.exist; + }); + + it('Display receipts table when receipts CTA is clicked', async () => { + const el = await fixture( + html`` + ); + + const titleEl = el.querySelector('iaux-mgc-title'); + expect(titleEl).to.exist; + + const receiptsButton = titleEl!.querySelector( + 'iaux-button' + ) as IauxButton; + + expect(receiptsButton.innerText).to.equal('View recent donation history'); + + const innerButton = receiptsButton.shadowRoot?.querySelector( + 'button' + ) as HTMLButtonElement; + innerButton.click(); + + await el.updateComplete; + + expect(el.viewToDisplay).to.equal('receipts'); + + const welcomeEl = el.querySelector('iaux-mgc-welcome'); + expect(welcomeEl).to.not.exist; + + // shows proper title + const titleEl2 = el.querySelector('iaux-mgc-title'); + expect(titleEl2).to.exist; + expect(titleEl2!.getAttribute('titlestyle')).to.equal('default'); + const titleValueEl = titleEl2?.querySelector( + 'span[slot="title"]' + ) as HTMLSpanElement; + expect(titleValueEl).to.exist; + expect(titleValueEl.innerText).to.equal('Recent donations'); + + // shows back button + const backButton = titleEl2?.querySelector( + 'iaux-button#close-receipts' + ) as IauxButton; + expect(backButton!.innerText).to.equal('Back to account settings'); + + // shows receipts element + const receiptsEl = el.querySelector('iaux-mgc-receipts'); + expect(receiptsEl).to.exist; + + // goes back to welcome page if back button is clicked + // dig into the shadowRoot to get the button + backButton.shadowRoot?.querySelector('button')?.click(); + await el.updateComplete; + + expect(el.viewToDisplay).to.equal('welcome'); + + const titleEl3 = el.querySelector('iaux-mgc-title'); + expect(titleEl3).to.exist; + expect(titleEl3!.getAttribute('titlestyle')).to.equal('heart'); + const welcomeTitle = titleEl3?.querySelector( + 'span[slot="title"]' + ) as HTMLSpanElement; + expect(welcomeTitle.innerText).to.equal('Monthly Giving Circle'); + }); }); }); diff --git a/test/receipt-email-request-flow.test.ts b/test/receipt-email-request-flow.test.ts new file mode 100644 index 0000000..0bc82ce --- /dev/null +++ b/test/receipt-email-request-flow.test.ts @@ -0,0 +1,72 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { html, fixture, expect } from '@open-wc/testing'; +import Sinon from 'sinon'; + +import type { MonthlyGivingCircle } from '../src/monthly-giving-circle'; + +import '../src/monthly-giving-circle'; +import type { IauxMgcReceipts } from '../src/receipts'; +import type { IauxButton } from '../src/presentational/iaux-button'; + +describe('Receipts: When requesting an email', () => { + describe('`` fires event: EmailReceiptRequest', () => { + it('and receives updates via `update`', async () => { + const el = await fixture( + html`` + ); + + // open receipt view + const titleEl = el.querySelector('iaux-mgc-title'); + const receiptsDisplayButton = titleEl!.querySelector( + 'iaux-button' + ) as IauxButton; + const innerButton = receiptsDisplayButton.shadowRoot?.querySelector( + 'button' + ) as HTMLButtonElement; + innerButton.click(); + + await el.updateComplete; + + // set spies for receipt function + const receiptsEl = el.querySelector( + 'iaux-mgc-receipts' + ) as IauxMgcReceipts; + const receiptElSpy = Sinon.spy(receiptsEl, 'emailSent'); + + const mainElementUpdateReceivedSpy = Sinon.spy(el, 'updateReceived'); + + // set handler for EmailReceiptRequest event + let emailRequested = false; + el.addEventListener('EmailReceiptRequest', async () => { + emailRequested = true; + expect(emailRequested).to.be.true; + expect(mainElementUpdateReceivedSpy.calledOnce).to.equal(444); + + el.updateReceived({ + message: 'Email sent', + status: 'success', + donationId: 'foo-id-1', + }); + + await el.updateComplete; + + expect(receiptElSpy.calledOnce).to.equal(true); + }); + + // request an email + const requestReceiptButton = receiptsEl!.shadowRoot!.querySelector( + 'tr#donation-foo-id-1 iaux-button' + ) as IauxButton; + requestReceiptButton!.click(); + }); + }); +});