add a more comprehensive demo UI workflow for SMS signup

This commit is contained in:
2026-08-13 09:45:16 -06:00
parent ee8fb80cfe
commit 3f291b0d63
5 changed files with 502 additions and 57 deletions

View File

@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [?] - 2026
### Added
- Full flow for setting up SMS notifications
## [1.3.8] - 2026
### Added
- Device wake-up for notifications

View File

@@ -0,0 +1,284 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Step 1: enter phone number -->
<div v-if="step === 'phone'">
<h1 class="text-xl font-bold text-center mb-2">Verify Your Phone</h1>
<p class="text-sm text-slate-500 mb-4">
Enter the mobile number where you want to receive New Activity text
messages. We'll send a one-time code to confirm it's yours. Standard
message and data rates may apply.
</p>
<label for="sms-phone" class="text-slate-500 text-sm font-bold">
Mobile number
</label>
<input
id="sms-phone"
v-model="phoneNumber"
type="tel"
inputmode="tel"
autocomplete="tel"
placeholder="+1 555 123 4567"
class="block w-full rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
:disabled="sending"
@keyup.enter="sendCode()"
/>
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
{{ errorMessage }}
</p>
<div class="mt-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
:class="primaryButtonClasses"
:disabled="sending"
@click="sendCode()"
>
<font-awesome v-if="sending" icon="spinner" spin class="mr-1" />
{{ sending ? "Sending…" : "Send Code" }}
</button>
<button
type="button"
:class="cancelButtonClasses"
:disabled="sending"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
<!-- Step 2: enter the code -->
<div v-else-if="step === 'code'">
<h1 class="text-xl font-bold text-center mb-2">Enter the Code</h1>
<p class="text-sm text-slate-500 mb-4">
We sent a 6-digit code to
<b>{{ sentTo }}</b
>. Enter it below to finish verifying your number.
</p>
<label for="sms-code" class="text-slate-500 text-sm font-bold">
Verification code
</label>
<input
id="sms-code"
v-model="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="123456"
class="block w-full tracking-[0.4em] text-center text-lg rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
:disabled="verifying"
@keyup.enter="verifyCode()"
/>
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
{{ errorMessage }}
</p>
<div class="text-center mb-2">
<button
type="button"
class="text-sm text-blue-500"
:disabled="sending || verifying"
@click="resendCode()"
>
{{ sending ? "Resending…" : "Resend code" }}
</button>
</div>
<div class="mt-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
:class="primaryButtonClasses"
:disabled="verifying"
@click="verifyCode()"
>
<font-awesome v-if="verifying" icon="spinner" spin class="mr-1" />
{{ verifying ? "Verifying…" : "Verify" }}
</button>
<button
type="button"
:class="cancelButtonClasses"
:disabled="verifying"
@click="onClickCancel()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { logger } from "@/utils/logger";
/**
* SmsVerificationDialog Component
*
* A two-step modal that walks the user through verifying a mobile number so
* they can opt in to SMS "New Activity" notifications:
* 1. Enter phone number -> service texts a one-time code
* 2. Enter the code -> service confirms the number belongs to them
*
* On successful verification the callback fires with (true, phoneNumber) so the
* caller can then let the user turn the SMS notification on. Cancelling fires
* the callback with (false).
*
* MOCK-UP NOTE: every back-end interaction here is stubbed. The send-code and
* verify-code steps only simulate the round-trips locally so the screens can be
* exercised end-to-end. See the TODO markers for where the notify-api calls go.
*/
@Component({
mixins: [PlatformServiceMixin],
})
export default class SmsVerificationDialog extends Vue {
visible = false;
step: "phone" | "code" = "phone";
phoneNumber = "";
sentTo = "";
code = "";
sending = false;
verifying = false;
errorMessage = "";
callback: (success: boolean, phoneNumber?: string) => void = () => {};
/**
* Opens the dialog at the phone-entry step.
* @param aCallback - fired with (true, phoneNumber) on success, (false) on cancel
* @param prefillPhone - optional number to pre-populate (e.g. re-verifying)
*/
open(
aCallback?: (success: boolean, phoneNumber?: string) => void,
prefillPhone = "",
) {
this.callback = aCallback || this.callback;
this.step = "phone";
this.phoneNumber = prefillPhone;
this.sentTo = "";
this.code = "";
this.sending = false;
this.verifying = false;
this.errorMessage = "";
this.visible = true;
}
/**
* Validate the phone number and (STUB) ask the service to text a code.
*/
async sendCode(): Promise<void> {
this.errorMessage = "";
const normalized = this.normalizePhone(this.phoneNumber);
if (!normalized) {
this.errorMessage = "Please enter a valid mobile number.";
return;
}
this.sending = true;
try {
// TODO(notify-api): POST /api/sms/register { phoneNumber } -> triggers
// the verification SMS. Handle rate-limit / invalid-number responses.
await this.stubBackendCall();
this.phoneNumber = normalized;
this.sentTo = normalized;
this.code = "";
this.step = "code";
} catch (error) {
logger.error("[SmsVerificationDialog] sendCode failed:", error);
this.errorMessage = "Could not send the code. Please try again.";
} finally {
this.sending = false;
}
}
/**
* (STUB) Ask the service to send a fresh code to the same number.
*/
async resendCode(): Promise<void> {
this.errorMessage = "";
this.sending = true;
try {
// TODO(notify-api): POST /api/sms/register again to re-send the code.
await this.stubBackendCall();
this.code = "";
} catch (error) {
logger.error("[SmsVerificationDialog] resendCode failed:", error);
this.errorMessage = "Could not resend the code. Please try again.";
} finally {
this.sending = false;
}
}
/**
* Validate the entered code and (STUB) confirm it with the service.
*/
async verifyCode(): Promise<void> {
this.errorMessage = "";
if (!/^\d{6}$/.test(this.code.trim())) {
this.errorMessage = "Enter the 6-digit code we texted you.";
return;
}
this.verifying = true;
try {
// TODO(notify-api): POST /api/sms/verify { phoneNumber, code }. On a
// mismatch, show an "incorrect code" error instead of succeeding. For
// this mock any 6-digit code is accepted.
await this.stubBackendCall();
this.visible = false;
this.callback(true, this.phoneNumber);
} catch (error) {
logger.error("[SmsVerificationDialog] verifyCode failed:", error);
this.errorMessage = "Could not verify the code. Please try again.";
} finally {
this.verifying = false;
}
}
onClickCancel(): void {
this.visible = false;
this.callback(false);
}
/**
* Loosely normalize a phone number for display. Real E.164 validation and
* formatting will happen server-side once the notify-api is wired in.
*/
private normalizePhone(raw: string): string {
const trimmed = (raw || "").trim();
const digits = trimmed.replace(/[^\d]/g, "");
if (digits.length < 7) {
return "";
}
return trimmed;
}
/**
* MOCK-UP helper standing in for a notify-api round-trip. Resolves on the
* next tick so the loading states are exercised without a real network call.
*/
private stubBackendCall(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 400));
}
get primaryButtonClasses(): string {
return "block w-full text-center text-lg font-bold uppercase 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-2 py-3 rounded-md disabled:opacity-50";
}
get cancelButtonClasses(): string {
return "block w-full text-center text-md uppercase 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-2 py-3 rounded-md disabled:opacity-50";
}
}
</script>

