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:
parent
7238c97a98
commit
1b7c453c6d
5 changed files with 363 additions and 26 deletions
|
|
@ -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();
|
||||
|
|
|
|||
351
components/mobile/profile/ProfileChannelsSection.tsx
Normal file
351
components/mobile/profile/ProfileChannelsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue