feat(09-05): ProfileChannelsSection (Teams + ntfy + QR code) + qrcode.react install

- 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)
This commit is contained in:
lorentz 2026-05-10 07:48:03 -04:00
parent 7238c97a98
commit 1b7c453c6d
5 changed files with 363 additions and 26 deletions

View file

@ -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<string, string>;
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 (
<div className="flex items-center gap-2 text-xs">
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
<span className="text-green-600">Channel verified</span>
</div>
);
}
return (
<div className="flex items-center gap-2 text-xs">
<XCircle className="w-3.5 h-3.5 text-destructive" />
<span className="text-destructive">
Test failed {test.status ?? test.error ?? 'unknown'}
</span>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function ProfileChannelsSection() {
const [loading, setLoading] = useState(true);
const [channels, setChannels] = useState<ChannelsState>({});
// Teams state
const [teamsInput, setTeamsInput] = useState('');
const [teamsTest, setTeamsTest] = useState<ChannelTestResult | null>(null);
const [teamsError, setTeamsError] = useState<string | null>(null);
// ntfy state
const [ntfyTest, setNtfyTest] = useState<ChannelTestResult | null>(null);
const [customTopicInput, setCustomTopicInput] = useState('');
const [customTopicError, setCustomTopicError] = useState<string | null>(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 (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle>Personal Channels</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
<div className="space-y-3">
<div className="h-10 w-full rounded bg-muted animate-pulse" />
<div className="h-10 w-full rounded bg-muted animate-pulse" />
</div>
</CardContent>
</Card>
);
}
const ntfyTopic = channels.ntfy?.config?.topic;
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle>Personal Channels</CardTitle>
<CardDescription>Receive notifications directly on your devices.</CardDescription>
</CardHeader>
<CardContent className="px-4 py-4 space-y-4">
{/* ── Teams sub-section ──────────────────────────────────────────── */}
<div className="space-y-2">
<Label htmlFor="teams-webhook-url">Microsoft Teams webhook URL</Label>
<Input
id="teams-webhook-url"
placeholder="https://yourorg.webhook.office.com/..."
value={teamsInput}
onChange={(e) => {
setTeamsInput(e.target.value);
setTeamsError(null);
}}
/>
{teamsError && (
<p className="text-xs text-destructive">{teamsError}</p>
)}
{teamsTest && <TestResultRow test={teamsTest} />}
<div className="flex gap-2 pt-1">
<Button
onClick={saveTeams}
className="min-h-[44px]"
>
Save Teams URL
</Button>
<Button
variant="ghost"
onClick={clearTeams}
className="text-destructive min-h-[44px]"
>
Clear
</Button>
</div>
</div>
<Separator className="my-4" />
{/* ── ntfy sub-section ───────────────────────────────────────────── */}
<div className="space-y-2">
<Label>Mobile push (ntfy)</Label>
{!ntfyTopic ? (
/* State A — no topic yet */
<>
<p className="text-sm text-muted-foreground">
Pulse will generate a private topic for you.
</p>
<Button
className="w-full min-h-[44px]"
onClick={enableNtfy}
>
Enable mobile push
</Button>
</>
) : (
/* State B — topic minted */
<div className="space-y-3">
<a
href={`https://ntfy.sh/${ntfyTopic}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary text-sm underline break-all"
>
{`https://ntfy.sh/${ntfyTopic}`}
</a>
<div
role="img"
aria-label={`Subscribe to ${ntfyTopic} on ntfy`}
className="flex justify-center"
>
<QRCodeSVG value={`https://ntfy.sh/${ntfyTopic}`} size={200} />
</div>
<p className="text-xs text-muted-foreground">
Scan with the ntfy app to subscribe.
</p>
{ntfyTest && <TestResultRow test={ntfyTest} />}
<details>
<summary className="text-sm text-primary cursor-pointer">
Edit advanced
</summary>
<div className="space-y-2 pt-2">
<Label htmlFor="custom-ntfy-topic">Custom ntfy topic</Label>
<Input
id="custom-ntfy-topic"
value={customTopicInput}
onChange={(e) => {
setCustomTopicInput(e.target.value);
setCustomTopicError(null);
}}
/>
{customTopicError && (
<p className="text-xs text-destructive">{customTopicError}</p>
)}
<Button
onClick={saveCustomTopic}
className="min-h-[44px]"
>
Save custom topic
</Button>
</div>
</details>
<div className="flex gap-2 pt-1">
<Button
onClick={testNtfy}
className="min-h-[44px]"
>
Test now
</Button>
<Button
variant="ghost"
onClick={removeNtfy}
className="text-destructive min-h-[44px]"
>
Remove
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -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 (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle>Personal Channels</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
<p className="text-sm text-muted-foreground">
Coming soon channel configuration ships in Plan 05.
</p>
</CardContent>
</Card>
);
}