Compare commits

..

5 Commits

21 changed files with 714 additions and 124 deletions

View File

@@ -18,6 +18,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain #VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
# Using shared test notify API (no local notify server by default).
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

View File

@@ -11,4 +11,3 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app

View File

@@ -15,5 +15,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# #
# Husky Commit Message Hook # Husky Commit Message Hook
# Validates commit message format using commitlint # Validates commit message format using commitlint
# #
. "$(dirname -- "$0")/_/husky.sh"
# Run commitlint but don't fail the commit (|| true) # Run commitlint but don't fail the commit (|| true)
# This provides helpful feedback without blocking commits # This provides helpful feedback without blocking commits

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# #
# Husky Pre-commit Hook # Husky Pre-commit Hook
# Runs lint-fix and Build Architecture Guard on staged files # Runs lint-fix and Build Architecture Guard on staged files
# #
. "$(dirname -- "$0")/_/husky.sh"
echo "🔍 Running pre-commit hooks..." echo "🔍 Running pre-commit hooks..."

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env bash
# #
# Husky Pre-push Hook # Husky Pre-push Hook
# Runs Build Architecture Guard to check commits being pushed # Runs Build Architecture Guard to check commits being pushed
# #
. "$(dirname -- "$0")/_/husky.sh"
echo "🔍 Running Build Architecture Guard (pre-push)..." echo "🔍 Running Build Architecture Guard (pre-push)..."

View File

@@ -164,7 +164,6 @@ cp .env.example .env.development
# - VITE_DEFAULT_ENDORSER_API_SERVER # - VITE_DEFAULT_ENDORSER_API_SERVER
# - VITE_DEFAULT_PARTNER_API_SERVER # - VITE_DEFAULT_PARTNER_API_SERVER
# - VITE_DEFAULT_IMAGE_API_SERVER # - VITE_DEFAULT_IMAGE_API_SERVER
# - VITE_DEFAULT_NOTIFY_API_SERVER
``` ```
#### Platform-Specific Development #### Platform-Specific Development
@@ -1651,7 +1650,6 @@ The build system supports multiple environment file patterns for different scena
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
# Platform Configuration # Platform Configuration
VITE_PLATFORM=web|electron|capacitor VITE_PLATFORM=web|electron|capacitor
@@ -1671,7 +1669,6 @@ VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZY
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=http://localhost:8080 VITE_APP_SERVER=http://localhost:8080
``` ```
@@ -1682,7 +1679,6 @@ VITE_APP_SERVER=http://localhost:8080
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=https://test.timesafari.app VITE_APP_SERVER=https://test.timesafari.app
``` ```
@@ -1693,7 +1689,6 @@ VITE_APP_SERVER=https://test.timesafari.app
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
VITE_APP_SERVER=https://timesafari.app VITE_APP_SERVER=https://timesafari.app
``` ```
@@ -1951,7 +1946,6 @@ The build system supports multiple environment file patterns:
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
# Platform Configuration # Platform Configuration
VITE_PLATFORM=web|electron|capacitor VITE_PLATFORM=web|electron|capacitor

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

@@ -135,7 +135,6 @@ Create or edit `.env.development` with your computer's IP:
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000 VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=http://192.168.1.100:8080 VITE_APP_SERVER=http://192.168.1.100:8080
``` ```

View File

