Files
MultiRoombaRover/webui/src/components/VipPanel/index.jsx
T
2026-09-28 12:42:31 -04:00

132 lines
5.1 KiB
React

// Vip Panel
// Purpose: Defines the Vip Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
import VipMidiBeeperCard from '../vip/VipMidiBeeperCard/index.jsx';
import VipVerificationCard from '../vip/VipVerificationCard.jsx';
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
import VipRoverLearningCard from '../vip/VipRoverLearningCard.jsx';
export default function VipPanel() {
const session = useSessionSelector((state) => state.session);
const {
identifySession,
requestVerification,
requestPrivateRoverAccess,
playUploadedAudio,
stopUploadedAudio,
startMicWhip,
readyMicWhip,
stopMicWhip,
} = useSessionActions();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
const currentStoredKey = useMemo(() => (identity?.cookieUserId || '').trim(), [identity?.cookieUserId]);
const nickname = (profile?.nickname || '').trim();
const isVerified = Boolean(session?.isVerified);
const pendingRequestId = session?.verification?.pendingRequestId || null;
const requestablePrivateRovers = session?.privateRoverAccess?.requestableRovers || [];
const pendingPrivateRoverRequests = session?.privateRoverAccess?.pendingRequests || [];
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const [message, setMessage] = useState('');
const applyIdentityKey = async (nextRaw) => {
const next = String(nextRaw || '').trim().toLowerCase();
if (!next) {
throw new Error('Identity key required.');
}
if (!COOKIE_KEY_REGEX.test(next)) {
throw new Error('Identity key must match format: cu_ + 32 lowercase hex chars.');
}
saveIdentity((current) => ({ ...(current || {}), cookieUserId: next }));
await identifySession({ cookieUserId: next, nickname });
return next;
};
// VipPanel owns its outer grid rather than placing it inside one CardFrame,
// so it establishes its own query boundary for the same width-aware behavior.
return (
<section className="@container space-y-2 text-base">
<div className="grid grid-cols-1 gap-2 @[32rem]:grid-cols-[minmax(0,1.25fr)_minmax(0,0.9fr)]">
{!isVerified ? (
<div className="@[32rem]:col-span-2">
<VipVerificationCard
pendingRequestId={pendingRequestId}
currentStoredKey={currentStoredKey}
nickname={nickname}
requestVerification={requestVerification}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
fullWidth
/>
</div>
) : null}
<div className="@[32rem]:col-span-2">
{isVerified ? (
<div className="space-y-2">
<VipRoverLearningCard />
<VipMidiBeeperCard />
<VipAudioUploadCard
ownRoverId={ownRoverId}
audioForwardByRover={session?.audioForward || {}}
playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
startMicWhip={startMicWhip}
readyMicWhip={readyMicWhip}
stopMicWhip={stopMicWhip}
/>
</div>
) : (
<section className="surface h-full">
<div className="flex h-full flex-col items-center justify-center text-center text-xs text-slate-400">
Verify your account to unlock VIP features.
</div>
</section>
)}
</div>
<div className="@[32rem]:col-span-2">
<VipPrivateRoverAccessCard
requestableRovers={requestablePrivateRovers}
pendingRequests={pendingPrivateRoverRequests}
requestPrivateRoverAccess={requestPrivateRoverAccess}
onMessage={setMessage}
fullWidth
/>
</div>
<div className="@[32rem]:col-span-2">
<VipIdentityCard
currentStoredKey={currentStoredKey}
applyIdentityKey={applyIdentityKey}
onMessage={setMessage}
fullWidth
/>
</div>
<div className="@[32rem]:col-span-2">
<VipProfileImageCard
isVerified={isVerified}
onMessage={setMessage}
fullWidth
/>
</div>
</div>
{message ? (
<div className={flowWrapClass}>
<p className="text-xs text-slate-400 text-center">{message}</p>
</div>
) : null}
</section>
);
}