View File

@@ -65,6 +65,8 @@ export const ACCOUNT_VIEW_CONSTANTS = {
Standard message and data rates may apply, and you can turn it off at any time.
Do you want more details?
`,
SMS_FORGET_PHONE_CONFIRM:
"This will remove your phone number and turn off all text messages. You will have to verify a number again to re-enable them. Are you sure?",
REMINDER_INFO: `
This will notify you at a specific time each day.
Note that it does not give you personalized notifications,

View File

@@ -220,7 +220,7 @@
</div>
</div>
<!-- Channel 2: SMS text message (opt-in) -->
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
@@ -229,10 +229,63 @@
class="fa-fw text-slate-400"
aria-hidden="true"
/>
SMS Text Message
{{
notifyingNewActivitySmsPhone
? "SMS Text Message"
: "Enable Text Messages"
}}
</div>
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="smsEnabled"
aria-label="Toggle text message delivery"
tabindex="0"
@click.stop.prevent="toggleSmsEnabled()"
>
<input
:checked="smsEnabled"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
>
<!-- Label and value must share one line: Vue's whitespace
condensing deletes a newline-only text node between elements,
which would glue "Texting:" to the number. -->
<div>
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
</div>
<button
class="text-red-600"
aria-label="Forget phone number and disable text messages"
@click="forgetSmsPhoneNumber()"
>
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
Forget Phone Number
</button>
</div>
</div>
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
<div v-if="smsEnabled" class="w-full pl-4">
<div class="flex items-center justify-between mt-1 mb-2">
<div>
New Activity Text
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity SMS notifications"
aria-label="Learn more about New Activity text messages"
@click.stop="showNewActivityNotificationInfo(true)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
@@ -243,7 +296,7 @@
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivitySms"
aria-label="Toggle New Activity notifications via SMS"
aria-label="Toggle New Activity text messages"
tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
>
@@ -261,7 +314,7 @@
></div>
</div>
</div>
<div v-if="notifyingNewActivitySms" class="w-full pl-1">
<div v-if="notifyingNewActivitySms" class="w-full">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
@@ -269,10 +322,6 @@
<b>Time:</b>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
</div>
<div>
<b>Text to:</b>
<i>{{ notifyingNewActivitySmsPhone || "(number not set)" }}</i>
</div>
</div>
<div class="mt-2 text-center">
<button
@@ -283,6 +332,7 @@
</button>
</div>
</div>
</div>
<div class="mt-2 text-center">
<router-link class="text-sm text-blue-500" to="/help-notifications">
@@ -291,6 +341,7 @@
</div>
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<SmsVerificationDialog ref="smsVerificationDialog" />
<!-- User Profile -->
<section
@@ -886,6 +937,7 @@ import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
import QuickNav from "../components/QuickNav.vue";
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import DataExportSection from "../components/DataExportSection.vue";
@@ -967,6 +1019,7 @@ interface PushNotificationPermissionRef {
LTileLayer,
PushNotificationPermission,
QuickNav,
SmsVerificationDialog,
TopMessage,
UserNameDialog,
DataExportSection,
@@ -1021,12 +1074,15 @@ export default class AccountViewView extends Vue {
// Notification properties
notifyingNewActivity: boolean = false;
notifyingNewActivityTime: string = "";
// SMS delivery channel for New Activity (opt-in). MOCK-UP: state is currently
// component-local. TODO: persist via settings + register the number and time
// with the notify-api (SMS registration/verification flow).
// SMS channel (opt-in). MOCK-UP: state is component-local fake data so the
// flow can be walked through on an emulator; nothing is persisted. TODO:
// register/verify/forget the number and preferences with the notify-api.
/** Master switch: the SMS channel is on (requires a verified phone). */
smsEnabled: boolean = false;
/** Verified phone number; kept when smsEnabled turns off, cleared by Forget. */
notifyingNewActivitySmsPhone: string = "";
notifyingNewActivitySms: boolean = false;
notifyingNewActivitySmsTime: string = "";
notifyingNewActivitySmsPhone: string = "";
notifyingReminder: boolean = false;
notifyingReminderMessage: string = "";
notifyingReminderTime: string = "";
@@ -1365,37 +1421,99 @@ export default class AccountViewView extends Vue {
}
/**
* Toggle the SMS delivery channel for New Activity notifications.
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
* Message" master switch).
*
* MOCK-UP: reuses the time-picker dialog so the UI behaves like the in-app
* channel, but the picked time is only held in component state for now.
* Enabling requires a verified phone number: the user enters a number, the
* service texts a code, and the toggle only turns on once the code is
* confirmed. Turning the toggle off pauses the channel but keeps the
* verified number, so re-enabling skips re-verification; "Forget Phone
* Number" is the destructive path.
*
* TODO (notify-api wiring): on enable, ensure the user has a verified phone
* number, then POST the SMS-notification preference to the notify-api with
* SMS-specific params (channel="sms", phoneNumber, sendTime) -- the same
* alert as the in-app channel, delivered by a different transport. On
* disable, tell the notify-api to remove the SMS preference.
* MOCK-UP: the verification round-trips are stubbed in
* SmsVerificationDialog and all state is component-local.
*/
async toggleSmsEnabled(): Promise<void> {
if (this.smsEnabled) {
// Pause the channel; the verified number stays for easy re-enable.
// TODO(notify-api): disable all SMS preferences server-side.
this.smsEnabled = false;
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
return;
}
if (this.notifyingNewActivitySmsPhone) {
// Number already verified: just switch the channel back on.
this.smsEnabled = true;
return;
}
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
async (success: boolean, phoneNumber?: string) => {
if (success && phoneNumber) {
this.notifyingNewActivitySmsPhone = phoneNumber;
this.smsEnabled = true;
}
},
);
}
/**
* Forget the verified phone number entirely: the master toggle turns off
* and every SMS notification becomes unavailable until a number is
* verified again.
*/
async forgetSmsPhoneNumber(): Promise<void> {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_FORGET_PHONE_CONFIRM,
async () => {
// TODO(notify-api): DELETE /api/sms/register to remove the number
// and all SMS preferences server-side.
this.notifyingNewActivitySmsPhone = "";
this.smsEnabled = false;
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
},
);
}
/**
* Toggle the "New Activity Text" notification (only reachable while the
* SMS channel is enabled).
*
* TODO (notify-api wiring): on enable, POST the SMS-notification preference
* with SMS-specific params (channel="sms", phoneNumber, sendTime) -- the
* same alert as the in-app channel, delivered by a different transport. On
* disable, tell the notify-api to remove it.
*/
async showNewActivitySmsNotificationChoice(): Promise<void> {
if (!this.notifyingNewActivitySms) {
(
this.$refs.pushNotificationPermission as PushNotificationPermission
).open(
if (this.notifyingNewActivitySms) {
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
return;
}
await this.promptSmsTimeAndEnable();
}
/**
* Prompt for the SMS send time (reusing the time-picker dialog) and enable
* the SMS channel on success.
*
* MOCK-UP: the chosen time is held in component state only.
*/
async promptSmsTimeAndEnable(): Promise<void> {
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (success) {
// TODO: verify phone number + register SMS preference with notify-api
// TODO: register SMS preference with notify-api (phone + time)
this.notifyingNewActivitySms = true;
this.notifyingNewActivitySmsTime = timeText;
}
},
{ skipSchedule: true },
);
} else {
// TODO: remove SMS preference from notify-api
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
}
}
/**
@@ -1922,6 +2040,10 @@ export default class AccountViewView extends Vue {
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
this.notifyingReminderTime = "";
// SMS channel (mock-up, component-local). TODO: also tell the notify-api.
// Leaves the verified phone number in place intentionally.
this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = "";
}
/**

View File

@@ -63,6 +63,38 @@
It will only trigger if something involves you or a project of interest; it will not
bug you for other, general activity.
</p>
<p class="mt-2">Notes and caveats for text messages:</p>
<ul class="list-disc list-outside ml-5 mt-1">
<li>
Text messages are opt-in: they are only sent after you enter your
phone number and confirm it with a verification code.
</li>
<li>
Standard message and data rates from your carrier may apply.
</li>
<li>
Message frequency is at most one New Activity text per day, at the
time you choose.
</li>
<li>
You can stop at any time: turn off the toggle in your settings, use
"Forget Phone Number" to remove your number entirely, or reply STOP
to any message. Reply HELP for help.
</li>
<li>
Delivery is not guaranteed or precisely timed; carriers may delay
or drop messages, and neither we nor the carriers are liable for
late or undelivered texts.
</li>
<li>
Only US phone numbers are currently supported.
</li>
<li>
Your phone number is used only to send you these notifications; it
is stored on the notification server and is deleted when you choose
"Forget Phone Number".
</li>
</ul>
</div>
</div>