Refactor: create reusable component version of registration/onboarding notice #179

Open
jose wants to merge 4 commits from onboard-alert-component into master
  1. 163
      src/components/RegistrationNotice.vue
  2. 23
      src/views/AccountViewView.vue
  3. 109
      src/views/HomeView.vue
  4. 80
      src/views/ProjectsView.vue
  5. 4
      test-playwright/00-noid-tests.spec.ts

163
src/components/RegistrationNotice.vue

@ -1,33 +1,154 @@
/** * @file RegistrationNotice.vue * @description Reusable component for
displaying user registration status and related actions. * Shows registration
notice when user is not registered, with options to show identifier info * or
access advanced options. * * @author Matthew Raymer * @version 1.0.0 * @created
2025-08-21T17:25:28-08:00 */
<template> <template>
<div <div
v-if="!isRegistered && show" id="noticeSomeoneMustRegisterYou"
id="noticeBeforeAnnounce" class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 my-4"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
role="alert"
aria-live="polite"
> >
<p class="mb-4"> <p class="mb-4">{{ message }}</p>
Before you can publicly announce a new project or time commitment, a <div class="grid grid-cols-1 gap-2 sm:flex sm:justify-center">
friend needs to register you. <button
</p> class="inline-block text-md font-bold 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-4 py-2 rounded-md"
<button @click="showNameThenIdDialog"
class="inline-block 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-4 py-2 rounded-md" >
@click="shareInfo" Show them {{ passkeysEnabled ? "default" : "your" }} identifier info
> </button>
Share Your Info <button
</button> class="inline-block text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="openAdvancedOptions"
>
See advanced options
</button>
</div>
</div> </div>
<UserNameDialog ref="userNameDialog" />
<ChoiceButtonDialog ref="choiceButtonDialog" />
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator"; import { Component, Vue, Prop } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Capacitor } from "@capacitor/core";
import UserNameDialog from "./UserNameDialog.vue";
import ChoiceButtonDialog from "./ChoiceButtonDialog.vue";
@Component({ name: "RegistrationNotice" }) /**
* RegistrationNotice Component
*
* Displays registration status notice and provides actions for unregistered users.
* Handles all registration-related flows internally without requiring parent component intervention.
*
* Template Usage:
* ```vue
* <RegistrationNotice
* v-if="!isUserRegistered"
* :passkeys-enabled="PASSKEYS_ENABLED"
* :given-name="givenName"
* message="Custom registration message here"
* />
* ```
*
* Component Dependencies:
* - UserNameDialog: Dialog for entering user name
* - ChoiceButtonDialog: Dialog for sharing method selection
*/
@Component({
name: "RegistrationNotice",
components: {
UserNameDialog,
ChoiceButtonDialog,
},
})
export default class RegistrationNotice extends Vue { export default class RegistrationNotice extends Vue {
@Prop({ required: true }) isRegistered!: boolean; $router!: Router;
@Prop({ required: true }) show!: boolean;
/**
* Whether passkeys are enabled in the application
*/
@Prop({ required: true })
passkeysEnabled!: boolean;
/**
* User's given name for dialog pre-population
*/
@Prop({ required: true })
givenName!: string;
/**
* Custom message to display in the registration notice
* Defaults to "To share, someone must register you."
*/
@Prop({ default: "To share, someone must register you." })
message!: string;
/**
* Shows name input dialog if needed
* Handles the full flow internally without requiring parent component intervention
*/
showNameThenIdDialog() {
this.openUserNameDialog(() => {
this.promptForShareMethod();
});
}
/**
* Opens advanced options page
* Navigates directly to the start page
*/
openAdvancedOptions() {
this.$router.push({ name: "start" });
}
/**
* Shows dialog for sharing method selection
* Provides options for different sharing scenarios
*/
promptForShareMethod() {
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are in a meeting together",
option2Text: "We are nearby with cameras",
option3Text: "We will share some other way",
onOption1: () => {
this.$router.push({ name: "onboard-meeting-list" });
},
onOption2: () => {
this.handleQRCodeClick();
},
onOption3: () => {
this.$router.push({ name: "share-my-contact-info" });
},
});
}
/**
* Handles QR code sharing based on platform
* Navigates to appropriate QR code page
*/
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
@Emit("share-info") /**
shareInfo() {} * Opens the user name dialog if needed
*
* @param callback Function to call after name is entered
*/
openUserNameDialog(callback: () => void) {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(callback);
} else {
callback();
}
}
} }
</script> </script>

23
src/views/AccountViewView.vue

@ -55,9 +55,10 @@
<!-- Registration notice --> <!-- Registration notice -->
<RegistrationNotice <RegistrationNotice
:is-registered="isRegistered" v-if="!isRegistered"
:show="showRegistrationNotice" :passkeys-enabled="PASSKEYS_ENABLED"
@share-info="onShareInfo" :given-name="givenName"
message="Before you can publicly announce a new project or time commitment, a friend needs to register you."
/> />
<!-- Notifications --> <!-- Notifications -->
@ -781,6 +782,7 @@ import {
DEFAULT_PUSH_SERVER, DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE, IMAGE_TYPE_PROFILE,
NotificationIface, NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app"; } from "../constants/app";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { import {
@ -851,6 +853,7 @@ export default class AccountViewView extends Vue {
readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER; readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER;
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER; readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER; readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Identity and settings properties // Identity and settings properties
activeDid: string = ""; activeDid: string = "";
@ -1789,20 +1792,6 @@ export default class AccountViewView extends Vue {
this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy)); this.doCopyTwoSecRedo(did, () => (this.showDidCopy = !this.showDidCopy));
} }
get showRegistrationNotice(): boolean {
// Show the notice if not registered and any other conditions you want
return !this.isRegistered;
}
onShareInfo() {
// Navigate to QR code sharing page - mobile uses full scan, web uses basic
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
onRecheckLimits() { onRecheckLimits() {
this.checkLimits(); this.checkLimits();
} }

109
src/views/HomeView.vue

@ -86,33 +86,14 @@ Raymer * @version 1.0.0 */
Identity creation is now handled by router navigation guard. Identity creation is now handled by router navigation guard.
--> -->
<div class="mb-4"> <div class="mb-4">
<div <RegistrationNotice
v-if="!isUserRegistered" v-if="!isUserRegistered"
id="noticeSomeoneMustRegisterYou" :passkeys-enabled="PASSKEYS_ENABLED"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4" :given-name="givenName"
> message="To share, someone must register you."
To share, someone must register you. />
<div class="block text-center">
<button
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="showNameThenIdDialog()"
>
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
info
</button>
</div>
<UserNameDialog ref="userNameDialog" />
<div class="flex justify-end w-full">
<router-link
:to="{ name: 'start' }"
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
See advanced options
</router-link>
</div>
</div>
<div v-else id="sectionRecordSomethingGiven"> <div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action --> <!-- Record Quick-Action -->
<div class="mb-6"> <div class="mb-6">
<div class="flex gap-2 items-center mb-2"> <div class="flex gap-2 items-center mb-2">
@ -252,8 +233,6 @@ Raymer * @version 1.0.0 */
</div> </div>
</section> </section>
<ChoiceButtonDialog ref="choiceButtonDialog" />
<ImageViewer v-model:is-open="isImageViewerOpen" :image-url="selectedImage" /> <ImageViewer v-model:is-open="isImageViewerOpen" :image-url="selectedImage" />
</template> </template>
@ -261,7 +240,6 @@ Raymer * @version 1.0.0 */
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { Capacitor } from "@capacitor/core";
//import App from "../App.vue"; //import App from "../App.vue";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
@ -272,10 +250,9 @@ import InfiniteScroll from "../components/InfiniteScroll.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue"; import OnboardingDialog from "../components/OnboardingDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue"; import ImageViewer from "../components/ImageViewer.vue";
import ActivityListItem from "../components/ActivityListItem.vue"; import ActivityListItem from "../components/ActivityListItem.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import { import {
AppString, AppString,
NotificationIface, NotificationIface,
@ -383,12 +360,11 @@ interface FeedError {
GiftedPrompts, GiftedPrompts,
InfiniteScroll, InfiniteScroll,
OnboardingDialog, OnboardingDialog,
ChoiceButtonDialog,
QuickNav, QuickNav,
TopMessage, TopMessage,
UserNameDialog,
ImageViewer, ImageViewer,
ActivityListItem, ActivityListItem,
RegistrationNotice,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -1644,67 +1620,6 @@ export default class HomeView extends Vue {
return known ? "text-slate-500" : "text-slate-100"; return known ? "text-slate-500" : "text-slate-100";
} }
/**
* Shows name input dialog if needed
*
* @public
* @callGraph
* Called by: Template
* Calls:
* - UserNameDialog.open()
* - promptForShareMethod()
*
* @chain
* Template -> showNameThenIdDialog() -> promptForShareMethod()
*
* @requires
* - this.$refs.userNameDialog
* - this.givenName
*/
showNameThenIdDialog() {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(() => {
this.promptForShareMethod();
});
} else {
this.promptForShareMethod();
}
}
/**
* Shows dialog for sharing method selection
*
* @internal
* @callGraph
* Called by: showNameThenIdDialog()
* Calls: ChoiceButtonDialog.open()
*
* @chain
* Template -> showNameThenIdDialog() -> promptForShareMethod()
*
* @requires
* - this.$refs.choiceButtonDialog
* - this.$router
*/
promptForShareMethod() {
(this.$refs.choiceButtonDialog as ChoiceButtonDialog).open({
title: "How can you share your info?",
text: "",
option1Text: "We are in a meeting together",
option2Text: "We are nearby with cameras",
option3Text: "We will share some other way",
onOption1: () => {
this.$router.push({ name: "onboard-meeting-list" });
},
onOption2: () => {
this.handleQRCodeClick();
},
onOption3: () => {
this.$router.push({ name: "share-my-contact-info" });
},
});
}
/** /**
* Opens image viewer dialog * Opens image viewer dialog
* *
@ -1717,14 +1632,6 @@ export default class HomeView extends Vue {
this.isImageViewerOpen = true; this.isImageViewerOpen = true;
} }
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
openPersonDialog( openPersonDialog(
giver?: GiverReceiverInputInfo | "Unnamed", giver?: GiverReceiverInputInfo | "Unnamed",
prompt?: string, prompt?: string,

80
src/views/ProjectsView.vue

@ -216,15 +216,12 @@
<font-awesome icon="plus" :class="plusIconClasses" /> <font-awesome icon="plus" :class="plusIconClasses" />
button. You'll never know until you try. button. You'll never know until you try.
</div> </div>
<div v-else> <RegistrationNotice
<button v-else
:class="onboardingButtonClasses" :passkeys-enabled="PASSKEYS_ENABLED"
@click="showNameThenIdDialog()" :given-name="givenName"
> message="To announce a project, get someone to onboard you."
Get someone to onboard you. />
</button>
<UserNameDialog ref="userNameDialog" />
</div>
</div> </div>
<ul id="listProjects" class="border-t border-slate-300"> <ul id="listProjects" class="border-t border-slate-300">
<li <li
@ -266,14 +263,14 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
// Capacitor import removed - using QRNavigationService instead // Capacitor import removed - using QRNavigationService instead
import { NotificationIface } from "../constants/app"; import { NotificationIface, PASSKEYS_ENABLED } from "../constants/app";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue"; import InfiniteScroll from "../components/InfiniteScroll.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue"; import OnboardingDialog from "../components/OnboardingDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue"; import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import RegistrationNotice from "../components/RegistrationNotice.vue";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer"; import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records"; import { OfferSummaryRecord, PlanData } from "../interfaces/records";
@ -281,14 +278,13 @@ import { OnboardPage, iconForUnitCode } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { QRNavigationService } from "@/services/QRNavigationService";
import { import {
NOTIFY_NO_ACCOUNT_ERROR, NOTIFY_NO_ACCOUNT_ERROR,
NOTIFY_PROJECT_LOAD_ERROR, NOTIFY_PROJECT_LOAD_ERROR,
NOTIFY_PROJECT_INIT_ERROR, NOTIFY_PROJECT_INIT_ERROR,
NOTIFY_OFFERS_LOAD_ERROR, NOTIFY_OFFERS_LOAD_ERROR,
NOTIFY_OFFERS_FETCH_ERROR, NOTIFY_OFFERS_FETCH_ERROR,
NOTIFY_CAMERA_SHARE_METHOD,
} from "@/constants/notifications"; } from "@/constants/notifications";
/** /**
@ -318,7 +314,7 @@ import {
OnboardingDialog, OnboardingDialog,
ProjectIcon, ProjectIcon,
TopMessage, TopMessage,
UserNameDialog, RegistrationNotice,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@ -336,6 +332,7 @@ export default class ProjectsView extends Vue {
givenName = ""; givenName = "";
isLoading = false; isLoading = false;
isRegistered = false; isRegistered = false;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Data collections // Data collections
offers: OfferSummaryRecord[] = []; offers: OfferSummaryRecord[] = [];
@ -624,39 +621,6 @@ export default class ProjectsView extends Vue {
* Ensures user has provided their name before proceeding with contact sharing. * Ensures user has provided their name before proceeding with contact sharing.
* Uses UserNameDialog component if name is not set. * Uses UserNameDialog component if name is not set.
*/ */
showNameThenIdDialog() {
if (!this.givenName) {
(this.$refs.userNameDialog as UserNameDialog).open(() => {
this.promptForShareMethod();
});
} else {
this.promptForShareMethod();
}
}
/**
* Prompts user to choose contact sharing method
*
* Presents modal dialog asking if users are nearby with cameras.
* Routes to appropriate sharing method based on user's choice:
* - QR code sharing for nearby users with cameras
* - Alternative sharing methods for remote users
*/
promptForShareMethod() {
this.$notify(
{
group: "modal",
type: "confirm",
title: NOTIFY_CAMERA_SHARE_METHOD.title,
text: NOTIFY_CAMERA_SHARE_METHOD.text,
onYes: () => this.handleQRCodeClick(),
onNo: () => this.$router.push({ name: "share-my-contact-info" }),
yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText,
noText: NOTIFY_CAMERA_SHARE_METHOD.noText,
},
TIMEOUTS.MODAL,
);
}
/** /**
* Computed properties for template logic streamlining * Computed properties for template logic streamlining
@ -722,14 +686,6 @@ export default class ProjectsView extends Vue {
return "bg-green-600 text-white px-1.5 py-1 rounded-full"; return "bg-green-600 text-white px-1.5 py-1 rounded-full";
} }
/**
* CSS class names for onboarding button
* @returns String with CSS classes for the onboarding button
*/
get onboardingButtonClasses() {
return "text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md";
}
/** /**
* CSS class names for project tab styling * CSS class names for project tab styling
* @returns Object with CSS classes based on current tab selection * @returns Object with CSS classes based on current tab selection
@ -754,20 +710,6 @@ export default class ProjectsView extends Vue {
* Utility methods * Utility methods
*/ */
/**
* Handles QR code sharing functionality with platform detection
*
* Routes to appropriate QR code interface based on current platform:
* - Full QR scanner for native mobile platforms
* - Web-based QR interface for browser environments
*/
private handleQRCodeClick() {
const qrNavigationService = QRNavigationService.getInstance();
const route = qrNavigationService.getQRScannerRoute();
this.$router.push(route);
}
/** /**
* Legacy method compatibility * Legacy method compatibility
* @deprecated Use computedOfferTabClassNames for backward compatibility * @deprecated Use computedOfferTabClassNames for backward compatibility

4
test-playwright/00-noid-tests.spec.ts

@ -37,7 +37,7 @@
* Key Selectors: * Key Selectors:
* - Activity list: 'ul#listLatestActivity li' * - Activity list: 'ul#listLatestActivity li'
* - Discover list: 'ul#listDiscoverResults li' * - Discover list: 'ul#listDiscoverResults li'
* - Account notices: '#noticeBeforeShare', '#noticeBeforeAnnounce' * - Account notices: '#noticeBeforeShare', '#noticeSomeoneMustRegisterYou'
* - Identity details: '#sectionIdentityDetails code.truncate' * - Identity details: '#sectionIdentityDetails code.truncate'
* *
* State Verification: * State Verification:
@ -99,7 +99,7 @@ test('Check no-ID messaging in account', async ({ page }) => {
await page.goto('./account'); await page.goto('./account');
// Check 'a friend needs to register you' notice // Check 'a friend needs to register you' notice
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible(); await expect(page.locator('#noticeSomeoneMustRegisterYou')).toBeVisible();
}); });
test('Check ability to share contact', async ({ page }) => { test('Check ability to share contact', async ({ page }) => {

Loading…
Cancel
Save