added qr, cleaned up profile views, and added the critical alerts
This commit is contained in:
+248
-52
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import birdSilhouette from './assets/bird-silhouette.jpg';
|
||||
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
||||
import defaultBirdPhoto from './assets/yoda-default.png';
|
||||
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||
@@ -29,6 +31,8 @@ type Bird = {
|
||||
photoDataUrl: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
publicProfileCode: string | null;
|
||||
publicProfileEnabled: boolean;
|
||||
memorializedAt: string | null;
|
||||
memorializedOn: string | null;
|
||||
memorialNote: string | null;
|
||||
@@ -192,6 +196,16 @@ type BirdFormState = {
|
||||
photoDataUrl: string;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
publicProfileEnabled: boolean;
|
||||
};
|
||||
|
||||
type PublicBirdProfile = {
|
||||
id: string;
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string | null;
|
||||
photoDataUrl: string | null;
|
||||
};
|
||||
|
||||
type MemorializeBirdFormState = {
|
||||
@@ -317,6 +331,47 @@ type SettingsSection = 'collaborators' | 'integration-tokens' | 'new-workspace'
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
|
||||
const sessionTokenStorageKey = 'flockpal_auth_token';
|
||||
const dismissedAlertsStorageKey = 'flockpal_dismissed_alerts';
|
||||
const getPublicProfileCodeFromPath = () => window.location.pathname.match(/^\/b\/([A-Za-z0-9_-]{8,32})\/?$/)?.[1] ?? '';
|
||||
const getPublicProfileUrl = (code: string) => `${window.location.origin}/b/${code}`;
|
||||
const QR_MARGIN = 4;
|
||||
|
||||
const createQrPath = (value: string) => {
|
||||
const qr = QRCode.create(value, { errorCorrectionLevel: 'H' });
|
||||
const size = qr.modules.size;
|
||||
const data = qr.modules.data;
|
||||
const pathParts: string[] = [];
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
if (data[y * size + x]) {
|
||||
pathParts.push(`M${x + QR_MARGIN},${y + QR_MARGIN}h1v1h-1z`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: pathParts.join(''),
|
||||
size,
|
||||
viewBoxSize: size + QR_MARGIN * 2,
|
||||
};
|
||||
};
|
||||
|
||||
const QrCodeWithLogo = ({ value, label }: { value: string; label: string }) => {
|
||||
const qr = useMemo(() => createQrPath(value), [value]);
|
||||
const logoSize = Math.max(7, qr.size * 0.18);
|
||||
const logoPosition = (qr.viewBoxSize - logoSize) / 2;
|
||||
|
||||
return (
|
||||
<svg className="qr-code" viewBox={`0 0 ${qr.viewBoxSize} ${qr.viewBoxSize}`} role="img" aria-label={label}>
|
||||
<rect width={qr.viewBoxSize} height={qr.viewBoxSize} fill="#fff" />
|
||||
<path d={qr.path} fill="#111418" />
|
||||
<g className="qr-bird-mark" aria-hidden="true">
|
||||
<rect x={logoPosition - 0.45} y={logoPosition - 0.45} width={logoSize + 0.9} height={logoSize + 0.9} rx="1.7" />
|
||||
<image href={birdSilhouette} x={logoPosition} y={logoPosition} width={logoSize} height={logoSize} preserveAspectRatio="xMidYMid meet" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
const emptyBirdForm: BirdFormState = {
|
||||
name: '',
|
||||
tagId: '',
|
||||
@@ -331,6 +386,7 @@ const emptyBirdForm: BirdFormState = {
|
||||
photoDataUrl: '',
|
||||
notifyOnDob: false,
|
||||
notifyOnGotchaDay: false,
|
||||
publicProfileEnabled: false,
|
||||
};
|
||||
|
||||
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
|
||||
@@ -456,6 +512,7 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
|
||||
photoDataUrl: bird.photoDataUrl ?? '',
|
||||
notifyOnDob: bird.notifyOnDob,
|
||||
notifyOnGotchaDay: bird.notifyOnGotchaDay,
|
||||
publicProfileEnabled: bird.publicProfileEnabled,
|
||||
});
|
||||
|
||||
const formatDate = (value: string | null) => {
|
||||
@@ -1117,6 +1174,10 @@ function App() {
|
||||
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
|
||||
const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
|
||||
const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false);
|
||||
const [publicProfileCode] = useState(getPublicProfileCodeFromPath);
|
||||
const [publicProfile, setPublicProfile] = useState<PublicBirdProfile | null>(null);
|
||||
const [publicProfileLoading, setPublicProfileLoading] = useState(Boolean(getPublicProfileCodeFromPath()));
|
||||
const [publicProfileError, setPublicProfileError] = useState('');
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||
@@ -1159,6 +1220,7 @@ function App() {
|
||||
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
|
||||
const [switchingWorkspaceId, setSwitchingWorkspaceId] = useState<number | null>(null);
|
||||
const [showWeightAlertModal, setShowWeightAlertModal] = useState(false);
|
||||
const [qrBird, setQrBird] = useState<Bird | null>(null);
|
||||
const [speciesPickerOpen, setSpeciesPickerOpen] = useState(false);
|
||||
const [bulkWeightOpen, setBulkWeightOpen] = useState(false);
|
||||
const [savingBulkWeights, setSavingBulkWeights] = useState(false);
|
||||
@@ -1236,6 +1298,16 @@ function App() {
|
||||
|
||||
const showFlockDetailColumn = bulkWeightOpen || Boolean(selectedBird);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicProfile || !authSession || workspace?.id !== publicProfile.workspaceId || !birds.some((bird) => bird.id === publicProfile.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedBirdId(publicProfile.id);
|
||||
setActivePage('flock');
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
}, [authSession, birds, publicProfile, workspace?.id]);
|
||||
|
||||
const missingFirstWeightCount = useMemo(
|
||||
() => birds.filter((bird) => bird.latestWeightGrams === null).length,
|
||||
[birds],
|
||||
@@ -1394,8 +1466,6 @@ function App() {
|
||||
[activeVetVisitDueBirds, allBirdVetVisits, dismissedAlerts, workspace?.id],
|
||||
);
|
||||
|
||||
const vetVisitDueNames = vetVisitDueBirds.slice(0, 3).map((bird) => bird.name).join(', ');
|
||||
const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0);
|
||||
const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]);
|
||||
|
||||
const activeMedications = useMemo(
|
||||
@@ -1785,6 +1855,37 @@ function App() {
|
||||
void bootstrapSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicProfileCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadPublicProfile = async () => {
|
||||
try {
|
||||
setPublicProfileLoading(true);
|
||||
setPublicProfileError('');
|
||||
const response = await apiFetch(`/public/birds/${publicProfileCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Public bird profile not found.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ bird?: PublicBirdProfile }>(response)) ?? {};
|
||||
if (!data.bird) {
|
||||
throw new Error('Public bird profile not found.');
|
||||
}
|
||||
|
||||
setPublicProfile(data.bird);
|
||||
} catch (profileError) {
|
||||
setPublicProfileError(profileError instanceof Error ? profileError.message : 'Public bird profile not found.');
|
||||
} finally {
|
||||
setPublicProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadPublicProfile();
|
||||
}, [publicProfileCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken || !workspace?.id) {
|
||||
setLoading(false);
|
||||
@@ -2150,6 +2251,21 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPublicProfileBird = async () => {
|
||||
if (!publicProfile || !authSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspace?.id !== publicProfile.workspaceId) {
|
||||
await handleWorkspaceSwitch(publicProfile.workspaceId, 'flock');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedBirdId(publicProfile.id);
|
||||
setActivePage('flock');
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
};
|
||||
|
||||
const handleCreateIntegrationToken = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -3457,6 +3573,55 @@ function App() {
|
||||
setActivePage('flock');
|
||||
};
|
||||
|
||||
const publicProfileWorkspaceMembership = publicProfile
|
||||
? authSession?.workspaces.find((entry) => entry.workspace.id === publicProfile.workspaceId) ?? null
|
||||
: null;
|
||||
const shouldShowPublicProfilePage =
|
||||
Boolean(publicProfileCode) &&
|
||||
(!authSession ||
|
||||
!publicProfile ||
|
||||
workspace?.id !== publicProfile.workspaceId ||
|
||||
!birds.some((bird) => bird.id === publicProfile.id));
|
||||
|
||||
if (shouldShowPublicProfilePage) {
|
||||
return (
|
||||
<main className="auth-shell public-profile-shell">
|
||||
<section className="panel public-profile-card">
|
||||
{publicProfileLoading || authLoading ? (
|
||||
<p>Loading bird profile...</p>
|
||||
) : publicProfileError || !publicProfile ? (
|
||||
<>
|
||||
<p className="eyebrow">FlockPal</p>
|
||||
<h1>Public profile unavailable</h1>
|
||||
<p className="muted">{publicProfileError || 'This bird profile is not available publicly.'}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img className="public-profile-photo" src={publicProfile.photoDataUrl || defaultBirdPhoto} alt={publicProfile.name} />
|
||||
<div className="public-profile-copy">
|
||||
<h1>
|
||||
<span>{publicProfile.name}</span>
|
||||
<span aria-label={getBirdGenderLabel(publicProfile)} className={`gender-symbol ${publicProfile.gender}`}>
|
||||
{getBirdGenderSymbol(publicProfile)}
|
||||
</span>
|
||||
</h1>
|
||||
<article className="summary-card">
|
||||
<span>Hatch Day</span>
|
||||
<strong>{formatDate(publicProfile.dateOfBirth)}</strong>
|
||||
</article>
|
||||
{publicProfileWorkspaceMembership ? (
|
||||
<button className="primary-button" onClick={handleOpenPublicProfileBird} type="button">
|
||||
Open full profile
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
@@ -3770,6 +3935,35 @@ function App() {
|
||||
|
||||
<section className="content-shell">
|
||||
{error ? <p className="error-banner">{error}</p> : null}
|
||||
{(activePage === 'overview' || activePage === 'flock') && (totalWeightAlerts || vetVisitDueBirds.length) ? (
|
||||
<section className="top-alert-notification" role="alert" aria-label="Critical flock alert">
|
||||
<span className="notification-bell" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{totalWeightAlerts + vetVisitDueBirds.length} critical alert{totalWeightAlerts + vetVisitDueBirds.length === 1 ? '' : 's'}
|
||||
</strong>
|
||||
<span>
|
||||
{totalWeightAlerts ? `${totalWeightAlerts} weight alert${totalWeightAlerts === 1 ? '' : 's'}` : ''}
|
||||
{totalWeightAlerts && vetVisitDueBirds.length ? ' • ' : ''}
|
||||
{vetVisitDueBirds.length
|
||||
? `${vetVisitDueBirds.length} annual vet reminder${vetVisitDueBirds.length === 1 ? '' : 's'}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="top-alert-actions">
|
||||
{totalWeightAlerts ? (
|
||||
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
|
||||
Review weights
|
||||
</button>
|
||||
) : null}
|
||||
{vetVisitDueBirds.length ? (
|
||||
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
|
||||
Review vet visits
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activePage === 'overview' ? (
|
||||
<section className="stack-grid">
|
||||
@@ -3780,11 +3974,6 @@ function App() {
|
||||
<h2>30-day flock weight snapshot</h2>
|
||||
</div>
|
||||
<div className="button-row overview-alert-actions">
|
||||
{totalWeightAlerts ? (
|
||||
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
|
||||
{totalWeightAlerts} weight alert{totalWeightAlerts === 1 ? '' : 's'}
|
||||
</button>
|
||||
) : null}
|
||||
<p className="muted">
|
||||
{birdsWithRecentWeights.length} current
|
||||
{overviewHistoricalSeriesCount > 0 ? `, ${overviewHistoricalSeriesCount} previous-year` : ''}
|
||||
@@ -3909,43 +4098,12 @@ function App() {
|
||||
<strong>{missingFirstWeightCount}</strong>
|
||||
<span>Members still needing a first weight</span>
|
||||
</article>
|
||||
) : null}
|
||||
{totalWeightAlerts ? (
|
||||
<article className="summary-card summary-alert-card">
|
||||
<span>Weight alerts</span>
|
||||
<strong>
|
||||
{totalWeightAlerts} alert{totalWeightAlerts === 1 ? '' : 's'} need review
|
||||
</strong>
|
||||
{outOfRangeBirds.length ? (
|
||||
<span>
|
||||
{outOfRangeBirds.length} bird{outOfRangeBirds.length === 1 ? '' : 's'} outside typical ranges
|
||||
</span>
|
||||
) : null}
|
||||
{weightDropAlerts.length ? (
|
||||
<span>
|
||||
{weightDropAlerts.length} bird{weightDropAlerts.length === 1 ? '' : 's'} down 5-10% between recent entries
|
||||
</span>
|
||||
) : null}
|
||||
<button className="range-alert-button" onClick={handleWeightRangeAlertClick} type="button">
|
||||
Review alerts
|
||||
</button>
|
||||
) : (
|
||||
<article className="summary-card">
|
||||
<span>First weights</span>
|
||||
<strong>All recorded</strong>
|
||||
</article>
|
||||
) : null}
|
||||
{vetVisitDueBirds.length ? (
|
||||
<article className="summary-card summary-alert-card">
|
||||
<span>Vet visit reminder</span>
|
||||
<strong>
|
||||
{vetVisitDueBirds.length} member{vetVisitDueBirds.length === 1 ? '' : 's'} need annual visit review
|
||||
</strong>
|
||||
<span>
|
||||
No vet visit logged in the last 365 days for {vetVisitDueNames}
|
||||
{vetVisitDueOverflowCount ? ` and ${vetVisitDueOverflowCount} more` : ''}.
|
||||
</span>
|
||||
<button className="range-alert-button" onClick={handleVetVisitReminderClick} type="button">
|
||||
Review vet visits
|
||||
</button>
|
||||
</article>
|
||||
) : null}
|
||||
)}
|
||||
<article className="summary-card">
|
||||
<span>Weekly flock changes</span>
|
||||
{flockWeeklyTrendItems.length ? (
|
||||
@@ -4247,8 +4405,14 @@ function App() {
|
||||
<>
|
||||
<section className="profile-hero">
|
||||
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
||||
{selectedBird.publicProfileEnabled && selectedBird.publicProfileCode ? (
|
||||
<button className="qr-profile-button" onClick={() => setQrBird(selectedBird)} type="button" aria-label={`Open QR code for ${selectedBird.name}`}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M3 3h7v7H3V3Zm2 2v3h3V5H5Zm9-2h7v7h-7V3Zm2 2v3h3V5h-3ZM3 14h7v7H3v-7Zm2 2v3h3v-3H5Zm10-1h2v2h-2v-2Zm4 0h2v2h-2v-2Zm-5 4h2v2h-2v-2Zm3-2h2v2h-2v-2Zm2 2h2v2h-2v-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="profile-copy">
|
||||
<p className="eyebrow">Profile</p>
|
||||
<h3 className="profile-title">
|
||||
<span>{selectedBird.name}</span>
|
||||
<span
|
||||
@@ -4266,10 +4430,6 @@ function App() {
|
||||
</section>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Band ID</span>
|
||||
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Hatch Day</span>
|
||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||
@@ -4278,10 +4438,6 @@ function App() {
|
||||
<span>Gotcha day</span>
|
||||
<strong>{formatDate(selectedBird.gotchaDay)}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Species</span>
|
||||
<strong>{selectedBird.species}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Gender</span>
|
||||
<strong className="detail-gender">
|
||||
@@ -5333,6 +5489,14 @@ function App() {
|
||||
placeholder="Optional if unknown"
|
||||
/>
|
||||
</label>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={birdForm.publicProfileEnabled}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, publicProfileEnabled: event.target.checked })}
|
||||
/>
|
||||
<span>Enable QR public profile</span>
|
||||
</label>
|
||||
<label className="species-picker-field wide-field">
|
||||
Species
|
||||
<div className="species-picker">
|
||||
@@ -5897,6 +6061,38 @@ function App() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{qrBird?.publicProfileCode ? (
|
||||
<div className="app-modal-backdrop" role="presentation" onClick={() => setQrBird(null)}>
|
||||
<section
|
||||
className="app-modal qr-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="qr-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="panel-header no-print">
|
||||
<div>
|
||||
<p className="eyebrow">QR profile</p>
|
||||
<h2 id="qr-modal-title">{qrBird.name}</h2>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="secondary-button" onClick={() => window.print()} type="button">
|
||||
Print
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => setQrBird(null)} type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="qr-print-card">
|
||||
<QrCodeWithLogo value={getPublicProfileUrl(qrBird.publicProfileCode)} label={`QR code for ${qrBird.name}`} />
|
||||
<h3>{qrBird.name}</h3>
|
||||
<p>{getPublicProfileUrl(qrBird.publicProfileCode)}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showWeightAlertModal ? (
|
||||
<div className="app-modal-backdrop" role="presentation" onClick={() => setShowWeightAlertModal(false)}>
|
||||
<section
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
+273
-2
@@ -122,6 +122,70 @@ textarea {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.top-alert-notification {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(203, 58, 53, 0.26);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 247, 244, 0.98), rgba(255, 238, 231, 0.96));
|
||||
box-shadow: 0 16px 30px rgba(203, 58, 53, 0.14);
|
||||
}
|
||||
|
||||
.top-alert-notification div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.top-alert-notification strong {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.top-alert-notification span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(203, 58, 53, 0.12);
|
||||
border: 1px solid rgba(203, 58, 53, 0.22);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 7px;
|
||||
width: 12px;
|
||||
height: 15px;
|
||||
border: 2px solid var(--accent-red);
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 4px 4px;
|
||||
}
|
||||
|
||||
.notification-bell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 22px;
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-top: 2px solid var(--accent-red);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.top-alert-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
@@ -155,6 +219,41 @@ textarea {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.public-profile-shell {
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.public-profile-card {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-profile-photo {
|
||||
width: min(260px, 100%);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(39, 105, 179, 0.16);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.public-profile-copy {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.public-profile-copy h1 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.auth-hero-card {
|
||||
min-height: 280px;
|
||||
align-items: end;
|
||||
@@ -948,6 +1047,32 @@ textarea {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
||||
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-profile-button {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid rgba(39, 105, 179, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 254, 250, 0.9);
|
||||
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
||||
}
|
||||
|
||||
.qr-profile-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(35, 138, 90, 0.34);
|
||||
}
|
||||
|
||||
.qr-profile-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--accent-blue);
|
||||
}
|
||||
|
||||
.profile-copy {
|
||||
@@ -1371,6 +1496,21 @@ label {
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding-top: 1.75rem;
|
||||
}
|
||||
|
||||
.toggle-field input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
@@ -1555,11 +1695,79 @@ label {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.qr-modal {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.qr-print-card {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
border-radius: 22px;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.qr-bird-mark rect {
|
||||
fill: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.qr-print-card h3,
|
||||
.qr-print-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-print-card p {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-alert-list {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body::before,
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-modal-backdrop {
|
||||
position: static;
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app-modal {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.qr-print-card {
|
||||
min-height: 100vh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell,
|
||||
.auth-panel,
|
||||
@@ -1577,6 +1785,7 @@ label {
|
||||
|
||||
.app-shell {
|
||||
padding: 1rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
@@ -1588,11 +1797,73 @@ label {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
position: static;
|
||||
.top-alert-notification {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.top-alert-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: static;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.side-nav.panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.page-tabs {
|
||||
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.page-tab {
|
||||
min-height: 42px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.side-nav .secondary-button {
|
||||
min-height: 42px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-switcher {
|
||||
grid-column: 1 / -1;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.workspace-switcher-list {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.workspace-switcher-item {
|
||||
min-width: 160px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.workspace-switcher-item small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user