diff --git a/src/applications/personalization/profile/components/proof-of-veteran-status/ProofOfVeteranStatus.jsx b/src/applications/personalization/profile/components/proof-of-veteran-status/ProofOfVeteranStatus.jsx index ad98ce52c909..63a25ecf99e8 100644 --- a/src/applications/personalization/profile/components/proof-of-veteran-status/ProofOfVeteranStatus.jsx +++ b/src/applications/personalization/profile/components/proof-of-veteran-status/ProofOfVeteranStatus.jsx @@ -6,6 +6,7 @@ import { generatePdf } from '~/platform/pdf'; import { focusElement } from '~/platform/utilities/ui'; import { captureError } from '~/platform/user/profile/vap-svc/util/analytics'; import { CONTACTS } from '@department-of-veterans-affairs/component-library/contacts'; +import { useFeatureToggle } from '~/platform/utilities/feature-toggles'; import { formatFullName } from '../../../common/helpers'; import { getServiceBranchDisplayName } from '../../helpers'; @@ -32,7 +33,7 @@ const ProofOfVeteranStatus = ({ (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) || /android/i.test(userAgent); - const pdfData = { + const pdfDataOld = { title: `Veteran status card for ${formatFullName({ first, middle, @@ -60,6 +61,53 @@ const ProofOfVeteranStatus = ({ }, }, }; + const pdfDataNew = { + title: `Veteran status card for ${formatFullName({ + first, + middle, + last, + suffix, + })}`, + details: { + fullName: formatFullName({ + first, + middle, + last, + suffix, + }), + serviceHistory: serviceHistory.map(item => { + return { + ...item, + branchOfService: getServiceBranchDisplayName(item.branchOfService), + }; + }), + totalDisabilityRating, + edipi, + image: { + title: 'V-A logo', + url: '/img/design/logo/logo-black-and-white.png', + }, + seal: { + title: 'V-A Seal', + url: '/img/design/logo/seal-black-and-white.png', + }, + scissors: { + title: 'Scissors icon', + url: '/img/scissors-black.png', + }, + }, + }; + + const { + TOGGLE_NAMES, + useToggleValue, + useToggleLoadingValue, + } = useFeatureToggle(); + + const isLoadingFeatureFlags = useToggleLoadingValue(); + const showNewPdf = useToggleValue( + TOGGLE_NAMES.veteranStatusCardUseLighthouse, + ); useEffect( () => { @@ -75,9 +123,11 @@ const ProofOfVeteranStatus = ({ try { await generatePdf( - 'veteranStatus', + isLoadingFeatureFlags || !showNewPdf + ? 'veteranStatus' + : 'veteranStatusNew', 'Veteran status card', - pdfData, + isLoadingFeatureFlags || !showNewPdf ? pdfDataOld : pdfDataNew, !isMobile, ); } catch (error) { @@ -115,6 +165,10 @@ const ProofOfVeteranStatus = ({ ); }); + if (isLoadingFeatureFlags) { + return null; + } + return ( <>
diff --git a/src/applications/personalization/profile/mocks/server.js b/src/applications/personalization/profile/mocks/server.js index 32bd593dd618..9177f471ca1d 100644 --- a/src/applications/personalization/profile/mocks/server.js +++ b/src/applications/personalization/profile/mocks/server.js @@ -106,6 +106,7 @@ const responses = { profileUseExperimental: false, profileShowPrivacyPolicy: true, veteranOnboardingContactInfoFlow: true, + veteranStatusCardUseLighthouse: true, }), ), secondsOfDelay, @@ -236,7 +237,8 @@ const responses = { // .json(serviceHistory.generateServiceHistoryError('403')); }, 'GET /v0/disability_compensation_form/rating_info': (_req, res) => { - return res.status(200).json(ratingInfo.success); + // return res.status(200).json(ratingInfo.success.serviceConnected0); + return res.status(200).json(ratingInfo.success.serviceConnected40); // return res.status(500).json(genericErrors.error500); }, diff --git a/src/platform/pdf/templates/index.js b/src/platform/pdf/templates/index.js index c4fe72839638..301f1d98377a 100644 --- a/src/platform/pdf/templates/index.js +++ b/src/platform/pdf/templates/index.js @@ -16,4 +16,8 @@ templates.veteranStatus = () => { return require('./veteran_status'); }; +templates.veteranStatusNew = () => { + return require('./veteran_status_new'); +}; + export { templates }; diff --git a/src/platform/pdf/templates/veteran_status_new.js b/src/platform/pdf/templates/veteran_status_new.js new file mode 100644 index 000000000000..790b0160c72b --- /dev/null +++ b/src/platform/pdf/templates/veteran_status_new.js @@ -0,0 +1,318 @@ +/** + * Proof of Veteran Status PDF template. + * + * NB: The order in which items are added to the document is important, + * and thus PDFKit requires performing operations synchronously. + */ +/* eslint-disable no-await-in-loop */ + +import { MissingFieldsException } from '../utils/exceptions/MissingFieldsException'; +import { + createAccessibleDoc, + createHeading, + registerVaGovFonts, +} from './utils'; + +const config = { + margins: { + top: 30, + bottom: 40, + left: 40, + right: 40, + }, + headings: { + H1: { + font: 'Bitter-Bold', + size: 20, + }, + H2: { + font: 'SourceSansPro-Bold', + size: 13, + }, + }, + paragraph: { + font: 'SourceSansPro-Regular', + size: 16, + }, + text: { + boldFont: 'SourceSansPro-Bold', + font: 'SourceSansPro-Regular', + size: 9, + }, + disclaimer: { + font: 'SourceSansPro-Regular', + size: 9, + }, + instruction: { + font: 'SourceSansPro-Regular', + size: 12, + }, +}; + +// Reusable function to fetch and return an image as a base64 data URL +const fetchImage = async url => { + const fetchedImage = await fetch(url); + const contentType = fetchedImage.headers.get('Content-type'); + const base64Data = Buffer.from(await fetchedImage.arrayBuffer()).toString( + 'base64', + ); + return `data:${contentType};base64,${base64Data}`; +}; + +const validate = data => { + const requiredFields = ['fullName', 'serviceHistory']; // If there is no serviceHistory, there is also no DOD ID + + const missingFields = requiredFields.filter(field => !data[field]); + if (missingFields.length) { + throw new MissingFieldsException(missingFields); + } +}; + +const generate = async data => { + validate(data.details); + + const doc = createAccessibleDoc(data, config); + + await registerVaGovFonts(doc); + + doc.addPage({ margins: config.margins }); + + const wrapper = doc.struct('Document'); + doc.addStructure(wrapper); + + // VA logo + if (data.details.image) { + const logoGraphic = await fetchImage(data.details.image.url); + + const logoImage = doc.image( + logoGraphic, + doc.page.margins.left, + doc.page.margins.top, + { width: 155, alt: data.details.image.title }, + ); + + const logo = doc.struct('Figure', { alt: data.details.image.title }, [ + () => logoImage, + ]); + + wrapper.add(logo); + } + + doc.moveDown(0.5); + + // Heading + const heading = createHeading( + doc, + 'H1', + config, + 'Proof of Veteran status card', + { + x: doc.page.margins.left, + paragraphGap: 10, + }, + ); + wrapper.add(heading); + + // description + wrapper.add( + doc.struct('P', () => { + doc + .font(config.paragraph.font) + .fontSize(config.paragraph.size) + .text( + 'You can use your Veteran status card to get discounts for at many stores, businesses, and restaurants. Additionally, you’ll be able to use your Veteran status card to checkout at the commissaries and exchanges on base.', + doc.page.margins.left, + doc.y, + { + lineGap: 4, + width: 450, + }, + ); + }), + ); + + // veteran status card + // Add content synchronously to ensure that reading order + // is left intact for screen reader users. + + doc.moveDown(0.5); + const cardSection = doc.struct('Sect'); + wrapper.add(cardSection); + + const cardYPosition = doc.y; + const cardXPosition = doc.x; + const cardWidth = 252; // roughly results in a 2 x 3.5 inch rectangle + const cardHeight = 144; + const cardPadding = 12; + + // Add a dotted line to indicate where the card should be cut out + const cardWrapper = doc.struct('Artifact', () => { + doc + .lineWidth(0.5) + .roundedRect(doc.page.margins.left, doc.y, cardWidth, cardHeight, 8) + .dash(3.5, { space: 3.5 }) + .stroke(); + }); + + cardSection.add(cardWrapper); + + // Card title + doc.moveDown(0.5); + const cardHeading = doc.struct('H2', () => { + doc + .font(config.headings.H2.font) + .fontSize(config.headings.H2.size) + .text( + 'Proof of Veteran Status', + doc.page.margins.left + cardPadding, + doc.y, + ); + }); + cardSection.add(cardHeading); + + // VA seal + if (data.details.seal) { + const sealGraphic = await fetchImage(data.details.seal.url); + const sealWidth = 40; + const sealImage = doc.image( + sealGraphic, + cardXPosition + cardWidth - cardPadding - sealWidth, // positioned relative to top-right corner + cardYPosition + cardPadding, + { width: sealWidth, alt: data.details.image.title }, + ); + const seal = doc.struct('Figure', { alt: data.details.seal.title }, [ + () => sealImage, + ]); + cardSection.add(seal); + } + + // Generate content for latest period of service + const latestService = data.details.serviceHistory[0]; + const serviceStartYear = latestService.beginDate + ? latestService.beginDate.substring(0, 4) + : ''; + const serviceEndYear = latestService.endDate + ? latestService.endDate.substring(0, 4) + : ''; + const dateRange = + serviceStartYear.length || serviceEndYear.length + ? `${serviceStartYear}–${serviceEndYear}` + : ''; + + // First column of info items + doc.moveDown(0.25); + const infoItems = [ + { + heading: 'Name', + content: data.details.fullName, + }, + { + heading: 'Latest period of service', + content: `${latestService.branchOfService} • ${dateRange}`, + }, + { + heading: 'DoD ID Number', + content: data.details.edipi, + }, + ]; + + let lastHeaderY; + infoItems.forEach(item => { + lastHeaderY = doc.y; + const header = doc.struct('H2', () => { + doc + .font(config.text.boldFont) + .fontSize(config.text.size) + .text(`${item.heading}`); + }); + const content = doc.struct('P', () => { + doc + .font(config.text.font) + .fontSize(config.text.size) + .text(item.content) + .moveDown(0.5); + }); + cardSection.add(header); + cardSection.add(content); + }); + + // Second column of info items + const infoItems2 = [ + { + heading: 'VA disability rating', + content: `${data.details.totalDisabilityRating?.toString()}%`, + condition: data.details.totalDisabilityRating, + }, + ]; + infoItems2.forEach(item => { + if (item.condition) { + const header = doc.struct('H2', () => { + doc + .font(config.text.boldFont) + .fontSize(config.text.size) + .text(`${item.heading}`, doc.page.margins.left + 130, lastHeaderY); + }); + const content = doc.struct('P', () => { + doc + .font(config.text.font) + .fontSize(config.text.size) + .text(item.content) + .moveDown(0.5); + }); + cardSection.add(header); + cardSection.add(content); + } + }); + + doc.moveDown(0.5); + + // Disclaimer text + const disclaimerText = doc.struct('P', () => { + doc + .font(config.disclaimer.font) + .fontSize(config.disclaimer.size) + .text( + "This card doesn't entitle you to any VA benefits.", + doc.page.margins.left + cardPadding, + cardYPosition + cardHeight - cardPadding - config.disclaimer.size, // position this text relative to the bottom of the card + ); + }); + cardSection.add(disclaimerText); + + // Instructions + doc.moveDown(3); + const scissorsWidth = 10; + if (data.details.scissors) { + const scissorsGraphic = await fetchImage(data.details.scissors.url); + const scissorsImage = doc.image( + scissorsGraphic, + doc.page.margins.left, + doc.y, + { width: scissorsWidth }, + ); + const scissors = doc.struct( + 'Figure', + { alt: data.details.scissors.title }, + [() => scissorsImage], + ); + wrapper.add(scissors); + } + + const instructionText = doc.struct('P', () => { + doc + .font(config.instruction.font) + .fontSize(config.instruction.size) + .text( + 'Cut this card out and keep in your wallet.', + doc.page.margins.left + scissorsWidth + 4, + doc.y - scissorsWidth - 3, // position this text relative to the bottom of the card + ); + }); + wrapper.add(instructionText); + + wrapper.end(); + doc.flushPages(); + return doc; +}; + +export { generate }; diff --git a/src/platform/pdf/test/templates/veteran_status/fixtures/veteran_status.json b/src/platform/pdf/test/templates/veteran_status/fixtures/veteran_status.json index 46a041d05944..53f0816c2c8e 100644 --- a/src/platform/pdf/test/templates/veteran_status/fixtures/veteran_status.json +++ b/src/platform/pdf/test/templates/veteran_status/fixtures/veteran_status.json @@ -1,24 +1,24 @@ { - "title": "Veteran Status", + "title": "Proof of Veteran status card", "details": { "fullName": "John H. Doe", "serviceHistory": [ { - "branchOfService": "Air Force", + "branchOfService": "United States Air Force", "beginDate": "2009-04-12", "endDate": "2013-04-11", "personnelCategoryTypeCode": "V", "characterOfDischargeCode": "A" }, { - "branchOfService": "Army", + "branchOfService": "United States Army", "beginDate": "2005-04-12", "endDate": "2009-04-11", "personnelCategoryTypeCode": "A", "characterOfDischargeCode": "A" }, { - "branchOfService": "Navy", + "branchOfService": "United States Navy", "beginDate": "2003-04-12", "endDate": "2005-04-11", "personnelCategoryTypeCode": "A", diff --git a/src/platform/pdf/test/templates/veteran_status/veteran_status.unit.spec.js b/src/platform/pdf/test/templates/veteran_status/veteran_status.unit.spec.js index 63ef0c2948b0..6b11121ad03d 100644 --- a/src/platform/pdf/test/templates/veteran_status/veteran_status.unit.spec.js +++ b/src/platform/pdf/test/templates/veteran_status/veteran_status.unit.spec.js @@ -31,7 +31,7 @@ describe('Veteran Status PDF template', () => { }; describe('PDF Semantics', () => { - it('places the name in an H1', async () => { + it('places the page title in an H1', async () => { const data = require('./fixtures/veteran_status.json'); const { pdf } = await generateAndParsePdf(data); @@ -41,9 +41,7 @@ describe('Veteran Status PDF template', () => { const content = await page.getTextContent({ includeMarkedContent: true }); expect(content.items.filter(item => item.tag === 'H1').length).to.eq(1); - expect(content.items[3].str).to.eq(data.details.fullName); - // H1 in this case is set to 14 px - expect(content.items[3].height).to.eq(10.5); + expect(content.items[3].str).to.eq(data.details.title); }); it('All sections are contained by a root level Document element', async () => { @@ -63,7 +61,7 @@ describe('Veteran Status PDF template', () => { }); describe('Document section customization', () => { - it('Shows service details', async () => { + it('Shows latest service details', async () => { const data = require('./fixtures/veteran_status.json'); const { pdf } = await generateAndParsePdf(data); @@ -73,39 +71,13 @@ describe('Veteran Status PDF template', () => { const content = await page.getTextContent({ includeMarkedContent: true }); const listContent = content.items.filter( - item => item.str === 'Period of service', + item => item.str === 'Latest period of service', ); expect(listContent.length).to.eq(1); const airForceText = content.items.filter( - item => - item.str === `${data.details.serviceHistory[0].branchOfService}`, + item => item.str === 'United States Air Force • 2009–2013', ); expect(airForceText.length).to.eq(1); - const armyText = content.items.filter( - item => - item.str === `${data.details.serviceHistory[1].branchOfService}`, - ); - expect(armyText.length).to.eq(1); - }); - - it('Should only show the 2 most recent periods of service', async () => { - const data = require('./fixtures/veteran_status.json'); - const { pdf } = await generateAndParsePdf(data); - - // Fetch the first page - const pageNumber = 1; - const page = await pdf.getPage(pageNumber); - - const content = await page.getTextContent({ includeMarkedContent: true }); - - const listItems = content.items.filter(item => item.tag === 'Lbl'); - expect(listItems.length).to.eq(2); - - const navyService = content.items.filter( - item => - item.str === `${data.details.serviceHistory[2].branchOfService}`, - ); - expect(navyService.length).to.eq(0); }); it('Shows disability rating details', async () => { @@ -118,13 +90,12 @@ describe('Veteran Status PDF template', () => { const content = await page.getTextContent({ includeMarkedContent: true }); const ratingText = content.items.filter( - item => item.str === 'Disability rating:', + item => item.str === 'VA disability rating', ); expect(ratingText.length).to.eq(1); const ratingContent = content.items.filter( item => - item.str === - `${data.details.totalDisabilityRating.toString()}% service connected`, + item.str === `${data.details.totalDisabilityRating.toString()}%`, ); expect(ratingContent.length).to.eq(1); }); @@ -139,7 +110,7 @@ describe('Veteran Status PDF template', () => { const content = await page.getTextContent({ includeMarkedContent: true }); const dodIdTitle = content.items.filter( - item => item.str === 'DoD ID Number:', + item => item.str === 'DoD ID Number', ); expect(dodIdTitle.length).to.eq(1); const dodId = content.items.filter( @@ -148,26 +119,6 @@ describe('Veteran Status PDF template', () => { expect(dodId.length).to.eq(1); }); - it("Doesn't show DoD ID Number if it is unavailable", async () => { - const data = require('./fixtures/veteran_status.json'); - data.details.edipi = null; - const { pdf } = await generateAndParsePdf(data); - - // Fetch the first page - const pageNumber = 1; - const page = await pdf.getPage(pageNumber); - - const content = await page.getTextContent({ includeMarkedContent: true }); - const dodIdTitle = content.items.filter( - item => item.str === 'DoD ID Number:', - ); - expect(dodIdTitle.length).to.eq(0); - const dodId = content.items.filter( - item => item.str === data.details.edipi, - ); - expect(dodId.length).to.eq(0); - }); - it('Has a default language (english)', async () => { const data = require('./fixtures/veteran_status.json'); const { metadata } = await generateAndParsePdf(data); diff --git a/src/site/assets/img/design/logo/seal-black-and-white.png b/src/site/assets/img/design/logo/seal-black-and-white.png new file mode 100644 index 000000000000..45a5fec2c1c3 Binary files /dev/null and b/src/site/assets/img/design/logo/seal-black-and-white.png differ diff --git a/src/site/assets/img/scissors-black.png b/src/site/assets/img/scissors-black.png new file mode 100644 index 000000000000..a09c5e0ab8d5 Binary files /dev/null and b/src/site/assets/img/scissors-black.png differ