From 1b7c453c6da9f124dcbd2177ff8eb3ed304f89a5 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 10 May 2026 07:48:03 -0400 Subject: [PATCH] feat(09-05): ProfileChannelsSection (Teams + ntfy + QR code) + qrcode.react install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ProfileChannelsSection.tsx with Teams + ntfy sub-sections - Teams: URL input, inline 400 error (teamsError), save/clear buttons, inline test result - ntfy: mint-on-first-save (State A → State B), QR code via QRCodeSVG, subscribe link - ntfy: advanced disclosure with custom topic input + inline 400 error (customTopicError) - ntfy: test-now and remove buttons - Install qrcode.react ^4.2.0 (node_modules + package.json + package-lock.json updated) - Delete ProfileChannelsSectionPlaceholder.tsx (replaced by real component) - Update app/mobile/profile/page.tsx import to ProfileChannelsSection (not Placeholder) --- app/mobile/profile/page.tsx | 2 +- .../mobile/profile/ProfileChannelsSection.tsx | 351 ++++++++++++++++++ .../ProfileChannelsSectionPlaceholder.tsx | 25 -- package-lock.json | 10 + package.json | 1 + 5 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 components/mobile/profile/ProfileChannelsSection.tsx delete mode 100644 components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx diff --git a/app/mobile/profile/page.tsx b/app/mobile/profile/page.tsx index f7cf71a..6c5def9 100644 --- a/app/mobile/profile/page.tsx +++ b/app/mobile/profile/page.tsx @@ -6,7 +6,7 @@ import { requireAuth } from '@/lib/auth-utils'; import { ProfileTimezoneSection } from '@/components/mobile/profile/ProfileTimezoneSection'; import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection'; import { ProfileNotificationMatrix } from '@/components/mobile/profile/ProfileNotificationMatrix'; -import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSectionPlaceholder'; +import { ProfileChannelsSection } from '@/components/mobile/profile/ProfileChannelsSection'; export default async function MobileProfilePage() { const { session, error } = await requireAuth(); diff --git a/components/mobile/profile/ProfileChannelsSection.tsx b/components/mobile/profile/ProfileChannelsSection.tsx new file mode 100644 index 0000000..16e84cf --- /dev/null +++ b/components/mobile/profile/ProfileChannelsSection.tsx @@ -0,0 +1,351 @@ +'use client'; + +/** + * ProfileChannelsSection — Channels Card (CHAN-02..CHAN-05, CHAN-07). + * + * Two sub-sections separated by a Separator: + * 1. Teams — webhook URL input + Save / Clear buttons + inline test result + * 2. ntfy — mint-on-first-save + QR code + advanced topic override + * + * Each sub-section saves independently (no global Save). + * Inline errors display the server response body's `message` / `error` string + * on non-2xx, cleared on next successful save. + */ + +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface Channel { + id: string; + name: string; + channelType: string; + config: Record; + isActive: boolean; + ownerUserId: string; + createdAt: string; + updatedAt: string; +} + +interface ChannelTestResult { + ok: boolean; + status?: number; + error?: string; +} + +interface ChannelsState { + teams?: Channel; + ntfy?: Channel; +} + +// ── Inline test result row ──────────────────────────────────────────────────── + +function TestResultRow({ test }: { test: ChannelTestResult }) { + if (test.ok) { + return ( +
+ + Channel verified +
+ ); + } + return ( +
+ + + Test failed — {test.status ?? test.error ?? 'unknown'} + +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function ProfileChannelsSection() { + const [loading, setLoading] = useState(true); + const [channels, setChannels] = useState({}); + + // Teams state + const [teamsInput, setTeamsInput] = useState(''); + const [teamsTest, setTeamsTest] = useState(null); + const [teamsError, setTeamsError] = useState(null); + + // ntfy state + const [ntfyTest, setNtfyTest] = useState(null); + const [customTopicInput, setCustomTopicInput] = useState(''); + const [customTopicError, setCustomTopicError] = useState(null); + + // ── Load channels on mount ──────────────────────────────────────────────── + + useEffect(() => { + fetch('/api/me/channels') + .then((r) => r.json()) + .then((data: { channels: Channel[] }) => { + const teams = data.channels.find((c) => c.channelType === 'teams'); + const ntfy = data.channels.find((c) => c.channelType === 'ntfy'); + setChannels({ teams, ntfy }); + if (teams?.config?.webhook_url) { + setTeamsInput(teams.config.webhook_url); + } + if (ntfy?.config?.topic) { + setCustomTopicInput(ntfy.config.topic); + } + }) + .catch(() => toast.error('Failed to load channels')) + .finally(() => setLoading(false)); + }, []); + + // ── Teams handlers ──────────────────────────────────────────────────────── + + const saveTeams = async () => { + setTeamsError(null); + const resp = await fetch('/api/me/channels/teams', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ webhook_url: teamsInput }), + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + setTeamsError(body.message || body.error || `Save failed (${resp.status})`); + toast.error('Failed to save channel'); + return; + } + setChannels((c) => ({ ...c, teams: body.channel })); + setTeamsTest(body.test); + toast.success('Channel saved'); + }; + + const clearTeams = async () => { + await fetch('/api/me/channels/teams', { method: 'DELETE' }); + setChannels((c) => ({ ...c, teams: undefined })); + setTeamsInput(''); + setTeamsTest(null); + setTeamsError(null); + toast.success('Channel removed'); + }; + + // ── ntfy handlers ───────────────────────────────────────────────────────── + + const enableNtfy = async () => { + const resp = await fetch('/api/me/channels/ntfy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + toast.error('Failed to save channel'); + return; + } + setChannels((c) => ({ ...c, ntfy: data.channel })); + setNtfyTest(data.test); + if (data.channel?.config?.topic) { + setCustomTopicInput(data.channel.config.topic); + } + toast.success('Channel saved'); + }; + + const saveCustomTopic = async () => { + setCustomTopicError(null); + const resp = await fetch('/api/me/channels/ntfy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic: customTopicInput }), + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + setCustomTopicError(body.message || body.error || `Save failed (${resp.status})`); + toast.error('Failed to save channel'); + return; + } + setChannels((c) => ({ ...c, ntfy: body.channel })); + setNtfyTest(body.test); + setCustomTopicError(null); + toast.success('Channel saved'); + }; + + const testNtfy = async () => { + const resp = await fetch('/api/me/channels/ntfy/test', { method: 'POST' }); + const data = await resp.json().catch(() => ({})); + if (data.test) { + setNtfyTest(data.test); + } + }; + + const removeNtfy = async () => { + await fetch('/api/me/channels/ntfy', { method: 'DELETE' }); + setChannels((c) => ({ ...c, ntfy: undefined })); + setNtfyTest(null); + setCustomTopicInput(''); + setCustomTopicError(null); + toast.success('Channel removed'); + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + if (loading) { + return ( + + + Personal Channels + + +
+
+
+
+ + + ); + } + + const ntfyTopic = channels.ntfy?.config?.topic; + + return ( + + + Personal Channels + Receive notifications directly on your devices. + + + + {/* ── Teams sub-section ──────────────────────────────────────────── */} +
+ + { + setTeamsInput(e.target.value); + setTeamsError(null); + }} + /> + {teamsError && ( +

{teamsError}

+ )} + {teamsTest && } +
+ + +
+
+ + + + {/* ── ntfy sub-section ───────────────────────────────────────────── */} +
+ + + {!ntfyTopic ? ( + /* State A — no topic yet */ + <> +

+ Pulse will generate a private topic for you. +

+ + + ) : ( + /* State B — topic minted */ +
+ + {`https://ntfy.sh/${ntfyTopic}`} + + +
+ +
+ +

+ Scan with the ntfy app to subscribe. +

+ + {ntfyTest && } + +
+ + Edit advanced + +
+ + { + setCustomTopicInput(e.target.value); + setCustomTopicError(null); + }} + /> + {customTopicError && ( +

{customTopicError}

+ )} + +
+
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx b/components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx deleted file mode 100644 index 47c3d2a..0000000 --- a/components/mobile/profile/ProfileChannelsSectionPlaceholder.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -/** - * Placeholder for ProfileChannelsSection — wired in Plan 04 so the page - * compiles. Plan 05 replaces this file with the real component (Teams + - * ntfy + QR code). The named export `ProfileChannelsSection` matches the - * import in app/mobile/profile/page.tsx so the swap is a one-file change. - */ - -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; - -export function ProfileChannelsSection() { - return ( - - - Personal Channels - - -

- Coming soon — channel configuration ships in Plan 05. -

-
-
- ); -} diff --git a/package-lock.json b/package-lock.json index 1560de8..27f4151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "node-cron": "^4.2.1", "nodemailer": "^7.0.12", "pg": "^8.11.0", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-day-picker": "^9.13.0", @@ -14789,6 +14790,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 2ca5052..9d7217c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "node-cron": "^4.2.1", "nodemailer": "^7.0.12", "pg": "^8.11.0", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-day-picker": "^9.13.0",