diff --git a/doc/seed-phrase-reminder-implementation.md b/doc/seed-phrase-reminder-implementation.md new file mode 100644 index 00000000..400535f8 --- /dev/null +++ b/doc/seed-phrase-reminder-implementation.md @@ -0,0 +1,181 @@ +# Seed Phrase Backup Reminder Implementation + +## Overview + +This implementation adds a modal dialog that reminds users to back up their seed phrase if they haven't done so yet. The reminder appears after specific user actions and includes a 24-hour cooldown to avoid being too intrusive. + +## Features + +- **Modal Dialog**: Uses the existing notification group modal system from `App.vue` +- **Smart Timing**: Only shows when `hasBackedUpSeed = false` +- **24-Hour Cooldown**: Uses localStorage to prevent showing more than once per day +- **Action-Based Triggers**: Shows after specific user actions +- **User Choice**: "Backup Identifier Seed" or "Remind me Later" options + +## Implementation Details + +### Core Utility (`src/utils/seedPhraseReminder.ts`) + +The main utility provides: + +- `shouldShowSeedReminder(hasBackedUpSeed)`: Checks if reminder should be shown +- `markSeedReminderShown()`: Updates localStorage timestamp +- `createSeedReminderNotification()`: Creates the modal configuration +- `showSeedPhraseReminder(hasBackedUpSeed, notifyFunction)`: Main function to show reminder + +### Trigger Points + +The reminder is shown after these user actions: + +**Note**: The reminder is triggered by **claim creation** actions, not claim confirmations. This focuses on when users are actively creating new content rather than just confirming existing claims. + +1. **Profile Saving** (`AccountViewView.vue`) + - After clicking "Save Profile" button + - Only when profile save is successful + +2. **Claim Creation** (Multiple views) + - `ClaimAddRawView.vue`: After submitting raw claims + - `GiftedDialog.vue`: After creating gifts/claims + - `GiftedDetailsView.vue`: After recording gifts/claims + - `OfferDialog.vue`: After creating offers + +3. **QR Code Views Exit** + - `ContactQRScanFullView.vue`: When exiting via back button + - `ContactQRScanShowView.vue`: When exiting via back button + +### Modal Configuration + +```typescript +{ + group: "modal", + type: "confirm", + title: "Backup Your Identifier Seed?", + text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.", + yesText: "Backup Identifier Seed", + noText: "Remind me Later", + onYes: () => navigate to /seed-backup, + onNo: () => mark as shown for 24 hours, + onCancel: () => mark as shown for 24 hours +} +``` + +**Important**: The modal is configured with `timeout: -1` to ensure it stays open until the user explicitly interacts with one of the buttons. This prevents the dialog from closing automatically. + +### Cooldown Mechanism + +- **Storage Key**: `seedPhraseReminderLastShown` +- **Cooldown Period**: 24 hours (24 * 60 * 60 * 1000 milliseconds) +- **Implementation**: localStorage with timestamp comparison +- **Fallback**: Shows reminder if timestamp is invalid or missing + +## User Experience + +### When Reminder Appears + +- User has not backed up their seed phrase (`hasBackedUpSeed = false`) +- At least 24 hours have passed since last reminder +- User performs one of the trigger actions +- **1-second delay** after the success message to allow users to see the confirmation + +### User Options + +1. **"Backup Identifier Seed"**: Navigates to `/seed-backup` page +2. **"Remind me Later"**: Dismisses and won't show again for 24 hours +3. **Cancel/Close**: Same behavior as "Remind me Later" + +### Frequency Control + +- **First Time**: Always shows if user hasn't backed up +- **Subsequent**: Only shows after 24-hour cooldown +- **Automatic Reset**: When user completes seed backup (`hasBackedUpSeed = true`) + +## Technical Implementation + +### Error Handling + +- Graceful fallback if localStorage operations fail +- Logging of errors for debugging +- Non-blocking implementation (doesn't affect main functionality) + +### Integration Points + +- **Platform Service**: Uses `$accountSettings()` to check backup status +- **Notification System**: Integrates with existing `$notify` system +- **Router**: Uses `window.location.href` for navigation + +### Performance Considerations + +- Minimal localStorage operations +- No blocking operations +- Efficient timestamp comparisons +- **Timing Behavior**: 1-second delay before showing reminder to improve user experience flow + +## Testing + +### Manual Testing Scenarios + +1. **First Time User** + - Create new account + - Perform trigger action (save profile, create claim, exit QR view) + - Verify reminder appears + +2. **Repeat User (Within 24h)** + - Perform trigger action + - Verify reminder does NOT appear + +3. **Repeat User (After 24h)** + - Wait 24+ hours + - Perform trigger action + - Verify reminder appears again + +4. **User Who Has Backed Up** + - Complete seed backup + - Perform trigger action + - Verify reminder does NOT appear + +5. **QR Code View Exit** + - Navigate to QR code view (full or show) + - Exit via back button + - Verify reminder appears (if conditions are met) + +### Browser Testing + +- Test localStorage functionality +- Verify timestamp handling +- Check navigation to seed backup page + +## Future Enhancements + +### Potential Improvements + +1. **Customizable Cooldown**: Allow users to set reminder frequency +2. **Progressive Urgency**: Increase reminder frequency over time +3. **Analytics**: Track reminder effectiveness and user response +4. **A/B Testing**: Test different reminder messages and timing + +### Configuration Options + +- Reminder frequency settings +- Custom reminder messages +- Different trigger conditions +- Integration with other notification systems + +## Maintenance + +### Monitoring + +- Check localStorage usage in browser dev tools +- Monitor user feedback about reminder frequency +- Track navigation success to seed backup page + +### Updates + +- Modify reminder text in `createSeedReminderNotification()` +- Adjust cooldown period in `REMINDER_COOLDOWN_MS` constant +- Add new trigger points as needed + +## Conclusion + +This implementation provides a non-intrusive way to remind users about seed phrase backup while respecting their preferences and avoiding notification fatigue. The 24-hour cooldown ensures users aren't overwhelmed while maintaining the importance of the security reminder. + +The feature is fully integrated with the existing codebase architecture and follows established patterns for notifications, error handling, and user interaction. diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 61bf4753..17524e79 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -16,6 +16,12 @@ messages * - Conditional UI based on platform capabilities * * @component * :to="{ name: 'seed-backup' }" :class="backupButtonClasses" > + + Backup Identifier Seed @@ -98,6 +104,12 @@ export default class DataExportSection extends Vue { */ isExporting = false; + /** + * Flag indicating if the user has backed up their seed phrase + * Used to control the visibility of the notification dot + */ + hasBackedUpSeed = false; + /** * Notification helper for consistent notification patterns * Created as a getter to ensure $notify is available when called @@ -129,7 +141,7 @@ export default class DataExportSection extends Vue { * CSS classes for the backup button (router link) */ get backupButtonClasses(): string { - return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"; + return "block relative w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"; } /** @@ -218,6 +230,22 @@ export default class DataExportSection extends Vue { created() { this.notify = createNotifyHelpers(this.$notify); + this.loadSeedBackupStatus(); + } + + /** + * Loads the seed backup status from account settings + * Updates the hasBackedUpSeed flag to control notification dot visibility + */ + private async loadSeedBackupStatus(): Promise { + try { + const settings = await this.$accountSettings(); + this.hasBackedUpSeed = !!settings.hasBackedUpSeed; + } catch (err: unknown) { + logger.error("Failed to load seed backup status:", err); + // Default to false (show notification dot) if we can't load the setting + this.hasBackedUpSeed = false; + } } } diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index 9561fbc6..482f685e 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -82,6 +82,7 @@ import GiftDetailsStep from "../components/GiftDetailsStep.vue"; import { PlanData } from "../interfaces/records"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT, NOTIFY_GIFT_ERROR_NO_DESCRIPTION, @@ -420,6 +421,15 @@ export default class GiftedDialog extends Vue { ); } else { this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } + if (this.callbackOnSuccess) { this.callbackOnSuccess(amount); } diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index be486913..943a27fc 100644 --- a/src/components/OfferDialog.vue +++ b/src/components/OfferDialog.vue @@ -64,6 +64,7 @@ import * as libsUtil from "../libs/util"; import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { NOTIFY_OFFER_SETTINGS_ERROR, NOTIFY_OFFER_RECORDING, @@ -303,6 +304,14 @@ export default class OfferDialog extends Vue { ); } else { this.notify.success(NOTIFY_OFFER_SUCCESS.message, TIMEOUTS.VERY_LONG); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index b8f1a113..7dcbc9e2 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -6,6 +6,12 @@ import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { arrayBufferToBase64 } from "@/libs/crypto"; import { logger } from "@/utils/logger"; +// Database result interface for SQLite queries +interface DatabaseResult { + values?: unknown[][]; + [key: string]: unknown; +} + // Generate a random secret for the secret table // It's not really secure to maintain the secret next to the user's data. @@ -185,25 +191,28 @@ export async function runMigrations( try { // Check if we have accounts but no active selection const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts"); - const accountsCount = accountsResult - ? (accountsResult.values?.[0]?.[0] as number) - : 0; + const accountsCount = + accountsResult && (accountsResult as DatabaseResult).values + ? ((accountsResult as DatabaseResult).values?.[0]?.[0] as number) + : 0; const activeResult = await sqlQuery( "SELECT activeDid FROM active_identity WHERE id = 1", ); - const activeDid = activeResult - ? (activeResult.values?.[0]?.[0] as string) - : null; + const activeDid = + activeResult && (activeResult as DatabaseResult).values + ? ((activeResult as DatabaseResult).values?.[0]?.[0] as string) + : null; if (accountsCount > 0 && (!activeDid || activeDid === "")) { logger.info("[Migration] Auto-selecting first account as active"); const firstAccountResult = await sqlQuery( "SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1", ); - const firstAccountDid = firstAccountResult - ? (firstAccountResult.values?.[0]?.[0] as string) - : null; + const firstAccountDid = + firstAccountResult && (firstAccountResult as DatabaseResult).values + ? ((firstAccountResult as DatabaseResult).values?.[0]?.[0] as string) + : null; if (firstAccountDid) { await sqlExec( diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 0b86e355..ff43e0f8 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -29,6 +29,7 @@ export type Settings = { finishedOnboarding?: boolean; // the user has completed the onboarding process firstName?: string; // user's full name, may be null if unwanted for a particular account + hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase hideRegisterPromptOnNewContact?: boolean; isRegistered?: boolean; // imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable 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 51cf021c..8a30b6e3 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -160,6 +160,49 @@ export const isGiveAction = ( return isGiveClaimType(veriClaim.claimType); }; +export interface OfferFulfillment { + offerHandleId: string; + offerType: string; +} + +interface FulfillmentItem { + "@type": string; + identifier?: string; + [key: string]: unknown; +} + +/** + * Extract offer fulfillment information from the fulfills field + * Handles both array and single object cases + */ +export const extractOfferFulfillment = ( + fulfills: FulfillmentItem | FulfillmentItem[] | null | undefined, +): 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/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 77b5a98a..6c1b1f2a 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -52,7 +52,11 @@ import { logger } from "@/utils/logger"; import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts"; import { Account } from "@/db/tables/accounts"; import { Temp } from "@/db/tables/temp"; -import { QueryExecResult, DatabaseExecResult } from "@/interfaces/database"; +import { + QueryExecResult, + DatabaseExecResult, + SqlValue, +} from "@/interfaces/database"; import { generateInsertStatement, generateUpdateStatement, @@ -285,7 +289,7 @@ export const PlatformServiceMixin = { return []; } - return result.values.map((row: unknown[]) => row[0] as string); + return result.values.map((row: SqlValue[]) => row[0] as string); } catch (error) { logger.error( "[PlatformServiceMixin] Error getting available account DIDs:", @@ -498,7 +502,10 @@ export const PlatformServiceMixin = { /** * Enhanced database single row query method with error handling */ - async $dbGetOneRow(sql: string, params?: unknown[]) { + async $dbGetOneRow( + sql: string, + params?: unknown[], + ): Promise { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any return await (this as any).platformService.dbGetOneRow(sql, params); @@ -699,7 +706,7 @@ export const PlatformServiceMixin = { if (availableAccounts?.values?.length) { const accountDids = availableAccounts.values.map( - (row: unknown[]) => row[0] as string, + (row: SqlValue[]) => row[0] as string, ); logger.debug( "[PlatformServiceMixin] Available accounts for user selection:", @@ -845,7 +852,7 @@ export const PlatformServiceMixin = { async $one( sql: string, params: unknown[] = [], - ): Promise { + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any return await (this as any).platformService.dbGetOneRow(sql, params); }, @@ -1491,24 +1498,6 @@ export const PlatformServiceMixin = { } }, - /** - * Get all account DIDs - $getAllAccountDids() - * Retrieves all account DIDs from the accounts table - * @returns Promise Array of account DIDs - */ - async $getAllAccountDids(): Promise { - try { - const accounts = await this.$query("SELECT did FROM accounts"); - return accounts.map((account) => account.did); - } catch (error) { - logger.error( - "[PlatformServiceMixin] Error getting all account DIDs:", - error, - ); - return []; - } - }, - // ================================================= // TEMP TABLE METHODS (for temporary storage) // ================================================= @@ -1907,7 +1896,10 @@ export interface IPlatformServiceMixin { params?: unknown[], ): Promise; $dbExec(sql: string, params?: unknown[]): Promise; - $dbGetOneRow(sql: string, params?: unknown[]): Promise; + $dbGetOneRow( + sql: string, + params?: unknown[], + ): Promise; $getMasterSettings(fallback?: Settings | null): Promise; $getMergedSettings( defaultKey: string, @@ -2011,7 +2003,7 @@ declare module "@vue/runtime-core" { // Ultra-concise database methods (shortest possible names) $db(sql: string, params?: unknown[]): Promise; $exec(sql: string, params?: unknown[]): Promise; - $one(sql: string, params?: unknown[]): Promise; + $one(sql: string, params?: unknown[]): Promise; // Query + mapping combo methods $query>( diff --git a/src/utils/seedPhraseReminder.ts b/src/utils/seedPhraseReminder.ts new file mode 100644 index 00000000..9c0348fc --- /dev/null +++ b/src/utils/seedPhraseReminder.ts @@ -0,0 +1,90 @@ +import { NotificationIface } from "@/constants/app"; + +const SEED_REMINDER_KEY = "seedPhraseReminderLastShown"; +const REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + +/** + * Checks if the seed phrase backup reminder should be shown + * @param hasBackedUpSeed - Whether the user has backed up their seed phrase + * @returns true if the reminder should be shown, false otherwise + */ +export function shouldShowSeedReminder(hasBackedUpSeed: boolean): boolean { + // Don't show if user has already backed up + if (hasBackedUpSeed) { + return false; + } + + // Check localStorage for last shown time + const lastShown = localStorage.getItem(SEED_REMINDER_KEY); + if (!lastShown) { + return true; // First time, show the reminder + } + + try { + const lastShownTime = parseInt(lastShown, 10); + const now = Date.now(); + const timeSinceLastShown = now - lastShownTime; + + // Show if more than 24 hours have passed + return timeSinceLastShown >= REMINDER_COOLDOWN_MS; + } catch (error) { + // If there's an error parsing the timestamp, show the reminder + return true; + } +} + +/** + * Marks the seed phrase reminder as shown by updating localStorage + */ +export function markSeedReminderShown(): void { + localStorage.setItem(SEED_REMINDER_KEY, Date.now().toString()); +} + +/** + * Creates the seed phrase backup reminder notification + * @returns NotificationIface configuration for the reminder modal + */ +export function createSeedReminderNotification(): NotificationIface { + return { + group: "modal", + type: "confirm", + title: "Backup Your Identifier Seed?", + text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.", + yesText: "Backup Identifier Seed", + noText: "Remind me Later", + onYes: async () => { + // Navigate to seed backup page + window.location.href = "/seed-backup"; + }, + onNo: async () => { + // Mark as shown so it won't appear again for 24 hours + markSeedReminderShown(); + }, + onCancel: async () => { + // Mark as shown so it won't appear again for 24 hours + markSeedReminderShown(); + }, + }; +} + +/** + * Shows the seed phrase backup reminder if conditions are met + * @param hasBackedUpSeed - Whether the user has backed up their seed phrase + * @param notifyFunction - Function to show notifications + * @returns true if the reminder was shown, false otherwise + */ +export function showSeedPhraseReminder( + hasBackedUpSeed: boolean, + notifyFunction: (notification: NotificationIface, timeout?: number) => void, +): boolean { + if (shouldShowSeedReminder(hasBackedUpSeed)) { + const notification = createSeedReminderNotification(); + // Add 1-second delay before showing the modal to allow success message to be visible + setTimeout(() => { + // Pass -1 as timeout to ensure modal stays open until user interaction + notifyFunction(notification, -1); + }, 1000); + return true; + } + return false; +} diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 42fd7c5a..ae2931c9 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -811,6 +811,7 @@ import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { AccountSettings, isApiError, @@ -1699,6 +1700,14 @@ export default class AccountViewView extends Vue { ); if (success) { this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } } else { this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR); } diff --git a/src/views/ClaimAddRawView.vue b/src/views/ClaimAddRawView.vue index a9d2988f..2b4410f3 100644 --- a/src/views/ClaimAddRawView.vue +++ b/src/views/ClaimAddRawView.vue @@ -41,6 +41,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router"; import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; // Type guard for API responses function isApiResponse(response: unknown): response is AxiosResponse { @@ -228,6 +229,14 @@ export default class ClaimAddRawView extends Vue { ); if (result.success) { this.notify.success("Claim submitted.", TIMEOUTS.LONG); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } } else { logger.error("Got error submitting the claim:", result); this.notify.error( diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index c3ca1ffe..ecace60c 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 // ================================================= @@ -762,13 +799,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 + @@ -825,6 +855,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 188fce37..b5cae5e3 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; @@ -653,6 +658,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); } @@ -712,6 +719,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 */ @@ -854,27 +870,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 @@ -899,11 +894,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/src/views/ContactQRScanFullView.vue b/src/views/ContactQRScanFullView.vue index 32814499..ddddc174 100644 --- a/src/views/ContactQRScanFullView.vue +++ b/src/views/ContactQRScanFullView.vue @@ -144,6 +144,7 @@ import { QR_TIMEOUT_LONG, } from "@/constants/notifications"; import { createNotifyHelpers, NotifyFunction } from "../utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; interface QRScanResult { rawValue?: string; @@ -627,6 +628,15 @@ export default class ContactQRScanFull extends Vue { */ async handleBack() { await this.cleanupScanner(); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } + this.$router.back(); } diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index bbfe1af4..d62dd966 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -163,6 +163,7 @@ import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory"; import { CameraState } from "@/services/QRScanner/types"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers } from "@/utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { NOTIFY_QR_INITIALIZATION_ERROR, NOTIFY_QR_CAMERA_IN_USE, @@ -324,6 +325,15 @@ export default class ContactQRScanShow extends Vue { async handleBack(): Promise { await this.cleanupScanner(); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } + this.$router.back(); } @@ -743,24 +753,17 @@ export default class ContactQRScanShow extends Vue { !contact.registered ) { setTimeout(() => { - this.notify.confirm( - "Do you want to register them?", + this.$notify( { + group: "modal", + type: "confirm", + title: "Register", + text: "Do you want to register them?", onCancel: async (stopAsking?: boolean) => { - if (stopAsking) { - await this.$updateSettings({ - hideRegisterPromptOnNewContact: stopAsking, - }); - this.hideRegisterPromptOnNewContact = stopAsking; - } + await this.handleRegistrationPromptResponse(stopAsking); }, onNo: async (stopAsking?: boolean) => { - if (stopAsking) { - await this.$updateSettings({ - hideRegisterPromptOnNewContact: stopAsking, - }); - this.hideRegisterPromptOnNewContact = stopAsking; - } + await this.handleRegistrationPromptResponse(stopAsking); }, onYes: async () => { await this.register(contact); @@ -890,6 +893,17 @@ export default class ContactQRScanShow extends Vue { videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none"; } } + + private async handleRegistrationPromptResponse( + stopAsking?: boolean, + ): Promise { + if (stopAsking) { + await this.$saveSettings({ + hideRegisterPromptOnNewContact: stopAsking, + }); + this.hideRegisterPromptOnNewContact = stopAsking; + } + } } diff --git a/src/views/GiftedDetailsView.vue b/src/views/GiftedDetailsView.vue index e7a2c711..990a8b20 100644 --- a/src/views/GiftedDetailsView.vue +++ b/src/views/GiftedDetailsView.vue @@ -280,6 +280,7 @@ import { logger } from "../utils/logger"; import { Contact } from "@/db/tables/contacts"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR, NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM, @@ -774,6 +775,15 @@ export default class GiftedDetails extends Vue { NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message, TIMEOUTS.SHORT, ); + + // Show seed phrase backup reminder if needed + try { + const settings = await this.$accountSettings(); + showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify); + } catch (error) { + logger.error("Error checking seed backup status:", error); + } + localStorage.removeItem("imageUrl"); if (this.destinationPathAfter) { (this.$router as Router).push({ path: this.destinationPathAfter }); diff --git a/src/views/SeedBackupView.vue b/src/views/SeedBackupView.vue index c19caf13..fb9b7605 100644 --- a/src/views/SeedBackupView.vue +++ b/src/views/SeedBackupView.vue @@ -231,9 +231,24 @@ export default class SeedBackupView extends Vue { /** * Reveals the seed phrase to the user * Sets showSeed to true to display the sensitive seed phrase data + * Updates the hasBackedUpSeed setting to true to track that user has backed up */ - revealSeed(): void { + async revealSeed(): Promise { this.showSeed = true; + + // Update the account setting to track that user has backed up their seed + try { + const settings = await this.$accountSettings(); + if (settings.activeDid) { + await this.$saveUserSettings(settings.activeDid, { + hasBackedUpSeed: true, + }); + } + } catch (err: unknown) { + logger.error("Failed to update hasBackedUpSeed setting:", err); + // Don't show error to user as this is not critical to the main functionality + // The seed phrase is still revealed, just the tracking won't work + } } /** diff --git a/test-playwright/60-new-activity.spec.ts b/test-playwright/60-new-activity.spec.ts index 743c768e..bb13e79a 100644 --- a/test-playwright/60-new-activity.spec.ts +++ b/test-playwright/60-new-activity.spec.ts @@ -71,6 +71,22 @@ test('New offers for another user', async ({ page }) => { await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone + // Handle backup seed modal if it appears (following 00-noid-tests.spec.ts pattern) + try { + // Wait for backup seed modal to appear + await page.waitForFunction(() => { + const alert = document.querySelector('div[role="alert"]'); + return alert && alert.textContent?.includes('Backup Your Identifier Seed'); + }, { timeout: 3000 }); + + // Dismiss backup seed modal + await page.getByRole('button', { name: 'No, Remind me Later' }).click(); + await expect(page.locator('div[role="alert"]').filter({ hasText: 'Backup Your Identifier Seed' })).toBeHidden(); + } catch (error) { + // Backup modal might not appear, that's okay + console.log('Backup seed modal did not appear, continuing...'); + } + // make another offer to user 1 const randomString2 = Math.random().toString(36).substring(2, 5); await page.getByTestId('offerButton').click();