diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 667083bf..ca8a9e97 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1313,6 +1313,28 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => { : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); }; +/** + * Formats type string for display by adding spaces before capitals + * and optionally adds an appropriate article prefix (a/an) + * + * @param text - Text to format + * @returns Formatted string with article prefix + */ +export const capitalizeAndInsertSpacesBeforeCapsWithAPrefix = ( + text: string, +): string => { + const word = capitalizeAndInsertSpacesBeforeCaps(text); + if (word) { + // if the word starts with a vowel, use "an" instead of "a" + const firstLetter = word[0].toLowerCase(); + const vowels = ["a", "e", "i", "o", "u"]; + const particle = vowels.includes(firstLetter) ? "an" : "a"; + return particle + " " + word; + } else { + return ""; + } +}; + /** return readable summary of claim, or something generic diff --git a/src/libs/util.ts b/src/libs/util.ts index dfd3dde5..c64916cc 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -160,6 +160,41 @@ export const isGiveAction = ( return isGiveClaimType(veriClaim.claimType); }; +export interface OfferFulfillment { + offerHandleId: string; + offerType: string; +} + +/** + * Extract offer fulfillment information from the fulfills field + * Handles both array and single object cases + */ +export const extractOfferFulfillment = (fulfills: any): OfferFulfillment | null => { + if (!fulfills) { + return null; + } + + // Handle both array and single object cases + let offerFulfill = null; + + if (Array.isArray(fulfills)) { + // Find the Offer in the fulfills array + offerFulfill = fulfills.find((item) => item["@type"] === "Offer"); + } else if (fulfills["@type"] === "Offer") { + // fulfills is a single Offer object + offerFulfill = fulfills; + } + + if (offerFulfill) { + return { + offerHandleId: offerFulfill.identifier, + offerType: offerFulfill["@type"], + }; + } + + return null; +}; + export const shortDid = (did: string) => { if (did.startsWith("did:peer:")) { return ( diff --git a/src/services/QRScanner/WebInlineQRScanner.ts b/src/services/QRScanner/WebInlineQRScanner.ts index 5f5bceaf..d48775fe 100644 --- a/src/services/QRScanner/WebInlineQRScanner.ts +++ b/src/services/QRScanner/WebInlineQRScanner.ts @@ -36,7 +36,7 @@ export class WebInlineQRScanner implements QRScannerService { // Generate a short random ID for this scanner instance this.id = Math.random().toString(36).substring(2, 8).toUpperCase(); this.options = options ?? {}; - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Initializing scanner with options:`, { ...this.options, @@ -49,7 +49,7 @@ export class WebInlineQRScanner implements QRScannerService { this.context = this.canvas.getContext("2d", { willReadFrequently: true }); this.video = document.createElement("video"); this.video.setAttribute("playsinline", "true"); // Required for iOS - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] DOM elements created successfully`, ); } @@ -60,7 +60,7 @@ export class WebInlineQRScanner implements QRScannerService { this.cameraStateListeners.forEach((listener) => { try { listener.onStateChange(state, message); - logger.info( + logger.debug( `[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`, { state, @@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService { async checkPermissions(): Promise { try { this.updateCameraState("initializing", "Checking camera permissions..."); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Checking camera permissions...`, ); @@ -99,7 +99,7 @@ export class WebInlineQRScanner implements QRScannerService { const permissions = await navigator.permissions.query({ name: "camera" as PermissionName, }); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`, permissions.state, ); @@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService { "initializing", "Requesting camera permissions...", ); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, ); @@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService { (device) => device.kind === "videoinput", ); - logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Found video devices:`, { count: videoDevices.length, devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })), userAgent: navigator.userAgent, @@ -188,7 +188,7 @@ export class WebInlineQRScanner implements QRScannerService { } // Try to get a stream with specific constraints - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`, { facingMode: "environment", @@ -210,7 +210,7 @@ export class WebInlineQRScanner implements QRScannerService { // Stop the test stream immediately stream.getTracks().forEach((track) => { - logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { kind: track.kind, label: track.label, readyState: track.readyState, @@ -275,12 +275,12 @@ export class WebInlineQRScanner implements QRScannerService { async isSupported(): Promise { try { - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Checking browser support...`, ); // Check for secure context first if (!window.isSecureContext) { - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`, ); return false; @@ -300,7 +300,7 @@ export class WebInlineQRScanner implements QRScannerService { (device) => device.kind === "videoinput", ); - logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Device support check:`, { hasSecureContext: window.isSecureContext, hasMediaDevices: !!navigator.mediaDevices, hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia, @@ -379,7 +379,7 @@ export class WebInlineQRScanner implements QRScannerService { // Log scan attempt every 100 frames or 1 second if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) { - logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { attempt: this.scanAttempts, dimensions: { width: this.canvas.width, @@ -421,7 +421,7 @@ export class WebInlineQRScanner implements QRScannerService { !code.data || code.data.length === 0; - logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { data: code.data, location: code.location, attempts: this.scanAttempts, @@ -512,13 +512,13 @@ export class WebInlineQRScanner implements QRScannerService { this.scanAttempts = 0; this.lastScanTime = Date.now(); this.updateCameraState("initializing", "Starting camera..."); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Starting scan with options:`, this.options, ); // Get camera stream with options - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Requesting camera stream...`, ); this.stream = await navigator.mediaDevices.getUserMedia({ @@ -531,7 +531,7 @@ export class WebInlineQRScanner implements QRScannerService { this.updateCameraState("active", "Camera is active"); - logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { tracks: this.stream.getTracks().map((t) => ({ kind: t.kind, label: t.label, @@ -550,14 +550,14 @@ export class WebInlineQRScanner implements QRScannerService { this.video.style.display = "none"; } await this.video.play(); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Video element started playing`, ); } // Emit stream to component this.events.emit("stream", this.stream); - logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`); + logger.debug(`[WebInlineQRScanner:${this.id}] Stream event emitted`); // Start QR code scanning this.scanQRCode(); @@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService { } try { - logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Stopping scan`, { scanAttempts: this.scanAttempts, duration: Date.now() - this.lastScanTime, }); @@ -604,7 +604,7 @@ export class WebInlineQRScanner implements QRScannerService { if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Animation frame cancelled`, ); } @@ -613,13 +613,13 @@ export class WebInlineQRScanner implements QRScannerService { if (this.video) { this.video.pause(); this.video.srcObject = null; - logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); + logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`); } // Stop all tracks in the stream if (this.stream) { this.stream.getTracks().forEach((track) => { - logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { + logger.debug(`[WebInlineQRScanner:${this.id}] Stopping track:`, { kind: track.kind, label: track.label, readyState: track.readyState, @@ -631,7 +631,7 @@ export class WebInlineQRScanner implements QRScannerService { // Emit stream stopped event this.events.emit("stream", null); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, ); } catch (error) { @@ -643,17 +643,17 @@ export class WebInlineQRScanner implements QRScannerService { throw error; } finally { this.isScanning = false; - logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); + logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); } } addListener(listener: ScanListener): void { - logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); + logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`); this.scanListener = listener; } onStream(callback: (stream: MediaStream | null) => void): void { - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Adding stream event listener`, ); this.events.on("stream", callback); @@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService { async cleanup(): Promise { try { - logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); + logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`); await this.stopScan(); this.events.removeAllListeners(); - logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`); // Clean up DOM elements if (this.video) { this.video.remove(); this.video = null; - logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`); } if (this.canvas) { this.canvas.remove(); this.canvas = null; - logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`); } this.context = null; - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, ); } catch (error) { diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index f594dc9b..2c441687 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -24,7 +24,9 @@

{{ - capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "") + serverUtil.capitalizeAndInsertSpacesBeforeCaps( + veriClaim.claimType || "", + ) }}

@@ -556,6 +572,17 @@ export default class ClaimView extends Vue { fulfillsPlanHandleId?: string; fulfillsType?: string; fulfillsHandleId?: string; + fullClaim?: { + fulfills?: Array<{ + "@type": string; + identifier?: string; + }>; + }; + } | null = null; + // Additional offer information extracted from the fulfills array + detailsForGiveOfferFulfillment: { + offerHandleId?: string; + offerType?: string; } | null = null; detailsForOffer: { fulfillsPlanHandleId?: string } | null = null; // Project information for fulfillsPlanHandleId @@ -689,6 +716,7 @@ export default class ClaimView extends Vue { this.confsVisibleToIdList = []; this.detailsForGive = null; this.detailsForOffer = null; + this.detailsForGiveOfferFulfillment = null; this.projectInfo = null; this.fullClaim = null; this.fullClaimDump = ""; @@ -701,6 +729,15 @@ export default class ClaimView extends Vue { this.veriClaimDidsVisible = {}; } + /** + * Extract offer fulfillment information from the fulfills array + */ + extractOfferFulfillment() { + this.detailsForGiveOfferFulfillment = libsUtil.extractOfferFulfillment( + this.detailsForGive?.fullClaim?.fulfills + ); + } + // ================================================= // UTILITY METHODS // ================================================= @@ -758,13 +795,6 @@ export default class ClaimView extends Vue { this.canShare = !!navigator.share; } - // insert a space before any capital letters except the initial letter - // (and capitalize initial letter, just in case) - capitalizeAndInsertSpacesBeforeCaps(text: string): string { - if (!text) return ""; - return text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); - } - totalConfirmers() { return ( this.numConfsNotVisible + @@ -821,6 +851,8 @@ export default class ClaimView extends Vue { }); if (giveResp.status === 200 && giveResp.data.data?.length > 0) { this.detailsForGive = giveResp.data.data[0]; + // Extract offer information from the fulfills array + this.extractOfferFulfillment(); } else { await this.$logError( "Error getting detailed give info: " + JSON.stringify(giveResp), diff --git a/src/views/ConfirmGiftView.vue b/src/views/ConfirmGiftView.vue index c2274dab..95632bb7 100644 --- a/src/views/ConfirmGiftView.vue +++ b/src/views/ConfirmGiftView.vue @@ -96,50 +96,50 @@ +
+ +
+ + This fulfills a bigger plan + + +
- -
- - This fulfills a bigger plan - - -
- -
- - - This fulfills - {{ - capitalizeAndInsertSpacesBeforeCapsWithAPrefix( - giveDetails?.fulfillsType || "", - ) - }} - - + +
+ + + This fulfills + {{ + serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix( + giveDetailsOfferFulfillment.offerType || "Offer", + ) + }} + + +
@@ -493,6 +493,11 @@ export default class ConfirmGiftView extends Vue { confsVisibleErrorMessage = ""; confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer giveDetails?: GiveSummaryRecord; + // Additional offer information extracted from the fulfills array + giveDetailsOfferFulfillment: { + offerHandleId?: string; + offerType?: string; + } | null = null; giverName = ""; issuerName = ""; isLoading = false; @@ -648,6 +653,8 @@ export default class ConfirmGiftView extends Vue { if (resp.status === 200) { this.giveDetails = resp.data.data[0]; + // Extract offer information from the fulfills array + this.extractOfferFulfillment(); } else { throw new Error("Error getting detailed give info: " + resp.status); } @@ -707,6 +714,15 @@ export default class ConfirmGiftView extends Vue { } } + /** + * Extract offer fulfillment information from the fulfills array + */ + private extractOfferFulfillment() { + this.giveDetailsOfferFulfillment = libsUtil.extractOfferFulfillment( + this.giveDetails?.fullClaim?.fulfills + ); + } + /** * Fetches confirmer information for the claim */ @@ -849,27 +865,6 @@ export default class ConfirmGiftView extends Vue { ); } - /** - * Formats type string for display by adding spaces before capitals - * Optionally adds a prefix - * - * @param text - Text to format - * @param prefix - Optional prefix to add - * @returns Formatted string - */ - capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string): string { - const word = this.capitalizeAndInsertSpacesBeforeCaps(text); - if (word) { - // if the word starts with a vowel, use "an" instead of "a" - const firstLetter = word[0].toLowerCase(); - const vowels = ["a", "e", "i", "o", "u"]; - const particle = vowels.includes(firstLetter) ? "an" : "a"; - return particle + " " + word; - } else { - return ""; - } - } - /** * Initiates sharing of claim information * Handles share functionality based on platform capabilities @@ -894,11 +889,5 @@ export default class ConfirmGiftView extends Vue { this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaimDump = ""; } - - capitalizeAndInsertSpacesBeforeCaps(text: string) { - return !text - ? "" - : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); - } } diff --git a/test-playwright/05-invite.spec.ts b/test-playwright/05-invite.spec.ts index 821f7c39..d0cf5b19 100644 --- a/test-playwright/05-invite.spec.ts +++ b/test-playwright/05-invite.spec.ts @@ -8,6 +8,7 @@ * - Custom expiration date * 2. The invitation appears in the list after creation * 3. A new user can accept the invitation and become connected + * 4. The new user can create gift records from the front page * * Test Flow: * 1. Imports User 0 (test account) @@ -19,6 +20,8 @@ * 4. Creates a new user with Ethr DID * 5. Accepts the invitation as the new user * 6. Verifies the connection is established + * 7. Tests that the new user can create gift records from the front page + * 8. Verifies the gift appears in the home view * * Related Files: * - Frontend invite handling: src/libs/endorserServer.ts @@ -29,7 +32,7 @@ * @requires ./testUtils - For user management utilities */ import { test, expect } from '@playwright/test'; -import { deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils'; +import { createGiftFromFrontPageForNewUser, deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils'; test('Check User 0 can invite someone', async ({ page }) => { await importUser(page, '00'); @@ -58,4 +61,7 @@ test('Check User 0 can invite someone', async ({ page }) => { await page.locator('button:has-text("Save")').click(); await expect(page.locator('button:has-text("Save")')).toBeHidden(); await expect(page.locator(`li:has-text("My pal User #0")`)).toBeVisible(); + + // Verify the new user can create a gift record from the front page + const giftTitle = await createGiftFromFrontPageForNewUser(page, `Gift from new user ${neighborNum}`); }); diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index 6d232a99..d0128a4e 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -1,4 +1,5 @@ import { expect, Page } from "@playwright/test"; +import { UNNAMED_ENTITY_NAME } from '../src/constants/entities'; // Get test user data based on the ID. // '01' -> user 111 @@ -215,3 +216,44 @@ export function isResourceIntensiveTest(testPath: string): boolean { testPath.includes("40-add-contact") ); } + +/** + * Create a gift record from the front page + * @param page - Playwright page object + * @param giftTitle - Optional custom title, defaults to "Gift " + random string + * @param amount - Optional amount, defaults to random 1-99 + * @returns Promise resolving to the created gift title + */ +export async function createGiftFromFrontPageForNewUser( + page: Page, + giftTitle?: string, + amount?: number +): Promise { + // Generate random values if not provided + const randomString = Math.random().toString(36).substring(2, 6); + const finalTitle = giftTitle || `Gift ${randomString}`; + const finalAmount = amount || Math.floor(Math.random() * 99) + 1; + + // Navigate to home page and close onboarding + await page.goto('./'); + await page.getByTestId('closeOnboardingAndFinish').click(); + + // Start gift creation flow + await page.getByRole('button', { name: 'Person' }).click(); + await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click(); + + // Fill gift details + await page.getByPlaceholder('What was given').fill(finalTitle); + await page.getByRole('spinbutton').fill(finalAmount.toString()); + + // Submit gift + await page.getByRole('button', { name: 'Sign & Send' }).click(); + + // Verify success + await expect(page.getByText('That gift was recorded.')).toBeVisible(); + await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert + + // Verify the gift appears in the home view + await page.goto('./'); + await expect(page.locator('ul#listLatestActivity li').filter({ hasText: giftTitle })).toBeVisible(); +}