├── .dockerignore ├── jest.dom.ts ├── tests ├── unit │ ├── TestDoubles │ │ ├── SucceedingMembershipFeeChangeResource.ts │ │ ├── FailingBankValidationResource.ts │ │ ├── FakeCommentResource.ts │ │ ├── FakeCityAutocompleteResource.ts │ │ ├── FakeStreetAutocompleteResource.ts │ │ ├── FakeBankValidationResource.ts │ │ ├── FakeDataPersistenceRepository.ts │ │ ├── SucceedingBankValidationResource.ts │ │ └── FakeDataEncryptor.ts │ ├── utils │ │ ├── errorSummaryItemIsFunctional.ts │ │ └── createCampaignQueryString.spec.ts │ ├── components │ │ ├── shared │ │ │ └── ServerMessage.spec.ts │ │ ├── pages │ │ │ ├── donation_form │ │ │ │ └── PaymentSummary.spec.ts │ │ │ ├── membership_fee_change │ │ │ │ └── Sidebar.spec.ts │ │ │ └── donation_confirmation │ │ │ │ └── AddressAnonymous.spec.ts │ │ └── layout │ │ │ └── AppFooter.spec.ts │ └── store │ │ └── feeValidator.spec.ts └── data │ ├── bankdata.ts │ ├── countries.ts │ └── salutations.ts ├── src ├── config.ts ├── pattern_library │ ├── patterns │ │ ├── icon-sidebar │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── site-head │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── modal-dialogue │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── footer-nav │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── toggle │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── section-heading │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ └── index.ts │ │ ├── summary │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── mobile-nav-toggle │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── icon-text │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── button │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── nav │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── text-radio │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── locale │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ ├── markup.html │ │ │ └── Examples.vue │ │ ├── callout │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── donation-comment │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ ├── index.ts │ │ │ └── Examples.vue │ │ ├── pagination │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── more-info-toggle │ │ │ ├── description.md │ │ │ ├── markup.html │ │ │ └── index.ts │ │ ├── site-foot │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── Pattern.ts │ │ ├── info-columns │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── content-card │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── detailed-info-box │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── accordion │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── tooltip │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── field-container │ │ │ ├── description.md │ │ │ └── index.ts │ │ ├── verbose-checkbox │ │ │ ├── description.md │ │ │ ├── index.ts │ │ │ └── markup.html │ │ ├── combobox │ │ │ └── index.ts │ │ └── iban-calculator │ │ │ └── index.ts │ ├── css │ │ ├── blocks │ │ │ ├── section-heading.css │ │ │ ├── verbose-checkbox.css │ │ │ ├── summary.css │ │ │ ├── icon-sidebar.css │ │ │ ├── more-info-toggle.css │ │ │ ├── donation-comment.css │ │ │ ├── footer-nav.css │ │ │ ├── site-foot.css │ │ │ ├── info-columns.css │ │ │ ├── pagination.css │ │ │ ├── combobox.css │ │ │ ├── field-container.css │ │ │ ├── content-card.css │ │ │ ├── mobile-nav-toggle.css │ │ │ ├── icon-text.css │ │ │ └── site-head.css │ │ ├── utilities │ │ │ ├── display-toggler.css │ │ │ ├── footer-bottom.css │ │ │ ├── sticky.css │ │ │ ├── link-button.css │ │ │ ├── visually-hidden.css │ │ │ ├── stretch-single-content-card.css │ │ │ └── highlighted-content.css │ │ ├── compositions │ │ │ ├── flow.css │ │ │ ├── wrapper.css │ │ │ ├── repel.css │ │ │ ├── cluster.css │ │ │ └── flex-field-group.css │ │ └── global │ │ │ └── fonts.css │ ├── Design.afdesign │ ├── samples │ │ ├── Sample.ts │ │ ├── donation-form │ │ │ └── index.ts │ │ ├── compact-donation-form │ │ │ └── index.ts │ │ └── membership-fee-change │ │ │ └── index.ts │ ├── pages │ │ ├── Page.ts │ │ ├── spacing │ │ │ └── index.ts │ │ ├── font-scaling │ │ │ └── index.ts │ │ ├── css-variables │ │ │ ├── content.md │ │ │ └── index.ts │ │ └── utility-classes │ │ │ └── index.ts │ ├── index.ts │ └── components │ │ ├── icons │ │ ├── Close.vue │ │ ├── Burger.vue │ │ ├── Close.svg │ │ └── Burger.svg │ │ ├── PageDetail.vue │ │ └── PatternDetail.vue ├── __mocks__ │ └── axios.ts ├── view_models │ ├── Prefillable.ts │ ├── Validity.ts │ ├── CampaignValues.ts │ ├── TrackingData.ts │ ├── useOfFunds.ts │ ├── Country.ts │ ├── DataEncryptor.ts │ ├── PaymentType.ts │ ├── FieldInitialization.ts │ ├── Salutation.ts │ ├── salutationValueTranslations.ts │ ├── DataPersistenceRepository.ts │ ├── PaymentInitialisationPayload.ts │ ├── Contact.ts │ ├── Donation.ts │ ├── Comment.ts │ ├── supporters.ts │ ├── Validation.ts │ ├── MembershipTypeModel.ts │ ├── DataPersistence.ts │ ├── faq.ts │ ├── BankAccount.ts │ └── Payment.ts ├── scss │ ├── pattern-library-compatibility │ │ ├── switcher.scss │ │ ├── accordion.scss │ │ ├── combobox.scss │ │ ├── summary.scss │ │ ├── section-heading.scss │ │ ├── site-head.scss │ │ ├── text-radio.scss │ │ ├── wrapper.scss │ │ ├── toggle.scss │ │ └── button.scss │ ├── settings │ │ ├── _global.scss │ │ ├── _units.scss │ │ ├── _breakpoints.scss │ │ ├── _forms.scss │ │ ├── _colors.scss │ │ └── _fonts.scss │ ├── bulma.scss │ └── mixins │ │ └── _visibility.scss ├── components │ ├── shared │ │ ├── icons │ │ │ ├── ArrowUp.vue │ │ │ ├── ArrowDown.vue │ │ │ ├── ChevronUpIcon.vue │ │ │ ├── ChevronDown.vue │ │ │ ├── Checkmark.vue │ │ │ ├── TickIcon.vue │ │ │ ├── ExternalLink.vue │ │ │ ├── SuccessIcon.vue │ │ │ ├── BooksIcon.vue │ │ │ ├── ChevronLeftIcon.vue │ │ │ ├── ChevronRightIcon.vue │ │ │ ├── CloseIcon.vue │ │ │ └── EditIcon.vue │ │ ├── form_fields │ │ │ ├── FormOptions.ts │ │ │ ├── useFieldModel.ts │ │ │ ├── useMailingListModel.ts │ │ │ ├── useAutocompleteScrollIntoViewOnFocus.ts │ │ │ ├── updateAutocompleteScrollPosition.ts │ │ │ ├── useCitiesResource.ts │ │ │ └── useStreetsResource.ts │ │ ├── composables │ │ │ ├── useModalState.ts │ │ │ ├── useValueEqualsPlaceholderWarning.ts │ │ │ ├── useDisplaySwitch.ts │ │ │ ├── useDetectOutsideClick.ts │ │ │ ├── useReceiptModel.ts │ │ │ └── useAriaDescribedby.ts │ │ ├── form_elements │ │ │ ├── useInputFocusing.ts │ │ │ ├── useInputModel.ts │ │ │ ├── CheckboxSingleFormInput.vue │ │ │ ├── CheckboxToggle.vue │ │ │ ├── CheckboxMultipleFormInput.vue │ │ │ ├── SelectFormInput.vue │ │ │ └── PaymentTextFormButton.vue │ │ ├── useMailHostList.ts │ │ ├── PaymentSummarySection.vue │ │ ├── sidebar_cards │ │ │ └── BankInfo.vue │ │ ├── usePaymentType.ts │ │ └── ButtonLink.vue │ ├── patterns │ │ ├── SectionHeading.vue │ │ ├── Tooltip.vue │ │ ├── Accordion.vue │ │ ├── Summary.vue │ │ ├── IconText.vue │ │ ├── AccordionItem.vue │ │ ├── ContentCard.vue │ │ ├── Callout.vue │ │ └── FieldContainer.vue │ ├── pages │ │ ├── donation_form │ │ │ ├── Payment.ts │ │ │ ├── AddressTypeIds.ts │ │ │ ├── DonationReceipt │ │ │ │ ├── useAddressTypeModel.ts │ │ │ │ └── useReceiptModel.ts │ │ │ ├── useBankDataSummary.ts │ │ │ ├── DonationSummaryHeadline.vue │ │ │ └── Compact │ │ │ │ └── useReceiptModel.ts │ │ ├── contact │ │ │ ├── ContactFormItem.ts │ │ │ ├── ContactInitialFormData.ts │ │ │ └── ContactFormData.ts │ │ ├── membership_confirmation │ │ │ ├── SummaryLinks.vue │ │ │ ├── MembershipConfirmationBannerNotifier.vue │ │ │ └── MembershipSurvey.vue │ │ ├── membership_form │ │ │ ├── useIncentivesModel.ts │ │ │ ├── useMembershipTypeModel.ts │ │ │ ├── useReceiptModel.ts │ │ │ ├── Sidebar.vue │ │ │ └── useMembershipBankDataSummary.ts │ │ ├── StaticPage.vue │ │ ├── SystemMessage.vue │ │ ├── AccessDenied.vue │ │ ├── useCommentResource.ts │ │ ├── UpdateAddressSuccess.vue │ │ ├── PageNotFound.vue │ │ ├── membership_fee_change │ │ │ ├── ErrorMessage.vue │ │ │ ├── FeeAlreadyChangedMessage.vue │ │ │ └── Sidebar.vue │ │ ├── use_of_funds │ │ │ └── UseOfFundsContent.ts │ │ ├── donation_confirmation │ │ │ ├── DonationExported.vue │ │ │ └── DonationSurvey.vue │ │ └── SubscriptionConfirmation.vue │ └── layout │ │ ├── DefaultSidebar.vue │ │ ├── AppContent.vue │ │ └── NavigationItems.vue ├── Domain │ ├── MembershipFeeChange │ │ ├── FeeChangeResponse.ts │ │ └── FeeChangeRequest.ts │ ├── Validation │ │ └── ValidationSummaryItem.ts │ └── Membership │ │ ├── MembershipPaymentSummary.ts │ │ ├── MembershipAddress.ts │ │ ├── MembershipApplication.ts │ │ └── MembershipApplicationConfirmationData.ts ├── shims-vue.d.ts ├── store │ ├── payment │ │ ├── types.ts │ │ └── index.ts │ ├── membership_fee │ │ ├── constants.ts │ │ └── index.ts │ ├── intervalValidator.ts │ ├── ValidationResponse.ts │ ├── membership_address │ │ └── constants.ts │ ├── feeValidator.ts │ ├── paymentTypeValidator.ts │ ├── amountValidator.ts │ ├── data_persistence │ │ ├── address.ts │ │ ├── bankdata.ts │ │ └── InactiveDataPersister.ts │ ├── donor_update_store.ts │ ├── bankdata │ │ ├── index.ts │ │ └── mutations.ts │ └── update_address_store.ts ├── util │ ├── bucket_id_to_css_class.ts │ ├── camlize_name.ts │ ├── injectStrict.ts │ ├── FeatureFetcher.ts │ ├── append_campaign_query_params.ts │ ├── merge_validation_results.ts │ ├── street_and_building_number_tools.ts │ ├── wait_for_server_validation.ts │ ├── createFeatureToggle.ts │ ├── modalPageFreezer.ts │ └── FilteredUrlMembershipValues.ts ├── api │ ├── UpdateAddressResponse.ts │ ├── UpdateDonorRequest.ts │ ├── CityAutocompleteResource.ts │ ├── StreetAutocompleteResource.ts │ ├── DonorResource.ts │ ├── AddressChangeResource.ts │ ├── CommentResource.ts │ └── MembershipFeeChangeResource.ts └── pages │ ├── comment_ticker.ts │ └── comment_list.ts ├── .browserslistrc ├── webpack ├── env │ ├── dev.env.mjs │ ├── staging.env.mjs │ └── prod.env.mjs └── helpers.mjs ├── postcss.config.js ├── .github ├── CODEOWNERS └── workflows │ └── build.yml ├── public ├── fonts │ ├── fontello.eot │ ├── fontello.ttf │ ├── fontello.woff │ ├── fontello.woff2 │ ├── open-sans-v17-latin-700.eot │ ├── open-sans-v17-latin-700.ttf │ ├── open-sans-v17-latin-700.woff │ ├── open-sans-v17-latin-700.woff2 │ ├── open-sans-v17-latin-italic.eot │ ├── open-sans-v17-latin-italic.ttf │ ├── open-sans-v17-latin-italic.woff │ ├── open-sans-v17-latin-regular.eot │ ├── open-sans-v17-latin-regular.ttf │ ├── sourcesanspro-bold-webfont.woff │ ├── sourcesanspro-it-webfont.woff │ ├── sourcesanspro-it-webfont.woff2 │ ├── open-sans-v17-latin-italic.woff2 │ ├── open-sans-v17-latin-regular.woff │ ├── open-sans-v17-latin-regular.woff2 │ ├── sourcesanspro-bold-webfont.woff2 │ ├── sourcesanspro-regular-webfont.woff │ └── sourcesanspro-regular-webfont.woff2 ├── images │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── WMDE-funds-forwarding.gif │ └── Chevron-left.svg └── index.html ├── Dockerfile ├── webpack.config.mjs ├── types.d.ts ├── babel.config.js ├── .gitignore ├── tsconfig.json └── jest.setup.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /jest.dom.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/SucceedingMembershipFeeChangeResource.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const MAILING_LIST_ADDRESS_PAGE: boolean = true; 2 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-sidebar/description.md: -------------------------------------------------------------------------------- 1 | When we need 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | ie >= 11 4 | ios_saf >= 9 5 | -------------------------------------------------------------------------------- /webpack/env/dev.env.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | NODE_ENV: 'development', 3 | }; 4 | -------------------------------------------------------------------------------- /webpack/env/staging.env.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | NODE_ENV: 'staging', 3 | }; 4 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/site-head/description.md: -------------------------------------------------------------------------------- 1 | This is the main header of the site. -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/__mocks__/axios.ts: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios'; 2 | export default mockAxios; 3 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/modal-dialogue/description.md: -------------------------------------------------------------------------------- 1 | This is used to show content in popups. -------------------------------------------------------------------------------- /src/view_models/Prefillable.ts: -------------------------------------------------------------------------------- 1 | export interface Prefillable { 2 | initialized: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/footer-nav/description.md: -------------------------------------------------------------------------------- 1 | This is used to display the links in the footer. -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This marks the default owner group of all files in this repository 2 | * @wmde/funtech-core 3 | -------------------------------------------------------------------------------- /public/fonts/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/fontello.eot -------------------------------------------------------------------------------- /public/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/fontello.ttf -------------------------------------------------------------------------------- /public/fonts/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/fontello.woff -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/images/favicon.ico -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/section-heading.css: -------------------------------------------------------------------------------- 1 | .section-heading { 2 | --flow-space: var(--space-s); 3 | } 4 | -------------------------------------------------------------------------------- /src/view_models/Validity.ts: -------------------------------------------------------------------------------- 1 | export enum Validity { 2 | INVALID, 3 | VALID, 4 | INCOMPLETE, 5 | RESTORED, 6 | } 7 | -------------------------------------------------------------------------------- /public/fonts/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/fontello.woff2 -------------------------------------------------------------------------------- /src/pattern_library/patterns/toggle/description.md: -------------------------------------------------------------------------------- 1 | This is a checkbox field that is styled to look like a toggle button 2 | -------------------------------------------------------------------------------- /src/view_models/CampaignValues.ts: -------------------------------------------------------------------------------- 1 | export interface CampaignValues { 2 | campaign: string; 3 | keyword: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/section-heading/description.md: -------------------------------------------------------------------------------- 1 | A section heading is intended to be used on the top of content cards. -------------------------------------------------------------------------------- /public/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/images/apple-touch-icon.png -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/display-toggler.css: -------------------------------------------------------------------------------- 1 | .display-toggler:not(.display-toggler--visible) { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/summary/description.md: -------------------------------------------------------------------------------- 1 | This is for showing the donor a summary of the form they're currently filling out. -------------------------------------------------------------------------------- /src/pattern_library/Design.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/src/pattern_library/Design.afdesign -------------------------------------------------------------------------------- /src/pattern_library/patterns/mobile-nav-toggle/description.md: -------------------------------------------------------------------------------- 1 | This is the button that is used to display the navigation on smaller screens. -------------------------------------------------------------------------------- /src/pattern_library/samples/Sample.ts: -------------------------------------------------------------------------------- 1 | export interface Sample { 2 | name: string; 3 | url: string; 4 | component: object; 5 | } 6 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/switcher.scss: -------------------------------------------------------------------------------- 1 | .switcher { 2 | --switcher-target-container-width: calc((28rem - 100%) * 999); 3 | } -------------------------------------------------------------------------------- /public/images/WMDE-funds-forwarding.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/images/WMDE-funds-forwarding.gif -------------------------------------------------------------------------------- /src/components/shared/icons/ArrowUp.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/view_models/TrackingData.ts: -------------------------------------------------------------------------------- 1 | export interface TrackingData { 2 | bannerImpressionCount: number; 3 | impressionCount: number; 4 | } 5 | -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-700.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-700.eot -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-700.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-700.ttf -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-700.woff -------------------------------------------------------------------------------- /src/components/shared/icons/ArrowDown.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-text/description.md: -------------------------------------------------------------------------------- 1 | This is used in the various places where we show an icon alongside a heading or some text. -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-700.woff2 -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-italic.eot -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-italic.ttf -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-italic.woff -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-regular.eot -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-regular.ttf -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-bold-webfont.woff -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-it-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-it-webfont.woff -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-it-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-it-webfont.woff2 -------------------------------------------------------------------------------- /src/Domain/MembershipFeeChange/FeeChangeResponse.ts: -------------------------------------------------------------------------------- 1 | export interface FeeChangeResponse { 2 | status: 'OK' | 'ERR'; 3 | errors?: string[]; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/patterns/SectionHeading.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/button/description.md: -------------------------------------------------------------------------------- 1 | Buttons come in 2 sizes and 2 types. The button styles can be applied to links and button elements. -------------------------------------------------------------------------------- /src/pattern_library/patterns/nav/description.md: -------------------------------------------------------------------------------- 1 | This is used for the main menu links. It is one of the few patterns in the library that uses a breakpoint. -------------------------------------------------------------------------------- /src/pattern_library/patterns/text-radio/description.md: -------------------------------------------------------------------------------- 1 | This is used for a text field that adjoins a radio group to allow a donor to enter a custom value. -------------------------------------------------------------------------------- /src/view_models/useOfFunds.ts: -------------------------------------------------------------------------------- 1 | export interface FundsItem { 2 | percentage: number; 3 | colour: string; 4 | title: string; 5 | text: string; 6 | } 7 | -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-italic.woff2 -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-regular.woff -------------------------------------------------------------------------------- /public/fonts/open-sans-v17-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/open-sans-v17-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-bold-webfont.woff2 -------------------------------------------------------------------------------- /src/pattern_library/patterns/locale/description.md: -------------------------------------------------------------------------------- 1 | The locale changer is used in the header and allows a donor to select the language the site is displayed in. -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-regular-webfont.woff -------------------------------------------------------------------------------- /public/fonts/sourcesanspro-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmde/fundraising-app-frontend/main/public/fonts/sourcesanspro-regular-webfont.woff2 -------------------------------------------------------------------------------- /src/pattern_library/patterns/callout/description.md: -------------------------------------------------------------------------------- 1 | Alert boxes are used for displaying messages to the donors, this is mostly used for displaying error summaries. -------------------------------------------------------------------------------- /src/pattern_library/patterns/donation-comment/description.md: -------------------------------------------------------------------------------- 1 | When someone donates they have the option to leave a comment. This pattern is used to display them. -------------------------------------------------------------------------------- /src/pattern_library/patterns/pagination/description.md: -------------------------------------------------------------------------------- 1 | This is used at the end of the donation comments page. There will never be more than 10 pages of comments. -------------------------------------------------------------------------------- /src/pattern_library/patterns/toggle/markup.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/scss/settings/_global.scss: -------------------------------------------------------------------------------- 1 | $content-width: 1100px; 2 | $sidebar-width: 316px; 3 | $navbar-height: 72px; 4 | $easing: cubic-bezier(0.885, 0.090, 0.490, 0.850); 5 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/more-info-toggle/description.md: -------------------------------------------------------------------------------- 1 | This is used to display some extra information to a donor. If it follows a button it will snuggle up against it. -------------------------------------------------------------------------------- /src/view_models/Country.ts: -------------------------------------------------------------------------------- 1 | export interface Country { 2 | countryCode: string; 3 | countryFullName: string; 4 | group: string; 5 | postCodeValidation: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/site-foot/description.md: -------------------------------------------------------------------------------- 1 | This is the main footer for the site. There is no breakpoint, the navigation will fold to the next row when space runs out. -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/accordion.scss: -------------------------------------------------------------------------------- 1 | .accordion summary { 2 | cursor: pointer; 3 | } 4 | 5 | .accordion summary h2 { 6 | color: var(--color-blue-600); 7 | } -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/combobox.scss: -------------------------------------------------------------------------------- 1 | .combobox [role="listbox"] { 2 | z-index: 10; 3 | } 4 | 5 | .combobox [role='option'] { 6 | font-size: 16px; 7 | } 8 | -------------------------------------------------------------------------------- /src/view_models/DataEncryptor.ts: -------------------------------------------------------------------------------- 1 | export interface DataEncryptor { 2 | encrypt( data: string ): Promise; 3 | decrypt( data: ArrayBuffer ): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /src/scss/bulma.scss: -------------------------------------------------------------------------------- 1 | // Import the parts of Bulma that we're using 2 | @import "~bulma/sass/utilities/all"; 3 | @import "~bulma/sass/base/all"; 4 | @import "~bulma/sass/helpers/all"; 5 | -------------------------------------------------------------------------------- /src/pattern_library/pages/Page.ts: -------------------------------------------------------------------------------- 1 | export interface Page { 2 | url: string; 3 | name: string; 4 | content: string; 5 | codeSamples: [ { name: string; code: string } ] | undefined; 6 | } 7 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue'; 3 | const component: DefineComponent; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /src/view_models/PaymentType.ts: -------------------------------------------------------------------------------- 1 | export enum PaymentType { 2 | DIRECT_DEBIT = 'BEZ', 3 | BANK_TRANSFER = 'UEB', 4 | CREDIT_CARD = 'MCP', 5 | PAYPAL = 'PPL', 6 | SOFORT = 'SUB', 7 | } 8 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/Pattern.ts: -------------------------------------------------------------------------------- 1 | export interface Pattern { 2 | name: string; 3 | url: string; 4 | description: string; 5 | html: string; 6 | examples: object; 7 | css: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/summary.scss: -------------------------------------------------------------------------------- 1 | .summary { 2 | .bankdata { 3 | list-style: none; 4 | padding: 0; 5 | } 6 | 7 | > div { 8 | --flow-space: 0.5em; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | EXPOSE 7072 12 | 13 | CMD [ "npm", "run", "serve" ] 14 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/Payment.ts: -------------------------------------------------------------------------------- 1 | export type DisplaySection = 'amount' | 'interval' | 'paymentType'; 2 | 3 | export type DisplaySectionCollection = [ DisplaySection, ...DisplaySection[] ]; 4 | -------------------------------------------------------------------------------- /src/view_models/FieldInitialization.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from '@src/view_models/Validity'; 2 | 3 | export interface FieldInitialization { 4 | name: string; 5 | value: any; 6 | validity: Validity; 7 | } 8 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/info-columns/description.md: -------------------------------------------------------------------------------- 1 | There are cases where we have small columns of data. In this block we adjust the children of the `.cluster` composition in order to give them a fixed width. 2 | -------------------------------------------------------------------------------- /src/store/payment/types.ts: -------------------------------------------------------------------------------- 1 | import type { Payment } from '@src/view_models/Payment'; 2 | import type { Prefillable } from '@src/view_models/Prefillable'; 3 | 4 | export type DonationPayment = Payment & Prefillable; 5 | -------------------------------------------------------------------------------- /src/components/patterns/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/content-card/description.md: -------------------------------------------------------------------------------- 1 | All content on the site is contained in these cards. They have 4 variants, the standard card, the blue bordered card, the sidebar card, and the collapsable card. 2 | -------------------------------------------------------------------------------- /src/store/membership_fee/constants.ts: -------------------------------------------------------------------------------- 1 | export const MEMBERSHIP_MINIMUM_CENTS_FEE_PERSONAL = 2400; 2 | export const MEMBERSHIP_MINIMUM_CENTS_FEE_COMPANY = 10000; 3 | export const MEMBERSHIP_MAXIMUM_CENTS = 100_000_00; 4 | -------------------------------------------------------------------------------- /src/util/bucket_id_to_css_class.ts: -------------------------------------------------------------------------------- 1 | export function bucketIdToCssClass( bucketNames: string[] ): string[] { 2 | return bucketNames.map( b => b.replace( /(-{2,}|[^a-zA-Z0-9.]+)/g, '-' ).replace( /\./g, '--' ) ); 3 | } 4 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/section-heading.scss: -------------------------------------------------------------------------------- 1 | .section-heading { 2 | --flow-space: 8px; 3 | 4 | h1, h2, p { 5 | margin-block-end: 0; 6 | } 7 | 8 | hr { 9 | margin: 16px 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/site-head.scss: -------------------------------------------------------------------------------- 1 | @use "../../scss/settings/global"; 2 | 3 | .site-head { 4 | position: fixed; 5 | width: 100%; 6 | top: 0; 7 | z-index: 30; 8 | min-height: global.$navbar-height; 9 | } -------------------------------------------------------------------------------- /src/Domain/MembershipFeeChange/FeeChangeRequest.ts: -------------------------------------------------------------------------------- 1 | export interface FeeChangeRequest { 2 | uuid: string; 3 | memberName: string; 4 | amountInEuroCents: number; 5 | paymentType: string; 6 | iban?: string; 7 | bic?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/api/UpdateAddressResponse.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateAddressResponse { 2 | identifier: string; 3 | previousIdentifier: string; 4 | address: Record; 5 | donationReceipt: boolean; 6 | exportState: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/verbose-checkbox.css: -------------------------------------------------------------------------------- 1 | /** 2 | * This is currently only used in one place, for the mailing list checkbox field 3 | */ 4 | .verbose-checkbox > p { 5 | padding-left: calc(0.5em + var(--space-s)); 6 | } 7 | -------------------------------------------------------------------------------- /src/scss/settings/_units.scss: -------------------------------------------------------------------------------- 1 | $spacing: ( 2 | 'xx-small': 8px, 3 | 'x-small': 12px, 4 | 'small': 16px, 5 | 'medium': 24px, 6 | 'large': 32px, 7 | 'x-large': 40px, 8 | 'xx-large': 56px, 9 | 'xxx-large': 72px 10 | ); 11 | -------------------------------------------------------------------------------- /src/util/camlize_name.ts: -------------------------------------------------------------------------------- 1 | // from kebab-case to camelCase 2 | export function camelizeName( fieldName: string ): string { 3 | return fieldName.replace( /-(\w)/g, ( _: string, firstChar: string ) => firstChar.toUpperCase() ); 4 | } 5 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/summary.css: -------------------------------------------------------------------------------- 1 | .summary { 2 | margin-block-end: var(--space-xs); 3 | } 4 | 5 | .summary div { 6 | margin-block-end: var(--space-xs); 7 | } 8 | 9 | .summary__details { 10 | --flow-space: 0.5em; 11 | } 12 | -------------------------------------------------------------------------------- /src/store/intervalValidator.ts: -------------------------------------------------------------------------------- 1 | export function isValidInterval( interval: string, allowedIntervals: number[] ): boolean { 2 | if ( !allowedIntervals.includes( Number( interval ) ) ) { 3 | return false; 4 | } 5 | 6 | return true; 7 | } 8 | -------------------------------------------------------------------------------- /src/view_models/Salutation.ts: -------------------------------------------------------------------------------- 1 | export interface Salutation { 2 | label: string; 3 | value: string; 4 | display: string; 5 | greetings: { 6 | formal: string; 7 | informal: string; 8 | lastNameInformal: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/Domain/Validation/ValidationSummaryItem.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from '@src/view_models/Validity'; 2 | 3 | export interface ValidationSummaryItem { 4 | validity: Validity; 5 | message: String; 6 | focusElement: string; 7 | scrollElement: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/scss/mixins/_visibility.scss: -------------------------------------------------------------------------------- 1 | @mixin screen-reader-only { 2 | border: none; 3 | clip: rect( 0, 0, 0, 0 ); 4 | height: 0.01em; 5 | overflow: hidden; 6 | padding: 0; 7 | position: absolute; 8 | white-space: nowrap; 9 | width: 0.01em; 10 | } 11 | -------------------------------------------------------------------------------- /public/images/Chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/footer-bottom.css: -------------------------------------------------------------------------------- 1 | .footer-bottom { 2 | display: flex; 3 | flex-direction: column; 4 | min-height: calc(100vh - var(--navigation-height) - var(--gutter)); 5 | } 6 | 7 | .footer-bottom__stretch { 8 | flex-grow: 1; 9 | } 10 | -------------------------------------------------------------------------------- /src/view_models/salutationValueTranslations.ts: -------------------------------------------------------------------------------- 1 | export const salutationValueTranslations: Record = { 2 | 'Herr': 'Mr', 3 | 'Frau': 'Ms', 4 | 'Mr': 'Herr', 5 | 'Ms': 'Frau', 6 | 'No Salutation': 'Keine Anrede', 7 | 'Keine Anrede': 'No Salutation', 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/pages/contact/ContactFormItem.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from '@src/view_models/Validity'; 2 | 3 | export interface ContactFormItem { 4 | name: string; 5 | value: string; 6 | pattern: string; 7 | optionalField: boolean; 8 | validity: Validity; 9 | } 10 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/icon-sidebar.css: -------------------------------------------------------------------------------- 1 | .icon-sidebar { 2 | --sidebar-target-width: 2.5rem; 3 | --sidebar-content-min-width: 80%; 4 | 5 | row-gap: 0; 6 | } 7 | 8 | .icon-sidebar svg { 9 | width: 100%; 10 | max-width: 2.5rem; 11 | height: auto; 12 | } 13 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/detailed-info-box/description.md: -------------------------------------------------------------------------------- 1 | This info box is similar to an accordion / detail summary element, 2 | except it shows more information in the "collapsed" state. 3 | Both collapsed and expanded state show different information in the body of the element. -------------------------------------------------------------------------------- /src/view_models/DataPersistenceRepository.ts: -------------------------------------------------------------------------------- 1 | export interface DataPersistenceRepository { 2 | setItem( key: string, data: ArrayBuffer ): void; 3 | getItems(): { [key: string]: any }; 4 | getItem( key: string ): ArrayBuffer | null; 5 | removeItem( key: string ): void; 6 | } 7 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/more-info-toggle.css: -------------------------------------------------------------------------------- 1 | .more-info-toggle[open] summary { 2 | margin-block-end: 0; 3 | } 4 | 5 | .more-info-toggle summary::marker { 6 | color: var(--color-blue-600); 7 | } 8 | 9 | .button + .more-info-toggle { 10 | --flow-space: 0; 11 | } 12 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/text-radio.scss: -------------------------------------------------------------------------------- 1 | .text-radio { 2 | max-width: var(--form-field-max-width); 3 | } 4 | 5 | .text-radio__radio { 6 | left: 14px; 7 | } 8 | 9 | .text-radio:not([data-direction="rtl"]) .text-radio__currency { 10 | left: 28px; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/pages/contact/ContactInitialFormData.ts: -------------------------------------------------------------------------------- 1 | export interface ContactInitialFormData { 2 | firstname?: string; 3 | lastname?: string; 4 | donationNumber?: string; 5 | email?: string; 6 | category?: string; 7 | subject?: string; 8 | messageBody?: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/FormOptions.ts: -------------------------------------------------------------------------------- 1 | export interface CheckboxFormOption { 2 | value: string | number | boolean; 3 | label: string; 4 | id: string; 5 | } 6 | 7 | export interface SelectFormOption { 8 | value: string | number | boolean; 9 | label: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/sticky.css: -------------------------------------------------------------------------------- 1 | /* TODO: This will probably become a content pattern where the sidebar breaks at a breakpoint */ 2 | .sticky { 3 | --sticky-top: calc(var(--gutter) + var(--navigation-height)); 4 | 5 | position: sticky; 6 | top: var(--sticky-top); 7 | } 8 | -------------------------------------------------------------------------------- /src/pattern_library/samples/donation-form/index.ts: -------------------------------------------------------------------------------- 1 | import { Sample } from '@src/pattern_library/samples/Sample'; 2 | 3 | const name = 'Donation Form'; 4 | const url = 'donation-form'; 5 | import component from './Sample.vue'; 6 | 7 | export default { name, url, component } as Sample; 8 | -------------------------------------------------------------------------------- /src/view_models/PaymentInitialisationPayload.ts: -------------------------------------------------------------------------------- 1 | import type { InitialPaymentValues } from '@src/view_models/Payment'; 2 | 3 | export interface PaymentInitialisationPayload { 4 | initialValues: InitialPaymentValues; 5 | allowedIntervals: number[]; 6 | allowedPaymentTypes: string[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/patterns/Accordion.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /tests/data/bankdata.ts: -------------------------------------------------------------------------------- 1 | export const IBAN = 'DE12500105170648489890'; 2 | export const formattedIBAN = 'DE12 5001 0517 0648 4898 90'; 3 | export const BIC = 'INGDDEFFXXX'; 4 | export const accountNumber = '0648489890'; 5 | export const bankCode = '50010517'; 6 | export const bankName = 'ING-DiBa'; 7 | -------------------------------------------------------------------------------- /webpack.config.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const environment = ( process.env.NODE_ENV || 'development' ).trim(); 4 | 5 | import dev from './webpack/webpack.config.dev.mjs'; 6 | import prod from './webpack/webpack.config.prod.mjs'; 7 | 8 | export default environment === 'development' ? dev : prod; 9 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const value: string; 3 | export default value; 4 | } 5 | 6 | declare module '*.html?raw' { 7 | const value: string; 8 | export default value; 9 | } 10 | 11 | declare module '*.css?raw' { 12 | const value: string; 13 | export default value; 14 | } 15 | -------------------------------------------------------------------------------- /src/Domain/Membership/MembershipPaymentSummary.ts: -------------------------------------------------------------------------------- 1 | import { MembershipType } from '@src/view_models/MembershipTypeModel'; 2 | 3 | export interface MembershipPaymentSummary { 4 | paymentIntervalInMonths: string; 5 | membershipFee: number; 6 | paymentType: string; 7 | membershipType: MembershipType; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/patterns/Summary.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/pattern_library/samples/compact-donation-form/index.ts: -------------------------------------------------------------------------------- 1 | import { Sample } from '@src/pattern_library/samples/Sample'; 2 | 3 | const name = 'Compact Donation Form'; 4 | const url = 'compact-donation-form'; 5 | import component from './Sample.vue'; 6 | 7 | export default { name, url, component } as Sample; 8 | -------------------------------------------------------------------------------- /src/pattern_library/samples/membership-fee-change/index.ts: -------------------------------------------------------------------------------- 1 | import { Sample } from '@src/pattern_library/samples/Sample'; 2 | 3 | const name = 'Membership Fee Change'; 4 | const url = 'membership-fee-change'; 5 | import component from './Sample.vue'; 6 | 7 | export default { name, url, component } as Sample; 8 | -------------------------------------------------------------------------------- /src/components/shared/composables/useModalState.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref } from 'vue'; 2 | 3 | export enum ModalStates { 4 | Open, 5 | Closed, 6 | } 7 | 8 | const modalState = ref( ModalStates.Closed ); 9 | 10 | export function useModalState(): Ref { 11 | return modalState; 12 | } 13 | -------------------------------------------------------------------------------- /src/pattern_library/css/compositions/flow.css: -------------------------------------------------------------------------------- 1 | /* 2 | FLOW COMPOSITION 3 | Like the Every Layout stack: https://every-layout.dev/layouts/stack/ 4 | Info about this implementation: https://piccalil.li/blog/my-favourite-3-lines-of-css/ 5 | */ 6 | .flow > * + * { 7 | margin-block-start: var(--flow-space, 1em); 8 | } 9 | -------------------------------------------------------------------------------- /src/util/injectStrict.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectionKey } from 'vue'; 2 | 3 | export function injectStrict( key: InjectionKey ): T { 4 | const resolved = inject( key ); 5 | if ( !resolved ) { 6 | throw new Error( `Could not resolve ${key.description}` ); 7 | } 8 | 9 | return resolved; 10 | } 11 | -------------------------------------------------------------------------------- /src/Domain/Membership/MembershipAddress.ts: -------------------------------------------------------------------------------- 1 | export interface MembershipAddress { 2 | fullName: string; 3 | salutation: string; 4 | title: string; 5 | email: string; 6 | streetAddress: string; 7 | postalCode: string; 8 | city: string; 9 | countryCode: string; 10 | applicantType: 'person' | 'firma'; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/layout/DefaultSidebar.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /src/util/FeatureFetcher.ts: -------------------------------------------------------------------------------- 1 | export interface FeatureFetcher { 2 | getFeatures(): string[]; 3 | } 4 | 5 | export function createFeatureFetcher( selectedBuckets: string[], activeFeatures: string[] ): FeatureFetcher { 6 | return { 7 | getFeatures: () => [ ...selectedBuckets, ...activeFeatures ], 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/view_models/Contact.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from '@src/view_models/Validity'; 2 | 3 | export interface InputField { 4 | name: string; 5 | value: string; 6 | pattern: string; 7 | optionalField: boolean; 8 | validity: Validity; 9 | } 10 | 11 | export interface FormData { 12 | [key: string]: InputField; 13 | } 14 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/accordion/description.md: -------------------------------------------------------------------------------- 1 | Accordions are used for grouping lists of data. They come in 2 themes, the standard one and the Wikimedia coloured one that is used on the Use of Funds page. 2 | 3 | ## Web Standards 4 | 5 | Adding `tabindex="0"` to the summary element makes sure it's focusable in all browsers. -------------------------------------------------------------------------------- /src/pattern_library/patterns/tooltip/description.md: -------------------------------------------------------------------------------- 1 | When a radio field is disabled sometimes we show a tooltip that explains to the donor why. 2 | 3 | ## Web Standards 4 | It should be aria hidden, but also a visually hidden element that contains the same tooltip added next to it so screen readers will read it as part of the label. -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/wrapper.scss: -------------------------------------------------------------------------------- 1 | @use "../../scss/settings/breakpoints"; 2 | 3 | body { 4 | padding-top: 104px; 5 | line-height: 1.5; 6 | } 7 | 8 | .content-wrapper { 9 | --wrapper-padding: 6px; 10 | flex-grow: 1; 11 | 12 | @include breakpoints.tablet-up { 13 | --wrapper-padding: 18px; 14 | } 15 | } -------------------------------------------------------------------------------- /src/pattern_library/patterns/field-container/description.md: -------------------------------------------------------------------------------- 1 | This is the container for a form field. It handles the display of the fields, the help text and error states. 2 | It also contains a wrapper for when a grid of radio options is required. 3 | 4 | ## Usage 5 | 6 | To set the error state on a field you should add the attribute `data-error`. 7 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/toggle.scss: -------------------------------------------------------------------------------- 1 | @use "../settings/colors"; 2 | 3 | .toggle { 4 | appearance: none; 5 | cursor: pointer; 6 | border: 1px solid colors.$gray-mid; 7 | } 8 | 9 | .toggle:checked { 10 | background: colors.$primary; 11 | } 12 | 13 | .toggle:checked:not(:focus-visible) { 14 | border-color: colors.$primary; 15 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'presets': [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | 'modules': 'auto', 7 | 'useBuiltIns': 'entry', 8 | 'corejs': { 9 | 'version': 3, 10 | 'proposals': true, 11 | }, 12 | }, 13 | ], 14 | ], 15 | 'plugins': [ 16 | '@babel/plugin-transform-runtime', 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/nav/markup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/link-button.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is for styling a button that looks like a link 3 | */ 4 | .link-button { 5 | all: unset; 6 | color: var(--color-blue-600); 7 | cursor: pointer; 8 | text-decoration: underline; 9 | } 10 | 11 | .link-button:hover, 12 | .link-button:focus { 13 | color: var(--color-grey-800); 14 | } 15 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/button/markup.html: -------------------------------------------------------------------------------- 1 | 2 | Link Button 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/footer-nav/markup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/donation-comment.css: -------------------------------------------------------------------------------- 1 | .donation-comment { 2 | --flow-space: var(--space-l); 3 | } 4 | 5 | .donation-comment > * + * { 6 | margin-block-start: var(--space-2xs); 7 | } 8 | 9 | .donation-comment__meta { 10 | line-height: var(--leading-flat); 11 | } 12 | 13 | .donation-comment em { 14 | color: var(--color-grey-600); 15 | } 16 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/donation-comment/markup.html: -------------------------------------------------------------------------------- 1 |
2 |

€15.00 from David Lynch24.01.2025 um 01:18 Uhr

3 | We think we understand the rules when we become adults but what we really experience is a narrowing of the imagination. 4 |
5 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FailingBankValidationResource.ts: -------------------------------------------------------------------------------- 1 | import type { BankValidationResource } from '@src/api/BankValidationResource'; 2 | 3 | export const newFailingBankValidationResource = (): BankValidationResource => { 4 | return { 5 | validateBankNumber: jest.fn().mockRejectedValue( 'ERR' ), 6 | validateIban: jest.fn().mockRejectedValue( 'ERR' ), 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/layout/AppContent.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/visually-hidden.css: -------------------------------------------------------------------------------- 1 | /* 2 | VISUALLY HIDDEN UTILITY 3 | Info: https://piccalil.li/quick-tip/visually-hidden/ 4 | */ 5 | .visually-hidden { 6 | border: 0; 7 | clip: rect(0 0 0 0); 8 | height: 0; 9 | margin: 0; 10 | overflow: hidden; 11 | padding: 0; 12 | position: absolute; 13 | width: 1px; 14 | white-space: nowrap; 15 | } 16 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/verbose-checkbox/description.md: -------------------------------------------------------------------------------- 1 | The verbose checkbox is a special checkbox in that it has a long info paragraph that follows it that needs to line up with the label text. It is used for the mailing list field. 2 | 3 | ## Web Standards 4 | 5 | The input should have an `aria-describedby` attribute that points to the ID of the paragraph element. 6 | -------------------------------------------------------------------------------- /src/Domain/Membership/MembershipApplication.ts: -------------------------------------------------------------------------------- 1 | import { MembershipType } from '@src/view_models/MembershipTypeModel'; 2 | 3 | export interface MembershipApplication { 4 | paymentIntervalInMonths: string | number; 5 | membershipFee: string | number; 6 | membershipType: MembershipType; 7 | paymentType: string; 8 | incentives: string[]; 9 | isExported: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/stretch-single-content-card.css: -------------------------------------------------------------------------------- 1 | /** 2 | * When we have short pages, like a 404 or an error the main content is shorter than 3 | * the sidebar and looks weird. This makes a content card that's an only child stretch 4 | * vertically and fill the space. 5 | */ 6 | .stretch-single-content-card > .content-card:only-child { 7 | min-height: 100%; 8 | } -------------------------------------------------------------------------------- /src/components/shared/icons/ChevronUpIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/components/shared/icons/ChevronDown.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/store/ValidationResponse.ts: -------------------------------------------------------------------------------- 1 | // These types encapsulate the JSON responses that come from the server when calling a validation route 2 | 3 | export interface SuccessResponse { 4 | status: 'OK'; 5 | } 6 | 7 | export interface ErrorResponse { 8 | status: 'ERR'; 9 | messages: Record; 10 | } 11 | 12 | export type ValidationResponse = SuccessResponse | ErrorResponse; 13 | -------------------------------------------------------------------------------- /src/view_models/Donation.ts: -------------------------------------------------------------------------------- 1 | export interface Donation { 2 | accessToken: string; 3 | amount: number; 4 | bankTransferCode: string; 5 | cookieDuration: string; 6 | creationDate: string; 7 | id: number; 8 | interval: number; 9 | receipt: boolean; 10 | newsletter: boolean; 11 | paymentType: string; 12 | status: string; 13 | updateToken: string; 14 | isExported: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/patterns/IconText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /src/components/shared/icons/Checkmark.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/components/shared/icons/TickIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/footer-nav.css: -------------------------------------------------------------------------------- 1 | .footer-nav a { 2 | display: block; 3 | padding-block: var(--space-2xs); 4 | color: var(--color-grey-800); 5 | border-bottom: 1px solid var(--color-grey-600); 6 | } 7 | 8 | .footer-nav a:not(:hover):not(:focus) { 9 | text-decoration: none; 10 | } 11 | 12 | .footer-nav a:hover, 13 | .footer-nav a:focus { 14 | color: var(--color-blue-600); 15 | } 16 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/mobile-nav-toggle/markup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/UpdateDonorRequest.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateDonorRequest { 2 | donationId: number; 3 | updateToken: string; 4 | addressType: string; 5 | salutation: string; 6 | title: string; 7 | firstName: string; 8 | lastName: string; 9 | companyName: string; 10 | street: string; 11 | city: string; 12 | postcode: string; 13 | country: string; 14 | email: string; 15 | mailingList: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/pages/contact/ContactFormData.ts: -------------------------------------------------------------------------------- 1 | import type { ContactFormItem } from '@src/components/pages/contact/ContactFormItem'; 2 | 3 | export interface ContactFormData { 4 | firstname: ContactFormItem; 5 | lastname: ContactFormItem; 6 | donationNumber: ContactFormItem; 7 | email: ContactFormItem; 8 | topic: ContactFormItem; 9 | subject: ContactFormItem; 10 | comment: ContactFormItem; 11 | } 12 | -------------------------------------------------------------------------------- /src/store/membership_address/constants.ts: -------------------------------------------------------------------------------- 1 | import { AddressTypeModel } from '@src/view_models/AddressTypeModel'; 2 | 3 | export const REQUIRED_FIELDS: { [key: number]: string[] } = { 4 | [ AddressTypeModel.PERSON ]: [ 'salutation', 'firstName', 'lastName', 'street', 'postcode', 'city', 'country', 'email' ], 5 | [ AddressTypeModel.COMPANY ]: [ 'companyName', 'street', 'postcode', 'city', 'country', 'email' ], 6 | }; 7 | -------------------------------------------------------------------------------- /webpack/env/prod.env.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | NODE_ENV: 'production', 3 | VUE_APP_LOGGER: 'errbit', 4 | VUE_APP_ERRBIT_HOST: process.env.VUE_APP_ERRBIT_HOST || 'https://logging.wikimedia.de', 5 | // should be set in the CI/CD pipeline or when running locally 6 | // e.g. "VUE_APP_ERRBIT_PROJECT_KEY=1234567890abcdef npm run build" 7 | VUE_APP_ERRBIT_PROJECT_KEY: process.env.VUE_APP_ERRBIT_PROJECT_KEY || '', 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/AddressTypeIds.ts: -------------------------------------------------------------------------------- 1 | import { AddressTypeModel } from '@src/view_models/AddressTypeModel'; 2 | 3 | export const AddressTypeIds = new Map( [ 4 | [ AddressTypeModel.ANON, 'anonymous' ], 5 | [ AddressTypeModel.EMAIL, 'email' ], 6 | [ AddressTypeModel.PERSON, 'person' ], 7 | [ AddressTypeModel.COMPANY, 'company' ], 8 | [ AddressTypeModel.UNSET, 'unset' ], 9 | ] ); 10 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/nav/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Nav'; 4 | const url = 'nav'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/nav.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/site-foot.css: -------------------------------------------------------------------------------- 1 | .site-foot { 2 | background: var(--color-blue-300); 3 | padding-block: var(--space-s-l); 4 | margin-block-start: var(--space-2xl); 5 | } 6 | 7 | .site-foot__content { 8 | display: flex; 9 | } 10 | 11 | .site-foot__brand { 12 | flex-grow: 0; 13 | flex-shrink: 0; 14 | width: var(--space-3xl); 15 | } 16 | 17 | .site-foot__copy { 18 | padding-inline: var(--space-s-xl); 19 | } 20 | -------------------------------------------------------------------------------- /src/util/append_campaign_query_params.ts: -------------------------------------------------------------------------------- 1 | export const appendCampaignQueryParams = ( htmlContent: string, campaignParams: string ): string => { 2 | return htmlContent.replace( 3 | // check for absolute paths and fully-qualified links to fundraising application, 4 | // ignore absolute paths to resources folder 5 | /href="((\/(?!resources)|https:\/\/spenden\.wikimedia\.de)[^"]*)"/g, 6 | `href="$1?${campaignParams}"` 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/info-columns.css: -------------------------------------------------------------------------------- 1 | .info-columns { 2 | --cluster-item-width: 16ch; 3 | --cluster-vertical-alignment: start; 4 | } 5 | 6 | .info-columns > * { 7 | --flow-space: var(--space-3xs); 8 | 9 | flex-basis: var(--cluster-item-width, auto); 10 | } 11 | 12 | .info-columns__icon { 13 | width: 2.5rem; 14 | height: 2.5rem; 15 | } 16 | 17 | .info-columns__icon svg { 18 | width: 100%; 19 | height: auto; 20 | } 21 | -------------------------------------------------------------------------------- /src/pattern_library/pages/spacing/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@src/pattern_library/pages/Page'; 2 | 3 | const name = 'Spacing'; 4 | const url = 'spacing'; 5 | import content from './content.md'; 6 | 7 | import spacing from '@src/pattern_library/css/variables/spacing.css?raw'; 8 | 9 | const codeSamples = [ 10 | { name: 'spacing.css', code: spacing }, 11 | ]; 12 | 13 | export default { name, url, content, codeSamples } as Page; 14 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/more-info-toggle/markup.html: -------------------------------------------------------------------------------- 1 |
2 | More Information 3 |

This is all the more info. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloremque laudantium nisi possimus, quas repudiandae veritatis? Architecto blanditiis, et impedit in incidunt inventore laborum molestiae molestias odio pariatur rem sit veniam.

4 |
-------------------------------------------------------------------------------- /src/pattern_library/pages/font-scaling/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@src/pattern_library/pages/Page'; 2 | 3 | const name = 'Font Scaling'; 4 | const url = 'font-scaling'; 5 | import content from './content.md'; 6 | 7 | import type from '@src/pattern_library/css/variables/type.css?raw'; 8 | 9 | const codeSamples = [ 10 | { name: 'type.css', code: type }, 11 | ]; 12 | 13 | export default { name, url, content, codeSamples } as Page; 14 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/button/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Button'; 4 | const url = 'button'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/button.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/callout/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Callout'; 4 | const url = 'callout'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/callout.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/summary/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Summary'; 4 | const url = 'summary'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/summary.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/toggle/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Toggle'; 4 | const url = 'toggle'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/toggle.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Tooltip'; 4 | const url = 'tooltip'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/tooltip.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/components/shared/icons/ExternalLink.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/combobox/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Combobox'; 4 | const url = 'combobox'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/combobox.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/locale/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Locale Changer'; 4 | const url = 'locale'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/locale.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /coverage 6 | /tests/e2e/reports/ 7 | selenium-debug.log 8 | 9 | # tmp dir with npm cache, created by Makefile 10 | tmp 11 | 12 | # local env files 13 | .env.local 14 | .env.*.local 15 | 16 | # Log files 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Editor directories and files 22 | .idea 23 | .vscode 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw* 29 | -------------------------------------------------------------------------------- /src/components/pages/membership_confirmation/SummaryLinks.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/accordion/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Accordion'; 4 | const url = 'accordion'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/accordion.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-text/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Icon Text'; 4 | const url = 'icon-text'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/icon-text.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/site-foot/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Site Foot'; 4 | const url = 'site-foot'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/site-foot.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/site-head/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Site Head'; 4 | const url = 'site-head'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/site-head.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/footer-nav/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Footer Nav'; 4 | const url = 'footer-nav'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/footer-nav.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/pagination/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Pagination'; 4 | const url = 'pagination'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/pagination.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/section-heading/markup.html: -------------------------------------------------------------------------------- 1 |
2 |

A 2 level section heading

3 |

Section Heading 2

4 |
5 |
6 | 7 |
8 |

A single level section heading

9 |
10 |
11 | 12 |
13 |

A section heading with normal text

14 |

This is some normal text

15 |
16 |
17 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/text-radio/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Text Radio'; 4 | const url = 'text-radio'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/text-radio.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/components/shared/icons/SuccessIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/content-card/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Content Card'; 4 | const url = 'content-card'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/content-card.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-sidebar/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Icon Sidebar'; 4 | const url = 'icon-sidebar'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/icon-sidebar.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/info-columns/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Info Columns'; 4 | const url = 'info-columns'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/info-columns.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /tests/data/countries.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { countryCode: 'DE', countryFullName: 'Deutschland', group: '', postCodeValidation: '^[0-9]{5}$' }, 3 | { countryCode: 'AU', countryFullName: 'Austria', group: '', postCodeValidation: '^[0-9]{5}$' }, 4 | { countryCode: 'IE', countryFullName: 'Ireland', group: 'infrequent', postCodeValidation: '^[0-9]{11}$' }, 5 | { countryCode: 'AUS', countryFullName: 'Australia', group: 'infrequent', postCodeValidation: '^[0-9]{7}$' }, 6 | ]; 7 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/modal-dialogue/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Modal Dialogue'; 4 | const url = 'modal-dialogue'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/modal-dialogue.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/view_models/Comment.ts: -------------------------------------------------------------------------------- 1 | export interface Comment { 2 | amount: string; 3 | donor: string; 4 | comment: string; 5 | date: string; 6 | } 7 | 8 | export function commentModelsFromObject( obj: any ): Comment[] { 9 | return obj.map( ( rawComment: any ) => { 10 | return { 11 | amount: rawComment.betrag, 12 | donor: rawComment.spender, 13 | comment: rawComment.kommentar, 14 | date: rawComment.lokalisiertes_datum, 15 | } as Comment; 16 | } ); 17 | } 18 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/field-container/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Field Container'; 4 | const url = 'field-container'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/field-container.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/iban-calculator/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'IBAN Calculator'; 4 | const url = 'iban-calculator'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/iban-calculator.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/section-heading/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Section Heading'; 4 | const url = 'section-heading'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/section-heading.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/detailed-info-box/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Detailed Info Box'; 4 | const url = 'detailed-info-box'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/detailed-info-box.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/donation-comment/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Donation Comment'; 4 | const url = 'donation-comment'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/donation-comment.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/mobile-nav-toggle/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Mobile Nav Toggle'; 4 | const url = 'mobile-nav-toggle'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/mobile-nav-toggle.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/more-info-toggle/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'More Info Toggle'; 4 | const url = 'more-info-toggle'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/more-info-toggle.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/verbose-checkbox/index.ts: -------------------------------------------------------------------------------- 1 | import { Pattern } from '@src/pattern_library/patterns/Pattern'; 2 | 3 | const name = 'Verbose Checkbox'; 4 | const url = 'verbose-checkbox'; 5 | import description from './description.md'; 6 | import html from './markup.html?raw'; 7 | import examples from './Examples.vue'; 8 | import css from '../../css/blocks/verbose-checkbox.css?raw'; 9 | 10 | export default { name, url, description, html, examples, css } as Pattern; 11 | -------------------------------------------------------------------------------- /src/view_models/supporters.ts: -------------------------------------------------------------------------------- 1 | export interface SupportersData { 2 | visibleSupporterId: number | null; 3 | } 4 | 5 | export interface Supporter { 6 | name: string; 7 | amount: string; 8 | comment: string; 9 | } 10 | 11 | export function supportersFromObject( obj: any ): Supporter[] { 12 | return obj.supporters.map( ( supporter: any ) => { 13 | return { 14 | name: supporter.name, 15 | amount: supporter.amount, 16 | comment: supporter.comment, 17 | }; 18 | } ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/toggle/Examples.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/useFieldModel.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref, UnwrapRef, watch } from 'vue'; 2 | 3 | type VueRefReturnType = Ref | Ref | Ref, T | UnwrapRef>; 4 | 5 | export function useFieldModel( modelValue: () => UnwrapRef, initialValue: T ): VueRefReturnType { 6 | const fieldModel = ref( initialValue ); 7 | 8 | watch( modelValue, ( newValue: UnwrapRef ) => { 9 | fieldModel.value = newValue; 10 | } ); 11 | 12 | return fieldModel; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/useMailingListModel.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref, watch } from 'vue'; 2 | import { action } from '@src/store/util'; 3 | import { Store } from 'vuex'; 4 | 5 | export function useMailingListModel( store: Store ): Ref { 6 | const mailingList = ref( store.state.address.newsletter ); 7 | 8 | watch( mailingList, ( newValue: boolean ) => { 9 | store.dispatch( action( 'address', 'setNewsletterChoice' ), newValue ); 10 | } ); 11 | 12 | return mailingList; 13 | } 14 | -------------------------------------------------------------------------------- /src/store/feeValidator.ts: -------------------------------------------------------------------------------- 1 | import { FeeValidity } from '@src/view_models/MembershipFee'; 2 | import { MEMBERSHIP_MAXIMUM_CENTS } from '@src/store/membership_fee/constants'; 3 | 4 | export function validateFee( amount: number, minimumAmount: number ): FeeValidity { 5 | 6 | if ( amount === 0 || amount < minimumAmount ) { 7 | return FeeValidity.FEE_TOO_LOW; 8 | } 9 | 10 | if ( amount > MEMBERSHIP_MAXIMUM_CENTS ) { 11 | return FeeValidity.FEE_TOO_HIGH; 12 | } 13 | 14 | return FeeValidity.FEE_VALID; 15 | } 16 | -------------------------------------------------------------------------------- /src/view_models/Validation.ts: -------------------------------------------------------------------------------- 1 | export interface AddressValidation { 2 | salutation: string; 3 | title: string; 4 | companyName: string; 5 | firstName: string; 6 | lastName: string; 7 | street: string; 8 | city: string; 9 | postcode: string; 10 | country: string; 11 | email: string; 12 | } 13 | 14 | export interface ContactFormValidation { 15 | firstName: string; 16 | lastName: string; 17 | donationNumber: string; 18 | email: string; 19 | topic: string; 20 | subject: string; 21 | comment: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/useAutocompleteScrollIntoViewOnFocus.ts: -------------------------------------------------------------------------------- 1 | export const autoscrollMaxWidth = 769; 2 | 3 | export function useAutocompleteScrollIntoViewOnFocus( target: string, maxWidth: number ): () => void { 4 | return (): void => { 5 | if ( window.innerWidth > maxWidth ) { 6 | return; 7 | } 8 | 9 | const scrollIntoViewElement = document.getElementById( target ); 10 | if ( scrollIntoViewElement ) { 11 | scrollIntoViewElement.scrollIntoView( { behavior: 'auto' } ); 12 | } 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/pages/membership_form/useIncentivesModel.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { Ref, ref, watch } from 'vue'; 3 | import { action } from '@src/store/util'; 4 | 5 | export function useIncentivesModel( store: Store ): Ref { 6 | const incentives = ref( store.state.membership_address.incentives ); 7 | 8 | watch( incentives, ( newValue: string[] | null ) => { 9 | store.dispatch( action( 'membership_address', 'setIncentives' ), newValue ); 10 | } ); 11 | 12 | return incentives; 13 | } 14 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-sidebar/markup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Lorem ipsum dolor sit amet

5 |
6 |
7 |
8 |

Lorem ipsum dolor sit amet

9 |
10 |
11 |
12 |

Lorem ipsum dolor sit amet

13 |
14 |
15 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/info-columns/markup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Lorem ipsum dolor sit amet

5 |
6 |
7 |
8 |

Lorem ipsum dolor sit amet

9 |
10 |
11 |
12 |

Lorem ipsum dolor sit amet

13 |
14 |
15 | -------------------------------------------------------------------------------- /src/util/merge_validation_results.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationResult } from '@src/view_models/Address'; 2 | 3 | export function mergeValidationResults( results: ValidationResult[] ) { 4 | return results.reduce( 5 | ( result: ValidationResult, currentResult: ValidationResult ) => { 6 | if ( currentResult.status !== 'OK' ) { 7 | result.status = 'ERR'; 8 | result.messages = Object.assign( result.messages, currentResult.messages ); 9 | } 10 | return result; 11 | }, 12 | { status: 'OK', messages: {} }, 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /webpack/helpers.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | const filename = fileURLToPath( import.meta.url ); 7 | const dirname = path.dirname( filename ); 8 | 9 | export default { 10 | root: function ( args ) { 11 | args = Array.prototype.slice.call( arguments, 0 ); 12 | 13 | return path.join.apply( path, [ path.resolve( dirname, '..' ) ].concat( args ) ); 14 | }, 15 | assetsPath: function ( _path ) { 16 | return path.posix.join( 'static', _path ); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/DonationReceipt/useAddressTypeModel.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref, watch } from 'vue'; 2 | import { action } from '@src/store/util'; 3 | import { Store } from 'vuex'; 4 | 5 | export function useAddressTypeModel( store: Store ): Ref { 6 | const addressType = ref( store.state.address.addressType ); 7 | 8 | watch( addressType, ( newValue: number | null ) => { 9 | store.dispatch( action( 'address', 'setAddressType' ), newValue ); 10 | } ); 11 | 12 | return addressType; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/pages/membership_form/useMembershipTypeModel.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref, watch } from 'vue'; 2 | import { action } from '@src/store/util'; 3 | import { Store } from 'vuex'; 4 | 5 | export function useMembershipTypeModel( store: Store ): Ref { 6 | const membershipType = ref( store.state.membership_address.membershipType ); 7 | 8 | watch( membershipType, ( newValue: number ) => { 9 | store.dispatch( action( 'membership_address', 'setMembershipType' ), newValue ); 10 | } ); 11 | 12 | return membershipType; 13 | } 14 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/locale/markup.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 7 | 8 | 9 |
10 |
-------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeCommentResource.ts: -------------------------------------------------------------------------------- 1 | import type { CommentResource } from '@src/api/CommentResource'; 2 | 3 | export const successMessage = 'Success'; 4 | export const failureMessage = 'Fail'; 5 | 6 | export class FakeSucceedingCommentResource implements CommentResource { 7 | post(): Promise { 8 | return Promise.resolve( successMessage ); 9 | } 10 | } 11 | 12 | export class FakeFailingCommentResource implements CommentResource { 13 | post(): Promise { 14 | return Promise.reject( failureMessage ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/patterns/AccordionItem.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /src/store/paymentTypeValidator.ts: -------------------------------------------------------------------------------- 1 | import { PaymentType } from '@src/view_models/PaymentType'; 2 | 3 | export function isValidPaymentType( paymentType: string, interval: string, allowedPaymentTypes: string[] ): boolean { 4 | if ( !allowedPaymentTypes.includes( paymentType ) ) { 5 | return false; 6 | } 7 | 8 | // Sofort payments can't be recurring so clear the type if it is initialised with an interval 9 | if ( paymentType === PaymentType.SOFORT && ![ '', '0' ].includes( interval ) ) { 10 | return false; 11 | } 12 | 13 | return true; 14 | } 15 | -------------------------------------------------------------------------------- /src/pattern_library/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from '@src/pattern_library/components/App.vue'; 3 | import PageDataInitializer from '@src/util/page_data_initializer'; 4 | import { content } from '@src/pattern_library/content'; 5 | 6 | interface ApplicationVars { 7 | pattern: string; 8 | } 9 | 10 | const pageData = new PageDataInitializer( '#appdata' ); 11 | 12 | createApp( App, { 13 | patternID: pageData.applicationVars.pattern, 14 | content, 15 | assetsPath: pageData.assetsPath, 16 | } ).mount( '#app' ); 17 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeCityAutocompleteResource.ts: -------------------------------------------------------------------------------- 1 | import type { CityAutocompleteResource } from '@src/api/CityAutocompleteResource'; 2 | 3 | export class FakeCityAutocompleteResource implements CityAutocompleteResource { 4 | getCitiesInPostcode(): Promise> { 5 | return Promise.resolve( [ 6 | 'Takeshi\'s Castle', 7 | 'Mushroom Kingdom City', 8 | 'Alabastia', 9 | 'FÜN-Stadt', 10 | 'Ba Sing Se', 11 | 'Satan City', 12 | 'Gotham City', 13 | 'Kleinstes-Kaff-der-Welt', 14 | 'Entenhausen', 15 | ] ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeStreetAutocompleteResource.ts: -------------------------------------------------------------------------------- 1 | import type { StreetAutocompleteResource } from '@src/api/StreetAutocompleteResource'; 2 | 3 | export class FakeStreetAutocompleteResource implements StreetAutocompleteResource { 4 | getStreetsInPostcode(): Promise> { 5 | return Promise.resolve( [ 6 | 'Cherry Tree Lane', 7 | 'Jump Street', 8 | 'Evergreen Terrace', 9 | 'FÜN-Straße', 10 | 'Elm Street', 11 | 'Cobblestone Way', 12 | 'Spooner Street', 13 | 'Baker Street', 14 | 'Sesame Street', 15 | ] ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/data/salutations.ts: -------------------------------------------------------------------------------- 1 | import type { Salutation } from '@src/view_models/Salutation'; 2 | 3 | export const salutations: Salutation[] = [ 4 | { 5 | label: 'Herr', 6 | value: 'Herr', 7 | display: 'Herr', 8 | greetings: { 9 | formal: 'Good day', 10 | informal: 'Yo!', 11 | lastNameInformal: 'My Herr!', 12 | }, 13 | }, 14 | { 15 | label: 'Frau', 16 | value: 'Frau', 17 | display: 'Frau', 18 | greetings: { 19 | formal: 'Good day', 20 | informal: 'Yo!', 21 | lastNameInformal: 'My Frau!', 22 | }, 23 | }, 24 | ]; 25 | 26 | export default salutations; 27 | -------------------------------------------------------------------------------- /src/components/pages/StaticPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | -------------------------------------------------------------------------------- /src/pattern_library/css/compositions/wrapper.css: -------------------------------------------------------------------------------- 1 | /* CONTENT WRAPPER COMPOSITION 2 | A common wrapper/container. Usually used for the top-level elements on the page (children of `body`) to center them horizontally. 3 | 4 | Note: This is called `.content-wrapper` only because some ad-blockers target the class `.wrapper` 5 | */ 6 | .content-wrapper { 7 | margin-inline: auto; 8 | width: 100%; 9 | max-width: var(--wrapper-max-width, 1360px); 10 | padding-left: var(--wrapper-padding, var(--gutter)); 11 | padding-right: var(--wrapper-padding, var(--gutter)); 12 | position: relative; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/pages/SystemMessage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /tests/unit/utils/errorSummaryItemIsFunctional.ts: -------------------------------------------------------------------------------- 1 | import { VueWrapper } from '@vue/test-utils'; 2 | 3 | export const errorSummaryItemIsFunctional = ( wrapper: VueWrapper, formElement: string, scrollElement: string ): boolean => { 4 | const errorItemExists = wrapper.find( `.error-summary a[href="#${formElement}"][data-scroll-element="${scrollElement}"]` ).exists(); 5 | const formElementExists = wrapper.find( `#${formElement}` ).exists(); 6 | const scrollElementExists = wrapper.find( `#${scrollElement}` ).exists(); 7 | 8 | return errorItemExists && formElementExists && scrollElementExists; 9 | }; 10 | -------------------------------------------------------------------------------- /src/pages/comment_ticker.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import { createApp } from 'vue'; 3 | import PageDataInitializer from '@src/util/page_data_initializer'; 4 | import { createLocalisation } from '@src/util/createLocalisation'; 5 | import CommentTicker from '@src/components/pages/CommentTicker.vue'; 6 | 7 | const pageData = new PageDataInitializer( '#appdata' ); 8 | 9 | const i18n = createLocalisation( pageData.messages ); 10 | const app = createApp( CommentTicker, { 11 | pageTitle: 'comment_ticker_page_title', 12 | pageProps: { 13 | }, 14 | } ); 15 | 16 | app.use( i18n ); 17 | app.mount( '#app' ); 18 | -------------------------------------------------------------------------------- /src/components/pages/membership_form/useReceiptModel.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { Ref, ref, watch } from 'vue'; 3 | import { action } from '@src/store/util'; 4 | 5 | type ReturnType = { 6 | receiptNeeded: Ref; 7 | }; 8 | 9 | export function useReceiptModel( store: Store, initialValue: boolean ): ReturnType { 10 | const receiptNeeded = ref( initialValue ); 11 | 12 | watch( receiptNeeded, ( newValue: boolean | null ) => { 13 | store.dispatch( action( 'membership_address', 'setReceiptChoice' ), newValue ); 14 | } ); 15 | 16 | return { 17 | receiptNeeded, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/summary/markup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Your Contact Data

4 |

5 | Joe Bloggs
6 | Any Street
7 | 23456 Some City
8 | Germany
9 | joebloggs@example.com 10 |

11 |
12 |
13 |

Your Bank Details

14 | 15 |
    16 |
  • IBAN: DE1234 5678 1234 5678
  • 17 |
  • BIC: BS123
  • 18 |
  • Bank Name: Berliner Sparkasse
  • 19 |
20 |
21 |
-------------------------------------------------------------------------------- /src/store/amountValidator.ts: -------------------------------------------------------------------------------- 1 | import { AmountValidity } from '@src/view_models/Payment'; 2 | 3 | const minCentAmount = 100; 4 | const maxCentAmount = 9999999; 5 | 6 | export function validateAmount( amount: number ): AmountValidity { 7 | 8 | if ( amount < minCentAmount ) { 9 | return AmountValidity.AMOUNT_TOO_LOW; 10 | } 11 | 12 | if ( amount > maxCentAmount ) { 13 | return AmountValidity.AMOUNT_TOO_HIGH; 14 | } 15 | 16 | return AmountValidity.AMOUNT_VALID; 17 | } 18 | 19 | export function isValidAmount( amount: number ): boolean { 20 | return validateAmount( amount ) === AmountValidity.AMOUNT_VALID; 21 | } 22 | -------------------------------------------------------------------------------- /src/store/data_persistence/address.ts: -------------------------------------------------------------------------------- 1 | import { DataPersistenceMutationType } from '@src/view_models/DataPersistence'; 2 | import { mutation } from '@src/store/util'; 3 | 4 | export default ( namespace: string ) => { 5 | return { 6 | storageKey: 'address', 7 | mutationType: DataPersistenceMutationType.KEY_VALUE_PAIR, 8 | mutationKey: mutation( namespace, 'SET_ADDRESS_FIELD' ), 9 | fields: [ 10 | 'salutation', 11 | 'title', 12 | 'firstName', 13 | 'lastName', 14 | 'street', 15 | 'postcode', 16 | 'city', 17 | 'country', 18 | 'email', 19 | 'companyName', 20 | 'date', 21 | ], 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "es5", 5 | "skipLibCheck": true, 6 | "importHelpers": true, 7 | "sourceMap": true, 8 | "baseUrl": ".", 9 | "esModuleInterop": true, 10 | "resolveJsonModule": true, 11 | "types": [ 12 | "webpack-env", 13 | "jest" 14 | ], 15 | "paths": { 16 | "@src/*": [ "src/*" ], 17 | "@test/*": [ "tests/*" ] 18 | }, 19 | "lib": [ 20 | "esnext", 21 | "dom", 22 | "dom.iterable", 23 | "scripthost", 24 | "es5", 25 | "es2015.promise" 26 | ] 27 | }, 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/components/pages/AccessDenied.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Fundraising 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/shared/composables/useValueEqualsPlaceholderWarning.ts: -------------------------------------------------------------------------------- 1 | import { computed, ComputedRef, Ref } from 'vue'; 2 | import { useI18n } from 'vue-i18n'; 3 | 4 | export type ReturnType = { 5 | hasWarning: ComputedRef; 6 | warning: string; 7 | }; 8 | 9 | export function useValueEqualsPlaceholderWarning( value: Ref, placeholder: string, warning: string | null ): ReturnType { 10 | const { t } = useI18n(); 11 | return { 12 | hasWarning: computed( () => value.value.toString().toLowerCase() === placeholder.toLowerCase() ), 13 | warning: warning ? t( warning, { value: placeholder } ) : '', 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/pattern_library/pages/css-variables/content.md: -------------------------------------------------------------------------------- 1 | We define a lot of CSS variables that are used across the site, this helps to keep type sizes, spacing, and colours consistent. 2 | 3 | ## type.css 4 | The type file contains variables to handle fonts, weights, size scaling, and leading. 5 | 6 | ## spacing.css 7 | This defines variables to handle consistent spacing that scales based on viewport width. 8 | 9 | ## color.css 10 | This defines all the colours used on the site. 11 | 12 | ## global.css 13 | This defines some global variables that are used across the library and site, such as the navigation height, sidebar width, and animation easing. 14 | -------------------------------------------------------------------------------- /src/components/shared/icons/BooksIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/footer-nav/Examples.vue: -------------------------------------------------------------------------------- 1 | 19 | 22 | -------------------------------------------------------------------------------- /src/util/street_and_building_number_tools.ts: -------------------------------------------------------------------------------- 1 | export const separator = '|||'; 2 | 3 | export function splitStreetAndBuildingNumber( street: string ): { street: string; buildingNumber: string } { 4 | const values = street.split( separator ); 5 | return { 6 | street: values[ 0 ] ?? '', 7 | buildingNumber: values[ 1 ] ?? '', 8 | }; 9 | } 10 | 11 | export function joinStreetAndBuildingNumber( streetName: string, buildingNumber: string ): string { 12 | return streetName + separator + buildingNumber; 13 | } 14 | 15 | export function clearStreetAndBuildingNumberSeparator( street: string ): string { 16 | return street.replace( separator, ' ' ).trim(); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/useInputFocusing.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref } from 'vue'; 2 | 3 | type ReturnType = { 4 | labelRef: Ref; 5 | inputRef: Ref; 6 | focus(): void; 7 | click(): void; 8 | }; 9 | 10 | export function useInputFocusing(): ReturnType { 11 | const labelRef = ref( null ); 12 | const inputRef = ref( null ); 13 | 14 | // MacOS FireFox and Safari do not focus when clicked 15 | const focus = (): void => inputRef.value.focus(); 16 | const click = (): void => labelRef.value.click(); 17 | 18 | return { 19 | labelRef, 20 | inputRef, 21 | focus, 22 | click, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/store/donor_update_store.ts: -------------------------------------------------------------------------------- 1 | import Vuex, { StoreOptions } from 'vuex'; 2 | import createAddress from '@src/store/address'; 3 | import { DEFAULT_FIELDS, REQUIRED_FIELDS_DONOR_UPDATE } from '@src/store/address/constants'; 4 | 5 | export function createStore() { 6 | const storeBundle: StoreOptions = { 7 | modules: { 8 | [ 'address' ]: createAddress( REQUIRED_FIELDS_DONOR_UPDATE, DEFAULT_FIELDS ), 9 | }, 10 | strict: process.env.NODE_ENV !== 'production', 11 | getters: { 12 | isValidating: function ( state ): boolean { 13 | return state.address.isValidating; 14 | }, 15 | }, 16 | }; 17 | 18 | return new Vuex.Store( storeBundle ); 19 | } 20 | -------------------------------------------------------------------------------- /src/Domain/Membership/MembershipApplicationConfirmationData.ts: -------------------------------------------------------------------------------- 1 | import type { Salutation } from '@src/view_models/Salutation'; 2 | import type { MembershipApplication } from '@src/Domain/Membership/MembershipApplication'; 3 | import type { MembershipAddress } from '@src/Domain/Membership/MembershipAddress'; 4 | import type { Country } from '@src/view_models/Country'; 5 | 6 | export interface MembershipApplicationConfirmationData { 7 | piwik: { 8 | membershipApplicationConfirmationGoalId: number; 9 | }; 10 | salutations: Array; 11 | membershipApplication: MembershipApplication; 12 | address: MembershipAddress; 13 | countries: Country[]; 14 | tracking?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/button/Examples.vue: -------------------------------------------------------------------------------- 1 | 17 | 20 | -------------------------------------------------------------------------------- /src/scss/settings/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $tablet: 769px; 2 | $desktop: 1024px; 3 | $widescreen: 1216px; 4 | $full-hd: 1408px; 5 | 6 | /* 7 | MIXIN USAGE: 8 | 9 | .my-selector { 10 | @include breakpoints.small-up { 11 | // Stuff that happens when bigger than $small 12 | } 13 | } 14 | 15 | */ 16 | 17 | @mixin tablet-up { 18 | @media ( min-width: $tablet ) { 19 | @content; 20 | } 21 | } 22 | 23 | @mixin desktop-up { 24 | @media ( min-width: $desktop ) { 25 | @content; 26 | } 27 | } 28 | 29 | @mixin widescreen-up { 30 | @media ( min-width: $widescreen ) { 31 | @content; 32 | } 33 | } 34 | 35 | @mixin full-hd-up { 36 | @media ( min-width: $full-hd ) { 37 | @content; 38 | } 39 | } -------------------------------------------------------------------------------- /src/components/shared/icons/ChevronLeftIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeBankValidationResource.ts: -------------------------------------------------------------------------------- 1 | import type { BankValidationResource } from '@src/api/BankValidationResource'; 2 | import type { BankAccountResponse } from '@src/view_models/BankAccount'; 3 | 4 | export class FakeBankValidationResource implements BankValidationResource { 5 | async validateIban(): Promise { 6 | return { 7 | accountNumber: '', 8 | bankCode: '', 9 | bankName: '', 10 | iban: '', 11 | bic: '', 12 | }; 13 | } 14 | 15 | async validateBankNumber(): Promise { 16 | return { 17 | accountNumber: '', 18 | bankCode: '', 19 | bankName: '', 20 | iban: '', 21 | bic: '', 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/shared/icons/ChevronRightIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/components/pages/useCommentResource.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import type { Comment } from '@src/view_models/Comment'; 3 | import { commentModelsFromObject } from '@src/view_models/Comment'; 4 | import axios from 'axios'; 5 | 6 | export function useCommentResource() { 7 | const comments = ref( [] ); 8 | 9 | const fetchComments = async (): Promise => { 10 | try { 11 | let response = await axios.get( '/list-comments.json?n=100&anon=1' ); 12 | comments.value = commentModelsFromObject( response.data ); 13 | return Promise.resolve(); 14 | } catch { 15 | return Promise.reject(); 16 | } 17 | }; 18 | 19 | return { 20 | comments, 21 | fetchComments, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/store/data_persistence/bankdata.ts: -------------------------------------------------------------------------------- 1 | import { DataPersistenceMutationType } from '@src/view_models/DataPersistence'; 2 | import { mutation } from '@src/store/util'; 3 | 4 | export default [ 5 | { 6 | storageKey: 'bankName', 7 | mutationType: DataPersistenceMutationType.VALUE, 8 | mutationKey: mutation( 'bankdata', 'SET_BANK_NAME' ), 9 | fields: [], 10 | }, 11 | { 12 | storageKey: 'iban', 13 | mutationType: DataPersistenceMutationType.VALUE, 14 | mutationKey: mutation( 'bankdata', 'SET_IBAN' ), 15 | fields: [], 16 | }, 17 | { 18 | storageKey: 'bic', 19 | mutationType: DataPersistenceMutationType.VALUE, 20 | mutationKey: mutation( 'bankdata', 'SET_BIC' ), 21 | fields: [], 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /src/util/wait_for_server_validation.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | 3 | // These constants are mostly for testing 4 | export enum ValidationState { 5 | WAS_VALIDATING, 6 | IMMEDIATE, 7 | } 8 | 9 | export function waitForServerValidationToFinish( store: Store ): Promise { 10 | if ( !store.getters.isValidating ) { 11 | return Promise.resolve( ValidationState.IMMEDIATE ); 12 | } 13 | return new Promise( ( resolve ) => { 14 | const unwatch = store.watch( 15 | ( state, getters ) => getters.isValidating, 16 | ( isValidating ) => { 17 | if ( !isValidating ) { 18 | unwatch(); 19 | resolve( ValidationState.WAS_VALIDATING ); 20 | } 21 | } 22 | ); 23 | } ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/shared/composables/useDisplaySwitch.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, Ref, ref } from 'vue'; 2 | 3 | export function useDisplaySwitch( minWidthForLargeScreen: number ): Ref { 4 | 5 | const showComponentForLargeScreen = ref( window.innerWidth > minWidthForLargeScreen ); 6 | 7 | const onDisplaySwitchResize = (): void => { 8 | showComponentForLargeScreen.value = window.innerWidth > minWidthForLargeScreen; 9 | }; 10 | 11 | onMounted( () => { 12 | window.addEventListener( 'resize', onDisplaySwitchResize ); 13 | } ); 14 | 15 | onUnmounted( () => { 16 | window.removeEventListener( 'resize', onDisplaySwitchResize ); 17 | } ); 18 | 19 | return showComponentForLargeScreen; 20 | } 21 | -------------------------------------------------------------------------------- /src/store/bankdata/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { Validity } from '@src/view_models/Validity'; 3 | import { actions } from '@src/store/bankdata/actions'; 4 | import { mutations } from '@src/store/bankdata/mutations'; 5 | import type { BankAccount } from '@src/view_models/BankAccount'; 6 | 7 | export default function (): Module { 8 | const state: BankAccount = { 9 | isValidating: false, 10 | validity: { 11 | iban: Validity.INCOMPLETE, 12 | }, 13 | values: { 14 | bankName: '', 15 | iban: '', 16 | bic: '', 17 | }, 18 | }; 19 | 20 | const namespaced = true; 21 | 22 | return { 23 | namespaced, 24 | state, 25 | mutations, 26 | actions, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/util/createFeatureToggle.ts: -------------------------------------------------------------------------------- 1 | import { useSlots } from 'vue'; 2 | 3 | export function createFeatureToggle( activeFeatures?: string[] ) { 4 | return { 5 | props: { 6 | defaultTemplate: { 7 | type: String, 8 | default: '', 9 | }, 10 | }, 11 | setup() { 12 | const slots = useSlots(); 13 | const usedSlotNames = Object.keys( slots ); 14 | const slotsToShow = usedSlotNames.filter( ( slotName: string ) => activeFeatures.indexOf( slotName ) > -1 ); 15 | 16 | return { slotsToShow }; 17 | }, 18 | template: ` 19 | 20 | 21 | `, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/patterns/ContentCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 25 | -------------------------------------------------------------------------------- /src/pattern_library/css/compositions/repel.css: -------------------------------------------------------------------------------- 1 | /* 2 | REPEL 3 | Repel is used when you need 2 elements in a row that align to the left and right 4 | edges but will stack once there is not enough space for them. 5 | 6 | CUSTOM PROPERTIES AND CONFIGURATION 7 | --gutter (var(--space-m)): This defines the space 8 | between each item. 9 | 10 | --repel-vertical-alignment (center): How items should align vertically. 11 | Can be any acceptable flexbox alignment value. 12 | */ 13 | .repel { 14 | display: flex; 15 | flex-wrap: wrap; 16 | justify-content: space-between; 17 | align-items: var(--repel-vertical-alignment, center); 18 | gap: var(--gutter, var(--space-m)); 19 | } 20 | 21 | .repel[data-nowrap] { 22 | flex-wrap: nowrap; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/pages/UpdateAddressSuccess.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 28 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/verbose-checkbox/markup.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |

If you do not want to receive emails, please uncheck the box. You can also unsubscribe at any time in the future, for example, via the unsubscribe link at the bottom of each email or by sending an email to spenden@wikimedia.de. You can find further information in our privacy policy.

7 |
8 | -------------------------------------------------------------------------------- /src/scss/settings/_forms.scss: -------------------------------------------------------------------------------- 1 | @use './units'; 2 | @use './colors'; 3 | @use "sass:map"; 4 | @use 'sass:color'; 5 | 6 | $input: ( 7 | 'border-radius': 8px, 8 | 'border': 1px solid colors.$gray-mid, 9 | 'border-focus-color': colors.$primary, 10 | 'border-error-color': colors.$error, 11 | 'height': 46px, 12 | 'font-size': 16px, 13 | 'min-width': 272px, 14 | 'max-width': 414px, 15 | ); 16 | 17 | $checkbox-checkmark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E"); 18 | -------------------------------------------------------------------------------- /src/components/shared/useMailHostList.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref } from 'vue'; 2 | import axios from 'axios'; 3 | 4 | /** 5 | * This is a Vue composable that provides a list of popular mail providers. 6 | * 7 | * Right now it's used in the Email component to check for typos. 8 | */ 9 | export function useMailHostList(): Ref { 10 | const mailHostList = ref( [] ); 11 | 12 | axios.get( '/resources/mail_provider_suggestions.json' ) 13 | .then( ( response ) => { 14 | mailHostList.value = response.data; 15 | } ) 16 | .catch( () => { 17 | // Do nothing - if the request fails, we just don't have (optional) data, 18 | // but still have the empty array that the code can work with 19 | } ); 20 | 21 | return mailHostList; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/pages/membership_form/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/components/shared/PaymentSummarySection.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | -------------------------------------------------------------------------------- /src/store/update_address_store.ts: -------------------------------------------------------------------------------- 1 | import Vuex, { Store, StoreOptions } from 'vuex'; 2 | import createAddress from '@src/store/address'; 3 | import { DEFAULT_FIELDS, REQUIRED_FIELDS_ADDRESS_UPDATE } from '@src/store/address/constants'; 4 | 5 | export function createStore( plugins: Array< ( s: Store ) => void > = [] ) { 6 | const storeBundle: StoreOptions = { 7 | modules: { 8 | [ 'address' ]: createAddress( REQUIRED_FIELDS_ADDRESS_UPDATE, DEFAULT_FIELDS ), 9 | }, 10 | strict: process.env.NODE_ENV !== 'production', 11 | getters: { 12 | isValidating: function ( state ): boolean { 13 | return state.address.isValidating; 14 | }, 15 | }, 16 | plugins, 17 | }; 18 | 19 | return new Vuex.Store( storeBundle ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/shared/composables/useDetectOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount, onMounted, Ref } from 'vue'; 2 | 3 | export function useDetectOutsideClick( component: Ref, callback: () => void ) { 4 | if ( !component ) { 5 | return; 6 | } 7 | 8 | const listener = ( event: Event ): void => { 9 | if ( event.target !== component.value && event.composedPath().includes( component.value ) ) { 10 | return; 11 | } 12 | if ( typeof callback === 'function' ) { 13 | callback(); 14 | } 15 | }; 16 | 17 | onMounted( () => { 18 | window.addEventListener( 'click', listener ); 19 | } ); 20 | 21 | onBeforeUnmount( () => { 22 | window.removeEventListener( 'click', listener ); 23 | } ); 24 | 25 | return { listener }; 26 | } 27 | -------------------------------------------------------------------------------- /src/pattern_library/components/icons/Close.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/modal-dialogue/markup.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 15 | 16 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeDataPersistenceRepository.ts: -------------------------------------------------------------------------------- 1 | import type { DataPersistenceRepository } from '@src/view_models/DataPersistenceRepository'; 2 | 3 | export default class FakeDataPersistenceRepository implements DataPersistenceRepository { 4 | public items: Record; 5 | 6 | constructor() { 7 | this.items = {}; 8 | } 9 | 10 | getItems(): Record { 11 | return this.items; 12 | } 13 | 14 | getItem( key: string ): ArrayBuffer | null { 15 | const item = this.items[ key ]; 16 | return item !== undefined ? item : null; 17 | } 18 | 19 | removeItem( key: string ): void { 20 | delete this.items[ key ]; 21 | } 22 | 23 | setItem( key: string, data: ArrayBuffer ): void { 24 | this.items[ key ] = data; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/pagination.css: -------------------------------------------------------------------------------- 1 | .pagination { 2 | --cluster-horizontal-alignment: center; 3 | --gutter: var(--space-xs); 4 | --chevron-fill: var(--color-grey-800); 5 | --flow-space: var(--space-xl); 6 | } 7 | 8 | .pagination button { 9 | height: var(--space-l); 10 | width: var(--space-m); 11 | background: transparent; 12 | border: var(--stroke); 13 | border-radius: var(--border-radius); 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | .pagination button:where([aria-current], :hover, :focus) { 20 | border-color: var(--color-blue-600); 21 | } 22 | 23 | .pagination button[aria-current] { 24 | color: var(--color-blue-600); 25 | } 26 | 27 | .pagination button:disabled { 28 | opacity: 0.5; 29 | } 30 | -------------------------------------------------------------------------------- /src/view_models/MembershipTypeModel.ts: -------------------------------------------------------------------------------- 1 | export enum MembershipTypeModel { 2 | SUSTAINING, 3 | ACTIVE, 4 | } 5 | 6 | export type MembershipType = 'sustaining' | 'active'; 7 | 8 | export const MembershipTypeNames = new Map( [ 9 | [ MembershipTypeModel.SUSTAINING, 'sustaining' ], 10 | [ MembershipTypeModel.ACTIVE, 'active' ], 11 | ] ); 12 | 13 | export function membershipTypeName( t: MembershipTypeModel ): MembershipType { 14 | const name = MembershipTypeNames.get( t ); 15 | // poor man's type check to protect against future extensions of MembershipTypeNames, e.g. https://phabricator.wikimedia.org/T220367 16 | if ( typeof name === 'undefined' ) { 17 | throw new Error( 'Unknown membership type: ' + t ); 18 | } 19 | return name; 20 | } 21 | -------------------------------------------------------------------------------- /src/pattern_library/css/utilities/highlighted-content.css: -------------------------------------------------------------------------------- 1 | .highlighted-content-text { 2 | color: var( --color-blue-600 ); 3 | } 4 | 5 | .highlighted-content-text[data-success] { 6 | color: var( --color-green-500 ); 7 | } 8 | 9 | .highlighted-content-text[data-warning] { 10 | color: var( --color-yellow-300 ); 11 | } 12 | 13 | .highlighted-content-text[data-error] { 14 | color: var( --color-red-700 ); 15 | } 16 | 17 | .highlighted-icon { 18 | --icon-color: var( --color-blue-600 ); 19 | } 20 | 21 | .highlighted-icon[data-success] { 22 | --icon-color: var( --color-green-500 ); 23 | } 24 | 25 | .highlighted-icon[data-warning] { 26 | --icon-color: var( --color-yellow-300 ); 27 | } 28 | 29 | .highlighted-icon[data-error] { 30 | --icon-color: var( --color-red-700 ); 31 | } -------------------------------------------------------------------------------- /tests/unit/TestDoubles/SucceedingBankValidationResource.ts: -------------------------------------------------------------------------------- 1 | import type { BankAccountResponse } from '@src/view_models/BankAccount'; 2 | import type { BankValidationResource } from '@src/api/BankValidationResource'; 3 | import { accountNumber, bankCode, bankName, BIC, IBAN } from '@test/data/bankdata'; 4 | 5 | export const newSucceedingBankValidationResource = ( apiReturnValue: BankAccountResponse = null ): BankValidationResource => { 6 | const returnValue: BankAccountResponse = apiReturnValue ?? { 7 | accountNumber, 8 | bankCode, 9 | bankName, 10 | iban: IBAN, 11 | bic: BIC, 12 | }; 13 | return { 14 | validateBankNumber: jest.fn( () => Promise.resolve( returnValue ) ), 15 | validateIban: jest.fn( () => Promise.resolve( returnValue ) ), 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/pages/PageNotFound.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/useInputModel.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, UnwrapRef, watch, WritableComputedRef } from 'vue'; 2 | 3 | export function useInputModel( 4 | modelValue: () => UnwrapRef, 5 | initialValue: T, 6 | emit: ( event: string, value: UnwrapRef ) => void 7 | ): WritableComputedRef> { 8 | const backingValue = ref( initialValue ); 9 | 10 | const inputModel = computed>( { 11 | get: () => backingValue.value, 12 | set: ( newValue: UnwrapRef ): void => { 13 | backingValue.value = newValue; 14 | emit( 'update:modelValue', newValue ); 15 | }, 16 | } ); 17 | 18 | watch( modelValue, ( newModelValue: UnwrapRef ) => { 19 | backingValue.value = newModelValue; 20 | } ); 21 | 22 | return inputModel; 23 | } 24 | -------------------------------------------------------------------------------- /src/scss/settings/_colors.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | 3 | $red100: #fee7e6; 4 | $red500: #ff4242; 5 | $red600: #d73333; 6 | $red700: #b32424; 7 | 8 | $primary: #3366CC; 9 | $primary-light: #1E88CA; 10 | $primary-lighter: #A5CEE2; 11 | $primary-lightest: #DCE4E8; 12 | $primary-hover: color.adjust( $primary, $lightness: -5% ); 13 | $primary-locale-active: #EAF3FF; 14 | $primary-pale: #DDEDF6; 15 | $error: $red600; 16 | $error-light: #FDE1E1; 17 | 18 | $footer: #C7D1D8; 19 | 20 | $white: #ffffff; 21 | $black: #000000; 22 | 23 | $gray-light: #E5E5E5; 24 | $gray-lighter: #F5F5F5; 25 | $gray-mid: #B7B7B7; 26 | $gray-dark: #808080; 27 | $dark: color.adjust( $black, $lightness: 40% ); 28 | 29 | $transparent-black: rgba(0, 0, 0, 0.14); 30 | $very-transparent-black: rgba(0, 0, 0, 0.07); 31 | -------------------------------------------------------------------------------- /src/view_models/DataPersistence.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Data is captured by mutation name on Vuex mutations, 3 | * but address fields all share the same mutation name 4 | * so their values have to be captured differently 5 | */ 6 | export enum DataPersistenceMutationType { 7 | VALUE, 8 | KEY_VALUE_PAIR, 9 | } 10 | 11 | /** 12 | * The fields array is only used for KEY_VALUE_PAIR mutation types 13 | */ 14 | export interface DataPersistenceItem { 15 | storageKey: string; 16 | mutationType: DataPersistenceMutationType; 17 | mutationKey: string; 18 | fields: string[]; 19 | } 20 | 21 | export interface DataPersister { 22 | getPlugin( items: DataPersistenceItem[] ): any; 23 | initialize( items: DataPersistenceItem[] ): Promise; 24 | getValue( key: string ): string | null; 25 | } 26 | -------------------------------------------------------------------------------- /src/api/CityAutocompleteResource.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | 3 | export interface CityAutocompleteResource { 4 | getCitiesInPostcode( postcode: string ): Promise>; 5 | } 6 | 7 | export const NullCityAutocompleteResource = { 8 | getCitiesInPostcode(): Promise> { 9 | return Promise.resolve( [] ); 10 | }, 11 | }; 12 | 13 | export class ApiCityAutocompleteResource implements CityAutocompleteResource { 14 | async getCitiesInPostcode( postcode: string ): Promise> { 15 | const formData = new FormData(); 16 | formData.append( 'postcode', postcode ); 17 | return axios.post( '/api/v1/cities.json', formData ) 18 | .then( ( cities: AxiosResponse ) => { 19 | return cities.data; 20 | } ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/pages/membership_fee_change/ErrorMessage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /src/view_models/faq.ts: -------------------------------------------------------------------------------- 1 | export interface QuestionModel { 2 | question: string; 3 | visibleText: string; 4 | topic: string; 5 | } 6 | 7 | export interface Topic { 8 | id: string; 9 | name: string; 10 | } 11 | 12 | export interface FaqContent { 13 | topics: Topic[]; 14 | questions: QuestionModel[]; 15 | } 16 | 17 | /** 18 | * Convert from json format (with snake case) to proper typescript format in interfaces ( camel case ) 19 | */ 20 | export function faqContentFromObject( obj: any ): FaqContent { 21 | return { 22 | topics: obj.topics, 23 | questions: obj.questions.map( ( rawQuestion: any ) => { 24 | return { 25 | question: rawQuestion.question, 26 | visibleText: rawQuestion.visible_text, 27 | topic: rawQuestion.topic, 28 | }; 29 | } ), 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/combobox.css: -------------------------------------------------------------------------------- 1 | .combobox { 2 | position: relative; 3 | } 4 | 5 | .combobox [role='listbox'] { 6 | position: absolute; 7 | top: 100%; 8 | width: 100%; 9 | max-height: 200px; 10 | overflow-y: scroll; 11 | background: var(--color-white); 12 | box-shadow: 0 0.5em 1em -0.125em rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.02); 13 | transition: opacity 200ms ease-in-out; 14 | cursor: pointer; 15 | } 16 | 17 | .combobox [role='option'] { 18 | width: 100%; 19 | text-align: left; 20 | background: var(--color-white); 21 | border: 0; 22 | padding-block: var(--space-2xs); 23 | padding-inline: var(--space-xs); 24 | } 25 | 26 | .combobox [role='option']:hover, 27 | .combobox [role='option'][aria-selected='true'] { 28 | background: var(--color-grey-100); 29 | } 30 | -------------------------------------------------------------------------------- /src/pattern_library/pages/css-variables/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@src/pattern_library/pages/Page'; 2 | 3 | const name = 'CSS Variables'; 4 | const url = 'css-variables'; 5 | import content from './content.md'; 6 | 7 | import type from '@src/pattern_library/css/variables/type.css?raw'; 8 | import spacing from '@src/pattern_library/css/variables/spacing.css?raw'; 9 | import color from '@src/pattern_library/css/variables/color.css?raw'; 10 | import global from '@src/pattern_library/css/variables/global.css?raw'; 11 | 12 | const codeSamples = [ 13 | { name: 'type.css', code: type }, 14 | { name: 'spacing.css', code: spacing }, 15 | { name: 'color.css', code: color }, 16 | { name: 'global.css', code: global }, 17 | ]; 18 | 19 | export default { name, url, content, codeSamples } as Page; 20 | -------------------------------------------------------------------------------- /tests/unit/components/shared/ServerMessage.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import ServerMessage from '@src/components/shared/ServerMessage.vue'; 3 | import { nextTick } from 'vue'; 4 | 5 | describe( 'ServerMessage.vue', () => { 6 | it( 'focuses when becomes visible', async () => { 7 | const wrapper = shallowMount( ServerMessage, { 8 | props: { 9 | serverMessage: '', 10 | }, 11 | attachTo: document.body, 12 | } ); 13 | 14 | expect( wrapper.find( '.server-message' ).exists() ).toBeFalsy(); 15 | 16 | await wrapper.setProps( { serverMessage: 'Oops' } ); 17 | await nextTick(); 18 | 19 | expect( document.activeElement ).toStrictEqual( wrapper.element ); 20 | expect( wrapper.find( '.server-message' ).exists() ).toBeTruthy(); 21 | } ); 22 | } ); 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [22.x] 13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'npm' 22 | # Install packages, exact versions from package-lock.json 23 | - run: npm ci 24 | # Run CI script (linting and testing) 25 | - run: npm run ci 26 | # Build (in case the CI missed something) 27 | - run: npm run build 28 | -------------------------------------------------------------------------------- /src/api/StreetAutocompleteResource.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | 3 | export interface StreetAutocompleteResource { 4 | getStreetsInPostcode( postcode: string ): Promise>; 5 | } 6 | 7 | export const NullStreetAutocompleteResource = { 8 | getStreetsInPostcode(): Promise> { 9 | return Promise.resolve( [] ); 10 | }, 11 | }; 12 | 13 | export class ApiStreetAutocompleteResource implements StreetAutocompleteResource { 14 | async getStreetsInPostcode( postcode: string ): Promise> { 15 | const formData = new FormData(); 16 | formData.append( 'postcode', postcode ); 17 | return axios.post( '/api/v1/streets.json', formData ) 18 | .then( ( streets: AxiosResponse ) => { 19 | return streets.data; 20 | } ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/pages/membership_fee_change/FeeAlreadyChangedMessage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /src/util/modalPageFreezer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When a modal is open we set the body to fixed in order to hide the scroll bar 3 | * and stop it interfering with the modal's scrollbar. 4 | * 5 | * We also set the top style so the page doesn't jump. 6 | */ 7 | export function setModalOpened(): void { 8 | const scrollY = window.scrollY; 9 | document.body.style.position = 'fixed'; 10 | document.body.style.top = `-${ scrollY }px`; 11 | } 12 | 13 | /** 14 | * Remove the body position fixed and jump the window to where the user was before 15 | * they opened the modal. 16 | */ 17 | export function setModalClosed(): void { 18 | const scrollY = document.body.style.top; 19 | document.body.style.position = ''; 20 | document.body.style.top = ''; 21 | window.scrollTo( 0, parseInt( scrollY || '0' ) * -1 ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/pages/membership_form/useMembershipBankDataSummary.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { computed, ComputedRef } from 'vue'; 3 | import type { InitialBankAccountData } from '@src/view_models/BankAccount'; 4 | 5 | type ReturnType = { 6 | bankDataSummary: ComputedRef; 7 | }; 8 | 9 | export function useMembershipBankDataSummary( store: Store ): ReturnType { 10 | const bankData = store.state.bankdata.values; 11 | 12 | const bankDataSummary = computed( () => { 13 | const shouldShowSummary = bankData.iban.trim(); 14 | if ( shouldShowSummary ) { 15 | return { 16 | iban: bankData.iban, 17 | bankName: bankData.bankName, 18 | bic: bankData.bic, 19 | }; 20 | } 21 | } ); 22 | 23 | return { 24 | bankDataSummary, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/pattern_library/components/icons/Burger.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/store/data_persistence/InactiveDataPersister.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-empty-function */ 3 | import type { DataPersistenceItem, DataPersister } from '@src/view_models/DataPersistence'; 4 | 5 | export class InactiveDataPersister implements DataPersister { 6 | initialize( items: DataPersistenceItem[] ): Promise { 7 | return Promise.resolve( undefined ); 8 | } 9 | 10 | getValue( key: string ): string | null { 11 | return null; 12 | } 13 | 14 | getPlugin( items: DataPersistenceItem[] ): any { 15 | return () => {}; 16 | } 17 | 18 | loadFromRepository( key: string ): Promise { 19 | return Promise.resolve( null ); 20 | } 21 | 22 | saveToRepository( key: string, data: string ): void { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/shared/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /src/view_models/BankAccount.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from './Validity'; 2 | 3 | export interface BankAccountValues { 4 | bankName: string; 5 | iban: string; 6 | bic: string; 7 | } 8 | 9 | export interface BankAccount { 10 | isValidating: boolean; 11 | validity: { 12 | iban: Validity; 13 | }; 14 | values: BankAccountValues; 15 | } 16 | 17 | export interface InitialBankAccountData { 18 | bankName?: string; 19 | iban?: string; 20 | bic?: string; 21 | } 22 | 23 | export interface BankAccountNumberRequest { 24 | accountNumber: string; 25 | bankCode: string; 26 | } 27 | 28 | export interface BankIbanRequest { 29 | iban: string; 30 | } 31 | 32 | export interface BankAccountResponse { 33 | iban: string; 34 | bic: string; 35 | accountNumber: string; 36 | bankCode: string; 37 | bankName?: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/updateAutocompleteScrollPosition.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue'; 2 | 3 | export function updateAutocompleteScrollPosition( scrollElement: Ref ): void { 4 | const element = scrollElement.value.querySelector( '[aria-selected="true"]' ); 5 | 6 | if ( element === null ) { 7 | scrollElement.value.scrollTop = 0; 8 | return; 9 | } 10 | 11 | const visMin = scrollElement.value.scrollTop; 12 | const visMax = scrollElement.value.scrollTop + scrollElement.value.clientHeight - element.clientHeight; 13 | 14 | if ( element.offsetTop < visMin ) { 15 | scrollElement.value.scrollTop = element.offsetTop; 16 | } else if ( element.offsetTop >= visMax ) { 17 | scrollElement.value.scrollTop = element.offsetTop - scrollElement.value.clientHeight + element.clientHeight; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/pattern_library/components/PageDetail.vue: -------------------------------------------------------------------------------- 1 | 23 | 32 | -------------------------------------------------------------------------------- /src/pattern_library/css/global/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | src: url('../../fonts/sourcesanspro-regular-webfont.woff2') format('woff2'), 4 | url('../../fonts/sourcesanspro-regular-webfont.woff') format('woff'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Source Sans Pro'; 11 | src: url('../../fonts/sourcesanspro-it-webfont.woff2') format('woff2'), 12 | url('../../fonts/sourcesanspro-it-webfont.woff') format('woff'); 13 | font-weight: normal; 14 | font-style: italic; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Source Sans Pro'; 19 | src: url('../../fonts/sourcesanspro-bold-webfont.woff2') format('woff2'), 20 | url('../../fonts/sourcesanspro-bold-webfont.woff') format('woff'); 21 | font-weight: bold; 22 | font-style: normal; 23 | } 24 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/tooltip/markup.html: -------------------------------------------------------------------------------- 1 |
2 | How often would you like to donate? 3 |
4 |
5 | 17 | 21 |
22 |
23 |
-------------------------------------------------------------------------------- /src/components/patterns/Callout.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | -------------------------------------------------------------------------------- /src/components/shared/composables/useReceiptModel.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { computed, ComputedRef, Ref, ref, watch } from 'vue'; 3 | import { action } from '@src/store/util'; 4 | 5 | export type ReturnType = { 6 | receiptNeeded: Ref; 7 | showReceiptOptionError: ComputedRef; 8 | }; 9 | 10 | export function useReceiptModel( store: Store ): ReturnType { 11 | const receiptNeeded = ref( store.state.address.receipt ); 12 | 13 | const showReceiptOptionError = computed( () => { 14 | return !receiptNeeded.value && store.getters[ 'address/addressTypeIsInvalid' ]; 15 | } ); 16 | 17 | watch( receiptNeeded, ( newValue: boolean | null ) => { 18 | store.dispatch( action( 'address', 'setReceiptChoice' ), newValue ); 19 | } ); 20 | 21 | return { 22 | receiptNeeded, 23 | showReceiptOptionError, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /tests/unit/TestDoubles/FakeDataEncryptor.ts: -------------------------------------------------------------------------------- 1 | import type { DataEncryptor } from '@src/view_models/DataEncryptor'; 2 | import { TextEncoder, TextDecoder } from 'util'; 3 | 4 | export class FakeDataEncryptor implements DataEncryptor { 5 | decrypt( data: ArrayBuffer ): Promise { 6 | return Promise.resolve( new TextDecoder().decode( data ) ); 7 | } 8 | 9 | encrypt( data: string ): Promise { 10 | return Promise.resolve( new TextEncoder().encode( data ) as unknown as ArrayBuffer ); 11 | } 12 | } 13 | 14 | export class FakeFailingDataEncryptor implements DataEncryptor { 15 | 16 | decrypt(): Promise { 17 | throw new Error( 'I never return anything' ); 18 | } 19 | 20 | encrypt( data: string ): Promise { 21 | return Promise.resolve( new TextEncoder().encode( data ) as unknown as ArrayBuffer ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/shared/sidebar_cards/BankInfo.vue: -------------------------------------------------------------------------------- 1 | 17 | 24 | -------------------------------------------------------------------------------- /src/pattern_library/css/compositions/cluster.css: -------------------------------------------------------------------------------- 1 | /* 2 | CLUSTER 3 | More info: https://every-layout.dev/layouts/cluster/ 4 | A layout that lets you distribute items with consitent 5 | spacing, regardless of their size 6 | 7 | CUSTOM PROPERTIES AND CONFIGURATION 8 | --gutter (var(--space-m)): This defines the space 9 | between each item. 10 | 11 | --cluster-horizontal-alignment (flex-start): How items should align 12 | horizontally. Can be any acceptable flexbox aligmnent value. 13 | 14 | --cluster-vertical-alignment (center): How items should align vertically. 15 | Can be any acceptable flexbox alignment value. 16 | */ 17 | 18 | .cluster { 19 | display: flex; 20 | flex-wrap: wrap; 21 | gap: var(--gutter, var(--space-m)); 22 | justify-content: var(--cluster-horizontal-alignment, flex-start); 23 | align-items: var(--cluster-vertical-alignment, center); 24 | } 25 | -------------------------------------------------------------------------------- /src/scss/settings/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | src: url('/skins/laika/fonts/sourcesanspro-regular-webfont.woff2') format('woff2'), 4 | url('/skins/laika/fonts/sourcesanspro-regular-webfont.woff') format('woff'); 5 | font-weight: normal; 6 | font-style: normal; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Source Sans Pro'; 11 | src: url('/skins/laika/fonts/sourcesanspro-it-webfont.woff2') format('woff2'), 12 | url('/skins/laika/fonts/sourcesanspro-it-webfont.woff') format('woff'); 13 | font-weight: normal; 14 | font-style: italic; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Source Sans Pro'; 19 | src: url('/skins/laika/fonts/sourcesanspro-bold-webfont.woff2') format('woff2'), 20 | url('/skins/laika/fonts/sourcesanspro-bold-webfont.woff') format('woff'); 21 | font-weight: bold; 22 | font-style: normal; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/pages/membership_confirmation/MembershipConfirmationBannerNotifier.vue: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /src/view_models/Payment.ts: -------------------------------------------------------------------------------- 1 | import { Validity } from './Validity'; 2 | 3 | export interface PaymentValues { 4 | amount: string; 5 | type: string; 6 | interval: string; 7 | } 8 | 9 | export interface Payment { 10 | isValidating: boolean; 11 | validity: { 12 | [key: string]: Validity; 13 | }; 14 | values: PaymentValues; 15 | } 16 | 17 | export interface AmountData { 18 | amountValue: string; 19 | amountCustomValue: string; 20 | } 21 | 22 | export interface IntervalData { 23 | selectedInterval: String; 24 | } 25 | 26 | export interface TypeData { 27 | selectedType: string; 28 | } 29 | 30 | export interface InitialPaymentValues { 31 | amount: string; 32 | type: string; 33 | paymentIntervalInMonths: string; 34 | isCustomAmount: boolean; 35 | } 36 | 37 | export enum AmountValidity { 38 | AMOUNT_VALID, 39 | AMOUNT_TOO_LOW, 40 | AMOUNT_TOO_HIGH, 41 | } 42 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/field-container.css: -------------------------------------------------------------------------------- 1 | .field-container { 2 | --field-container-max-width: 32em; 3 | } 4 | 5 | .field-container > * { 6 | --flow-space: var(--space-2xs); 7 | } 8 | 9 | .field-container__message { 10 | color: var(--color-grey-500); 11 | font-style: italic; 12 | } 13 | 14 | /* This is for an implementation issue edge case with Vuejs. Sometimes a field will show an empty message element */ 15 | .field-container__message:empty { 16 | display: none; 17 | } 18 | 19 | .field-container__error-text { 20 | color: var(--color-red-700); 21 | } 22 | 23 | .field-container[data-max-width] { 24 | max-width: var(--field-container-max-width); 25 | } 26 | 27 | .field-container[data-error] { 28 | --form-field-border-color: var(--color-red-700); 29 | } 30 | 31 | .field-container:not([data-error]) .field-container__error-text { 32 | display: none; 33 | } 34 | -------------------------------------------------------------------------------- /src/store/payment/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { Validity } from '@src/view_models/Validity'; 3 | import { actions } from '@src/store/payment/actions'; 4 | import { getters } from '@src/store/payment/getters'; 5 | import { mutations } from '@src/store/payment/mutations'; 6 | import { DonationPayment } from '@src/store/payment/types'; 7 | 8 | export default function (): Module { 9 | const state: DonationPayment = { 10 | isValidating: false, 11 | validity: { 12 | amount: Validity.INCOMPLETE, 13 | type: Validity.INCOMPLETE, 14 | }, 15 | values: { 16 | amount: '', // amount in cents 17 | interval: '0', 18 | type: '', 19 | }, 20 | initialized: false, 21 | }; 22 | 23 | const namespaced = true; 24 | 25 | return { 26 | namespaced, 27 | state, 28 | getters, 29 | mutations, 30 | actions, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/store/bankdata/mutations.ts: -------------------------------------------------------------------------------- 1 | import { MutationTree } from 'vuex'; 2 | import type { BankAccount } from '@src/view_models/BankAccount'; 3 | import { Validity } from '@src/view_models/Validity'; 4 | 5 | export const mutations: MutationTree = { 6 | SET_BANK_NAME( state: BankAccount, bankName: string ) { 7 | state.values.bankName = bankName; 8 | }, 9 | SET_IBAN( state: BankAccount, iban: string ) { 10 | state.values.iban = iban; 11 | }, 12 | SET_BIC( state: BankAccount, bic: string ) { 13 | state.values.bic = bic; 14 | }, 15 | SET_IBAN_VALIDITY( state: BankAccount, validity: Validity ) { 16 | state.validity.iban = validity; 17 | }, 18 | SET_IS_VALIDATING( state: BankAccount, isValidating: boolean ) { 19 | state.isValidating = isValidating; 20 | }, 21 | MARK_EMPTY_IBAN_INVALID( state: BankAccount ) { 22 | state.validity.iban = Validity.INVALID; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/nav/Examples.vue: -------------------------------------------------------------------------------- 1 | 22 | 29 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/content-card.css: -------------------------------------------------------------------------------- 1 | .content-card { 2 | --card-padding: var(--space-xs-l); 3 | 4 | padding: var(--card-padding); 5 | background: var(--color-white); 6 | border-bottom: 1px solid var(--color-blue-600); 7 | } 8 | 9 | .content-card[data-theme='bordered'] { 10 | border: 3px solid var(--color-blue-600); 11 | } 12 | 13 | .content-card[data-sidebar-card] { 14 | --card-padding: var(--space-xs); 15 | } 16 | 17 | .content-card[data-collapsable] { 18 | --card-padding: 0; 19 | --summary-color: var(--color-blue-600); 20 | } 21 | 22 | .content-card[data-collapsable] summary { 23 | --summary-margin: 0; 24 | --summary-padding: var(--space-xs-l); 25 | --summary-border: 0; 26 | } 27 | 28 | .content-card[data-collapsable] details > div, 29 | .content-card[data-collapsable] .content-card__content { 30 | padding-inline: var(--space-xs-l); 31 | padding-block-end: var(--space-xs-l); 32 | } 33 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@vue/test-utils'; 2 | import { createI18n } from 'vue-i18n'; 3 | // See https://stackoverflow.com/a/57439821/130121 4 | require( 'regenerator-runtime/runtime' ); 5 | 6 | // eslint-disable-next-line no-underscore-dangle 7 | global._paq = { 8 | push: jest.fn(), 9 | }; 10 | 11 | config.global.plugins = [ createI18n( { legacy: false, missingWarn: false, fallbackWarn: false } ) ]; 12 | 13 | jest.mock( 'vue-i18n', () => { 14 | return { 15 | ...jest.requireActual( 'vue-i18n' ), 16 | useI18n: jest.fn().mockReturnValue( { 17 | t: ( key: string, params?: Object ) => JSON.stringify( { key, ...params } ), 18 | n: ( amount: string, keyOrOptions?: string | object ) => { 19 | if ( typeof keyOrOptions === 'string' ) { 20 | return `${amount}-${keyOrOptions}`; 21 | } 22 | 23 | return JSON.stringify( { amount, ...keyOrOptions } ); 24 | }, 25 | } ), 26 | }; 27 | } ); 28 | -------------------------------------------------------------------------------- /src/api/DonorResource.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from '@src/view_models/Address'; 2 | import axios, { AxiosResponse } from 'axios'; 3 | import type { UpdateDonorRequest } from '@src/api/UpdateDonorRequest'; 4 | 5 | export interface DonorResource { 6 | put: ( data: UpdateDonorRequest ) => Promise
; 7 | } 8 | 9 | export class ApiDonorResource implements DonorResource { 10 | 11 | putEndpoint: string; 12 | 13 | constructor( putEndpoint: string ) { 14 | this.putEndpoint = putEndpoint; 15 | } 16 | 17 | put( data: UpdateDonorRequest ): Promise
{ 18 | return axios.put( 19 | this.putEndpoint, 20 | data, 21 | { headers: { 'Content-Type': 'application/json' } } 22 | ).then( ( response: AxiosResponse
) => { 23 | return Promise.resolve( response.data ); 24 | } ).catch( ( error: any ) => { 25 | return Promise.reject( error.response.data.errors[ 0 ] ); 26 | } ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/pages/use_of_funds/UseOfFundsContent.ts: -------------------------------------------------------------------------------- 1 | interface AccordionItem { 2 | title: string; 3 | content: string; 4 | } 5 | 6 | interface BenefitsItem { 7 | icon: 'hand' | 'smartphone' | 'world' | 'megaphone' | 'twentyfourseven'; 8 | content: string; 9 | } 10 | 11 | export interface RevenueComparisonItem { 12 | name: string; 13 | budget: number; 14 | budgetString: string; 15 | link: string; 16 | linkText: string; 17 | } 18 | 19 | export interface UseOfFundsContent { 20 | title: string; 21 | summary: string; 22 | callToAction: string; 23 | accordion: { 24 | items: AccordionItem[]; 25 | summary: string; 26 | }; 27 | benefits: { 28 | title: string; 29 | items: BenefitsItem[]; 30 | }; 31 | revenueComparison: { 32 | title: string; 33 | content: string[]; 34 | companies: { 35 | title: string; 36 | items: RevenueComparisonItem[]; 37 | }; 38 | }; 39 | closingParagraph: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/mobile-nav-toggle.css: -------------------------------------------------------------------------------- 1 | .mobile-nav-toggle { 2 | --icon-fill: var(--color-grey-800); 3 | --button-width: var(--navigation-height); 4 | --button-height: var(--navigation-height); 5 | 6 | all: unset; 7 | display: flex; 8 | height: var(--button-width); 9 | width: var(--button-height); 10 | justify-content: center; 11 | align-items: center; 12 | } 13 | 14 | .mobile-nav-toggle:hover, 15 | .mobile-nav-toggle:focus { 16 | background-color: var(--color-grey-100); 17 | } 18 | 19 | .mobile-nav-toggle__open, 20 | .mobile-nav-toggle__close { 21 | justify-content: center; 22 | } 23 | 24 | .mobile-nav-toggle__open { 25 | display: flex; 26 | } 27 | 28 | .mobile-nav-toggle__close { 29 | display: none; 30 | } 31 | 32 | .mobile-nav-toggle--active .mobile-nav-toggle__close { 33 | display: flex; 34 | } 35 | 36 | .mobile-nav-toggle--active .mobile-nav-toggle__open { 37 | display: none; 38 | } 39 | -------------------------------------------------------------------------------- /src/store/membership_fee/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { Validity } from '@src/view_models/Validity'; 3 | import { actions } from '@src/store/membership_fee/actions'; 4 | import { getters } from '@src/store/membership_fee/getters'; 5 | import { mutations } from '@src/store/membership_fee/mutations'; 6 | import type { MembershipFee } from '@src/view_models/MembershipFee'; 7 | 8 | export default function (): Module { 9 | const state: MembershipFee = { 10 | isValidating: false, 11 | validity: { 12 | fee: Validity.INCOMPLETE, 13 | interval: Validity.INCOMPLETE, 14 | type: Validity.INCOMPLETE, 15 | }, 16 | values: { 17 | fee: '', // membership fee in cents 18 | interval: '', 19 | type: '', 20 | }, 21 | }; 22 | 23 | const namespaced = true; 24 | 25 | return { 26 | namespaced, 27 | state, 28 | getters, 29 | mutations, 30 | actions, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/useBankDataSummary.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { computed, ComputedRef } from 'vue'; 3 | import { usePaymentFunctions } from '@src/components/pages/donation_form/usePaymentFunctions'; 4 | 5 | type ReturnType = { 6 | bankDataSummary: ComputedRef<{ iban: string; bankName: string; bic: string } | undefined>; 7 | }; 8 | 9 | export function useBankDataSummary( store: Store ): ReturnType { 10 | const { isDirectDebitPayment } = usePaymentFunctions( store ); 11 | const bankData = store.state.bankdata.values; 12 | 13 | const bankDataSummary = computed( () => { 14 | const shouldShowSummary = bankData.iban.trim() && isDirectDebitPayment.value; 15 | if ( shouldShowSummary ) { 16 | return { 17 | iban: bankData.iban, 18 | bankName: bankData.bankName, 19 | bic: bankData.bic, 20 | }; 21 | } 22 | } ); 23 | 24 | return { 25 | bankDataSummary, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/pattern_library/components/icons/Close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/CheckboxSingleFormInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /src/components/pages/donation_confirmation/DonationExported.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 30 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/mobile-nav-toggle/Examples.vue: -------------------------------------------------------------------------------- 1 | 20 | 28 | -------------------------------------------------------------------------------- /src/components/shared/icons/EditIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/icon-text/markup.html: -------------------------------------------------------------------------------- 1 |
2 |

Icon Heading 1

3 |
4 | 5 |
6 |

Icon Heading 2

7 |
8 | 9 |
10 |

Icon Heading 3

11 |
12 | 13 |
14 |

Icon text short

15 |
16 | 17 |
18 |

Long text. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium amet, consectetur culpa doloremque ex, exercitationem harum illum in ipsa ipsum, modi natus nemo odio perferendis quaerat quis similique sint tempore.

19 |
-------------------------------------------------------------------------------- /tests/unit/components/pages/donation_form/PaymentSummary.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import PaymentSummary from '@src/components/pages/donation_form/PaymentSummary.vue'; 3 | 4 | describe( 'PaymentSummary.vue', () => { 5 | it( 'renders the payment summary with paymentType', () => { 6 | const wrapper = mount( PaymentSummary, { 7 | props: { 8 | amount: 50, 9 | interval: 'monthly', 10 | paymentType: 'Credit Card', 11 | }, 12 | } ); 13 | 14 | expect( wrapper.find( '.callout' ).html() ).toContain( 15 | 'donation_form_payment_summary' 16 | ); 17 | } ); 18 | 19 | it( 'renders the payment summary with paymentType', () => { 20 | const wrapper = mount( PaymentSummary, { 21 | props: { 22 | amount: 41.3, 23 | interval: 'yearly', 24 | }, 25 | } ); 26 | 27 | expect( wrapper.find( '.callout' ).html() ).toContain( 28 | 'donation_form_payment_summary_payment_type_missing' 29 | ); 30 | } ); 31 | } ); 32 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/donation-comment/Examples.vue: -------------------------------------------------------------------------------- 1 | 19 | 22 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/text-radio/markup.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 6 | 7 | 8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/api/AddressChangeResource.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from '@src/view_models/Address'; 2 | import axios, { AxiosResponse } from 'axios'; 3 | import type { UpdateAddressResponse } from '@src/api/UpdateAddressResponse'; 4 | 5 | export interface AddressChangeResource { 6 | put: ( data: Address ) => Promise; 7 | } 8 | 9 | export class ApiAddressChangeResource implements AddressChangeResource { 10 | 11 | putEndpoint: string; 12 | 13 | constructor( putEndpoint: string ) { 14 | this.putEndpoint = putEndpoint; 15 | } 16 | 17 | put( data: Address ): Promise { 18 | return axios.put( 19 | this.putEndpoint, 20 | data, 21 | { headers: { 'Content-Type': 'application/json' } } 22 | ).then( ( response: AxiosResponse ) => { 23 | return Promise.resolve( response.data ); 24 | } ).catch( ( error: any ) => { 25 | return Promise.reject( error.response.data.errors[ 0 ] ); 26 | } ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/CheckboxToggle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 38 | -------------------------------------------------------------------------------- /src/pattern_library/components/icons/Burger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/icon-text.css: -------------------------------------------------------------------------------- 1 | .icon-text { 2 | --icon-height: calc(var(--type-step-0) * var(--leading-standard)); 3 | --icon-gutter: 1ch; 4 | 5 | display: flex; 6 | align-items: var(--icon-text-vertical-alignment, flex-start); 7 | } 8 | 9 | .icon-text[data-small-heading] :is(h1, h2, h3) { 10 | font-size: var(--type-step-0); 11 | line-height: var(--leading-standard); 12 | font-weight: bold; 13 | } 14 | 15 | .icon-text:not([data-small-heading]):has(h1) { 16 | --icon-height: calc(var(--type-step-3) * var(--leading-fine)); 17 | } 18 | 19 | .icon-text:not([data-small-heading]):has(h2) { 20 | --icon-height: calc(var(--type-step-2) * var(--leading-fine)); 21 | } 22 | 23 | .icon-text:not([data-small-heading]):has(h3) { 24 | --icon-height: calc(var(--type-step-1) * var(--leading-fine)); 25 | } 26 | 27 | .icon-text__icon { 28 | margin-inline-end: var(--icon-gutter, var(--gutter)); 29 | height: var(--icon-height); 30 | display: flex; 31 | align-items: center; 32 | } 33 | -------------------------------------------------------------------------------- /src/pattern_library/components/PatternDetail.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 42 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/DonationSummaryHeadline.vue: -------------------------------------------------------------------------------- 1 | 8 | 24 | -------------------------------------------------------------------------------- /src/api/CommentResource.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | 3 | export interface CommentRequest { 4 | donationId: number; 5 | updateToken: string; 6 | comment: string; 7 | withName: boolean; 8 | isPublic: boolean; 9 | } 10 | 11 | interface CommentResponse { 12 | status: string; 13 | message: string; 14 | } 15 | 16 | export interface CommentResource { 17 | post: ( data: CommentRequest ) => Promise; 18 | } 19 | 20 | export class ApiCommentResource implements CommentResource { 21 | postEndpoint: string; 22 | 23 | constructor( postEndpoint: string ) { 24 | this.postEndpoint = postEndpoint; 25 | } 26 | 27 | post( data: CommentRequest ): Promise { 28 | return axios.post( this.postEndpoint, data ).then( ( validationResult: AxiosResponse ) => { 29 | if ( validationResult.data.status !== 'OK' ) { 30 | return Promise.reject( validationResult.data.message ); 31 | } 32 | return validationResult.data.message; 33 | } ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/pages/SubscriptionConfirmation.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/CheckboxMultipleFormInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 36 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/useCitiesResource.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref } from 'vue'; 2 | import type { CityAutocompleteResource } from '@src/api/CityAutocompleteResource'; 3 | 4 | type ReturnType = { cities: Ref>; fetchCitiesForPostcode: ( postcode: string ) => void }; 5 | const postcodePattern = /^[0-9]{5}$/; 6 | 7 | export function useCitiesResource( resource: CityAutocompleteResource ): ReturnType { 8 | const cities = ref>( [] ); 9 | let currentPostcode = ''; 10 | 11 | const fetchCitiesForPostcode = ( postcode: string ) => { 12 | if ( postcode === currentPostcode ) { 13 | return; 14 | } 15 | currentPostcode = postcode; 16 | 17 | if ( !postcodePattern.test( postcode ) ) { 18 | cities.value = []; 19 | return; 20 | } 21 | 22 | resource.getCitiesInPostcode( postcode ).then( 23 | ( newCities: Array ) => { 24 | cities.value = newCities; 25 | } 26 | ); 27 | }; 28 | 29 | return { 30 | cities, 31 | fetchCitiesForPostcode, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/shared/usePaymentType.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref, watch } from 'vue'; 2 | 3 | /** 4 | * Internal state of the payment type components 5 | * 6 | * We need to watch and synchronize the current type (a property from the parent of the component) 7 | * because it can change when the parent component changes properties (e.g. when reacting to store value changes). 8 | * This is less than ideal, because it mixes properties and state. But at the moment there doesn't seem a better way 9 | * to sync with our store while keeping the payment type components independent of the store. 10 | */ 11 | export function usePaymentType( currentType: Ref, emit: ( event: string, ...args: any[] ) => void ) { 12 | const selectedType = ref( currentType.value ); 13 | const setType = () => emit( 'payment-type-selected', selectedType.value ); 14 | 15 | watch( currentType, ( newType ) => { 16 | selectedType.value = newType; 17 | } ); 18 | 19 | return { 20 | selectedType, 21 | setType, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /tests/unit/components/layout/AppFooter.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import AppFooter from '@src/components/layout/AppFooter.vue'; 3 | import { QUERY_STRING_INJECTION_KEY } from '@src/util/createCampaignQueryString'; 4 | 5 | describe( 'AppFooter.vue', () => { 6 | it.each( [ 7 | [ 'contact', 1 ], 8 | [ 'imprint', 2 ], 9 | [ 'data_protection', 3 ], 10 | [ 'accessibility_statement', 4 ], 11 | [ 'supporters_list', 5 ], 12 | [ 'donor_comments', 6 ], 13 | ] )( 'highlights the correct navigation items', ( pageIdentifier: string, navItemIndex: number ) => { 14 | const wrapper = shallowMount( AppFooter, { 15 | props: { 16 | assetsPath: '', 17 | pageIdentifier, 18 | }, 19 | global: { 20 | provide: { 21 | [ QUERY_STRING_INJECTION_KEY ]: '', 22 | }, 23 | }, 24 | } ); 25 | 26 | const link = wrapper.find( '.footer-list li:nth-child(' + navItemIndex + ') a' ); 27 | expect( link.attributes( 'aria-current' ) ).toStrictEqual( 'page' ); 28 | } ); 29 | } ); 30 | -------------------------------------------------------------------------------- /src/components/pages/donation_confirmation/DonationSurvey.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/components/pages/membership_confirmation/MembershipSurvey.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/components/pages/membership_fee_change/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /src/components/shared/ButtonLink.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | 31 | 49 | -------------------------------------------------------------------------------- /src/components/shared/form_fields/useStreetsResource.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ref } from 'vue'; 2 | import type { StreetAutocompleteResource } from '@src/api/StreetAutocompleteResource'; 3 | 4 | type ReturnType = { streets: Ref>; fetchStreetsForPostcode: ( postcode: string ) => void }; 5 | const postcodePattern = /^[0-9]{5}$/; 6 | 7 | export function useStreetsResource( resource: StreetAutocompleteResource ): ReturnType { 8 | const streets = ref>( [] ); 9 | let currentPostcode = ''; 10 | 11 | const fetchStreetsForPostcode = ( postcode: string ) => { 12 | if ( postcode === currentPostcode ) { 13 | return; 14 | } 15 | currentPostcode = postcode; 16 | 17 | if ( !postcodePattern.test( postcode ) ) { 18 | streets.value = []; 19 | return; 20 | } 21 | 22 | resource.getStreetsInPostcode( postcode ).then( 23 | ( newStreets: Array ) => { 24 | streets.value = newStreets; 25 | } 26 | ); 27 | }; 28 | 29 | return { 30 | streets, 31 | fetchStreetsForPostcode, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /tests/unit/components/pages/membership_fee_change/Sidebar.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, VueWrapper } from '@vue/test-utils'; 2 | import Sidebar from '@src/components/pages/membership_fee_change/Sidebar.vue'; 3 | 4 | describe( 'Sidebar.vue', () => { 5 | const getWrapper = ( externalMemberId: number ): VueWrapper => { 6 | return mount( Sidebar, { 7 | props: { 8 | externalMemberId, 9 | }, 10 | } ); 11 | }; 12 | 13 | test( 'shows bank info card if there is no external member id', () => { 14 | const wrapper = getWrapper( null ); 15 | 16 | expect( wrapper.text() ).not.toContain( 'membership_fee_upgrade_sidebar_headline' ); 17 | expect( wrapper.text() ).toContain( 'bank_data_title_new_prefix' ); 18 | } ); 19 | 20 | test( 'shows external ID card if there is an external member id', () => { 21 | const wrapper = getWrapper( 123456 ); 22 | 23 | expect( wrapper.text() ).toContain( 'membership_fee_upgrade_sidebar_headline' ); 24 | expect( wrapper.text() ).not.toContain( 'bank_data_title_new_prefix' ); 25 | } ); 26 | } ); 27 | -------------------------------------------------------------------------------- /src/api/MembershipFeeChangeResource.ts: -------------------------------------------------------------------------------- 1 | import { FeeChangeRequest } from '@src/Domain/MembershipFeeChange/FeeChangeRequest'; 2 | import { FeeChangeResponse } from '@src/Domain/MembershipFeeChange/FeeChangeResponse'; 3 | import axios, { AxiosResponse } from 'axios'; 4 | 5 | export interface MembershipFeeChangeResource { 6 | put: ( data: FeeChangeRequest ) => Promise; 7 | } 8 | 9 | export class ApiMembershipFeeChangeResource implements MembershipFeeChangeResource { 10 | 11 | putEndpoint: string; 12 | 13 | constructor( putEndpoint: string ) { 14 | this.putEndpoint = putEndpoint; 15 | } 16 | 17 | put( data: FeeChangeRequest ): Promise { 18 | return axios.put( 19 | this.putEndpoint, 20 | data, 21 | { headers: { 'Content-Type': 'application/json' } } 22 | ).then( ( response: AxiosResponse ) => { 23 | return Promise.resolve( response.data ); 24 | } ).catch( ( error: any ) => { 25 | return Promise.reject( error.response.data.errors[ 0 ] ); 26 | } ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/shared/composables/useAriaDescribedby.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from 'vue'; 2 | 3 | export const helpTextPostfix = '-help-text'; 4 | export const errorPostfix = '-error'; 5 | export const messagePostfix = '-message'; 6 | 7 | export const useAriaDescribedby = ( 8 | inputId: string, 9 | hasHelpText: Ref, 10 | hasError: Ref, 11 | hasMessage: Ref, 12 | extraLabels: Ref 13 | ): Ref => { 14 | return computed( (): string => { 15 | let describedBy = ''; 16 | 17 | if ( hasHelpText.value ) { 18 | describedBy += ' ' + inputId + helpTextPostfix; 19 | } 20 | 21 | if ( hasError.value ) { 22 | describedBy += ' ' + inputId + errorPostfix; 23 | } else if ( hasMessage.value ) { 24 | describedBy += ' ' + inputId + messagePostfix; 25 | } 26 | 27 | if ( extraLabels.value ) { 28 | describedBy += ' ' + extraLabels.value; 29 | } 30 | 31 | describedBy = describedBy.trim(); 32 | return describedBy !== '' ? describedBy : null; 33 | } ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/SelectFormInput.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 39 | -------------------------------------------------------------------------------- /src/pages/comment_list.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/stable'; 2 | import { createVueApp } from '@src/createVueApp'; 3 | import PageDataInitializer from '@src/util/page_data_initializer'; 4 | import App from '@src/components/App.vue'; 5 | import CommentList from '@src/components/pages/CommentList.vue'; 6 | import { createFeatureFetcher } from '@src/util/FeatureFetcher'; 7 | import { bucketIdToCssClass } from '@src/util/bucket_id_to_css_class'; 8 | 9 | const PAGE_IDENTIFIER = 'comment-list'; 10 | const pageData = new PageDataInitializer( '#appdata' ); 11 | const featureFetcher = createFeatureFetcher( pageData.selectedBuckets, pageData.activeFeatures ); 12 | 13 | createVueApp( 14 | App, 15 | pageData.messages, 16 | pageData.allowedCampaignParameters, 17 | featureFetcher, 18 | { 19 | assetsPath: pageData.assetsPath, 20 | bucketClasses: bucketIdToCssClass( pageData.selectedBuckets ), 21 | pageIdentifier: PAGE_IDENTIFIER, 22 | page: CommentList, 23 | pageTitle: 'comment_list_page_title', 24 | pageProps: { 25 | }, 26 | } ).mount( '#app' ); 27 | -------------------------------------------------------------------------------- /src/pattern_library/css/compositions/flex-field-group.css: -------------------------------------------------------------------------------- 1 | /* 2 | FLEX FIELD GROUP 3 | This composition allows fields to be laid out horizontally, break to 4 | new lines when needed, and then fill the available space. 5 | 6 | CUSTOM PROPERTIES AND CONFIGURATION 7 | [data-nowrap]: Use this when you want to prevent a field group from wrapping. 8 | */ 9 | .flex-field-group { 10 | display: flex; 11 | flex-wrap: var(--flex-field-group-wrap, wrap); 12 | gap: var(--gutter, var(--space-l)); 13 | } 14 | 15 | .flex-field-group > * { 16 | flex-grow: 1; 17 | } 18 | 19 | .flex-field-group__stretch { 20 | flex-grow: 999; 21 | min-width: 16ch; 22 | } 23 | 24 | .flex-field-group__mini-field { 25 | flex-basis: min-content; 26 | min-width: 16ch; 27 | } 28 | 29 | .flex-field-group__sidebar-field { 30 | flex-basis: 0; 31 | flex-grow: 999; 32 | min-width: 20rem; 33 | } 34 | 35 | .flex-field-group__sidebar-field-sidebar { 36 | flex-basis: 16ch; 37 | flex-grow: 1; 38 | } 39 | 40 | .flex-field-group[data-nowrap] { 41 | --flex-field-group-wrap: nowrap; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/Compact/useReceiptModel.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { computed, ComputedRef, Ref, ref, watch } from 'vue'; 3 | import { action } from '@src/store/util'; 4 | import { Validity } from '@src/view_models/Validity'; 5 | 6 | export interface ReceiptModel { 7 | receiptNeeded: Ref; 8 | showReceiptOptionError: ComputedRef; 9 | } 10 | 11 | export function useReceiptModel( store: Store ): ReceiptModel { 12 | const initialReceipt = store.state.address.validity.receipt === Validity.RESTORED ? store.state.address.receipt : false; 13 | const receiptNeeded = ref( initialReceipt ); 14 | 15 | const showReceiptOptionError = computed( () => { 16 | return !receiptNeeded.value && store.getters[ 'address/addressTypeIsInvalid' ]; 17 | } ); 18 | 19 | watch( receiptNeeded, ( newValue: boolean | null ) => { 20 | store.dispatch( action( 'address', 'setReceiptChoice' ), newValue ); 21 | } ); 22 | 23 | return { 24 | receiptNeeded, 25 | showReceiptOptionError, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/layout/NavigationItems.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 38 | -------------------------------------------------------------------------------- /src/components/pages/donation_form/DonationReceipt/useReceiptModel.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { computed, ComputedRef, Ref, ref, watch } from 'vue'; 3 | import { action } from '@src/store/util'; 4 | import { Validity } from '@src/view_models/Validity'; 5 | 6 | export interface ReceiptModel { 7 | receiptNeeded: Ref; 8 | showReceiptOptionError: ComputedRef; 9 | } 10 | 11 | export function useReceiptModel( store: Store ): ReceiptModel { 12 | const initialReceipt = store.state.address.validity.receipt === Validity.RESTORED ? store.state.address.receipt : null; 13 | const receiptNeeded = ref( initialReceipt ); 14 | 15 | const showReceiptOptionError = computed( () => { 16 | return !receiptNeeded.value && store.getters[ 'address/addressTypeIsInvalid' ]; 17 | } ); 18 | 19 | watch( receiptNeeded, ( newValue: boolean | null ) => { 20 | store.dispatch( action( 'address', 'setReceiptChoice' ), newValue ); 21 | } ); 22 | 23 | return { 24 | receiptNeeded, 25 | showReceiptOptionError, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/pattern_library/css/blocks/site-head.css: -------------------------------------------------------------------------------- 1 | .site-head { 2 | --position: fixed; 3 | 4 | position: var(--position); 5 | z-index: 1; 6 | top: 0; 7 | height: var(--navigation-height); 8 | width: 100%; 9 | display: flex; 10 | justify-content: space-between; 11 | background: var(--color-white); 12 | box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.1); 13 | } 14 | 15 | .site-head__locale, 16 | .site-head__mobile-nav-toggle { 17 | width: var(--navigation-height); 18 | flex-grow: 0; 19 | flex-shrink: 0; 20 | } 21 | 22 | .site-head__brand { 23 | display: flex; 24 | align-items: center; 25 | flex-grow: 1; 26 | flex-shrink: 0; 27 | padding-inline-start: var(--gutter); 28 | } 29 | 30 | @media screen and (min-width: 760px) { 31 | .site-head__mobile-nav-toggle { 32 | display: none; 33 | } 34 | 35 | .site-head__brand { 36 | width: var(--navigation-height); 37 | justify-content: center; 38 | flex-grow: 0; 39 | flex-shrink: 0; 40 | padding-inline-start: 0; 41 | } 42 | 43 | .site-head__menu { 44 | flex-grow: 1; 45 | flex-shrink: 0; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pattern_library/pages/utility-classes/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from '@src/pattern_library/pages/Page'; 2 | 3 | const name = 'Utility Classes'; 4 | const url = 'utility-classes'; 5 | import content from './content.md'; 6 | 7 | import footerBottom from '@src/pattern_library/css/utilities/footer-bottom.css?raw'; 8 | import linkButton from '@src/pattern_library/css/utilities/link-button.css?raw'; 9 | import sticky from '@src/pattern_library/css/utilities/sticky.css?raw'; 10 | import stretchSingleContentCard from '@src/pattern_library/css/utilities/stretch-single-content-card.css?raw'; 11 | import visuallyHidden from '@src/pattern_library/css/utilities/visually-hidden.css?raw'; 12 | 13 | const codeSamples = [ 14 | { name: 'footer-bottom.css', code: footerBottom }, 15 | { name: 'link-button.css', code: linkButton }, 16 | { name: 'sticky.css', code: sticky }, 17 | { name: 'stretch-single-content-card.css', code: stretchSingleContentCard }, 18 | { name: 'visually-hidden.css', code: visuallyHidden }, 19 | ]; 20 | 21 | export default { name, url, content, codeSamples } as Page; 22 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/locale/Examples.vue: -------------------------------------------------------------------------------- 1 | 20 | 28 | -------------------------------------------------------------------------------- /tests/unit/utils/createCampaignQueryString.spec.ts: -------------------------------------------------------------------------------- 1 | import { createCampaignQueryString } from '@src/util/createCampaignQueryString'; 2 | 3 | describe( 'createCampaignQueryString', () => { 4 | 5 | test.each( [ 6 | // URL contains all parameters 7 | [ 'test1=test1&test2=test2', 'test1=test1&test2=test2' ], 8 | // URL contains additional params - they should be filtered 9 | [ 'test1=test1&utoken=TESTX&token=1&accessToken=2&id=123456', 'test1=test1' ], 10 | // URL contains no parameters - all should be filtered 11 | [ 'addressType=test1&amount=TESTX&interval=1&paymentType=2', '' ], 12 | // URL contains non-campaign tracking params - the output should contain them 13 | [ 14 | 'test1=test1&piwik_kwd=testKeyword&piwik_campaign=testCampaign&impCount=3&bImpCount=1', 15 | 'piwik_kwd=testKeyword&piwik_campaign=testCampaign&impCount=3&bImpCount=1&test1=test1', 16 | ], 17 | ] )( 'given query %p outputs %p', ( query: string, expected: string ) => { 18 | expect( createCampaignQueryString( query, [ 'test1', 'test2' ] ) ).toStrictEqual( expected ); 19 | } ); 20 | 21 | } ); 22 | -------------------------------------------------------------------------------- /src/pattern_library/patterns/pagination/markup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/scss/pattern-library-compatibility/button.scss: -------------------------------------------------------------------------------- 1 | @use "../variables"; 2 | 3 | @use '../settings/forms'; 4 | @use '../settings/colors'; 5 | @use '../settings/global'; 6 | @use 'sass:color'; 7 | @use 'sass:map'; 8 | 9 | $hover-color: color.adjust( colors.$primary, $lightness: -5% ); 10 | 11 | .button { 12 | font-weight: bold; 13 | font-family: variables.$family-primary; 14 | font-size: 16px; 15 | 16 | position: relative; 17 | display: inline-block; 18 | background: colors.$primary; 19 | color: colors.$white; 20 | border: 0; 21 | border-radius: map.get( forms.$input, 'border-radius' ); 22 | padding: 0; 23 | margin: 0; 24 | font-size: 1em; 25 | font-weight: 700; 26 | text-align: center; 27 | height: 54px; 28 | width: 240px; 29 | cursor: pointer; 30 | transition: background-color 200ms global.$easing, color 200ms global.$easing; 31 | text-decoration: none; 32 | 33 | &:hover, 34 | &:focus { 35 | color: colors.$white; 36 | text-decoration: none; 37 | background: $hover-color; 38 | border: 1px solid colors.$white; 39 | box-shadow: 0 0 0 2px colors.$primary; 40 | } 41 | } -------------------------------------------------------------------------------- /src/util/FilteredUrlMembershipValues.ts: -------------------------------------------------------------------------------- 1 | import type { InitialMembershipFeeValues } from '@src/view_models/MembershipFee'; 2 | 3 | export default class FilteredUrlMembershipValues implements InitialMembershipFeeValues { 4 | private params: URLSearchParams; 5 | public validateFeeUrl: string; 6 | public paymentType: string | null; 7 | 8 | constructor( params: URLSearchParams, validateFeeUrl: string ) { 9 | this.params = params; 10 | this.validateFeeUrl = validateFeeUrl; 11 | this.paymentType = null; 12 | } 13 | 14 | get fee() { 15 | const feeValue = this.params.get( 'fee' ) ?? ''; 16 | return feeValue.match( /^\d+$/ ) ? feeValue : ''; 17 | } 18 | 19 | get interval() { 20 | const rawInterval = this.params.get( 'interval' ) ?? ''; 21 | return [ '1', '3', '6', '12' ].indexOf( rawInterval ) > -1 ? rawInterval : ''; 22 | } 23 | 24 | get type() { 25 | return this.paymentType; 26 | } 27 | 28 | public setTypeFromAvailablePaymentTypes( paymentTypes: string[] ) { 29 | if ( paymentTypes.length === 1 ) { 30 | this.paymentType = paymentTypes[ 0 ]; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/unit/components/pages/donation_confirmation/AddressAnonymous.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, VueWrapper } from '@vue/test-utils'; 2 | import AddressAnonymous from '@src/components/pages/donation_confirmation/AddressAnonymous.vue'; 3 | 4 | describe( 'AddressAnonymous', () => { 5 | function getWrapper(): VueWrapper { 6 | return mount( 7 | AddressAnonymous, 8 | { 9 | props: { 10 | modalIsVisible: false, 11 | }, 12 | } 13 | ); 14 | } 15 | 16 | it( 'renders messages', () => { 17 | const wrapper = getWrapper(); 18 | 19 | expect( wrapper.text() ).toContain( 'donation_confirmation_cta_title_alt' ); 20 | expect( wrapper.text() ).toContain( 'donation_confirmation_cta_summary_alt' ); 21 | expect( wrapper.text() ).toContain( 'donation_confirmation_address_update_button_alt' ); 22 | } ); 23 | 24 | it( 'emits event when update address link is clicked', () => { 25 | const wrapper = getWrapper(); 26 | 27 | wrapper.find( '#address-change-button' ).trigger( 'click' ); 28 | 29 | expect( wrapper.emitted( 'show-address-modal' ) ).toBeTruthy(); 30 | } ); 31 | } ); 32 | -------------------------------------------------------------------------------- /src/components/shared/form_elements/PaymentTextFormButton.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 39 | -------------------------------------------------------------------------------- /tests/unit/store/feeValidator.spec.ts: -------------------------------------------------------------------------------- 1 | import { validateFee } from '@src/store/feeValidator'; 2 | import { FeeValidity } from '@src/view_models/MembershipFee'; 3 | 4 | describe( 'FeeValidator', () => { 5 | 6 | const MINIMUM_AMOUNT = 500; 7 | 8 | it.each( [ 9 | [ 0, 0, FeeValidity.FEE_TOO_LOW ], 10 | [ 0, MINIMUM_AMOUNT, FeeValidity.FEE_TOO_LOW ], 11 | [ MINIMUM_AMOUNT - 1, MINIMUM_AMOUNT, FeeValidity.FEE_TOO_LOW ], 12 | [ MINIMUM_AMOUNT, MINIMUM_AMOUNT, FeeValidity.FEE_VALID ], 13 | [ 99_999_99, MINIMUM_AMOUNT, FeeValidity.FEE_VALID ], 14 | [ 100_000_00, MINIMUM_AMOUNT, FeeValidity.FEE_VALID ], 15 | [ 100_000_01, MINIMUM_AMOUNT, FeeValidity.FEE_TOO_HIGH ], 16 | [ 100_001_00, MINIMUM_AMOUNT, FeeValidity.FEE_TOO_HIGH ], 17 | ] )( 'returns correct FeeValidity state for membership fees (amount: %d, minimum: %d)', ( 18 | amountToTest: number, 19 | minimumAmount: number, 20 | expectedValidity: FeeValidity 21 | ) => { 22 | const result: FeeValidity = validateFee( amountToTest, minimumAmount ); 23 | 24 | expect( result ).toEqual( expectedValidity ); 25 | } ); 26 | 27 | } ); 28 | -------------------------------------------------------------------------------- /src/components/patterns/FieldContainer.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | --------------------------------------------------------------------------------