@@ -275,9 +275,9 @@ Before ngrok end-to-end testing, confirm:
## 6. Configure the Notification Debug Panel backend override ## 6. Configure the Notification Debug Panel backend override
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding. The app normally calls `APP_SERVER` (from `VITE_APP_SERVER`). For local wakeup testing, override the notification API base URL without rebuilding.
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md). For a full panel reference (configuration, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
### Open the panel ### Open the panel
@@ -578,7 +578,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
3. Confirm **Backend Status → URL** matches the saved ngrok host. 3. Confirm **Backend Status → URL** matches the saved ngrok host.
4. Enable **Test Mode** if using dev backend behavior. 4. Enable **Test Mode** if using dev backend behavior.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`. **Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not production `APP_SERVER`) for `/notifications/register` and `/notifications/refresh`.
--- ---
@@ -893,7 +893,7 @@ Compare with panel **Backend Status** and Event Log error text.
**Verification:** Compare panel URL to current `ngrok http` **Forwarding** line; `curl -sS "$NEW_URL/health"`. **Verification:** Compare panel URL to current `ngrok http` **Forwarding** line; `curl -sS "$NEW_URL/health"`.
**Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `DEFAULT_NOTIFY_API_SERVER`. **Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `APP_SERVER`.
--- ---

View File

@@ -281,9 +281,9 @@ Before ngrok end-to-end testing, confirm:
## 6. Configure the Notification Debug Panel backend override ## 6. Configure the Notification Debug Panel backend override
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding. The app normally calls `APP_SERVER` (from `VITE_APP_SERVER`). For local wakeup testing, override the notification API base URL without rebuilding.
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md). For a full panel reference (configuration, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
### Open the panel ### Open the panel
@@ -507,7 +507,7 @@ Confirm parameters (token vs deviceId, auth headers) in that repos README or
### Stale ngrok URL ### Stale ngrok URL
- After restarting ngrok, update **Notification Backend URL** in the panel and tap **Save** - After restarting ngrok, update **Notification Backend URL** in the panel and tap **Save**
- Or clear override (empty field + Save) only if you intend to hit `DEFAULT_NOTIFY_API_SERVER` again - Or clear override (empty field + Save) only if you intend to hit `APP_SERVER` again
### Plugin / JWT errors after refresh ### Plugin / JWT errors after refresh

View File

@@ -1,50 +1,12 @@
# Notification Debug Panel # Notification Debug Panel
**Created:** 2026-07-07 **Created:** 2026-07-07
**Updated:** 2026-07-22
**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds. **Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds.
The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic. The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic.
--- ---
## Notification API base URL
Notification HTTP calls (`/notifications/register`, `/notifications/refresh`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`.
### Configuration constants
| Symbol | Location | Purpose |
|--------|----------|---------|
| `VITE_DEFAULT_NOTIFY_API_SERVER` | `.env.development` / `.env.test` / `.env.production` | Build-time default Notification API URL for that Vite mode (same pattern as other `VITE_DEFAULT_*` backends) |
| `DEFAULT_NOTIFY_API_SERVER` | `src/constants/app.ts` | Runtime constant: `import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER \|\| AppString.PROD_NOTIFY_API_SERVER` |
| `AppString.PROD_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded production fallback: `https://notify-api.timesafari.app` |
| `AppString.TEST_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded test host: `https://test-notify-api.timesafari.app` (for explicit UI/debug use; not the automatic fallback) |
Production, test, and development builds get different Notification API URLs from their respective `.env.*` files. Runtime request code always goes through `DEFAULT_NOTIFY_API_SERVER` (via `getNotificationApiBaseUrl()`), not by reading the env var directly at each call site.
Typical values today:
| Build / env file | `VITE_DEFAULT_NOTIFY_API_SERVER` |
|------------------|----------------------------------|
| `.env.production` | `https://notify-api.timesafari.app` |
| `.env.test` | `https://test-notify-api.timesafari.app` |
| `.env.development` | `https://test-notify-api.timesafari.app` |
### URL resolution order
`getNotificationApiBaseUrl()` selects the base URL in this order:
1. **Debug Panel backend override**`localStorage` key `notificationDebug.backendBaseUrl` (set via **Save Backend URL** or `setBackendBaseUrl()`)
2. **`VITE_DEFAULT_NOTIFY_API_SERVER`** — baked into the build as part of `DEFAULT_NOTIFY_API_SERVER`
3. **`AppString.PROD_NOTIFY_API_SERVER`** — hardcoded fallback when the env var is unset (`https://notify-api.timesafari.app`)
Clearing the Debug Panel override (empty field + Save) returns the app to step 2 / 3 (`DEFAULT_NOTIFY_API_SERVER`). The override never changes auth behavior by itself.
`APP_SERVER` / `VITE_APP_SERVER` remain for deep links and the main app web host only — not for notification API traffic.
---
## Access ## Access
1. Use a **non-production** build (for example `build:android:dev`, `build:ios:dev`, or `vite dev` with a non-`production` mode). 1. Use a **non-production** build (for example `build:android:dev`, `build:ios:dev`, or `vite dev` with a non-`production` mode).
@@ -61,7 +23,7 @@ Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
| Key | Default | Purpose | | Key | Default | Purpose |
|-----|---------|---------| |-----|---------|---------|
| `notificationDebug.backendBaseUrl` | *(unset — use `DEFAULT_NOTIFY_API_SERVER`)* | Override which notification server receives API calls | | `notificationDebug.backendBaseUrl` | *(unset — use `APP_SERVER`)* | Override which notification server receives API calls |
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) | | `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls | | `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
@@ -71,7 +33,7 @@ All notification API requests (`/notifications/register`, `/notifications/refres
Paste a base URL (no trailing slash) and tap **Save Backend URL**. This changes **only** which server the app calls (`getNotificationApiBaseUrl()`). It does **not** disable JWT authentication. Paste a base URL (no trailing slash) and tap **Save Backend URL**. This changes **only** which server the app calls (`getNotificationApiBaseUrl()`). It does **not** disable JWT authentication.
Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, from `VITE_DEFAULT_NOTIFY_API_SERVER` or `AppString.PROD_NOTIFY_API_SERVER`). The Debug Panel override still wins whenever a non-empty URL is saved. Leave empty to use the built-in default (`APP_SERVER` from `VITE_APP_SERVER`).
### Test Mode ### Test Mode
@@ -97,11 +59,9 @@ The panel **Backend Status** section shows the active URL, `testMode`, and `bypa
Example: `https://test-notify-api.timesafari.app` Example: `https://test-notify-api.timesafari.app`
On development and test builds, this host is already the default via `VITE_DEFAULT_NOTIFY_API_SERVER`. You can leave **Notification Backend URL** empty, or paste the same URL explicitly.
| Setting | Value | | Setting | Value |
|---------|-------| |---------|-------|
| **Notification Backend URL** | Empty (use default) or `https://test-notify-api.timesafari.app` | | **Notification Backend URL** | `https://test-notify-api.timesafari.app` |
| **Test Mode** | **ON** (if the server expects `testMode: true`) | | **Test Mode** | **ON** (if the server expects `testMode: true`) |
| **Skip JWT Authentication** | **OFF** | | **Skip JWT Authentication** | **OFF** |
@@ -215,9 +175,8 @@ See platform-specific guides for extended ngrok and FCM workflows:
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `src/constants/app.ts` | `DEFAULT_NOTIFY_API_SERVER`, `PROD_NOTIFY_API_SERVER`, `TEST_NOTIFY_API_SERVER` |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI | | `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/services/notifications/NotificationDebugConfig.ts` | Base URL resolution, testMode, bypassAuth persistence | | `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, bypassAuth persistence |
| `src/services/notifications/notificationApiAuth.ts` | JWT vs unauthenticated headers | | `src/services/notifications/notificationApiAuth.ts` | JWT vs unauthenticated headers |
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate | | `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers | | `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |

View File

@@ -133,7 +133,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://dev-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://dev-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://dev-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://dev-partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://dev-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://dev.timesafari.app VITE_DEFAULT_PUSH_SERVER=https://dev.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true
# .env.test # .env.test
@@ -142,7 +141,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://staging-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://staging-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://staging-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://staging-partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://staging-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://staging.timesafari.app VITE_DEFAULT_PUSH_SERVER=https://staging.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true
# .env.production # .env.production
@@ -151,7 +149,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true
``` ```

View File

@@ -25,6 +25,7 @@
<p :class="textClasses">{{ text }}</p> <p :class="textClasses">{{ text }}</p>
<button <button
v-if="option1Text"
:class="option1ButtonClasses" :class="option1ButtonClasses"
@click="handleOption1(close)" @click="handleOption1(close)"
> >
@@ -32,6 +33,7 @@
</button> </button>
<button <button
v-if="option2Text"
:class="option2ButtonClasses" :class="option2ButtonClasses"
@click="handleOption2(close)" @click="handleOption2(close)"
> >
@@ -39,6 +41,7 @@
</button> </button>
<button <button
v-if="option3Text"
:class="option3ButtonClasses" :class="option3ButtonClasses"
@click="handleOption3(close)" @click="handleOption3(close)"
> >

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

@@ -11,7 +11,7 @@
v-model="backendUrlDraft" v-model="backendUrlDraft"
type="url" type="url"
class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1" class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1"
placeholder="Leave empty for default (DEFAULT_NOTIFY_API_SERVER)" placeholder="Leave empty for default (APP_SERVER)"
:disabled="busy" :disabled="busy"
@keydown.enter="onSaveBackendUrl" @keydown.enter="onSaveBackendUrl"
/> />

View File

@@ -57,11 +57,20 @@ export const ACCOUNT_VIEW_CONSTANTS = {
// Notification messages // Notification messages
NOTIFICATIONS: { NOTIFICATIONS: {
NEW_ACTIVITY_INFO: ` NEW_ACTIVITY_INFO: `
This will only notify you when there is new relevant activity for you personally. This will notify you with an app message when there is new relevant activity for you personally.
Note that it runs on your device and many factors may affect delivery,
so if you want a reliable but simple daily notification then choose a 'Reminder'.
Do you want more details? Do you want more details?
`, `,
NEW_ACTIVITY_SMS_INFO: `
This is opt-in: by turning it on you agree to receive text messages when there is new relevant activity for you personally.
Standard message and data rates may apply, and you can turn it off at any time.
Do you want more details?
`,
SMS_OPT_IN_CHOICE: `
This is opt-in: by entering your phone number you agree to receive text messages when there is new relevant activity for you personally.
Standard message and data rates may apply, and you can turn it off at any time.
`,
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

@@ -26,9 +26,6 @@ export enum AppString {
TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST1_PUSH_SERVER = "https://test.timesafari.app",
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app",
TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app",
NO_CONTACT_NAME = "(no name)", NO_CONTACT_NAME = "(no name)",
} }
@@ -50,10 +47,6 @@ export const DEFAULT_PARTNER_API_SERVER =
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER; import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
export const DEFAULT_NOTIFY_API_SERVER =
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
AppString.PROD_NOTIFY_API_SERVER;
export const IMAGE_TYPE_PROFILE = "profile"; export const IMAGE_TYPE_PROFILE = "profile";
/** /**

View File

@@ -3,7 +3,7 @@
* Persists overrides in localStorage; production defaults apply when unset. * Persists overrides in localStorage; production defaults apply when unset.
*/ */
import { DEFAULT_NOTIFY_API_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
const LOG = "[NotificationDebug]"; const LOG = "[NotificationDebug]";
const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl"; const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
@@ -45,7 +45,7 @@ function writeStorage(key: string, value: string | null): void {
} }
} }
/** Backend URL override, or null when using the default Notification API. */ /** Backend URL override, or null when using the default app server. */
export function getBackendBaseUrl(): string | null { export function getBackendBaseUrl(): string | null {
const raw = readStorage(STORAGE_KEY_BACKEND_URL); const raw = readStorage(STORAGE_KEY_BACKEND_URL);
if (raw === null) { if (raw === null) {
@@ -101,15 +101,12 @@ export function setBypassAuth(enabled: boolean): void {
/** /**
* Base URL for `/notifications/*` API calls. * Base URL for `/notifications/*` API calls.
* Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER. * Uses debug override when set; otherwise the built-in app server (production default).
*/ */
export function getNotificationApiBaseUrl(): string { export function getNotificationApiBaseUrl(): string {
const override = getBackendBaseUrl(); const override = getBackendBaseUrl();
if (override) { if (override) {
return override; return override;
} }
return ( return normalizeNotificationBackendUrl(APP_SERVER) ?? APP_SERVER;
normalizeNotificationBackendUrl(DEFAULT_NOTIFY_API_SERVER) ??
DEFAULT_NOTIFY_API_SERVER
);
} }

View File

@@ -143,24 +143,48 @@
</button> </button>
</div> </div>
</div> </div>
<div class="flex items-center justify-between mt-4 mb-2">
<!-- label --> <!--
<div> New Activity Notification: two delivery channels for the same alert.
Both let the user pick a time; they differ only in how the alert is
delivered (and therefore in the parameters passed to the notify-api):
- In-App: on-device notification via the daily-notification-plugin.
- SMS: text message sent by the notify-api (opt-in).
-->
<div class="mt-4 mb-1">
<div class="font-semibold">
New Activity Notification New Activity Notification
<button <button
class="text-slate-400 fa-fw cursor-pointer" class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity notifications" aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo" @click.stop="showNewActivityNotificationInfo(false)"
> >
<font-awesome icon="question-circle" aria-hidden="true" /> <font-awesome icon="question-circle" aria-hidden="true" />
</button> </button>
</div> </div>
<p class="text-xs text-slate-500">
Get told about new relevant activity for you. Choose a delivery
channel below -- you can turn on either one, or both.
</p>
</div>
<!-- Channel 1: In-App notification -->
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
<!-- label -->
<div>
<font-awesome
icon="comment"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
In-App Notification
</div>
<!-- toggle --> <!-- toggle -->
<div <div
class="relative ml-2 cursor-pointer" class="relative ml-2 cursor-pointer"
role="switch" role="switch"
:aria-checked="notifyingNewActivity" :aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications" aria-label="Toggle New Activity notifications in app"
tabindex="0" tabindex="0"
@click.stop.prevent="showNewActivityNotificationChoice()" @click.stop.prevent="showNewActivityNotificationChoice()"
> >
@@ -178,7 +202,7 @@
></div> ></div>
</div> </div>
</div> </div>
<div v-if="notifyingNewActivity" class="w-full"> <div v-if="notifyingNewActivity" 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"
> >
@@ -191,10 +215,125 @@
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="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="editNewActivityNotification" @click="editNewActivityNotification"
> >
Edit New Activity Notification Edit In-App Time
</button> </button>
</div> </div>
</div> </div>
<!-- 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>
<font-awesome
icon="message"
class="fa-fw text-slate-400"
aria-hidden="true"
/>
{{
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 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 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"
>
<div>
<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 class="mt-2 text-center"> <div class="mt-2 text-center">
<router-link class="text-sm text-blue-500" to="/help-notifications"> <router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your notifications&hellip; Troubleshoot your notifications&hellip;
@@ -202,6 +341,8 @@
</div> </div>
</section> </section>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
<SmsVerificationDialog ref="smsVerificationDialog" />
<ChoiceButtonDialog ref="smsOptInChoiceDialog" />
<!-- User Profile --> <!-- User Profile -->
<section <section
@@ -546,7 +687,8 @@
</div> </div>
<!-- Notification Push Server setting - only show on web platforms --> <!-- Notification Push Server setting - only show on web platforms -->
<div v-if="!isNativePlatform"> <!-- This will be removed because we're no longer using PWA notifications. -->
<div v-if="false">
<h2 class="text-slate-500 text-sm font-bold mb-2"> <h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server Notification Push Server
</h2> </h2>
@@ -792,10 +934,12 @@ import { copyToClipboard } from "../services/ClipboardService";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet"; import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import EntityIcon from "../components/EntityIcon.vue"; 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";
@@ -870,6 +1014,7 @@ interface PushNotificationPermissionRef {
@Component({ @Component({
components: { components: {
ChoiceButtonDialog,
EntityIcon, EntityIcon,
ImageMethodDialog, ImageMethodDialog,
LMap, LMap,
@@ -877,6 +1022,7 @@ interface PushNotificationPermissionRef {
LTileLayer, LTileLayer,
PushNotificationPermission, PushNotificationPermission,
QuickNav, QuickNav,
SmsVerificationDialog,
TopMessage, TopMessage,
UserNameDialog, UserNameDialog,
DataExportSection, DataExportSection,
@@ -931,6 +1077,15 @@ export default class AccountViewView extends Vue {
// Notification properties // Notification properties
notifyingNewActivity: boolean = false; notifyingNewActivity: boolean = false;
notifyingNewActivityTime: string = ""; notifyingNewActivityTime: string = "";
// 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 = "";
notifyingReminder: boolean = false; notifyingReminder: boolean = false;
notifyingReminderMessage: string = ""; notifyingReminderMessage: string = "";
notifyingReminderTime: string = ""; notifyingReminderTime: string = "";
@@ -1216,9 +1371,11 @@ export default class AccountViewView extends Vue {
} }
} }
async showNewActivityNotificationInfo(): Promise<void> { async showNewActivityNotificationInfo(isSms: boolean): Promise<void> {
this.notify.confirm( this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO, isSms
? ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_SMS_INFO
: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
async () => { async () => {
await (this.$router as Router).push({ await (this.$router as Router).push({
name: "help-notification-types", name: "help-notification-types",
@@ -1266,6 +1423,166 @@ export default class AccountViewView extends Vue {
} }
} }
/**
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
* Message" master switch).
*
* Enabling first shows an opt-in choice (see more info / enter phone
* number / cancel) before any phone number is requested. Only "Enter
* Phone Number" proceeds to SmsVerificationDialog, where the toggle turns
* on once the texted 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 that
* forces a full opt-in + re-verification next time.
*
* 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.smsOptInChoiceDialog as ChoiceButtonDialog).open({
title: "Enable Text Messages",
text: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_OPT_IN_CHOICE,
option1Text: "See More Info",
option2Text: "Enter Phone Number",
onOption1: async () => {
await (this.$router as Router).push({
name: "help-notification-types",
});
},
onOption2: () => {
(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 },
);
}
/**
* Edit the time for the SMS delivery channel.
*
* MOCK-UP: updates component state only. TODO: PUT the updated sendTime to
* the notify-api SMS preference.
*/
async editNewActivitySmsNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
dialog.open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (!success) return;
// TODO: PUT updated sendTime to notify-api SMS preference
this.notifyingNewActivitySmsTime = timeText;
this.notify.success(
"New Activity SMS time updated.",
TIMEOUTS.STANDARD,
);
},
{ skipSchedule: true },
);
// Pre-populate the dialog with the current SMS time
setTimeout(() => {
const timeMatch = this.notifyingNewActivitySmsTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
}
}
}, 150);
}
/** /**
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification. * Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
*/ */
@@ -1741,6 +2058,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

@@ -39,31 +39,70 @@
something, so you can record thanks in here. something, so you can record thanks in here.
</p> </p>
<p> <p>
This is a reliable message, but it doesn't contain any details about This is your own message activated in the app, but it doesn't contain any details about
activity that might be especially interesting to you. activity that might be especially interesting to you.
Note that the timing is not precise: the device may deliver it a bit later than scheduled.
</p> </p>
</div> </div>
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2> <h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
<div> <div>
<p> <p>
The New Activity Notification will be sent to you when there is new, relevant activity The New Activity Notification will be activated in your app when there is new, relevant activity
for you. for you.
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>
This type is not as reliable as a Reminder Notification because mobile devices often
suppress such notifications to save battery. (If you want to quickly check for relevant
activity daily, use the Reminder Notification and open the app and look for a large green
button that points out new activity that is personal to you. We are working on other
ways to notify you more reliably.
<router-link class="text-blue-500" to="/help">
go here to follow us or contact us
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
</router-link>.)
</p>
</div> </div>
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- Texts From A Server</h2>
<div>
<p>
This New Activity Notification SMS will be sent to you when there is new, relevant activity
for you.
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>
<li>
See our
<a href="https://timesafari.app/help-terms" target="_blank" class="text-blue-500">
Terms & Conditions and Privacy Policy docs here.
</a>
</li>
</ul>
</div>
</div> </div>
<!-- eslint-enable --> <!-- eslint-enable -->
</section> </section>