diff --git a/@types/mjml-template.d.ts b/@types/mjml-template.d.ts index aa05b0123..50637b84a 100644 --- a/@types/mjml-template.d.ts +++ b/@types/mjml-template.d.ts @@ -1,4 +1,4 @@ declare module '*.mjml?raw' { const content: string export default content -} \ No newline at end of file +} diff --git a/@types/pagedjs.d.ts b/@types/pagedjs.d.ts index 6580d1c86..cd2b27003 100644 --- a/@types/pagedjs.d.ts +++ b/@types/pagedjs.d.ts @@ -19,4 +19,4 @@ declare module 'pagedjs' { } export const registeredHandlers: PagedJsHandlerConstructor[] -} \ No newline at end of file +} diff --git a/_TODO.md b/_TODO.md index 936b66832..f6c583b20 100644 --- a/_TODO.md +++ b/_TODO.md @@ -30,9 +30,9 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2 ## Contact Form - `0/2000` characters should show number of characters left instead +- Workflow right now puts the "Success" toast under the submit button when the submit button returns to normal after a submission. It seems like the button should have some time out after a successful submission to make sure it's not hammered, like five seconds. And it just looks visually odd - maybe the button should be part of the layout of the success toast, or moved down under it. ## Newsletter / MJML Templates -- We need to make sure the images point to the full production URL, not a relative import - Need to move the unsubscribe link into an Action and handle it entirely within our website instead of on Hubspot - Need to add a newsletter publishing workflow as an action, using the newsletter static segment imported from Hubspot diff --git a/cover.jpg b/cover.jpg deleted file mode 100644 index cc0c5c7db..000000000 Binary files a/cover.jpg and /dev/null differ diff --git a/public/pdf/resume.pdf b/public/pdf/resume.pdf new file mode 100644 index 000000000..46b827efa Binary files /dev/null and b/public/pdf/resume.pdf differ diff --git a/src/actions/contact/__tests__/action.spec.ts b/src/actions/contact/__tests__/action.spec.ts index 08d356a70..74562f3b1 100644 --- a/src/actions/contact/__tests__/action.spec.ts +++ b/src/actions/contact/__tests__/action.spec.ts @@ -12,7 +12,9 @@ type ContactSubmitOutput = { message: string } -const getMockedHandler = (action: unknown): ActionConfig['handler'] => { +const getMockedHandler = ( + action: unknown +): ActionConfig['handler'] => { return (action as ActionConfig).handler } @@ -106,7 +108,7 @@ vi.mock('@actions/utils/errors', async () => { ? messageOrError : messageOrError instanceof Error ? messageOrError.message - : options?.message ?? 'Internal server error' + : (options?.message ?? 'Internal server error') super(message) this.name = 'ActionsFunctionError' this.status = options?.status ?? 500 @@ -239,4 +241,4 @@ describe('contact.submit.handler', () => { }) ) }) -}) \ No newline at end of file +}) diff --git a/src/actions/contact/__tests__/domain.spec.ts b/src/actions/contact/__tests__/domain.spec.ts index beddab16c..f43f3d766 100644 --- a/src/actions/contact/__tests__/domain.spec.ts +++ b/src/actions/contact/__tests__/domain.spec.ts @@ -49,7 +49,9 @@ describe('contact domain validation', () => { if (result.success) { throw new Error('Expected schema validation to fail') } - expect(z.flattenError(result.error).fieldErrors['timeline']).toContain('Invalid project timeline') + expect(z.flattenError(result.error).fieldErrors['timeline']).toContain( + 'Invalid project timeline' + ) }) it('rejects messages that appear to contain spam', () => { @@ -65,6 +67,8 @@ describe('contact domain validation', () => { if (result.success) { throw new Error('Expected schema validation to fail') } - expect(z.flattenError(result.error).fieldErrors['message']).toContain('Message appears to contain spam') + expect(z.flattenError(result.error).fieldErrors['message']).toContain( + 'Message appears to contain spam' + ) }) }) diff --git a/src/actions/contact/__tests__/responder.spec.ts b/src/actions/contact/__tests__/responder.spec.ts index 4fed07feb..4a2ef11fc 100644 --- a/src/actions/contact/__tests__/responder.spec.ts +++ b/src/actions/contact/__tests__/responder.spec.ts @@ -107,9 +107,7 @@ describe('contact responder', () => { { label: 'Budget', value: '$5k-$10k' }, { label: 'Timeline', value: '2-3-months' }, ]) - expect(templateData.attachments).toEqual([ - { filename: 'brief.pdf', sizeLabel: '1.21 KB' }, - ]) + expect(templateData.attachments).toEqual([{ filename: 'brief.pdf', sizeLabel: '1.21 KB' }]) expect(templateData.consentGiven).toBe('Yes') expect(templateData.messageHtml).toContain('<ASAP>') expect(templateData.messageHtml).toContain('&') diff --git a/src/actions/contact/action.ts b/src/actions/contact/action.ts index 68e9e54dd..5b48232d9 100644 --- a/src/actions/contact/action.ts +++ b/src/actions/contact/action.ts @@ -8,7 +8,11 @@ import { getResendApiKey, isProd, } from '@actions/utils/environment/environmentActions' -import { ActionsFunctionError, handleActionsFunctionError, throwActionError } from '@actions/utils/errors' +import { + ActionsFunctionError, + handleActionsFunctionError, + throwActionError, +} from '@actions/utils/errors' import { contactFormSender, contactInbox, contactReplyTo } from '@actions/utils/email/resendSenders' import { createConsentRecord } from '@actions/gdpr/entities/consent' import { createOrUpdateContact, setMarketingOptIn } from '@actions/utils/hubspot' @@ -121,7 +125,8 @@ export const contact = { userAgent, ipAddress: ip !== 'unknown' ? ip : null, privacyPolicyVersion: getPrivacyPolicyVersion(), - consentText: null, + consentText: + 'I consent to Webstack Builders processing my personal data for responding to your inquiry. See our Privacy Policy and Cookie Policy.', verified: true, }) } @@ -189,9 +194,13 @@ export const contact = { throw error } - throwActionError(error, { route, operation: 'submit' }, { - fallbackMessage: 'Failed to send email. Please try again later.', - }) + throwActionError( + error, + { route, operation: 'submit' }, + { + fallbackMessage: 'Failed to send email. Please try again later.', + } + ) } }, }), diff --git a/src/actions/contact/utils.ts b/src/actions/contact/utils.ts index 8f33a3e3f..4508c74be 100644 --- a/src/actions/contact/utils.ts +++ b/src/actions/contact/utils.ts @@ -1,15 +1,11 @@ import { z } from 'astro/zod' -import type { - ContactTimeline, - RequiredStringOptions, - } from '@actions/contact/@types' +import type { ContactTimeline, RequiredStringOptions } from '@actions/contact/@types' import { isAllowedTimeline } from './responder' -const requiredStringError = ( - requiredMessage: string, - invalidTypeMessage: string -) => (issue: { input?: unknown }): string => - issue.input === undefined ? requiredMessage : invalidTypeMessage +const requiredStringError = + (requiredMessage: string, invalidTypeMessage: string) => + (issue: { input?: unknown }): string => + issue.input === undefined ? requiredMessage : invalidTypeMessage export function escapeHtml(text: string): string { const map: Record = { @@ -39,7 +35,8 @@ export function readString(form: FormData, key: string): string { return typeof value === 'string' ? value : '' } -export const trimString = (value: unknown): unknown => (typeof value === 'string' ? value.trim() : value) +export const trimString = (value: unknown): unknown => + typeof value === 'string' ? value.trim() : value export const emptyStringToUndefined = (value: unknown): unknown => { if (value === null) return undefined @@ -66,8 +63,8 @@ export const requiredString = (options: RequiredStringOptions) => { ) } -export const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File - +export const isFile = (value: unknown): value is File => + typeof File !== 'undefined' && value instanceof File export const optionalFile = () => z.custom(isFile).optional() diff --git a/src/actions/downloads/__tests__/action.spec.ts b/src/actions/downloads/__tests__/action.spec.ts index 8d0b1902f..939747094 100644 --- a/src/actions/downloads/__tests__/action.spec.ts +++ b/src/actions/downloads/__tests__/action.spec.ts @@ -4,7 +4,9 @@ type ActionConfig = { handler: (_input: Input, _context: unknown) => Promise } -const getMockedHandler = (action: unknown): ActionConfig['handler'] => { +const getMockedHandler = ( + action: unknown +): ActionConfig['handler'] => { return (action as ActionConfig).handler } @@ -95,11 +97,14 @@ describe('downloads.submit.handler', () => { clientAddress: '203.0.113.10', } - const response = await getMockedHandler(downloads.submit)({ - firstName: 'Jane', - lastName: 'Doe', - workEmail: 'jane@example.com', - }, context) + const response = await getMockedHandler(downloads.submit)( + { + firstName: 'Jane', + lastName: 'Doe', + workEmail: 'jane@example.com', + }, + context + ) expect(response).toEqual({ success: true, @@ -112,4 +117,4 @@ describe('downloads.submit.handler', () => { }) expect(createConsentRecord).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) diff --git a/src/actions/downloads/action.ts b/src/actions/downloads/action.ts index 4ff475c1c..9ecac4cd7 100644 --- a/src/actions/downloads/action.ts +++ b/src/actions/downloads/action.ts @@ -60,7 +60,8 @@ export const downloads = { userAgent, ipAddress: ip !== 'unknown' ? ip : null, privacyPolicyVersion: getPrivacyPolicyVersion(), - consentText: null, + consentText: + 'I consent to Webstack Builders processing my personal data for providing your requested download. See our Privacy Policy and Cookie Policy.', verified: true, }) } diff --git a/src/actions/gdpr/__tests__/responder.spec.ts b/src/actions/gdpr/__tests__/responder.spec.ts index de5de1820..507f33891 100644 --- a/src/actions/gdpr/__tests__/responder.spec.ts +++ b/src/actions/gdpr/__tests__/responder.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -vi.mock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') +vi.mock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getPrivacyPolicyVersion: () => 'test-privacy-policy-version', diff --git a/src/actions/gdpr/action.ts b/src/actions/gdpr/action.ts index 56de7038f..12dc9b133 100644 --- a/src/actions/gdpr/action.ts +++ b/src/actions/gdpr/action.ts @@ -2,18 +2,9 @@ import emailValidator from 'email-validator' import { validate as uuidValidate } from 'uuid' import { defineAction } from 'astro:actions' import { z } from 'astro/zod' -import { - checkRateLimit, - rateLimiters -} from '@actions/utils/rateLimit' -import { - buildRequestFingerprint, - createRateLimitIdentifier -} from '@actions/utils/requestContext' -import { - ActionsFunctionError, - handleActionsFunctionError -} from '@actions/utils/errors' +import { checkRateLimit, rateLimiters } from '@actions/utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/utils/requestContext' +import { ActionsFunctionError, handleActionsFunctionError } from '@actions/utils/errors' import type { ConsentResponse, DSARRequestInput, @@ -26,10 +17,7 @@ import { consentDeleteSchema, dsarRequestSchema, } from '@actions/gdpr/domain' -import { - buildRateLimitError, - mapConsentRecord, -} from '@actions/gdpr/responder' +import { buildRateLimitError, mapConsentRecord } from '@actions/gdpr/responder' import { createConsentRecord, createConsentRecordInput, diff --git a/src/actions/gdpr/constants.ts b/src/actions/gdpr/constants.ts index 0f4aef3f4..c51b7aaa6 100644 --- a/src/actions/gdpr/constants.ts +++ b/src/actions/gdpr/constants.ts @@ -1,4 +1,10 @@ -export const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'functional', 'downloads'] as const +export const CONSENT_PURPOSES = [ + 'contact', + 'marketing', + 'analytics', + 'functional', + 'downloads', +] as const export type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] diff --git a/src/actions/gdpr/email/dsarText.ts b/src/actions/gdpr/email/dsarText.ts index 2e84109b2..c1e7975ef 100644 --- a/src/actions/gdpr/email/dsarText.ts +++ b/src/actions/gdpr/email/dsarText.ts @@ -34,4 +34,4 @@ Questions? Contact us at ${company.dataProtectionOfficer.email} © ${new Date().getFullYear()} ${company.name}. All rights reserved. `.trim() -} \ No newline at end of file +} diff --git a/src/actions/gdpr/entities/consent.ts b/src/actions/gdpr/entities/consent.ts index 5669646db..1ed54d3b3 100644 --- a/src/actions/gdpr/entities/consent.ts +++ b/src/actions/gdpr/entities/consent.ts @@ -5,7 +5,7 @@ import type { ConsentEventRecord, ConsentRequest, CreateConsentRecordInput, - DbConsentRecord + DbConsentRecord, } from '@actions/gdpr/@types' import { normalizeNullableString, diff --git a/src/actions/gdpr/entities/dsar.ts b/src/actions/gdpr/entities/dsar.ts index 7d9a7a1c6..717543be1 100644 --- a/src/actions/gdpr/entities/dsar.ts +++ b/src/actions/gdpr/entities/dsar.ts @@ -5,10 +5,13 @@ import type { DsarRequestRecord, DSARRequest, DsarVerifyResult, - RequestType + RequestType, } from '@actions/gdpr/@types' import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/domain' -import { deleteConsentRecordsByEmail, findConsentRecordsByEmail } from '@actions/gdpr/entities/consent' +import { + deleteConsentRecordsByEmail, + findConsentRecordsByEmail, +} from '@actions/gdpr/entities/consent' import { handleActionsFunctionError } from '@actions/utils/errors' import { purgeContact } from '@actions/utils/hubspot' @@ -110,6 +113,103 @@ export async function verifyDsarToken(token: string): Promise createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, })), + legalDisclosures: { + retentionPeriods: { + consentRecords: + 'Retained for 7 years from the date of consent to meet legal compliance and audit requirements.', + contactFormSubmissions: + 'Retained for 3 years from the date of submission for legitimate business correspondence purposes.', + newsletterSubscriptions: + 'Retained until you unsubscribe, plus 12 months thereafter for audit purposes.', + downloadRecords: + 'Retained for 2 years from the date of download for business records purposes.', + }, + thirdPartyRecipients: [ + { + name: 'Resend', + category: 'Email delivery service', + purpose: + 'Delivers transactional and marketing emails on our behalf. Receives your email address and name.', + privacyPolicy: 'https://resend.com/privacy', + }, + { + name: 'HubSpot', + category: 'Customer relationship management (CRM) and marketing platform', + purpose: + 'Stores contact details and newsletter subscription status for marketing communications. Receives your email address and name when you subscribe to the newsletter or submit the contact form with marketing consent.', + privacyPolicy: 'https://legal.hubspot.com/privacy-policy', + }, + { + name: 'Upstash', + category: 'Rate-limiting and caching service', + purpose: + 'Stores anonymised request fingerprints to enforce rate limits. Does not receive personally identifiable information.', + privacyPolicy: 'https://upstash.com/privacy', + }, + { + name: 'Sentry', + category: 'Error monitoring service', + purpose: + 'Captures application error reports which may include request metadata. Configured to scrub personally identifiable information from error payloads.', + privacyPolicy: 'https://sentry.io/privacy/', + }, + { + name: 'Vercel', + category: 'Hosting and infrastructure provider', + purpose: + 'Hosts and serves this website. All web requests are processed through Vercel infrastructure.', + privacyPolicy: 'https://vercel.com/legal/privacy-policy', + }, + ], + yourRights: { + summary: + 'Under the UK GDPR and EU GDPR you have the following rights regarding your personal data.', + rights: [ + { + article: 'Article 15', + right: 'Right of access', + description: + 'You have the right to obtain a copy of the personal data we hold about you and supplementary information about how it is processed.', + }, + { + article: 'Article 16', + right: 'Right to rectification', + description: + 'You have the right to request correction of inaccurate personal data we hold about you.', + }, + { + article: 'Article 17', + right: 'Right to erasure', + description: + "You have the right to request deletion of your personal data ('right to be forgotten'). Use the Delete My Data form at https://www.webstackbuilders.com/privacy/my-data to submit a deletion request.", + }, + { + article: 'Article 18', + right: 'Right to restriction of processing', + description: + 'You have the right to request that we restrict the processing of your personal data in certain circumstances.', + }, + { + article: 'Article 20', + right: 'Right to data portability', + description: + 'You have the right to receive your personal data in a structured, commonly used, machine-readable format (such as this JSON file) and to transmit it to another controller.', + }, + { + article: 'Article 21', + right: 'Right to object', + description: + 'You have the right to object to the processing of your personal data for direct marketing purposes at any time.', + }, + ], + howToExercise: + 'To exercise any of these rights, contact us at privacy@webstackbuilders.com. We will respond within 30 days.', + supervisoryAuthority: + 'You have the right to lodge a complaint with a supervisory authority. In the UK this is the Information Commissioner\'s Office (ICO): https://ico.org.uk. In the EU, contact the supervisory authority in your country of residence.', + }, + dataSource: + 'All personal data in this export was collected directly from you through first-party form submissions on webstackbuilders.com. No data has been obtained from third-party sources.', + }, } return { diff --git a/src/actions/newsletter/@types/index.d.ts b/src/actions/newsletter/@types/index.d.ts index 97a8b8cfe..369408bb0 100644 --- a/src/actions/newsletter/@types/index.d.ts +++ b/src/actions/newsletter/@types/index.d.ts @@ -26,7 +26,11 @@ export type NewsletterSubscribeInput = { DataSubjectId?: string } -export type NewsletterSubscribeOutput = { success: true; message: string; requiresConfirmation: true } +export type NewsletterSubscribeOutput = { + success: true + message: string + requiresConfirmation: true +} export type NewsletterConfirmInput = { token: string } diff --git a/src/actions/newsletter/__tests__/action.spec.ts b/src/actions/newsletter/__tests__/action.spec.ts index 5a783e8d3..bf57f7cae 100644 --- a/src/actions/newsletter/__tests__/action.spec.ts +++ b/src/actions/newsletter/__tests__/action.spec.ts @@ -7,7 +7,9 @@ import { ActionConfig, } from '@actions/newsletter/@types' -const getMockedHandler = (action: unknown): ActionConfig['handler'] => { +const getMockedHandler = ( + action: unknown +): ActionConfig['handler'] => { return (action as ActionConfig).handler } @@ -98,14 +100,13 @@ vi.mock('@actions/utils/errors', async () => { }, options?: { status?: number } ) { - const message = typeof messageOrOptions === 'string' ? messageOrOptions : messageOrOptions.message + const message = + typeof messageOrOptions === 'string' ? messageOrOptions : messageOrOptions.message super(message) this.name = 'ActionsFunctionError' const status = - typeof messageOrOptions === 'string' - ? options?.status - : messageOrOptions.status + typeof messageOrOptions === 'string' ? options?.status : messageOrOptions.status this.status = typeof status === 'number' ? status : 500 } @@ -206,10 +207,7 @@ describe('newsletter.subscribe.handler', () => { const response = await getMockedHandler( newsletter.subscribe - )( - { email: ' TEST@Example.com ', consentGiven: true }, - context - ) + )({ email: ' TEST@Example.com ', consentGiven: true }, context) expect(response).toEqual({ success: true, @@ -422,7 +420,8 @@ describe('newsletter.confirm.handler', () => { const { newsletter } = await import('../action') const { markConsentRecordsVerified } = await import('@actions/gdpr/entities/consent') const { sendWelcomeEmail } = await import('@actions/newsletter/entities/email') - const { createOrUpdateContact, setMarketingOptIn, addContactToNewsletterList } = await import('@actions/utils/hubspot') + const { createOrUpdateContact, setMarketingOptIn, addContactToNewsletterList } = + await import('@actions/utils/hubspot') const context = { request: new Request('https://example.com/_actions/newsletter/confirm', { method: 'POST' }), @@ -439,7 +438,10 @@ describe('newsletter.confirm.handler', () => { '3f2d0e5a-7e8d-4b3c-9a6a-2b5d84c6f3a2' ) expect(sendWelcomeEmail).toHaveBeenCalledWith('test@example.com', 'Test') - expect(createOrUpdateContact).toHaveBeenCalledWith({ email: 'test@example.com', firstname: 'Test' }) + expect(createOrUpdateContact).toHaveBeenCalledWith({ + email: 'test@example.com', + firstname: 'Test', + }) expect(setMarketingOptIn).toHaveBeenCalledWith('42', true) expect(addContactToNewsletterList).toHaveBeenCalledWith('42') diff --git a/src/actions/newsletter/action.ts b/src/actions/newsletter/action.ts index a85abb2be..7d674c341 100644 --- a/src/actions/newsletter/action.ts +++ b/src/actions/newsletter/action.ts @@ -13,7 +13,11 @@ import { createConsentRecord, markConsentRecordsVerified } from '@actions/gdpr/e import { createPendingSubscription, confirmSubscription } from '@actions/newsletter/domain' import { validateEmail } from '@actions/newsletter/utils' import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/entities/email' -import { createOrUpdateContact, addContactToNewsletterList, setMarketingOptIn } from '@actions/utils/hubspot' +import { + createOrUpdateContact, + addContactToNewsletterList, + setMarketingOptIn, +} from '@actions/utils/hubspot' const subscribeSchema = z.object({ email: z.string(), @@ -152,7 +156,8 @@ export const newsletter = { ? context.clientAddress : null, privacyPolicyVersion: getPrivacyPolicyVersion(), - consentText: null, + consentText: + 'I consent to Webstack Builders processing my personal data for marketing communications (unsubscribe anytime). See our Privacy Policy and Cookie Policy.', verified: false, }) diff --git a/src/actions/newsletter/email/confirmationHtml.ts b/src/actions/newsletter/email/confirmationHtml.ts index e7130f762..47cdba1c6 100644 --- a/src/actions/newsletter/email/confirmationHtml.ts +++ b/src/actions/newsletter/email/confirmationHtml.ts @@ -1,5 +1,8 @@ import confirmationTemplateContent from './confirmation.mjml?raw' -import { compileEmailTemplate, createImportedEmailTemplate } from '@actions/utils/email/templateCompiler' +import { + compileEmailTemplate, + createImportedEmailTemplate, +} from '@actions/utils/email/templateCompiler' const confirmationTemplate = createImportedEmailTemplate( 'src/actions/newsletter/email/confirmation.mjml', @@ -50,4 +53,4 @@ export async function generateConfirmationEmailText( ) return text -} \ No newline at end of file +} diff --git a/src/actions/newsletter/email/index.ts b/src/actions/newsletter/email/index.ts index e1080b58c..c35b3f799 100644 --- a/src/actions/newsletter/email/index.ts +++ b/src/actions/newsletter/email/index.ts @@ -1,4 +1,4 @@ export { generateConfirmationEmailHtml } from './confirmationHtml' export { generateConfirmationEmailText } from './confirmationHtml' export { generateWelcomeEmailHtml } from './welcomeHtml' -export { generateWelcomeEmailText } from './welcomeHtml' \ No newline at end of file +export { generateWelcomeEmailText } from './welcomeHtml' diff --git a/src/actions/newsletter/email/welcomeHtml.ts b/src/actions/newsletter/email/welcomeHtml.ts index 17715b130..46657dea7 100644 --- a/src/actions/newsletter/email/welcomeHtml.ts +++ b/src/actions/newsletter/email/welcomeHtml.ts @@ -1,5 +1,8 @@ import welcomeTemplateContent from './welcome.mjml?raw' -import { compileEmailTemplate, createImportedEmailTemplate } from '@actions/utils/email/templateCompiler' +import { + compileEmailTemplate, + createImportedEmailTemplate, +} from '@actions/utils/email/templateCompiler' const welcomeTemplate = createImportedEmailTemplate( 'src/actions/newsletter/email/welcome.mjml', @@ -19,19 +22,13 @@ const createWelcomeTemplateData = (firstName?: string): WelcomeTemplateData => ( }) export async function generateWelcomeEmailHtml(firstName?: string): Promise { - const { html } = await compileEmailTemplate( - welcomeTemplate, - createWelcomeTemplateData(firstName) - ) + const { html } = await compileEmailTemplate(welcomeTemplate, createWelcomeTemplateData(firstName)) return html } export async function generateWelcomeEmailText(firstName?: string): Promise { - const { text } = await compileEmailTemplate( - welcomeTemplate, - createWelcomeTemplateData(firstName) - ) + const { text } = await compileEmailTemplate(welcomeTemplate, createWelcomeTemplateData(firstName)) return text -} \ No newline at end of file +} diff --git a/src/actions/newsletter/entities/__tests__/email.spec.ts b/src/actions/newsletter/entities/__tests__/email.spec.ts index 57b5ff789..3d07e9f3b 100644 --- a/src/actions/newsletter/entities/__tests__/email.spec.ts +++ b/src/actions/newsletter/entities/__tests__/email.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -vi.mock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') +vi.mock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getResendApiKey: () => 'resend-test-key', @@ -43,8 +44,9 @@ describe('newsletter email entity', () => { }) it('throws ActionsFunctionError when Resend returns an error in prod', async () => { - vi.doMock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') + vi.doMock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getResendApiKey: () => 'resend-test-key', @@ -67,7 +69,9 @@ describe('newsletter email entity', () => { vi.resetModules() const { sendConfirmationEmail: prodSendConfirmationEmail } = await import('../email') - await expect(prodSendConfirmationEmail('test@example.com', 'token-1', 'Jane')).rejects.toMatchObject({ + await expect( + prodSendConfirmationEmail('test@example.com', 'token-1', 'Jane') + ).rejects.toMatchObject({ name: 'ActionsFunctionError', status: 502, }) diff --git a/src/actions/utils/email/__tests__/templateCompiler.spec.ts b/src/actions/utils/email/__tests__/templateCompiler.spec.ts index 0f2c85247..b4b04a726 100644 --- a/src/actions/utils/email/__tests__/templateCompiler.spec.ts +++ b/src/actions/utils/email/__tests__/templateCompiler.spec.ts @@ -100,7 +100,9 @@ describe('compileEmailTemplate', () => { expect(result.html).toContain('support@webstackbuilders.com') expect(result.text).toContain('Hello, Alex!') - expect(result.text).toContain('Visit www.webstackbuilders.com [https://www.webstackbuilders.com].') + expect(result.text).toContain( + 'Visit www.webstackbuilders.com [https://www.webstackbuilders.com].' + ) expect(result.text).toContain('Contact Webstack Builders at support@webstackbuilders.com.') }) @@ -167,4 +169,4 @@ describe('compileEmailTemplate', () => { status: 500, }) }) -}) \ No newline at end of file +}) diff --git a/src/actions/utils/email/resendSenders.ts b/src/actions/utils/email/resendSenders.ts index 9ddd1b7ce..5a3a3eb3c 100644 --- a/src/actions/utils/email/resendSenders.ts +++ b/src/actions/utils/email/resendSenders.ts @@ -6,4 +6,4 @@ export const contactFormSender = `contact@${resendSendingDomain}` export const newsletterSender = `Webstack Builders ` export const newsletterReplyTo = 'hello@webstackbuilders.com' export const gdprSender = `Webstack Builders ` -export const gdprReplyTo = 'privacy@webstackbuilders.com' \ No newline at end of file +export const gdprReplyTo = 'privacy@webstackbuilders.com' diff --git a/src/actions/utils/email/templateCompiler.ts b/src/actions/utils/email/templateCompiler.ts index b3f4c1f56..d6ad60f5f 100644 --- a/src/actions/utils/email/templateCompiler.ts +++ b/src/actions/utils/email/templateCompiler.ts @@ -181,11 +181,11 @@ const renderTemplateString = (template: EmailTemplateSource, data: EmailTemplate return nunjucksTemplate.render(mergeCommonEmailTemplateData(data)) } -const normalizeTemplatePath = (templatePath: string | URL): { absolutePath: string; relativePath: string } => { +const normalizeTemplatePath = ( + templatePath: string | URL +): { absolutePath: string; relativePath: string } => { const rawPath = templatePath instanceof URL ? fileURLToPath(templatePath) : templatePath - const absolutePath = isAbsolute(rawPath) - ? rawPath - : resolve(projectRoot, rawPath) + const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(projectRoot, rawPath) const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/') if (!relativePath || relativePath.startsWith('..')) { @@ -219,7 +219,9 @@ export const createEmailTemplate = ( } } -const createMjmlRenderOptions = (absolutePath: string): { +const createMjmlRenderOptions = ( + absolutePath: string +): { filePath?: string keepComments: boolean } => { @@ -263,13 +265,8 @@ export async function compileEmailTemplate( const { absolutePath, relativePath } = normalizeTemplatePath(template.filePath) const mjmlWithData = renderTemplateString(template, data) const mjmlModule = await import('mjml') - const mjml2html = ( - 'default' in mjmlModule ? mjmlModule.default : mjmlModule - ) as MjmlRenderer - const { html, errors } = await mjml2html( - mjmlWithData, - createMjmlRenderOptions(absolutePath) - ) + const mjml2html = ('default' in mjmlModule ? mjmlModule.default : mjmlModule) as MjmlRenderer + const { html, errors } = await mjml2html(mjmlWithData, createMjmlRenderOptions(absolutePath)) if (errors.length > 0) { throw new ActionsFunctionError({ @@ -309,4 +306,4 @@ export async function compileEmailTemplate( }, }) } -} \ No newline at end of file +} diff --git a/src/actions/utils/environment/environmentActions.ts b/src/actions/utils/environment/environmentActions.ts index be27e887b..d34a16972 100644 --- a/src/actions/utils/environment/environmentActions.ts +++ b/src/actions/utils/environment/environmentActions.ts @@ -6,11 +6,7 @@ import { PUBLIC_UPSTASH_SEARCH_READONLY_TOKEN, PUBLIC_UPSTASH_SEARCH_REST_URL, } from 'astro:env/client' -import { - HUBSPOT_ACCESS_TOKEN, - HUBSPOT_NEWSLETTER_LIST_ID, - RESEND_API_KEY, -} from 'astro:env/server' +import { HUBSPOT_ACCESS_TOKEN, HUBSPOT_NEWSLETTER_LIST_ID, RESEND_API_KEY } from 'astro:env/server' export { isCI, diff --git a/src/actions/utils/hubspot/__tests__/hubspot.spec.ts b/src/actions/utils/hubspot/__tests__/hubspot.spec.ts index 59fad935f..72f0174e0 100644 --- a/src/actions/utils/hubspot/__tests__/hubspot.spec.ts +++ b/src/actions/utils/hubspot/__tests__/hubspot.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -vi.mock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') +vi.mock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getHubspotAccessToken: () => 'hs-test-token', @@ -56,15 +57,16 @@ describe('hubspot contacts', () => { await setMarketingOptIn('0', true) - expect(logSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] HubSpot setMarketingOptIn:', - { contactId: '0', optIn: true } - ) + expect(logSpy).toHaveBeenCalledWith('[DEV/TEST MODE] HubSpot setMarketingOptIn:', { + contactId: '0', + optIn: true, + }) }) it('creates a new contact when search returns no results in prod', async () => { - vi.doMock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') + vi.doMock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getHubspotAccessToken: () => 'hs-test-token', @@ -97,8 +99,9 @@ describe('hubspot contacts', () => { }) it('updates an existing contact when search returns a result in prod', async () => { - vi.doMock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') + vi.doMock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getHubspotAccessToken: () => 'hs-test-token', @@ -142,15 +145,15 @@ describe('hubspot newsletter', () => { await addContactToNewsletterList('42') - expect(logSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] HubSpot addContactToNewsletterList:', - { contactId: '42' } - ) + expect(logSpy).toHaveBeenCalledWith('[DEV/TEST MODE] HubSpot addContactToNewsletterList:', { + contactId: '42', + }) }) it('calls membershipsApi.add in prod', async () => { - vi.doMock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') + vi.doMock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getHubspotAccessToken: () => 'hs-test-token', @@ -178,15 +181,15 @@ describe('hubspot gdpr', () => { await purgeContact('test@example.com') - expect(logSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] HubSpot purgeContact:', - { email: 'test@example.com' } - ) + expect(logSpy).toHaveBeenCalledWith('[DEV/TEST MODE] HubSpot purgeContact:', { + email: 'test@example.com', + }) }) it('calls basicApi.purge in prod', async () => { - vi.doMock('@actions/utils/environment/environmentActions', async (importOriginal) => { - const actual = (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') + vi.doMock('@actions/utils/environment/environmentActions', async importOriginal => { + const actual = + (await importOriginal()) as typeof import('@actions/utils/environment/environmentActions') return { ...actual, getHubspotAccessToken: () => 'hs-test-token', diff --git a/src/actions/utils/hubspot/contacts.ts b/src/actions/utils/hubspot/contacts.ts index 01a1d6c0c..6128390a9 100644 --- a/src/actions/utils/hubspot/contacts.ts +++ b/src/actions/utils/hubspot/contacts.ts @@ -34,7 +34,9 @@ export async function createOrUpdateContact( const searchResponse = await client.crm.contacts.searchApi.doSearch({ filterGroups: [ { - filters: [{ propertyName: 'email', operator: FilterOperatorEnum.Eq, value: properties.email }], + filters: [ + { propertyName: 'email', operator: FilterOperatorEnum.Eq, value: properties.email }, + ], }, ], properties: ['email', 'firstname', 'lastname'], @@ -82,10 +84,7 @@ export async function createOrUpdateContact( * Sets the hs_marketable_status property on a contact. * Pass `true` to opt the contact in to marketing emails. */ -export async function setMarketingOptIn( - contactId: string, - optIn: boolean -): Promise { +export async function setMarketingOptIn(contactId: string, optIn: boolean): Promise { if (!isProd()) { console.log('[DEV/TEST MODE] HubSpot setMarketingOptIn:', { contactId, optIn }) return diff --git a/src/actions/webmentions/@types/index.ts b/src/actions/webmentions/@types/index.ts index 814d13aa2..3413ff8e2 100644 --- a/src/actions/webmentions/@types/index.ts +++ b/src/actions/webmentions/@types/index.ts @@ -12,4 +12,4 @@ export interface WebmentionsListResult { likesCount: number mentions: WebmentionDisplayItem[] repostsCount: number -} \ No newline at end of file +} diff --git a/src/actions/webmentions/__tests__/action.spec.ts b/src/actions/webmentions/__tests__/action.spec.ts index 352f30290..5638645c2 100644 --- a/src/actions/webmentions/__tests__/action.spec.ts +++ b/src/actions/webmentions/__tests__/action.spec.ts @@ -120,4 +120,4 @@ describe('webmentions actions', () => { { fallbackMessage: 'Webmentions could not be loaded.' } ) }) -}) \ No newline at end of file +}) diff --git a/src/actions/webmentions/action.ts b/src/actions/webmentions/action.ts index 37515ff30..0a295a5b8 100644 --- a/src/actions/webmentions/action.ts +++ b/src/actions/webmentions/action.ts @@ -29,9 +29,13 @@ export const webmentions = { handler: async (input): Promise => { try { const mentions = await fetchWebmentions(input.url) - const displayMentions = mentions.filter(mention => displayProperties.has(mention['wm-property'])) + const displayMentions = mentions.filter(mention => + displayProperties.has(mention['wm-property']) + ) const likesCount = mentions.filter(mention => mention['wm-property'] === 'like-of').length - const repostsCount = mentions.filter(mention => mention['wm-property'] === 'repost-of').length + const repostsCount = mentions.filter( + mention => mention['wm-property'] === 'repost-of' + ).length return { likesCount, @@ -47,4 +51,4 @@ export const webmentions = { } }, }), -} \ No newline at end of file +} diff --git a/src/components/Analytics/client/selectors.ts b/src/components/Analytics/client/selectors.ts index 20801168f..a508e0821 100644 --- a/src/components/Analytics/client/selectors.ts +++ b/src/components/Analytics/client/selectors.ts @@ -7,4 +7,4 @@ export const SELECTORS = { export const queryAnalyticsStateElement = (root: ParentNode = document): HTMLElement | null => { const element = root.querySelector(SELECTORS.analyticsState) return isType1Element(element) && element instanceof HTMLElement ? element : null -} \ No newline at end of file +} diff --git a/src/components/Analytics/index.astro b/src/components/Analytics/index.astro index 5e89f5242..b2410f87d 100644 --- a/src/components/Analytics/index.astro +++ b/src/components/Analytics/index.astro @@ -6,10 +6,7 @@ const paramsStr = JSON.stringify(Astro.params) const pathname = Astro.url.pathname --- - - -)} +{ + src && ( + <> + + + +
+
+ + + {/** Intentionally not using Button component. */} + +
+
+ +
+ + + + + ) +} diff --git a/src/components/Inset/__tests__/index.spec.ts b/src/components/Inset/__tests__/index.spec.ts index 4cce1867e..fbbd84d2a 100644 --- a/src/components/Inset/__tests__/index.spec.ts +++ b/src/components/Inset/__tests__/index.spec.ts @@ -90,4 +90,4 @@ describe('Inset (Astro)', () => { ) }) }) -}) \ No newline at end of file +}) diff --git a/src/components/Inset/index.astro b/src/components/Inset/index.astro index daac5a6dd..d8ff5ac54 100644 --- a/src/components/Inset/index.astro +++ b/src/components/Inset/index.astro @@ -26,12 +26,12 @@ const defaultInsetProps = { ---
- {variant === 'default' && ( - - - - )} - {figure && ( -
- )} + { + variant === 'default' && ( + + + + ) + } + {figure &&
}
diff --git a/src/components/Layout/Copyright/index.astro b/src/components/Layout/Copyright/index.astro index add75b367..37cc2a2d0 100644 --- a/src/components/Layout/Copyright/index.astro +++ b/src/components/Layout/Copyright/index.astro @@ -12,6 +12,5 @@ const { publishDate, variant = 'default', link } = Astro.props const publishYear = publishDate.getFullYear() --- - {variant === 'default' && } {variant === 'print' && } diff --git a/src/components/Layout/Copyright/layouts/default.astro b/src/components/Layout/Copyright/layouts/default.astro index ff51caf94..af5c13351 100644 --- a/src/components/Layout/Copyright/layouts/default.astro +++ b/src/components/Layout/Copyright/layouts/default.astro @@ -11,12 +11,7 @@ const { publishYear } = Astro.props
Copyright - + diff --git a/src/components/Layout/Copyright/layouts/print.astro b/src/components/Layout/Copyright/layouts/print.astro index f2b7f9bd5..9c6f860a0 100644 --- a/src/components/Layout/Copyright/layouts/print.astro +++ b/src/components/Layout/Copyright/layouts/print.astro @@ -9,19 +9,16 @@ export interface Props { const { link, publishYear } = Astro.props const companyName = 'Webstack Builders, Inc.' -const creativeCommonsLicense = 'The text, diagrams, and images in this work are licensed under CC BY-NC 4.0' -const mitLicense = 'All code samples in this article are licensed under the MIT License. Feel free to use, modify, and distribute them in any project.' +const creativeCommonsLicense = + 'The text, diagrams, and images in this work are licensed under CC BY-NC 4.0' +const mitLicense = + 'All code samples in this article are licensed under the MIT License. Feel free to use, modify, and distribute them in any project.' ---
Copyright - + diff --git a/src/components/Layout/Markdown/Lead/__tests__/index.spec.ts b/src/components/Layout/Markdown/Lead/__tests__/index.spec.ts index 815ff60c4..bafe03f53 100644 --- a/src/components/Layout/Markdown/Lead/__tests__/index.spec.ts +++ b/src/components/Layout/Markdown/Lead/__tests__/index.spec.ts @@ -7,7 +7,7 @@ import Lead from '../index.astro' const fixtureModules = import.meta.glob( '../../../../Avatar/server/__fixtures__/avatars/*.{jpg,jpeg,png,webp}', - { eager: true, import: 'default' }, + { eager: true, import: 'default' } ) vi.mock('@components/Avatar/server/avatarImports', () => ({ @@ -36,7 +36,9 @@ describe('Lead', () => { const document = dom.window.document const normalizeWhitespace = (value: string) => value.replace(/\s+/g, ' ').trim() - const primaryLineText = normalizeWhitespace(document.querySelector('.text-primary')?.textContent ?? '') + const primaryLineText = normalizeWhitespace( + document.querySelector('.text-primary')?.textContent ?? '' + ) expect(html).toContain('href="/about"') expect(html).toContain('Kevin Brown') diff --git a/src/components/Layout/Markdown/Lead/index.astro b/src/components/Layout/Markdown/Lead/index.astro index 8ce74176b..bdbb722c0 100644 --- a/src/components/Layout/Markdown/Lead/index.astro +++ b/src/components/Layout/Markdown/Lead/index.astro @@ -21,8 +21,11 @@ const formatShortDate = (date: Date) => }) const publishDateText = publishDate ? formatShortDate(publishDate) : null -const shouldShowModifiedDate = Boolean(modifiedDate && publishDate && modifiedDate.getTime() !== publishDate.getTime()) -const modifiedDateText = shouldShowModifiedDate && modifiedDate ? formatShortDate(modifiedDate) : null +const shouldShowModifiedDate = Boolean( + modifiedDate && publishDate && modifiedDate.getTime() !== publishDate.getTime() +) +const modifiedDateText = + shouldShowModifiedDate && modifiedDate ? formatShortDate(modifiedDate) : null ---
@@ -32,35 +35,47 @@ const modifiedDateText = shouldShowModifiedDate && modifiedDate ? formatShortDat
- {author && ( - - {author} - - )} - {author && publishDateText && {' '}on{' '}} - {publishDateText && ( - - )} + { + author && ( + + {author} + + ) + } + {author && publishDateText && on } + { + publishDateText && ( + + ) + }
- {modifiedDateText && ( - - )} + { + modifiedDateText && ( + + ) + } {readingTime &&
{readingTime}
}
- {tags && tags.length > 0 && ( - - )} + { + tags && tags.length > 0 && ( + + ) + }
diff --git a/src/components/Layout/Markdown/Tags/index.astro b/src/components/Layout/Markdown/Tags/index.astro index 3407f3e73..3573dc7d9 100644 --- a/src/components/Layout/Markdown/Tags/index.astro +++ b/src/components/Layout/Markdown/Tags/index.astro @@ -61,9 +61,9 @@ const resolveTagMeta = (rawTag: string) => { data-tooltip title={`See All ${label} Articles`} class:list={[ - "inline-block rounded-full transition-colors px-3 py-1 ml-2", - "uppercase text-content-inverse text-xs no-underline", - "bg-warning-offset hover:bg-warning", + 'inline-block rounded-full transition-colors px-3 py-1 ml-2', + 'uppercase text-content-inverse text-xs no-underline', + 'bg-warning-offset hover:bg-warning', "relative focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-visible:after:-inset-1 focus-visible:after:border-spotlight", ]} > diff --git a/src/components/Layout/Print/Cover/index.astro b/src/components/Layout/Print/Cover/index.astro index 1d859c06b..6afa5fe25 100644 --- a/src/components/Layout/Print/Cover/index.astro +++ b/src/components/Layout/Print/Cover/index.astro @@ -19,27 +19,23 @@ const publishDateLabel = article.data.publishDate.toLocaleDateString(undefined, }) --- -
-
- +
+
+
-

+

{article.data.title}

- {article.data.coverAlt} + {article.data.coverAlt}
@@ -57,62 +53,62 @@ const publishDateLabel = article.data.publishDate.toLocaleDateString(undefined,
diff --git a/src/components/Layout/Print/Toc/index.astro b/src/components/Layout/Print/Toc/index.astro index fe307ecbc..884eb114f 100644 --- a/src/components/Layout/Print/Toc/index.astro +++ b/src/components/Layout/Print/Toc/index.astro @@ -17,28 +17,30 @@ const { headings } = Astro.props const tocTree = buildTocTree(headings) --- -{tocTree.length > 0 && ( - + ) +} diff --git a/src/components/Layout/SubHeader/index.astro b/src/components/Layout/SubHeader/index.astro index bc9b7befd..ce26b7ce6 100644 --- a/src/components/Layout/SubHeader/index.astro +++ b/src/components/Layout/SubHeader/index.astro @@ -12,13 +12,13 @@ const { title, description, icon, subtitle } = Astro.props
- +

{title}

- {description && (

)} - {subtitle && (

)} + { + description && ( +

+ ) + } + {subtitle &&

}

diff --git a/src/components/List/ListItem.astro b/src/components/List/ListItem.astro index d47944b92..7478d10af 100644 --- a/src/components/List/ListItem.astro +++ b/src/components/List/ListItem.astro @@ -6,4 +6,4 @@ export type Props = { const { lead } = Astro.props as Props --- - \ No newline at end of file + diff --git a/src/components/List/__fixtures__/mixedApi.fixture.astro b/src/components/List/__fixtures__/mixedApi.fixture.astro index 5824f4e0c..b67d3c3e5 100644 --- a/src/components/List/__fixtures__/mixedApi.fixture.astro +++ b/src/components/List/__fixtures__/mixedApi.fixture.astro @@ -13,4 +13,4 @@ import ListItem from '@components/List/ListItem.astro' ]} > This should never render. - \ No newline at end of file + diff --git a/src/components/List/__fixtures__/richItems.fixture.astro b/src/components/List/__fixtures__/richItems.fixture.astro index 0c5d84c07..254ba8b69 100644 --- a/src/components/List/__fixtures__/richItems.fixture.astro +++ b/src/components/List/__fixtures__/richItems.fixture.astro @@ -7,7 +7,5 @@ import ListItem from '@components/List/ListItem.astro' Separate VPCs1 can provide stronger boundaries. - - Dedicated node pools reduce resource contention. - - \ No newline at end of file + Dedicated node pools reduce resource contention. + diff --git a/src/components/List/__tests__/index.spec.ts b/src/components/List/__tests__/index.spec.ts index e9190d19d..d9bbae4aa 100644 --- a/src/components/List/__tests__/index.spec.ts +++ b/src/components/List/__tests__/index.spec.ts @@ -36,8 +36,8 @@ describe('List (Astro)', () => { const definitionList = window.document.querySelector('dl') expect(definitionList).toBeTruthy() - expect(definitionList?.className).toContain('max-w-2xl') - expect(definitionList?.className).toContain('mx-auto') + expect(definitionList?.className).toContain('max-w-2xl') + expect(definitionList?.className).toContain('mx-auto') const questions = window.document.querySelectorAll('dt') const answers = window.document.querySelectorAll('dd') @@ -227,7 +227,9 @@ describe('List (Astro)', () => { expect(list?.className).toContain('text-content-offset') expect(item?.className).toContain('flex') expect(svg).toBeTruthy() - expect(item?.textContent).toContain('Built self-service infrastructure provisioning workflows.') + expect(item?.textContent).toContain( + 'Built self-service infrastructure provisioning workflows.' + ) }) }) @@ -284,4 +286,4 @@ describe('List (Astro)', () => { 'List: received both the `items` prop and ListItem children. Use one API or the other.' ) }) -}) \ No newline at end of file +}) diff --git a/src/components/List/index.astro b/src/components/List/index.astro index f5a505c37..ce658a246 100644 --- a/src/components/List/index.astro +++ b/src/components/List/index.astro @@ -48,7 +48,15 @@ export type Props = { style?: Record } -const { items, size, color, startNumber, variant = 'default', classes, style } = Astro.props as Props +const { + items, + size, + color, + startNumber, + variant = 'default', + classes, + style, +} = Astro.props as Props const hasItemsProp = Object.prototype.hasOwnProperty.call(Astro.props, 'items') const hasDefaultSlot = Astro.slots.has('default') @@ -90,26 +98,119 @@ const itemsWithIconAndColor = normalizedItems as Array<{ inverseColor?: string bgColor?: string }> -const plainIconItems = normalizedItems.filter((item): item is NonNullable[number] & { icon: string } => { - return typeof item.icon === 'string' && item.icon.trim().length > 0 -}) +const plainIconItems = normalizedItems.filter( + (item): item is NonNullable[number] & { icon: string } => { + return typeof item.icon === 'string' && item.icon.trim().length > 0 + } +) ---
- {variant === 'accent-border-left-list' && } - {variant === 'badge-list' && } - {variant === 'card-grid-list' && } - {variant === 'chat-bubbles' && } - {variant === 'check-icons-list' && } - {variant === 'chevron-list' && } - {variant === 'colored-marker-list' && } + { + variant === 'accent-border-left-list' && ( + + ) + } + { + variant === 'badge-list' && ( + + ) + } + { + variant === 'card-grid-list' && ( + + ) + } + { + variant === 'chat-bubbles' && ( + + ) + } + { + variant === 'check-icons-list' && ( + + ) + } + { + variant === 'chevron-list' && ( + + ) + } + { + variant === 'colored-marker-list' && ( + + ) + } {variant === 'experience-list' && } - {variant === 'numbered-with-background-list' && } - {variant === 'plain-icon-list' && } - {variant === 'side-by-side-list' && } - {variant === 'timeline-list' && } - {variant === 'two-column-check-icons-list' && } - {variant === 'two-column-icon-list' && } - {variant === 'three-column-icon-list' && } - {variant === 'zebra-list' && } + { + variant === 'numbered-with-background-list' && ( + + ) + } + { + variant === 'plain-icon-list' && ( + + ) + } + { + variant === 'side-by-side-list' && ( + + ) + } + { + variant === 'timeline-list' && ( + + ) + } + { + variant === 'two-column-check-icons-list' && ( + + ) + } + { + variant === 'two-column-icon-list' && ( + + ) + } + { + variant === 'three-column-icon-list' && ( + + ) + } + { + variant === 'zebra-list' && ( + + ) + }
diff --git a/src/components/List/layouts/AccentBorderLeftList.astro b/src/components/List/layouts/AccentBorderLeftList.astro index 57a706377..979edb8b3 100644 --- a/src/components/List/layouts/AccentBorderLeftList.astro +++ b/src/components/List/layouts/AccentBorderLeftList.astro @@ -14,25 +14,25 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = "space-y-4 list-none pl-0" -const liClass = "border-l-4 pl-4" -const headerClass = "text-page-inverse font-semibold mb-2" -const textClass = "text-content-offset text-sm" +const ulClass = 'space-y-4 list-none pl-0' +const liClass = 'border-l-4 pl-4' +const headerClass = 'text-page-inverse font-semibold mb-2' +const textClass = 'text-content-offset text-sm' ---
    { - items.map((item) => ( + items.map(item => (
  • - {item.lead &&

    } + {item.lead &&

    } {Array.isArray(item.text) ? (
    - {item.text.map((paragraph) => ( -

    + {item.text.map(paragraph => ( +

    ))}

    ) : ( -

    +

    )}

  • )) diff --git a/src/components/List/layouts/BadgeList.astro b/src/components/List/layouts/BadgeList.astro index 08fa2c950..1625c6222 100644 --- a/src/components/List/layouts/BadgeList.astro +++ b/src/components/List/layouts/BadgeList.astro @@ -17,32 +17,34 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = ["space-y-4 sm:space-y-0 sm:table sm:border-separate sm:border-spacing-x-4 sm:border-spacing-y-4", classes?.ul] -const liClass = ["flex flex-col gap-2 sm:table-row", classes?.li] -const titleCellClass = "sm:table-cell sm:align-top" +const ulClass = [ + 'space-y-4 sm:space-y-0 sm:table sm:border-separate sm:border-spacing-x-4 sm:border-spacing-y-4', + classes?.ul, +] +const liClass = ['flex flex-col gap-2 sm:table-row', classes?.li] +const titleCellClass = 'sm:table-cell sm:align-top' const titleClass = [ - "inline-block px-2 py-1 rounded bg-content text-page-base font-mono text-xs font-bold sm:mt-1", + 'inline-block px-2 py-1 rounded bg-content text-page-base font-mono text-xs font-bold sm:mt-1', classes?.titleClass, ] -const bodyClass = ["sm:table-cell sm:align-top", classes?.content] -const emClass = ["text-content font-bold not-italic mr-2", classes?.em] +const bodyClass = ['sm:table-cell sm:align-top', classes?.content] +const emClass = ['text-content font-bold not-italic mr-2', classes?.em] ---
      { - items.map((item) => ( + items.map(item => (
    • {item.title && ( - + )} - {item.lead && } - + {item.lead && } +
    • )) }
    - diff --git a/src/components/List/layouts/CardGridList.astro b/src/components/List/layouts/CardGridList.astro index 5e27765cf..feb30571a 100644 --- a/src/components/List/layouts/CardGridList.astro +++ b/src/components/List/layouts/CardGridList.astro @@ -16,25 +16,25 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = ["grid grid-cols-1 md:grid-cols-2 gap-4", classes?.ul] +const ulClass = ['grid grid-cols-1 md:grid-cols-2 gap-4', classes?.ul] const liClass = [ - "bg-page-offset border border-trim rounded-lg p-5 transition-all duration-200 ease-out hover:-translate-y-1 hover:border-primary hover:bg-page-base hover:shadow-md focus-within:-translate-y-1 focus-within:border-primary focus-within:bg-page-base focus-within:shadow-md", + 'bg-page-offset border border-trim rounded-lg p-5 transition-all duration-200 ease-out hover:-translate-y-1 hover:border-primary hover:bg-page-base hover:shadow-md focus-within:-translate-y-1 focus-within:border-primary focus-within:bg-page-base focus-within:shadow-md', classes?.li, ] -const emClass = ["block text-page-inverse font-semibold not-italic mb-2", classes?.em] +const emClass = ['block text-page-inverse font-semibold not-italic mb-2', classes?.em] ---
      { - items.map((item) => ( + items.map(item => (
    • {item.icon && ( )} - {item.lead && } + {item.lead && }
      - +
    • )) } diff --git a/src/components/List/layouts/ChatBubbles.astro b/src/components/List/layouts/ChatBubbles.astro index 15cbae1a1..88006249b 100644 --- a/src/components/List/layouts/ChatBubbles.astro +++ b/src/components/List/layouts/ChatBubbles.astro @@ -37,19 +37,23 @@ const ddClass = [
      { - items.map((item) => ( + items.map(item => (
      {item.lead && (
      - - + +
      )}
      - - + +
      )) } -
      \ No newline at end of file + diff --git a/src/components/List/layouts/CheckIconsList.astro b/src/components/List/layouts/CheckIconsList.astro index c8268da55..e3482cc63 100644 --- a/src/components/List/layouts/CheckIconsList.astro +++ b/src/components/List/layouts/CheckIconsList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -17,30 +17,25 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = ["space-y-3", classes?.ul] -const liClass = ["flex items-start gap-3", classes?.li] -const emClass = ["text-content font-semibold not-italic", classes?.em] -const textClass = ["text-content-offset", classes?.text] +const ulClass = ['space-y-3', classes?.ul] +const liClass = ['flex items-start gap-3', classes?.li] +const emClass = ['text-content font-semibold not-italic', classes?.em] +const textClass = ['text-content-offset', classes?.text] ---
        { - items.map((item) => ( + items.map(item => (
      • - + {item.lead && ( <> - + )} - +
      • )) diff --git a/src/components/List/layouts/ChevronList.astro b/src/components/List/layouts/ChevronList.astro index 0c62918ea..f1148a640 100644 --- a/src/components/List/layouts/ChevronList.astro +++ b/src/components/List/layouts/ChevronList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -16,28 +16,28 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = ["space-y-3", classes?.ul] -const liClass = ["group", classes?.li] -const emClass = ["text-content font-semibold not-italic", classes?.em] -const textClass = "block text-content-offset mt-0.5" +const ulClass = ['space-y-3', classes?.ul] +const liClass = ['group', classes?.li] +const emClass = ['text-content font-semibold not-italic', classes?.em] +const textClass = 'block text-content-offset mt-0.5' ---
          { - items.map((item) => ( + items.map(item => (
        • - {item.lead && } - + {item.lead && } +
        • diff --git a/src/components/List/layouts/ColoredMarkerList.astro b/src/components/List/layouts/ColoredMarkerList.astro index bcea5ede6..838f8a711 100644 --- a/src/components/List/layouts/ColoredMarkerList.astro +++ b/src/components/List/layouts/ColoredMarkerList.astro @@ -17,23 +17,23 @@ export type Props = { const { items, classes, size = 2 }: Props = Astro.props -const ulClass = ["list-none pl-0 text-content mt-4 mb-2 space-y-2", classes?.ul] -const liClass = ["flex items-start", classes?.li] -const emClass = ["not-italic font-bold", classes?.em] +const ulClass = ['list-none pl-0 text-content mt-4 mb-2 space-y-2', classes?.ul] +const liClass = ['flex items-start', classes?.li] +const emClass = ['not-italic font-bold', classes?.em] const markerSizeInRem = `${size * 0.25}rem` const markerStyle = `width: ${markerSizeInRem}; height: ${markerSizeInRem};` -const iconClass = ["rounded-full mt-2 mr-3 shrink-0", classes?.icon] +const iconClass = ['rounded-full mt-2 mr-3 shrink-0', classes?.icon] const textClass = classes?.text ? [classes.text] : [] ---
            { - items.map((item) => ( + items.map(item => (
          • - {item.lead && } - + {item.lead && } +
          • )) diff --git a/src/components/List/layouts/ExperienceList.astro b/src/components/List/layouts/ExperienceList.astro index c0264a938..0ef174c3d 100644 --- a/src/components/List/layouts/ExperienceList.astro +++ b/src/components/List/layouts/ExperienceList.astro @@ -1,4 +1,3 @@ - --- import Icon from '@components/Icon/index.astro' @@ -19,22 +18,25 @@ export type Props = { const { items, classes }: Props = Astro.props -const ulClass = ['ml-3 mr-1 space-y-2 text-sm text-content-offset', classes?.ul] -const liClass = ['flex items-start gap-3', classes?.li] -const emClass = ['not-italic font-semibold text-content', classes?.em] -const iconClass = ['mt-0.5 shrink-0', classes?.icon] +const ulClass = ['ml-3 mr-8 space-y-2 text-md text-content-offset', classes?.ul] +const liClass = ['flex items-start gap-1', classes?.li] +const emClass = ['not-italic font-medium text-primary-offset', classes?.em] +const iconClass = ['mt-1.75 shrink-0', classes?.icon] ---
              { - items.map((item) => ( + items.map(item => (
            • -
              - {item.lead && } - +
              + {item.lead && } +
            • )) diff --git a/src/components/List/layouts/NumberedWithBackgroundList.astro b/src/components/List/layouts/NumberedWithBackgroundList.astro index 62dc9d228..b40274c5f 100644 --- a/src/components/List/layouts/NumberedWithBackgroundList.astro +++ b/src/components/List/layouts/NumberedWithBackgroundList.astro @@ -14,14 +14,16 @@ export type Props = { } const { items, classes, color, startNumber = 1 }: Props = Astro.props -const resolvedColor = typeof color === 'string' && color.trim().length > 0 ? color.trim() : undefined -const safeColorToken = resolvedColor && /^[-a-z0-9]+$/i.test(resolvedColor) ? resolvedColor : undefined +const resolvedColor = + typeof color === 'string' && color.trim().length > 0 ? color.trim() : undefined +const safeColorToken = + resolvedColor && /^[-a-z0-9]+$/i.test(resolvedColor) ? resolvedColor : undefined const pillStyle = safeColorToken ? `background-color: var(--color-${safeColorToken});` : undefined const leadStyle = safeColorToken ? `color: var(--color-${safeColorToken});` : undefined -const olClass = ["space-y-3", classes?.ol] -const liClass = ["flex items-start gap-4 bg-page-offset/50 rounded-lg p-4", classes?.li] -const emClass = ["font-semibold not-italic", !safeColorToken && 'text-primary-offset', classes?.em] +const olClass = ['space-y-3', classes?.ol] +const liClass = ['flex items-start gap-4 bg-page-offset/50 rounded-lg p-4', classes?.li] +const emClass = ['font-semibold not-italic', !safeColorToken && 'text-primary-offset', classes?.em] ---
                @@ -30,7 +32,7 @@ const emClass = ["font-semibold not-italic", !safeColorToken && 'text-primary-of
              1. - {item.lead && } -
                + {item.lead && } +
              2. )) diff --git a/src/components/List/layouts/PlainIconList.astro b/src/components/List/layouts/PlainIconList.astro index c63456dcb..122748f60 100644 --- a/src/components/List/layouts/PlainIconList.astro +++ b/src/components/List/layouts/PlainIconList.astro @@ -18,8 +18,8 @@ export type Props = { type PlainIconListItem = Props['items'][number] const props: Props = Astro.props -const ulClass = ["list-none pl-0 space-y-3 mb-0 mt-2", props.classes?.ul] -const liClass = ["flex items-start gap-3", props.classes?.li] +const ulClass = ['list-none pl-0 space-y-3 mb-0 mt-2', props.classes?.ul] +const liClass = ['flex items-start gap-3', props.classes?.li] const markerClasses = props.classes?.svg --- @@ -29,10 +29,15 @@ const markerClasses = props.classes?.svg const resolvedColor = color?.trim() ? color : 'currentColor' return ( -
              3. - - -
              4. +
              5. + + +
              6. ) }) } diff --git a/src/components/List/layouts/ThreeColumnIconList.astro b/src/components/List/layouts/ThreeColumnIconList.astro index a7543d4ee..a07c9f50f 100644 --- a/src/components/List/layouts/ThreeColumnIconList.astro +++ b/src/components/List/layouts/ThreeColumnIconList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -24,31 +24,42 @@ export type Props = { const { items, classes, size }: Props = Astro.props -const ulClass = ["grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", classes?.ul].filter(Boolean).join(" ") -const liClass = ["flex items-start gap-2 bg-white rounded-xl p-6 shadow-md transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-lg", classes?.li].filter(Boolean).join(" ") -const iconWrapper = ["shrink-0 rounded-lg flex items-center justify-center mt-1 p-2", classes?.icon].filter(Boolean).join(" ") -const contentWrapper = ["text-page-inverse mb-1 ml-2", classes?.content].filter(Boolean).join(" ") -const headerClass = ["font-sans text-lg font-semibold text-gray-900 mt-0 mb-2", classes?.header].filter(Boolean).join(" ") -const textClass = ["text-sm text-note-offset", classes?.text].filter(Boolean).join(" ") +const ulClass = ['grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', classes?.ul] + .filter(Boolean) + .join(' ') +const liClass = [ + 'flex items-start gap-2 bg-white rounded-xl p-6 shadow-md transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-lg', + classes?.li, +] + .filter(Boolean) + .join(' ') +const iconWrapper = ['shrink-0 rounded-lg flex items-center justify-center mt-1 p-2', classes?.icon] + .filter(Boolean) + .join(' ') +const contentWrapper = ['text-page-inverse mb-1 ml-2', classes?.content].filter(Boolean).join(' ') +const headerClass = ['font-sans text-lg font-semibold text-gray-900 mt-0 mb-2', classes?.header] + .filter(Boolean) + .join(' ') +const textClass = ['text-sm text-note-offset', classes?.text].filter(Boolean).join(' ') ---
                  { - items.map((item) => { - const icon = item.icon ?? "check" + items.map(item => { + const icon = item.icon ?? 'check' return (
                • -
                  - +
                  +
                  - {item.title &&

                  } -

                  + {item.title &&

                  } +

                • ) diff --git a/src/components/List/layouts/TimelineList.astro b/src/components/List/layouts/TimelineList.astro index be9de5e76..a044e0807 100644 --- a/src/components/List/layouts/TimelineList.astro +++ b/src/components/List/layouts/TimelineList.astro @@ -21,25 +21,30 @@ const timelineItems = items.map((item: TimelineItem, index: number) => ({ showConnector: index < items.length - 1, })) -const ulClass = ["space-y-0", classes?.ul] -const liClass = ["relative grid grid-cols-[1.5rem_1fr] gap-4", classes?.li] -const emClass = ["block text-content font-bold not-italic mb-1", classes?.em] -const wrapperClass = "mb-4" -const iconClass = ["relative h-5 w-5 mt-0.5 rounded-full border-2 border-primary bg-page-base z-10", classes?.icon] -const connectorClass = ["absolute left-1/2 top-[1.375rem] -bottom-1 -translate-x-1/2 w-0.5 bg-content-offset"] +const ulClass = ['space-y-0', classes?.ul] +const liClass = ['relative grid grid-cols-[1.5rem_1fr] gap-4', classes?.li] +const emClass = ['block text-content font-bold not-italic mb-1', classes?.em] +const wrapperClass = 'mb-4' +const iconClass = [ + 'relative h-5 w-5 mt-0.5 rounded-full border-2 border-primary bg-page-base z-10', + classes?.icon, +] +const connectorClass = [ + 'absolute left-1/2 top-[1.375rem] -bottom-1 -translate-x-1/2 w-0.5 bg-content-offset', +] ---
                    { - timelineItems.map((item) => ( + timelineItems.map(item => (
                  • - {item.showConnector && } - + {item.showConnector &&
                    -
                    - {item.lead && } - +
                    + {item.lead && } +
                  • )) diff --git a/src/components/List/layouts/TwoColumnCheckIconsList.astro b/src/components/List/layouts/TwoColumnCheckIconsList.astro index 6984856a4..1984dd73b 100644 --- a/src/components/List/layouts/TwoColumnCheckIconsList.astro +++ b/src/components/List/layouts/TwoColumnCheckIconsList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -16,21 +16,17 @@ export type Props = { const { items, classes, size }: Props = Astro.props -const ulClass = ["grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-content mb-1", classes?.ul] -const liClass = ["flex items-center gap-2", classes?.li] +const ulClass = ['grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-content mb-1', classes?.ul] +const liClass = ['flex items-center gap-2', classes?.li] ---
                      { - items.map((item) => ( + items.map(item => (
                    • - + - +
                    • )) diff --git a/src/components/List/layouts/TwoColumnIconList.astro b/src/components/List/layouts/TwoColumnIconList.astro index 34ee6812c..fcdceaf9c 100644 --- a/src/components/List/layouts/TwoColumnIconList.astro +++ b/src/components/List/layouts/TwoColumnIconList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -23,31 +23,35 @@ export type Props = { const { items, classes, size }: Props = Astro.props -const ulClass = ["grid grid-cols-1 md:grid-cols-2 gap-6", classes?.ul].filter(Boolean).join(" ") -const liClass = ["flex items-start gap-2", classes?.li].filter(Boolean).join(" ") -const iconWrapper = ["shrink-0 rounded-md flex items-center justify-center p-2 mt-1", classes?.icon].filter(Boolean).join(" ") -const contentWrapper = ["text-page-inverse mb-1 ml-2", classes?.content].filter(Boolean).join(" ") -const headerClass = ["font-sans text-xl text-content mt-1 mb-1", classes?.header].filter(Boolean).join(" ") -const textClass = ["text-sm text-note-offset", classes?.text].filter(Boolean).join(" ") +const ulClass = ['grid grid-cols-1 md:grid-cols-2 gap-6', classes?.ul].filter(Boolean).join(' ') +const liClass = ['flex items-start gap-2', classes?.li].filter(Boolean).join(' ') +const iconWrapper = ['shrink-0 rounded-md flex items-center justify-center p-2 mt-1', classes?.icon] + .filter(Boolean) + .join(' ') +const contentWrapper = ['text-page-inverse mb-1 ml-2', classes?.content].filter(Boolean).join(' ') +const headerClass = ['font-sans text-xl text-content mt-1 mb-1', classes?.header] + .filter(Boolean) + .join(' ') +const textClass = ['text-sm text-note-offset', classes?.text].filter(Boolean).join(' ') ---
                        { - items.map((item) => { - const icon = item.icon ?? "check" + items.map(item => { + const icon = item.icon ?? 'check' return (
                      • -
                        - +
                        +
                        - {item.title &&

                        } -

                        + {item.title &&

                        } +

                      • ) diff --git a/src/components/List/layouts/ZebraList.astro b/src/components/List/layouts/ZebraList.astro index f8ddb0e45..81b131588 100644 --- a/src/components/List/layouts/ZebraList.astro +++ b/src/components/List/layouts/ZebraList.astro @@ -1,5 +1,5 @@ --- -import Icon from "@components/Icon/index.astro" +import Icon from '@components/Icon/index.astro' export type Props = { items: { @@ -19,27 +19,33 @@ export type Props = { const { items, classes, style }: Props = Astro.props -const ulClass = ["print-zebra-list border border-trim rounded-lg overflow-hidden divide-y divide-trim", classes?.ul] -const liClass = ["print-zebra-list-item p-4 bg-page-base flex items-center justify-between group hover:bg-page-offset transition-colors", classes?.li] -const emClass = ["text-page-inverse font-bold not-italic block mb-0.5", classes?.em] -const textClass = ["text-content"] +const ulClass = [ + 'print-zebra-list border border-trim rounded-lg overflow-hidden divide-y divide-trim', + classes?.ul, +] +const liClass = [ + 'print-zebra-list-item p-4 bg-page-base flex items-center justify-between group hover:bg-page-offset transition-colors', + classes?.li, +] +const emClass = ['text-page-inverse font-bold not-italic block mb-0.5', classes?.em] +const textClass = ['text-content'] ---
                          { - items.map((item) => ( + items.map(item => (
                        • - {item.lead && } - + {item.lead && } +
                          {!style?.hideIcon && (
                          )} diff --git a/src/components/List/server/selectors.ts b/src/components/List/server/selectors.ts index e0935de95..54d3ba71d 100644 --- a/src/components/List/server/selectors.ts +++ b/src/components/List/server/selectors.ts @@ -2,4 +2,4 @@ import type { HTMLElement as NHPElement } from 'node-html-parser' export const queryListItemElements = (context: NHPElement): NHPElement[] => { return context.querySelectorAll('wsb-list-item') -} \ No newline at end of file +} diff --git a/src/components/List/server/slotItems.ts b/src/components/List/server/slotItems.ts index fe3d4d856..e9283f3ae 100644 --- a/src/components/List/server/slotItems.ts +++ b/src/components/List/server/slotItems.ts @@ -46,7 +46,7 @@ export function getListItemsFromSlotMarkup(markup: string, variant: string): Lis ) } - return listItemElements.map((element) => { + return listItemElements.map(element => { const lead = element.getAttribute('data-lead') return { @@ -54,4 +54,4 @@ export function getListItemsFromSlotMarkup(markup: string, variant: string): Lis text: element.innerHTML.trim(), } }) -} \ No newline at end of file +} diff --git a/src/components/Map/client/index.ts b/src/components/Map/client/index.ts index 95152df9d..edd9d8095 100644 --- a/src/components/Map/client/index.ts +++ b/src/components/Map/client/index.ts @@ -1,7 +1,11 @@ import { APILoader } from '@googlemaps/extended-component-library/api_loader.js' import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' -import { getGoogleMapId, getGoogleMapsApiKey, isE2eTest } from '@components/scripts/utils/environmentClient' +import { + getGoogleMapId, + getGoogleMapsApiKey, + isE2eTest, +} from '@components/scripts/utils/environmentClient' import { getCompanyMapAddress, queryCompanyMapElement, @@ -20,9 +24,7 @@ async function geocodeAddress(address: string): Promise<{ lat: number; lng: numb const Geocoder = ( geocodingLibrary as unknown as { Geocoder: new () => { - geocode: (_request: { - address: string - }) => Promise<{ + geocode: (_request: { address: string }) => Promise<{ results?: Array<{ geometry?: { location?: { lat: () => number; lng: () => number } } }> }> } diff --git a/src/components/Navigation/index.astro b/src/components/Navigation/index.astro index 5110b1683..0e9b15f8d 100644 --- a/src/components/Navigation/index.astro +++ b/src/components/Navigation/index.astro @@ -13,10 +13,7 @@ const { path } = Astro.props
                          {/* Main Navigation Menu */} - + {/* Mobile navigation toggle button to show menu on full-page splash screen */} diff --git a/src/components/Pages/About/index.astro b/src/components/Pages/About/index.astro index 2fc49787e..39813cb88 100644 --- a/src/components/Pages/About/index.astro +++ b/src/components/Pages/About/index.astro @@ -63,12 +63,11 @@ const resumeLink = `${getSiteUrl()}/resume` ---
                          - {/* ── Journey ── */}

                          {content.journey.header} - +

                          @@ -76,36 +75,39 @@ const resumeLink = `${getSiteUrl()}/resume`
                          - {content.journey.itemlist.map((item, index) => ( -
                          - {/* Timeline node */} -
                          - -
                          + { + content.journey.itemlist.map((item, index) => ( +
                          + {/* Timeline node */} +
                          + +
                          - {/* Card */} -
                          -

                          {item.title}

                          -

                          {item.description}

                          - {index === 0 && ( -
                          -
                          - )} + {/* Card */} +
                          +

                          {item.title}

                          +

                          {item.description}

                          + {index === 0 && ( +
                          +
                          + )} +
                          -
                          - ))} + )) + }
                          @@ -114,31 +116,27 @@ const resumeLink = `${getSiteUrl()}/resume`

                          {content.expertise.header} - +

                          - {content.expertise.itemlist.map((item, index) => ( -
                          - {/* Accent top border on hover */} -
                          @@ -146,34 +144,40 @@ const resumeLink = `${getSiteUrl()}/resume`

                          {content.proficiencies.header} - +

                          - {content.proficiencies.cards.map((card) => ( -
                          -

                          - {card.subheader} -

                          - -
                          - ))} + { + content.proficiencies.cards.map(card => ( +
                          +

                          + {card.subheader} +

                          + +
                          + )) + }
                          @@ -181,7 +185,7 @@ const resumeLink = `${getSiteUrl()}/resume`

                          {content.proficiencies.philosophy.subheader} - +

                          - {content.proficiencies.philosophy.itemlist.map((item) => ( -
                          -
                          - + { + content.proficiencies.philosophy.itemlist.map(item => ( +
                          +
                          + +
                          +

                          {item.name}

                          +

                          {item.text}

                          -

                          - {item.name} -

                          -

                          - {item.text} -

                          -
                          - ))} + )) + }
                          diff --git a/src/components/Pages/Consent/client/__tests__/index.spec.ts b/src/components/Pages/Consent/client/__tests__/index.spec.ts index 9a9984592..2137384e3 100644 --- a/src/components/Pages/Consent/client/__tests__/index.spec.ts +++ b/src/components/Pages/Consent/client/__tests__/index.spec.ts @@ -16,7 +16,7 @@ const CONSENT_PREFERENCES_READY_EVENT = 'consent-preferences:ready' const SAVE_DELAY_SETTLE_MS = 450 const waitForSaveDelay = async () => { - await new Promise((resolve) => { + await new Promise(resolve => { setTimeout(resolve, SAVE_DELAY_SETTLE_MS) }) } @@ -385,7 +385,9 @@ describe('ConsentPreferencesElement', () => { contactLink.href = '/contact/' contactLink.textContent = 'Contact' window.document.body.append(contactLink) - const discardBtn = window.document.getElementById('consent-unsaved-discard') as HTMLButtonElement | null + const discardBtn = window.document.getElementById( + 'consent-unsaved-discard' + ) as HTMLButtonElement | null const dialog = window.document.getElementById('consent-unsaved-dialog') as HTMLElement | null expect(functionalCheckbox).not.toBeNull() diff --git a/src/components/Pages/Consent/client/index.ts b/src/components/Pages/Consent/client/index.ts index 0a433503a..db72840f4 100644 --- a/src/components/Pages/Consent/client/index.ts +++ b/src/components/Pages/Consent/client/index.ts @@ -39,7 +39,10 @@ export class ConsentPreferencesElement extends LitElement { 'hover:bg-secondary', ] - private static readonly saveButtonEnabledClasses = ['bg-page-inverse', 'hover:bg-secondary-offset'] + private static readonly saveButtonEnabledClasses = [ + 'bg-page-inverse', + 'hover:bg-secondary-offset', + ] private static readonly saveButtonSavingClasses = [ 'bg-secondary-offset', @@ -246,7 +249,12 @@ export class ConsentPreferencesElement extends LitElement { private bindUnsavedDialogListeners(): void { this.removeUnsavedDialogListeners() - if (!this.unsavedDialog || !this.unsavedSaveBtn || !this.unsavedDiscardBtn || !this.unsavedStayBtn) { + if ( + !this.unsavedDialog || + !this.unsavedSaveBtn || + !this.unsavedDiscardBtn || + !this.unsavedStayBtn + ) { return } @@ -518,7 +526,7 @@ export class ConsentPreferencesElement extends LitElement { this.updateSaveButtonState() try { - await new Promise((resolve) => { + await new Promise(resolve => { window.setTimeout(resolve, SAVE_PREFERENCES_DELAY_MS) }) diff --git a/src/components/Pages/Consent/index.astro b/src/components/Pages/Consent/index.astro index 8ce45795f..3be867e52 100644 --- a/src/components/Pages/Consent/index.astro +++ b/src/components/Pages/Consent/index.astro @@ -42,7 +42,7 @@ export type Props = { save: string acceptAll: string rejectAll: string - }, + } cookies: { heading: string description: string @@ -126,7 +126,9 @@ const { content } = Astro.props
                          -

                          {content.essential.heading}

                          +

                          + {content.essential.heading} +

                          {content.functional.description}

                          -

                          +

                          @@ -328,7 +333,7 @@ const { content } = Astro.props class="text-2xl md:text-3xl font-bold text-primary-offset mb-2" > {content.cookies.heading} - +

                          @@ -339,10 +344,10 @@ const { content } = Astro.props items={content.cookies.types} size={6} classes={{ - ul: "mt-6 mb-0 pl-0", - icon: "w-12 h-12 bg-blue-100 mr-2", - li: "border-2 border-page-offset p-4 rounded-xl", - text: "mb-0", + ul: 'mt-6 mb-0 pl-0', + icon: 'w-12 h-12 bg-blue-100 mr-2', + li: 'border-2 border-page-offset p-4 rounded-xl', + text: 'mb-0', }} />

                          @@ -354,7 +359,7 @@ const { content } = Astro.props class="text-2xl md:text-3xl font-bold text-primary-offset mb-4" > {content.management.heading} - +

                          {content.management.subheading} @@ -364,11 +369,11 @@ const { content } = Astro.props items={content.management.types} size={6} classes={{ - ul: "mt-6 mb-0 pl-0", - icon: "w-12 h-12 bg-blue-100 mr-2", - li: "border-2 border-page-offset p-4 rounded-xl", - header: "mb-1 mt-0", - text: "mb-0", + ul: 'mt-6 mb-0 pl-0', + icon: 'w-12 h-12 bg-blue-100 mr-2', + li: 'border-2 border-page-offset p-4 rounded-xl', + header: 'mb-1 mt-0', + text: 'mb-0', }} />

@@ -379,35 +384,31 @@ const { content } = Astro.props class="text-2xl md:text-3xl font-bold text-primary-offset mb-4" > {content.questions.heading} - +

{content.questions.subheading}

- {content.questions.methods.map((item) => { - return ( -
-
- -
-
-

{item.lead}

- - {item.text} - + { + content.questions.methods.map(item => { + return ( +
+
+ +
+
+

{item.lead}

+ + {item.text} + +
-
- ) - })} + ) + }) + }
diff --git a/src/components/Pages/Consent/unsaved.astro b/src/components/Pages/Consent/unsaved.astro index 5f0b87b09..a53d7a70e 100644 --- a/src/components/Pages/Consent/unsaved.astro +++ b/src/components/Pages/Consent/unsaved.astro @@ -3,38 +3,25 @@ import Button from '@components/Button/index.astro' --- -
- -
+
+ +
- + -
-
+
diff --git a/src/components/Pages/Contact/client/__fixtures__/contactForm.fixture.astro b/src/components/Pages/Contact/client/__fixtures__/contactForm.fixture.astro index e242e5379..153f2d82a 100644 --- a/src/components/Pages/Contact/client/__fixtures__/contactForm.fixture.astro +++ b/src/components/Pages/Contact/client/__fixtures__/contactForm.fixture.astro @@ -3,76 +3,76 @@ import ContactFormComponent from '../../index.astro' import type { Props as ContactPageProps } from '../../index.astro' const content = { - header: { - title: 'Contact', - description: 'Get in touch', - }, - contact: { - header: 'Contact Details', - details: [ - { - text: 'Email', - value: 'test@example.com', - icon: 'email', - }, - ], - expertise: { - header: 'Expertise', - areas: [{ text: 'Platform Engineering' }], - }, - chooseUs: { - header: 'Why Us', - areas: [{ text: 'Fast response' }], - }, - }, - form: { - header: 'Send a message', - description: 'Tell us about your project', - formErrorMssg: 'Please fix form errors.', - contact: { - header: 'Your Contact Info', - company: 'Company', - companyPlaceholder: 'Acme Inc', - email: 'Email', - emailPlaceholder: 'you@example.com', - fullName: 'Full Name', - fullNamePlaceholder: 'Jane Doe', - phone: 'Phone', - phonePlaceholder: '+1 555 555 5555', - }, - project: { - header: 'Project Details', - description: 'Description', - descriptionPlaceholder: 'Project details', - timeline: 'Timeline', - timelineOptions: [ - { text: 'Select timeline', value: '' }, - { text: 'Within 1 month', value: 'within-1-month' }, - ], - timelinePlaceholder: 'Timeline', - type: 'Project Type', - typeOptions: [ - { text: 'Select type', value: '' }, - { text: 'Infrastructure', value: 'infrastructure' }, - ], - typePlaceholder: 'Project Type', - }, - files: { - header: 'Attachments', - subheader: 'Optional uploads', - supportedFormats: 'PDF, DOCX', - maxFileSizeText: 'Max file size:', - maxFileSize: '10MB', - maxFilesText: 'Max files:', - maxFiles: '3', - }, - info: [{ lead: 'Note', text: 'We reply quickly.' }], - submitButtonText: 'Send', - waitingText: 'Sending...', - successText: 'Sent successfully.', - errorText: 'Submission failed.', - errorEmail: 'Please provide a valid email.', - }, + header: { + title: 'Contact', + description: 'Get in touch', + }, + contact: { + header: 'Contact Details', + details: [ + { + text: 'Email', + value: 'test@example.com', + icon: 'email', + }, + ], + expertise: { + header: 'Expertise', + areas: [{ text: 'Platform Engineering' }], + }, + chooseUs: { + header: 'Why Us', + areas: [{ text: 'Fast response' }], + }, + }, + form: { + header: 'Send a message', + description: 'Tell us about your project', + formErrorMssg: 'Please fix form errors.', + contact: { + header: 'Your Contact Info', + company: 'Company', + companyPlaceholder: 'Acme Inc', + email: 'Email', + emailPlaceholder: 'you@example.com', + fullName: 'Full Name', + fullNamePlaceholder: 'Jane Doe', + phone: 'Phone', + phonePlaceholder: '+1 555 555 5555', + }, + project: { + header: 'Project Details', + description: 'Description', + descriptionPlaceholder: 'Project details', + timeline: 'Timeline', + timelineOptions: [ + { text: 'Select timeline', value: '' }, + { text: 'Within 1 month', value: 'within-1-month' }, + ], + timelinePlaceholder: 'Timeline', + type: 'Project Type', + typeOptions: [ + { text: 'Select type', value: '' }, + { text: 'Infrastructure', value: 'infrastructure' }, + ], + typePlaceholder: 'Project Type', + }, + files: { + header: 'Attachments', + subheader: 'Optional uploads', + supportedFormats: 'PDF, DOCX', + maxFileSizeText: 'Max file size:', + maxFileSize: '10MB', + maxFilesText: 'Max files:', + maxFiles: '3', + }, + info: [{ lead: 'Note', text: 'We reply quickly.' }], + submitButtonText: 'Send', + waitingText: 'Sending...', + successText: 'Sent successfully.', + errorText: 'Submission failed.', + errorEmail: 'Please provide a valid email.', + }, } satisfies ContactPageProps['content'] --- diff --git a/src/components/Pages/Contact/client/__tests__/formSubmission.spec.ts b/src/components/Pages/Contact/client/__tests__/formSubmission.spec.ts index 6404da74b..ee5638404 100644 --- a/src/components/Pages/Contact/client/__tests__/formSubmission.spec.ts +++ b/src/components/Pages/Contact/client/__tests__/formSubmission.spec.ts @@ -161,7 +161,9 @@ describe('ContactForm submission', () => { expect(context.elements.fields.email.feedback.textContent).toBe( 'Please enter a valid email address.' ) - expect(context.elements.fields.message.feedback.textContent).toBe('Please describe your project') + expect(context.elements.fields.message.feedback.textContent).toBe( + 'Please describe your project' + ) }) }) @@ -306,7 +308,9 @@ describe('ContactForm submission', () => { ) expect(context.elements.fields.email.feedback.classList.contains('hidden')).toBe(false) - expect(context.elements.fields.email.feedback.textContent).toBe('Enter a valid email address.') + expect(context.elements.fields.email.feedback.textContent).toBe( + 'Enter a valid email address.' + ) expect(context.elements.fields.email.input.getAttribute('aria-invalid')).toBe('true') expect(context.elements.fields.email.input.classList.contains('error')).toBe(true) }) diff --git a/src/components/Pages/Contact/client/__tests__/upload.spec.ts b/src/components/Pages/Contact/client/__tests__/upload.spec.ts index 1f1e10f4f..54119fe2a 100644 --- a/src/components/Pages/Contact/client/__tests__/upload.spec.ts +++ b/src/components/Pages/Contact/client/__tests__/upload.spec.ts @@ -56,4 +56,4 @@ describe('Contact upload accessibility', () => { observer.disconnect() }) }) -}) \ No newline at end of file +}) diff --git a/src/components/Pages/Contact/client/formSubmission.ts b/src/components/Pages/Contact/client/formSubmission.ts index 89d52ce51..c936335ce 100644 --- a/src/components/Pages/Contact/client/formSubmission.ts +++ b/src/components/Pages/Contact/client/formSubmission.ts @@ -17,7 +17,13 @@ import { queryContactFormGeneratedFieldError, queryContactFormGenericFields } fr export type ContactUiState = 'idle' | 'loading' | 'success' | 'error' | 'validation' -export const contactPreviewStates = ['loading', 'success', 'error', 'validation', 'confetti'] as const +export const contactPreviewStates = [ + 'loading', + 'success', + 'error', + 'validation', + 'confetti', +] as const type ContactPreviewState = (typeof contactPreviewStates)[number] type ContactPreviewMode = 'confetti' diff --git a/src/components/Pages/Contact/client/index.ts b/src/components/Pages/Contact/client/index.ts index e8f7a7fd9..12808cf6a 100644 --- a/src/components/Pages/Contact/client/index.ts +++ b/src/components/Pages/Contact/client/index.ts @@ -8,12 +8,20 @@ import { LitElement } from 'lit' import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { initStickySidebar } from '@components/scripts/stickySidebar' -import { getContactFormElements, queryContactProjectTypeSelect, queryContactStickySidebar } from './selectors' +import { + getContactFormElements, + queryContactProjectTypeSelect, + queryContactStickySidebar, +} from './selectors' import type { ContactFormConfig } from './@types' import { initCharacterCounter, initUploadPlaceholder } from './utils' import { initLabelHandlers, type LabelController } from './feedback' import { initEmailValidationHandler } from './email' -import { applyContactPreviewState, initFormSubmission, resolveContactPreviewState } from './formSubmission' +import { + applyContactPreviewState, + initFormSubmission, + resolveContactPreviewState, +} from './formSubmission' import { initGenericValidation, initNameLengthHandler, initMssgLengthHandler } from './validation' import { defineCustomElement } from '@components/scripts/utils' import { isProd } from '@components/scripts/utils/environmentClient' @@ -91,7 +99,9 @@ export class ContactFormElement extends LitElement { return } - const projectType = window.location.search ? new URLSearchParams(window.location.search).get('type') : null + const projectType = window.location.search + ? new URLSearchParams(window.location.search).get('type') + : null if (!projectType) { return } @@ -101,7 +111,9 @@ export class ContactFormElement extends LitElement { return } - const hasOption = Array.from(projectTypeSelect.options).some(option => option.value === projectType) + const hasOption = Array.from(projectTypeSelect.options).some( + option => option.value === projectType + ) if (!hasOption) { return } diff --git a/src/components/Pages/Contact/client/selectors.ts b/src/components/Pages/Contact/client/selectors.ts index 3f7771803..8d55477a3 100644 --- a/src/components/Pages/Contact/client/selectors.ts +++ b/src/components/Pages/Contact/client/selectors.ts @@ -155,16 +155,18 @@ const isUppyDashboardRoot = (element: Element): element is UppyDashboardRoot => } export const queryAccessibilityLabelTargets = (root: ParentNode): AccessibilityLabelTarget[] => { - return Array.from(root.querySelectorAll('input[type="text"], input[type="email"], textarea')).filter( - isAccessibilityLabelTarget - ) + return Array.from( + root.querySelectorAll('input[type="text"], input[type="email"], textarea') + ).filter(isAccessibilityLabelTarget) } export const queryUppyDashboardRoots = (root: ParentNode): UppyDashboardRoot[] => { return Array.from(root.querySelectorAll('.uppy-Dashboard')).filter(isUppyDashboardRoot) } -export const queryContactProjectTypeSelect = (root: ParentNode = document): HTMLSelectElement | null => { +export const queryContactProjectTypeSelect = ( + root: ParentNode = document +): HTMLSelectElement | null => { const projectTypeSelect = root.querySelector('#project_type') return projectTypeSelect instanceof HTMLSelectElement ? projectTypeSelect : null } diff --git a/src/components/Pages/Contact/index.astro b/src/components/Pages/Contact/index.astro index d54eca742..400a67981 100644 --- a/src/components/Pages/Contact/index.astro +++ b/src/components/Pages/Contact/index.astro @@ -49,11 +49,11 @@ export type Props = { expertise: { header: string areas: { text: string }[] - }, + } chooseUs: { - header: string, - areas: { text: string }[], - }, + header: string + areas: { text: string }[] + } } form: { header: string @@ -69,7 +69,7 @@ export type Props = { fullNamePlaceholder: string phone: string phonePlaceholder: string - }, + } project: { header: string description: string @@ -86,7 +86,7 @@ export type Props = { value: string }[] typePlaceholder: string - }, + } files: { header: string subheader: string @@ -95,7 +95,7 @@ export type Props = { maxFileSize: string maxFilesText: string maxFiles: string - }, + } info: { lead: string text: string @@ -121,323 +121,321 @@ const { content, prefilledProjectType = '' } = Astro.props {/** Contact Form Section */}
-
+
{/** Contact Form */}
- {/** Contact FormHeader */} -
-

- {content.form.header} -

-

{content.form.description}

-
+ {/** Contact FormHeader */} +
+

+ {content.form.header} +

+

{content.form.description}

+
-
+ - {/** Personal Information */} -
-

-
- {content.form.contact.header} -

+ {/** Personal Information */} +
+

+
+ {content.form.contact.header} +

-
-
- - - -
+
+
+ + + +
-
- - - -
+
+ + +
+
-
-
- - -
+
+
+ + +
-
- - -
+
+ +
+
- {/** Project Information */} -
-

-
- {content.form.project.header} -

+ {/** Project Information */} +
+

+
+ {content.form.project.header} +

-
-
- - + { + content.form.project.typeOptions.map(item => ( + - ))} - -
- -
- - -
+ )) + } +
-
-
- {/** File Upload Section */} -
-

-
- {content.form.files.header} -

-

{content.form.files.subheader}

+
+ + + +
+ 0/2000 characters +
+
+
-
- -
-

- Supported formats: {content.form.files.supportedFormats} -

-

- {content.form.files.maxFileSizeText} {content.form.files.maxFileSize} • {content.form.files.maxFilesText} {content.form.files.maxFiles} -

-
+ {/** File Upload Section */} +
+

+
+ {content.form.files.header} +

+

{content.form.files.subheader}

+ +
+ +
+

+ Supported formats: + {content.form.files.supportedFormats} +

+

+ {content.form.files.maxFileSizeText} + {content.form.files.maxFileSize} • {content.form.files.maxFilesText} + {content.form.files.maxFiles} +

+
- {/** GDPR Consent */} - + {/** GDPR Consent */} + - {/** Data Retention Notice */} -
- {content.form.info.map((item) => ( + {/** Data Retention Notice */} +
+ { + content.form.info.map(item => (

- {item.lead}{item.text} + {item.lead} + {item.text} {item.email && ( - {item.email} + + {item.email} + )}

- ))} + )) + } +
+ + +
+ {/** Intentionally not using Button component. */} +
+
+ - -
- {/** Intentionally not using Button component. */} - + {/** Form Messages */} +
@@ -126,7 +120,9 @@ const normalizedDownloadUrl = `/downloads/${fileName}`
-

+

Instant Access

Get The Ebook

@@ -238,7 +234,12 @@ const normalizedDownloadUrl = `/downloads/${fileName}` class="block w-full bg-success text-primary-inverse font-semibold py-3 px-6 rounded-xl hover:bg-success-offset focus:outline-none focus:ring-2 focus:ring-success focus:ring-offset-2 focus:ring-offset-page-active transition-all duration-200 text-center" id="downloadBtn" > - + Download {fileType}
diff --git a/src/components/Pages/MyData/client/__tests__/index.spec.ts b/src/components/Pages/MyData/client/__tests__/index.spec.ts index 2e3f65a5c..8ba36ffed 100644 --- a/src/components/Pages/MyData/client/__tests__/index.spec.ts +++ b/src/components/Pages/MyData/client/__tests__/index.spec.ts @@ -15,9 +15,13 @@ type VerifyResult = | { status: 'deleted' } | { status: 'expired' } -const requestDataMock = vi.fn< - (_input: { email: string; requestType: 'ACCESS' | 'DELETE' }) => Promise> ->() +const requestDataMock = + vi.fn< + (_input: { + email: string + requestType: 'ACCESS' | 'DELETE' + }) => Promise> + >() const verifyDsarMock = vi.fn<(_input: { token: string }) => Promise>>() @@ -105,8 +109,12 @@ describe('PrivacyForm behavior', () => { elements.accessForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushMicrotasks() - expect(requestDataMock).toHaveBeenCalledWith({ email: 'test@example.com', requestType: 'ACCESS' }) - expect(elements.accessMessage.textContent).toBe('Access request sent.') + expect(requestDataMock).toHaveBeenCalledWith({ + email: 'test@example.com', + requestType: 'ACCESS', + }) + expect(elements.accessMessage.textContent).toContain('Request Sent') + expect(elements.accessMessage.textContent).toContain('Access request sent.') expect(elements.accessMessage.classList.contains('hidden')).toBe(false) expect(elements.accessMessage.classList.contains('border-success')).toBe(true) expect(elements.accessEmailInput.value).toBe('') @@ -114,6 +122,56 @@ describe('PrivacyForm behavior', () => { }) }) + it('renders preview states from query parameters for both workflows', async () => { + await executeRender({ + container, + component: PrivacyForm, + moduleSpecifier: '@components/Pages/MyData/client/index', + args: { + props: { + content: myDataContent, + }, + }, + waitForReady: async (element: PrivacyFormElementInstance) => { + window.history.replaceState( + {}, + '', + 'http://localhost/privacy/my-data?accessState=loading&deleteState=validation' + ) + element.initialize() + }, + assert: async ({ element }) => { + const elements = getPrivacyFormElements(element) + const accessSubmitButton = elements.accessForm.querySelector('button[type="submit"]') + + expect(elements.accessForm.dataset['privacyState']).toBe('loading') + expect(elements.accessForm.getAttribute('aria-busy')).toBe('true') + expect(accessSubmitButton).toBeInstanceOf(HTMLButtonElement) + expect((accessSubmitButton as HTMLButtonElement).disabled).toBe(true) + + const accessLoadingToast = element.querySelector('#access-preview-toast-loading') + expect(accessLoadingToast).not.toBeNull() + expect(accessLoadingToast?.classList.contains('hidden')).toBe(false) + expect(accessLoadingToast?.textContent).toContain('Sending Request') + expect(accessLoadingToast?.textContent).toContain('Your request is being prepared and submitted.') + + expect(elements.deleteForm.dataset['privacyState']).toBe('validation') + expect(elements.deleteEmailInput.getAttribute('aria-invalid')).toBe('true') + expect(elements.deleteConfirmCheckbox.getAttribute('aria-invalid')).toBe('true') + + const deleteValidationToast = element.querySelector('#delete-preview-toast-validation') + expect(deleteValidationToast).not.toBeNull() + expect(deleteValidationToast?.classList.contains('hidden')).toBe(false) + expect(deleteValidationToast?.textContent).toContain('Check Your Details') + expect(deleteValidationToast?.textContent).toContain( + 'Enter a valid email address and confirm the deletion request before submitting.' + ) + + expect(requestDataMock).not.toHaveBeenCalled() + }, + }) + }) + it('blocks delete submit when confirmation is not checked', async () => { requestDataMock.mockResolvedValue({ data: { message: 'Delete request sent.' } }) @@ -139,7 +197,10 @@ describe('PrivacyForm behavior', () => { await flushMicrotasks() expect(requestDataMock).not.toHaveBeenCalled() - expect(elements.deleteMessage.textContent).toBe('Please confirm you understand the deletion request.') + expect(elements.deleteMessage.textContent).toContain('Check Your Details') + expect(elements.deleteMessage.textContent).toContain( + 'Please confirm you understand the deletion request.' + ) expect(elements.deleteMessage.classList.contains('border-danger')).toBe(true) }, }) @@ -163,8 +224,13 @@ describe('PrivacyForm behavior', () => { }, }, waitForReady: async (element: PrivacyFormElementInstance) => { - window.history.replaceState({}, '', 'http://localhost/privacy/my-data?token=unit-test-token') - ;(element as unknown as { downloadJson: typeof downloadJsonSpy }).downloadJson = downloadJsonSpy + window.history.replaceState( + {}, + '', + 'http://localhost/privacy/my-data?token=unit-test-token' + ) + ;(element as unknown as { downloadJson: typeof downloadJsonSpy }).downloadJson = + downloadJsonSpy ;(element as unknown as { navigateTo: typeof navigateToSpy }).navigateTo = navigateToSpy element.initialize() }, diff --git a/src/components/Pages/MyData/client/__tests__/selectors.spec.ts b/src/components/Pages/MyData/client/__tests__/selectors.spec.ts index e84bef66e..4a16e04de 100644 --- a/src/components/Pages/MyData/client/__tests__/selectors.spec.ts +++ b/src/components/Pages/MyData/client/__tests__/selectors.spec.ts @@ -10,11 +10,16 @@ type PrivacyFormModule = WebComponentModule type ActionResult = { data?: TData; error?: { message?: string } } -const requestDataMock = vi.fn< - (_input: { email: string; requestType: 'ACCESS' | 'DELETE' }) => Promise> ->() +const requestDataMock = + vi.fn< + (_input: { + email: string + requestType: 'ACCESS' | 'DELETE' + }) => Promise> + >() -const verifyDsarMock = vi.fn<(_input: { token: string }) => Promise>>() +const verifyDsarMock = + vi.fn<(_input: { token: string }) => Promise>>() const myDataContent = { header: { diff --git a/src/components/Pages/MyData/client/index.ts b/src/components/Pages/MyData/client/index.ts index f6d3cd262..f03bb30e9 100644 --- a/src/components/Pages/MyData/client/index.ts +++ b/src/components/Pages/MyData/client/index.ts @@ -4,10 +4,17 @@ import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/erro import { handleScriptError } from '@components/scripts/errors/handler' import { defineCustomElement } from '@components/scripts/utils' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' -import { getPrivacyFormElements } from './selectors' +import { + getPrivacyFormElements, + getPrivacyPreviewToastElement, + getPrivacyPreviewToastElements, + getPrivacySubmitButton, +} from './selectors' type MessageType = 'success' | 'error' | 'info' type RequestType = 'ACCESS' | 'DELETE' +type RequestPreviewState = 'loading' | 'success' | 'error' | 'validation' +type RequestFormType = 'access' | 'delete' type DsarVerifyResult = | { status: 'download'; filename: string; json: string } @@ -16,6 +23,15 @@ type DsarVerifyResult = type RequestDataResult = { message: string } +type RequestToastTone = 'success' | 'error' | 'info' + +const requestPreviewStates = ['loading', 'success', 'error', 'validation'] as const + +const requestPreviewQueryParams: Record = { + access: 'accessState', + delete: 'deleteState', +} + const statusMessages: Record = { sent: { type: 'success', @@ -58,6 +74,10 @@ export class PrivacyFormElement extends LitElement { private deleteConfirmCheckbox!: HTMLInputElement private deleteMessage!: HTMLElement + private getPreviewToastElements(requestType: RequestType): HTMLElement[] { + return getPrivacyPreviewToastElements(requestType, this) + } + override connectedCallback(): void { super.connectedCallback() @@ -89,6 +109,7 @@ export class PrivacyFormElement extends LitElement { this.bindEvents() this.isInitialized = true + this.renderRequestPreviewStatesFromQueryString() this.renderStatusFromQueryString() void this.handleVerificationToken() } catch (error) { @@ -136,12 +157,193 @@ export class PrivacyFormElement extends LitElement { }) } - private setMessage(target: HTMLElement, message: string, type: MessageType): void { + private setRequestState( + form: HTMLFormElement, + state: RequestPreviewState | 'idle', + requestType?: RequestType + ): void { + form.dataset['privacyState'] = state + form.setAttribute('aria-busy', String(state === 'loading')) + + if (!requestType) { + return + } + + const rootAttributeName = requestType === 'ACCESS' ? 'data-access-state' : 'data-delete-state' + this.setAttribute(rootAttributeName, state) + } + + private setSubmitLoading(form: HTMLFormElement, loading: boolean): void { + const submitButton = getPrivacySubmitButton(form) + if (submitButton) { + submitButton.disabled = loading + } + } + + private setEmailInvalid(input: HTMLInputElement, invalid: boolean): void { + input.setAttribute('aria-invalid', String(invalid)) + input.classList.toggle('border-danger', invalid) + input.classList.toggle('focus:border-danger', invalid) + } + + private setDeleteConfirmationInvalid(invalid: boolean): void { + this.deleteConfirmCheckbox.setAttribute('aria-invalid', String(invalid)) + } + + private hidePreviewToast(requestType: RequestType): void { + for (const el of this.getPreviewToastElements(requestType)) { + el.classList.add('hidden') + } + } + + private resolveRequestToastConfig( + requestType: RequestType, + message: string, + type: RequestToastTone + ): { + title: string + icon: 'check-stylized' | 'warning' | 'spinner' + containerClasses: string[] + iconContainerClasses: string[] + titleClasses: string[] + } { + if (type === 'success') { + return { + title: requestType === 'ACCESS' ? 'Request Sent' : 'Deletion Request Sent', + icon: 'check-stylized', + containerClasses: ['border-success', 'bg-success-inverse', 'text-success'], + iconContainerClasses: ['bg-success', 'text-content-inverse'], + titleClasses: ['text-success'], + } + } + + if (type === 'info') { + return { + title: 'Sending Request', + icon: 'spinner', + containerClasses: ['border-info', 'bg-info-inverse', 'text-info'], + iconContainerClasses: ['bg-info', 'text-content-inverse'], + titleClasses: ['text-info'], + } + } + + const isValidationMessage = + message.includes('valid email') || message.includes('confirm you understand') || message.includes('confirm the deletion request') + + return { + title: isValidationMessage + ? 'Check Your Details' + : requestType === 'ACCESS' + ? 'Request Failed' + : 'Deletion Request Failed', + icon: 'warning', + containerClasses: ['border-danger', 'bg-danger-inverse', 'text-danger'], + iconContainerClasses: ['bg-danger', 'text-content-inverse'], + titleClasses: ['text-danger'], + } + } + + private renderRequestToast(target: HTMLElement, requestType: RequestType, message: string, type: RequestToastTone): void { + const config = this.resolveRequestToastConfig(requestType, message, type) + + target.replaceChildren() + target.classList.remove('px-4', 'py-3', 'text-sm', 'bg-danger-offset') + target.classList.add('w-full', 'rounded-xl', 'border') + target.classList.add(...config.containerClasses) + + const wrapper = document.createElement('div') + wrapper.className = 'flex items-start gap-4 p-6' + + const iconContainer = document.createElement('div') + iconContainer.className = 'shrink-0 flex h-6 w-6 items-center justify-center rounded-full' + iconContainer.classList.add(...config.iconContainerClasses) + + const icon = document.createElement('span') + icon.setAttribute('aria-hidden', 'true') + icon.textContent = config.icon === 'check-stylized' ? '✓' : config.icon === 'spinner' ? '◌' : '!' + iconContainer.appendChild(icon) + + const content = document.createElement('div') + + const title = document.createElement('h3') + title.className = 'mb-2 text-lg font-semibold' + title.classList.add(...config.titleClasses) + title.textContent = config.title + + const body = document.createElement('p') + body.textContent = message + + content.append(title, body) + wrapper.append(iconContainer, content) + target.appendChild(wrapper) + } + + private resetMessage(target: HTMLElement): void { + target.replaceChildren() + target.classList.add('hidden') + target.classList.remove( + 'w-full', + 'rounded-xl', + 'border', + 'border-success', + 'bg-success-inverse', + 'text-success', + 'border-danger', + 'bg-danger-offset', + 'bg-danger-inverse', + 'text-danger', + 'border-info', + 'bg-info-inverse', + 'text-info', + 'px-4', + 'py-3', + 'text-sm' + ) + target.classList.add('rounded-xl', 'border', 'px-4', 'py-3', 'text-sm') + } + + private resetRequestState(requestType: RequestType): void { + const isAccessRequest = requestType === 'ACCESS' + const form = isAccessRequest ? this.accessForm : this.deleteForm + const emailInput = isAccessRequest ? this.accessEmailInput : this.deleteEmailInput + const message = isAccessRequest ? this.accessMessage : this.deleteMessage + + this.hidePreviewToast(requestType) + this.resetMessage(message) + this.setRequestState(form, 'idle', requestType) + this.setSubmitLoading(form, false) + this.setEmailInvalid(emailInput, false) + + if (!isAccessRequest) { + this.setDeleteConfirmationInvalid(false) + } + } + + private setMessage( + target: HTMLElement, + message: string, + type: MessageType, + options: { focus?: boolean } = {} + ): void { target.setAttribute('role', type === 'error' ? 'alert' : 'status') target.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite') - target.textContent = message target.classList.remove('hidden') + const requestType = + target.id === 'access-message' ? 'ACCESS' : target.id === 'delete-message' ? 'DELETE' : null + + if (requestType) { + this.renderRequestToast(target, requestType, message, type) + + if (options.focus ?? true) { + target.focus() + } + + return + } + + target.textContent = message + const variantClasses = [ 'border-success', 'bg-success-inverse', @@ -167,7 +369,66 @@ export class PrivacyFormElement extends LitElement { target.classList.add('border-info', 'bg-info-inverse', 'text-info') } - target.focus() + if (options.focus ?? true) { + target.focus() + } + } + + private resolvePreviewState( + params: URLSearchParams, + requestFormType: RequestFormType + ): RequestPreviewState | null { + const previewState = params + .get(requestPreviewQueryParams[requestFormType]) + ?.trim() + .toLowerCase() + + if (!previewState) { + return null + } + + return requestPreviewStates.includes(previewState as RequestPreviewState) + ? (previewState as RequestPreviewState) + : null + } + + private applyRequestPreviewState( + requestType: RequestType, + previewState: RequestPreviewState + ): void { + const isAccessRequest = requestType === 'ACCESS' + const form = isAccessRequest ? this.accessForm : this.deleteForm + const emailInput = isAccessRequest ? this.accessEmailInput : this.deleteEmailInput + + // Show only the matching static SSR preview toast; hide all others for this form. + for (const el of this.getPreviewToastElements(requestType)) { + el.classList.add('hidden') + } + getPrivacyPreviewToastElement(requestType, previewState, this)?.classList.remove('hidden') + + this.setRequestState(form, previewState, requestType) + this.setSubmitLoading(form, previewState === 'loading') + + if (previewState === 'validation') { + this.setEmailInvalid(emailInput, true) + if (!isAccessRequest) { + this.setDeleteConfirmationInvalid(true) + } + } + } + + private renderRequestPreviewStatesFromQueryString(): void { + const params = new URLSearchParams(window.location.search) + const accessPreviewState = this.resolvePreviewState(params, 'access') + const deletePreviewState = this.resolvePreviewState(params, 'delete') + + if (accessPreviewState) { + this.applyRequestPreviewState('ACCESS', accessPreviewState) + } + + if (deletePreviewState) { + this.applyRequestPreviewState('DELETE', deletePreviewState) + } } private renderStatusFromQueryString(): void { @@ -240,25 +501,37 @@ export class PrivacyFormElement extends LitElement { const formEl = requestType === 'ACCESS' ? this.accessForm : this.deleteForm const emailInput = requestType === 'ACCESS' ? this.accessEmailInput : this.deleteEmailInput + this.resetRequestState(requestType) + if (requestType === 'DELETE' && !this.deleteConfirmCheckbox.checked) { + this.setRequestState(formEl, 'validation', requestType) + this.setDeleteConfirmationInvalid(true) this.setMessage(messageEl, 'Please confirm you understand the deletion request.', 'error') return } const email = emailInput.value + this.setRequestState(formEl, 'loading', requestType) + this.setSubmitLoading(formEl, true) this.setMessage(messageEl, 'Sending request...', 'info') try { const { data, error } = await actions.gdpr.requestData({ email, requestType }) if (error || !data) { + this.setRequestState(formEl, 'error', requestType) + this.setSubmitLoading(formEl, false) this.setMessage(messageEl, error?.message || 'Request failed', 'error') return } const resultData = data as RequestDataResult + this.setRequestState(formEl, 'success', requestType) + this.setSubmitLoading(formEl, false) this.setMessage(messageEl, resultData.message, 'success') formEl.reset() } catch (error) { + this.setRequestState(formEl, 'error', requestType) + this.setSubmitLoading(formEl, false) this.setMessage( messageEl, error instanceof Error ? error.message : 'Network or server error', diff --git a/src/components/Pages/MyData/client/selectors.ts b/src/components/Pages/MyData/client/selectors.ts index b7bb4d010..1f031d202 100644 --- a/src/components/Pages/MyData/client/selectors.ts +++ b/src/components/Pages/MyData/client/selectors.ts @@ -1,6 +1,7 @@ /** * Selectors for PrivacyForm component elements */ +import { isButtonElement } from '@components/scripts/assertions/elements' import { ClientScriptError } from '@components/scripts/errors' type SelectorRoot = Document | DocumentFragment | Element @@ -28,6 +29,19 @@ function queryRequiredElement( const isHtmlElement = (element: Element): element is HTMLElement => element instanceof HTMLElement +type RequestType = 'ACCESS' | 'DELETE' +type RequestPreviewState = 'loading' | 'success' | 'error' | 'validation' + +const previewToastStates: RequestPreviewState[] = ['success', 'loading', 'error', 'validation'] + +const getPreviewToastSelector = ( + requestType: RequestType, + previewState: RequestPreviewState +): string => { + const prefix = requestType === 'ACCESS' ? 'access' : 'delete' + return `#${prefix}-preview-toast-${previewState}` +} + export interface PrivacyFormElements { statusMessage: HTMLElement | undefined @@ -93,3 +107,28 @@ export function getPrivacyFormElements(root?: SelectorRoot): PrivacyFormElements ), } } + +export function getPrivacyPreviewToastElements( + requestType: RequestType, + root?: SelectorRoot +): HTMLElement[] { + const resolvedRoot = resolveRoot(root) + + return previewToastStates + .map(previewState => resolvedRoot.querySelector(getPreviewToastSelector(requestType, previewState))) + .filter((element): element is HTMLElement => element instanceof HTMLElement) +} + +export function getPrivacyPreviewToastElement( + requestType: RequestType, + previewState: RequestPreviewState, + root?: SelectorRoot +): HTMLElement | undefined { + const element = resolveRoot(root).querySelector(getPreviewToastSelector(requestType, previewState)) + return element instanceof HTMLElement ? element : undefined +} + +export function getPrivacySubmitButton(form: HTMLFormElement): HTMLButtonElement | undefined { + const button = form.querySelector('button[type="submit"]') + return isButtonElement(button) ? button : undefined +} diff --git a/src/components/Pages/MyData/index.astro b/src/components/Pages/MyData/index.astro index c2bbe6de3..49e6a9dcd 100644 --- a/src/components/Pages/MyData/index.astro +++ b/src/components/Pages/MyData/index.astro @@ -3,6 +3,19 @@ import Button from '@components/Button/index.astro' import Icon from '@components/Icon/index.astro' import List from '@components/List/index.astro' +/** + * Privacy request preview states for styling: + * + * - /privacy/my-data?accessState=loading + * - /privacy/my-data?accessState=success + * - /privacy/my-data?accessState=error + * - /privacy/my-data?accessState=validation + * - /privacy/my-data?deleteState=loading + * - /privacy/my-data?deleteState=success + * - /privacy/my-data?deleteState=error + * - /privacy/my-data?deleteState=validation + */ + export type Props = { content: { header: { @@ -19,14 +32,14 @@ export type Props = { description: string label: string buttonText: string - }, - deleteData: { + } + deleteData: { heading: string description: string label: string buttonText: string confirmText: string - }, + } next: { heading: string items: { @@ -37,10 +50,30 @@ export type Props = { } } +type PreviewState = 'idle' | 'loading' | 'success' | 'error' | 'validation' + const { content } = Astro.props + +const previewStates = new Set(['idle', 'loading', 'success', 'error', 'validation']) + +const resolvePreviewState = (value: string | null): PreviewState => { + if (!value) { + return 'idle' + } + + const normalizedValue = value.trim().toLowerCase() + return previewStates.has(normalizedValue as PreviewState) ? (normalizedValue as PreviewState) : 'idle' +} + +const accessPreviewState = resolvePreviewState(Astro.url.searchParams.get('accessState')) +const deletePreviewState = resolvePreviewState(Astro.url.searchParams.get('deleteState')) --- - +
+ > +
-
+
@@ -107,8 +149,93 @@ const { content } = Astro.props variant="info" class="mt-8 px-6 py-3" text={content.accessData.buttonText} + disabled={accessPreviewState === 'loading'} /> +
+
+
+ +
+
+

Request Sent

+

We sent a verification email so you can confirm your data access request.

+
+
+
+ +
+
+
+ +
+
+

Sending Request

+

Your request is being prepared and submitted.

+
+
+
+ + + + + + > +
@@ -128,18 +256,23 @@ const { content } = Astro.props {content.deleteData.heading} -
+
- + {content.deleteData.description}
-
+
@@ -159,6 +293,8 @@ const { content } = Astro.props type="checkbox" id="confirm-delete" required + aria-invalid={deletePreviewState === 'validation' ? 'true' : 'false'} + checked={deletePreviewState === 'success' || deletePreviewState === 'loading'} class="mt-1 focus-visible:outline-2 focus-visible:outline-spotlight focus-visible:outline-offset-2 focus-visible:rounded-none" /> {content.deleteData.confirmText} @@ -169,8 +305,93 @@ const { content } = Astro.props variant="warning" class="px-6 py-3" text={content.deleteData.buttonText} + disabled={deletePreviewState === 'loading'} /> +
+
+
+ +
+
+

Deletion Request Sent

+

We sent a verification email so you can confirm your deletion request.

+
+
+
+ +
+
+
+ +
+
+

Sending Request

+

Your request is being prepared pnd submitted.

+
+
+
+ + + + + + > +
@@ -187,10 +409,7 @@ const { content } = Astro.props

{content.next.heading}

- +
diff --git a/src/components/Pages/MyData/server/index.ts b/src/components/Pages/MyData/server/index.ts index ac9974fb4..a21917842 100644 --- a/src/components/Pages/MyData/server/index.ts +++ b/src/components/Pages/MyData/server/index.ts @@ -1,4 +1,7 @@ -export const statusMessages: Record = { +export const statusMessages: Record< + string, + { type: 'success' | 'error' | 'info'; message: string } +> = { sent: { type: 'success', message: diff --git a/src/components/Pages/Newsletter/Confirm/client/__tests__/index.spec.ts b/src/components/Pages/Newsletter/Confirm/client/__tests__/index.spec.ts index cd335891e..5a9bed5ca 100644 --- a/src/components/Pages/Newsletter/Confirm/client/__tests__/index.spec.ts +++ b/src/components/Pages/Newsletter/Confirm/client/__tests__/index.spec.ts @@ -17,9 +17,7 @@ type ConfirmActionData = { message?: string } -const confirmMock = vi.fn< - (_input: { token: string }) => Promise> ->() +const confirmMock = vi.fn<(_input: { token: string }) => Promise>>() vi.mock('astro:actions', () => ({ actions: { diff --git a/src/components/Pages/Newsletter/Confirm/client/__tests__/selectors.spec.ts b/src/components/Pages/Newsletter/Confirm/client/__tests__/selectors.spec.ts index 27072b6e0..dc59e2bfe 100644 --- a/src/components/Pages/Newsletter/Confirm/client/__tests__/selectors.spec.ts +++ b/src/components/Pages/Newsletter/Confirm/client/__tests__/selectors.spec.ts @@ -17,9 +17,7 @@ type ConfirmActionData = { message?: string } -const confirmMock = vi.fn< - (_input: { token: string }) => Promise> ->() +const confirmMock = vi.fn<(_input: { token: string }) => Promise>>() vi.mock('astro:actions', () => ({ actions: { diff --git a/src/components/Pages/Newsletter/Confirm/client/selectors.ts b/src/components/Pages/Newsletter/Confirm/client/selectors.ts index 3673ada41..abb28dae8 100644 --- a/src/components/Pages/Newsletter/Confirm/client/selectors.ts +++ b/src/components/Pages/Newsletter/Confirm/client/selectors.ts @@ -73,8 +73,18 @@ export function getNewsletterConfirmElements(root?: SelectorRoot): NewsletterCon 'Newsletter confirm status announcer not found', root ), - userEmail: queryRequiredElement('#user-email', isHtmlElement, 'Newsletter confirm email not found', root), - errorTitle: queryRequiredElement('#error-title', isHtmlElement, 'Newsletter confirm error title not found', root), + userEmail: queryRequiredElement( + '#user-email', + isHtmlElement, + 'Newsletter confirm email not found', + root + ), + errorTitle: queryRequiredElement( + '#error-title', + isHtmlElement, + 'Newsletter confirm error title not found', + root + ), errorMessage: queryRequiredElement( '#error-message', isHtmlElement, diff --git a/src/components/Pages/Newsletter/Confirm/index.astro b/src/components/Pages/Newsletter/Confirm/index.astro index 8e6a5ccf6..12b9f5175 100644 --- a/src/components/Pages/Newsletter/Confirm/index.astro +++ b/src/components/Pages/Newsletter/Confirm/index.astro @@ -24,28 +24,23 @@ const { token } = Astro.props
-

+

-
- +
+
-

+

Confirming Your Subscription

@@ -53,9 +48,7 @@ const { token } = Astro.props

Please wait while we verify your confirmation link.

-

- This usually takes just a moment. -

+

This usually takes just a moment.

@@ -64,14 +57,16 @@ const { token } = Astro.props @@ -133,14 +124,16 @@ const { token } = Astro.props