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). 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 ## [1.3.8] - 2026
### Added ### Added
- Device wake-up for notifications - 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. Standard message and data rates may apply, and you can turn it off at any time.
Do you want more details? 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: ` REMINDER_INFO: `
This will notify you at a specific time each day. This will notify you at a specific time each day.
Note that it does not give you personalized notifications, Note that it does not give you personalized notifications,

View File

@@ -220,7 +220,7 @@
</div> </div>
</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"> <div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label --> <!-- label -->
<div> <div>
@@ -229,26 +229,23 @@
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
aria-hidden="true" aria-hidden="true"
/> />
SMS Text Message {{
<button notifyingNewActivitySmsPhone
class="text-slate-400 fa-fw cursor-pointer" ? "SMS Text Message"
aria-label="Learn more about New Activity SMS notifications" : "Enable Text Messages"
@click.stop="showNewActivityNotificationInfo(true)" }}
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div> </div>
<!-- toggle --> <!-- master toggle: enables the SMS channel (verifies phone on first use) -->
<div <div
class="relative ml-2 cursor-pointer" class="relative ml-2 cursor-pointer"
role="switch" role="switch"
:aria-checked="notifyingNewActivitySms" :aria-checked="smsEnabled"
aria-label="Toggle New Activity notifications via SMS" aria-label="Toggle text message delivery"
tabindex="0" tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()" @click.stop.prevent="toggleSmsEnabled()"
> >
<input <input
:checked="notifyingNewActivitySms" :checked="smsEnabled"
type="checkbox" type="checkbox"
class="sr-only" class="sr-only"
readonly readonly
@@ -261,26 +258,79 @@
></div> ></div>
</div> </div>
</div> </div>
<div v-if="notifyingNewActivitySms" class="w-full pl-1"> <div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
<div <div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200" 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> <div>
<b>Time:</b> <b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
</div> </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> <div>
<b>Text to:</b> New Activity Text
<i>{{ notifyingNewActivitySmsPhone || "(number not set)" }}</i> <button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity text messages"
@click.stop="showNewActivityNotificationInfo(true)"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivitySms"
aria-label="Toggle New Activity text messages"
tabindex="0"
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
>
<input
:checked="notifyingNewActivitySms"
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> </div>
<div class="mt-2 text-center"> <div v-if="notifyingNewActivitySms" class="w-full">
<button <div
class="w-full 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" class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
@click="editNewActivitySmsNotification"
> >
Edit SMS Time <div>
</button> <b>Time:</b>
{{ notifyingNewActivitySmsTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full 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="editNewActivitySmsNotification"
>
Edit SMS Time…
</button>
</div>
</div> </div>
</div> </div>
@@ -291,6 +341,7 @@
</div> </div>
</section> </section>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
<SmsVerificationDialog ref="smsVerificationDialog" />
<!-- User Profile --> <!-- User Profile -->
<section <section
@@ -886,6 +937,7 @@ import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue"; import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import PushNotificationPermission from "../components/PushNotificationPermission.vue"; import PushNotificationPermission from "../components/PushNotificationPermission.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import DataExportSection from "../components/DataExportSection.vue"; import DataExportSection from "../components/DataExportSection.vue";
@@ -967,6 +1019,7 @@ interface PushNotificationPermissionRef {
LTileLayer, LTileLayer,
PushNotificationPermission, PushNotificationPermission,
QuickNav, QuickNav,
SmsVerificationDialog,
TopMessage, TopMessage,
UserNameDialog, UserNameDialog,
DataExportSection, DataExportSection,
@@ -1021,12 +1074,15 @@ export default class AccountViewView extends Vue {
// Notification properties // Notification properties
notifyingNewActivity: boolean = false; notifyingNewActivity: boolean = false;
notifyingNewActivityTime: string = ""; notifyingNewActivityTime: string = "";
// SMS delivery channel for New Activity (opt-in). MOCK-UP: state is currently // SMS channel (opt-in). MOCK-UP: state is component-local fake data so the
// component-local. TODO: persist via settings + register the number and time // flow can be walked through on an emulator; nothing is persisted. TODO:
// with the notify-api (SMS registration/verification flow). // 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; notifyingNewActivitySms: boolean = false;
notifyingNewActivitySmsTime: string = ""; notifyingNewActivitySmsTime: string = "";
notifyingNewActivitySmsPhone: string = "";
notifyingReminder: boolean = false; notifyingReminder: boolean = false;
notifyingReminderMessage: string = ""; notifyingReminderMessage: string = "";
notifyingReminderTime: 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 * Enabling requires a verified phone number: the user enters a number, the
* channel, but the picked time is only held in component state for now. * 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 * MOCK-UP: the verification round-trips are stubbed in
* number, then POST the SMS-notification preference to the notify-api with * SmsVerificationDialog and all state is component-local.
* 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.
*/ */
async showNewActivitySmsNotificationChoice(): Promise<void> { async toggleSmsEnabled(): Promise<void> {
if (!this.notifyingNewActivitySms) { if (this.smsEnabled) {
( // Pause the channel; the verified number stays for easy re-enable.
this.$refs.pushNotificationPermission as PushNotificationPermission // TODO(notify-api): disable all SMS preferences server-side.
).open( this.smsEnabled = false;
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (success) {
// TODO: verify phone number + register SMS preference with notify-api
this.notifyingNewActivitySms = true;
this.notifyingNewActivitySmsTime = timeText;
}
},
{ skipSchedule: true },
);
} else {
// TODO: remove SMS preference from notify-api
this.notifyingNewActivitySms = false; this.notifyingNewActivitySms = false;
this.notifyingNewActivitySmsTime = ""; 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.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: register SMS preference with notify-api (phone + time)
this.notifyingNewActivitySms = true;
this.notifyingNewActivitySmsTime = timeText;
}
},
{ skipSchedule: true },
);
} }
/** /**
@@ -1922,6 +2040,10 @@ export default class AccountViewView extends Vue {
this.notifyingReminder = false; this.notifyingReminder = false;
this.notifyingReminderMessage = ""; this.notifyingReminderMessage = "";
this.notifyingReminderTime = ""; 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 It will only trigger if something involves you or a project of interest; it will not
bug you for other, general activity. bug you for other, general activity.
</p> </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>
</div> </div>