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 { ProfileTimezoneSection } from '@/components/mobile/profile/ProfileTimezoneSection';
|
||||||
import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection';
|
import { ProfileThemeSection } from '@/components/mobile/profile/ProfileThemeSection';
|
||||||
import { ProfileNotificationMatrix } from '@/components/mobile/profile/ProfileNotificationMatrix';
|
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() {
|
export default async function MobileProfilePage() {
|
||||||
const { session, error } = await requireAuth();
|
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",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
|
|
@ -14789,6 +14790,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue