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

Open
jose wants to merge 4 commits from onboard-alert-component into master
  1. 157
      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

157
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>
<div
v-if="!isRegistered && show"
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 mt-4"
role="alert"
aria-live="polite"
id="noticeSomeoneMustRegisterYou"
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"
>
<p class="mb-4">
Before you can publicly announce a new project or time commitment, a
friend needs to register you.
</p>
<p class="mb-4">{{ message }}</p>
<div class="grid grid-cols-1 gap-2 sm:flex sm:justify-center">
<button
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"
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"
@click="showNameThenIdDialog"
>
Share Your Info
Show them {{ passkeysEnabled ? "default" : "your" }} identifier 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>
<UserNameDialog ref="userNameDialog" />
<ChoiceButtonDialog ref="choiceButtonDialog" />
</template>
<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 {
@Prop({ required: true }) isRegistered!: boolean;
@Prop({ required: true }) show!: boolean;
$router!: Router;
/**
* 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();
});
}
@Emit("share-info")
shareInfo() {}
/**
* 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" });
}
}
/**
* 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>

23
src/views/AccountViewView.vue

@ -55,9 +55,10 @@
<!-- Registration notice -->
<RegistrationNotice
:is-registered="isRegistered"
:show="showRegistrationNotice"
@share-info="onShareInfo"
v-if="!isRegistered"
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="Before you can publicly announce a new project or time commitment, a friend needs to register you."
/>
<!-- Notifications -->
@ -781,6 +782,7 @@ import {
DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
@ -851,6 +853,7 @@ export default class AccountViewView extends Vue {
readonly DEFAULT_PUSH_SERVER: string = DEFAULT_PUSH_SERVER;
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Identity and settings properties
activeDid: string = "";
@ -1789,20 +1792,6 @@ export default class AccountViewView extends Vue {
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() {
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.
-->
<div class="mb-4">
<div
<RegistrationNotice
v-if="!isUserRegistered"
id="noticeSomeoneMustRegisterYou"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
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>
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="To share, someone must register you."
/>
<div v-else id="sectionRecordSomethingGiven">
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
@ -252,8 +233,6 @@ Raymer * @version 1.0.0 */
</div>
</section>
<ChoiceButtonDialog ref="choiceButtonDialog" />
<ImageViewer v-model:is-open="isImageViewerOpen" :image-url="selectedImage" />
</template>
@ -261,7 +240,6 @@ Raymer * @version 1.0.0 */
import { UAParser } from "ua-parser-js";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { Capacitor } from "@capacitor/core";
//import App from "../App.vue";
import EntityIcon from "../components/EntityIcon.vue";
@ -272,10 +250,9 @@ import InfiniteScroll from "../components/InfiniteScroll.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import QuickNav from "../components/QuickNav.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 ActivityListItem from "../components/ActivityListItem.vue";
import RegistrationNotice from "../components/RegistrationNotice.vue";
import {
AppString,
NotificationIface,
@ -383,12 +360,11 @@ interface FeedError {
GiftedPrompts,
InfiniteScroll,
OnboardingDialog,
ChoiceButtonDialog,
QuickNav,
TopMessage,
UserNameDialog,
ImageViewer,
ActivityListItem,
RegistrationNotice,
},
mixins: [PlatformServiceMixin],
})
@ -1644,67 +1620,6 @@ export default class HomeView extends Vue {
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
*
@ -1717,14 +1632,6 @@ export default class HomeView extends Vue {
this.isImageViewerOpen = true;
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
openPersonDialog(
giver?: GiverReceiverInputInfo | "Unnamed",
prompt?: string,

80
src/views/ProjectsView.vue

@ -216,15 +216,12 @@
<font-awesome icon="plus" :class="plusIconClasses" />
button. You'll never know until you try.
</div>
<div v-else>
<button
:class="onboardingButtonClasses"
@click="showNameThenIdDialog()"
>
Get someone to onboard you.
</button>
<UserNameDialog ref="userNameDialog" />
</div>
<RegistrationNotice
v-else
:passkeys-enabled="PASSKEYS_ENABLED"
:given-name="givenName"
message="To announce a project, get someone to onboard you."
/>
</div>
<ul id="listProjects" class="border-t border-slate-300">
<li
@ -266,14 +263,14 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
// 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 InfiniteScroll from "../components/InfiniteScroll.vue";
import QuickNav from "../components/QuickNav.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import ProjectIcon from "../components/ProjectIcon.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 { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
@ -281,14 +278,13 @@ import { OnboardPage, iconForUnitCode } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { QRNavigationService } from "@/services/QRNavigationService";
import {
NOTIFY_NO_ACCOUNT_ERROR,
NOTIFY_PROJECT_LOAD_ERROR,
NOTIFY_PROJECT_INIT_ERROR,
NOTIFY_OFFERS_LOAD_ERROR,
NOTIFY_OFFERS_FETCH_ERROR,
NOTIFY_CAMERA_SHARE_METHOD,
} from "@/constants/notifications";
/**
@ -318,7 +314,7 @@ import {
OnboardingDialog,
ProjectIcon,
TopMessage,
UserNameDialog,
RegistrationNotice,
},
mixins: [PlatformServiceMixin],
})
@ -336,6 +332,7 @@ export default class ProjectsView extends Vue {
givenName = "";
isLoading = false;
isRegistered = false;
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
// Data collections
offers: OfferSummaryRecord[] = [];
@ -624,39 +621,6 @@ export default class ProjectsView extends Vue {
* Ensures user has provided their name before proceeding with contact sharing.
* 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
@ -722,14 +686,6 @@ export default class ProjectsView extends Vue {
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
* @returns Object with CSS classes based on current tab selection
@ -754,20 +710,6 @@ export default class ProjectsView extends Vue {
* 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
* @deprecated Use computedOfferTabClassNames for backward compatibility

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

@ -37,7 +37,7 @@
* Key Selectors:
* - Activity list: 'ul#listLatestActivity li'
* - Discover list: 'ul#listDiscoverResults li'
* - Account notices: '#noticeBeforeShare', '#noticeBeforeAnnounce'
* - Account notices: '#noticeBeforeShare', '#noticeSomeoneMustRegisterYou'
* - Identity details: '#sectionIdentityDetails code.truncate'
*
* State Verification:
@ -99,7 +99,7 @@ test('Check no-ID messaging in account', async ({ page }) => {
await page.goto('./account');
// 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 }) => {

Loading…
Cancel
Save