Added missing bird
This commit is contained in:
+148
-1
@@ -31,6 +31,7 @@ import {
|
|||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
|
findBirdsByBandId,
|
||||||
createVetVisitForBird,
|
createVetVisitForBird,
|
||||||
createWeightForBird,
|
createWeightForBird,
|
||||||
deleteBird,
|
deleteBird,
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
|
listWorkspaceNotificationEmails,
|
||||||
listWorkspaceMembers,
|
listWorkspaceMembers,
|
||||||
setWorkspaceStripeCustomerId,
|
setWorkspaceStripeCustomerId,
|
||||||
setWorkspaceStripeSubscription,
|
setWorkspaceStripeSubscription,
|
||||||
@@ -75,6 +77,7 @@ import type {
|
|||||||
BirdGender,
|
BirdGender,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
|
LostBirdMatchRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
RescueVerificationStatus,
|
RescueVerificationStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -108,7 +111,16 @@ if (trustProxy) {
|
|||||||
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAllowedOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173'];
|
const defaultAllowedOrigins = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://127.0.0.1:3000',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'http://localhost:8088',
|
||||||
|
'http://127.0.0.1:8088',
|
||||||
|
'https://flockpal.app',
|
||||||
|
'https://www.flockpal.app',
|
||||||
|
];
|
||||||
|
|
||||||
const allowedOrigins = Array.from(
|
const allowedOrigins = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -172,6 +184,14 @@ const flockTransferSchema = z.object({
|
|||||||
destinationOwnerEmail: z.string().trim().email().max(255),
|
destinationOwnerEmail: z.string().trim().email().max(255),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lostBirdReportSchema = z.object({
|
||||||
|
tagId: z.string().trim().min(1).max(80),
|
||||||
|
finderName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
finderEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
||||||
|
foundLocation: z.string().trim().max(255).optional().or(z.literal('')),
|
||||||
|
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
|
});
|
||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().min(1).max(80),
|
tagId: z.string().trim().min(1).max(80),
|
||||||
@@ -456,6 +476,13 @@ app.use(
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const lostBirdReportLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
limit: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many found bird reports. Please try again later.' },
|
||||||
|
});
|
||||||
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
|
||||||
if (!stripeWebhookSecret) {
|
if (!stripeWebhookSecret) {
|
||||||
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
res.status(503).json({ error: 'Stripe webhook is not configured.' });
|
||||||
@@ -846,6 +873,71 @@ const issueBirdTransferInvite = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendLostBirdReportNotification = async ({
|
||||||
|
bird,
|
||||||
|
recipients,
|
||||||
|
report,
|
||||||
|
}: {
|
||||||
|
bird: LostBirdMatchRow;
|
||||||
|
recipients: string[];
|
||||||
|
report: z.infer<typeof lostBirdReportSchema>;
|
||||||
|
}) => {
|
||||||
|
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!uniqueRecipients.length) {
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finderName = emptyToNull(report.finderName) ?? 'Not provided';
|
||||||
|
const finderEmail = emptyToNull(report.finderEmail) ?? 'Not provided';
|
||||||
|
const foundLocation = emptyToNull(report.foundLocation) ?? 'Not provided';
|
||||||
|
const message = emptyToNull(report.message) ?? 'Not provided';
|
||||||
|
const subject = `Possible found bird report for ${bird.name}`;
|
||||||
|
const lines = [
|
||||||
|
`A possible found bird report was submitted for ${bird.name}.`,
|
||||||
|
'',
|
||||||
|
`Band ID: ${bird.tag_id}`,
|
||||||
|
`Species: ${bird.species}`,
|
||||||
|
`Flock: ${bird.workspace_name}`,
|
||||||
|
'',
|
||||||
|
`Finder name: ${finderName}`,
|
||||||
|
`Finder email: ${finderEmail}`,
|
||||||
|
`Found location: ${foundLocation}`,
|
||||||
|
`Message: ${message}`,
|
||||||
|
'',
|
||||||
|
'FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mailTransport) {
|
||||||
|
console.log(`Found bird report for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
||||||
|
return { delivered: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailTransport.sendMail({
|
||||||
|
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||||
|
to: smtpFromEmail,
|
||||||
|
bcc: uniqueRecipients,
|
||||||
|
replyTo: emptyToNull(report.finderEmail) ?? undefined,
|
||||||
|
subject,
|
||||||
|
text: lines.join('\n'),
|
||||||
|
html: `
|
||||||
|
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id)}</li>
|
||||||
|
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
|
||||||
|
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
|
||||||
|
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
|
||||||
|
<li><strong>Finder email:</strong> ${escapeHtml(finderEmail)}</li>
|
||||||
|
<li><strong>Found location:</strong> ${escapeHtml(foundLocation)}</li>
|
||||||
|
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
|
||||||
|
</ul>
|
||||||
|
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { delivered: true };
|
||||||
|
};
|
||||||
|
|
||||||
const readBearerToken = (authorizationHeader?: string) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
return '';
|
||||||
@@ -956,6 +1048,61 @@ app.get('/api/health', (_req: Request, res: Response) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, res: Response) => {
|
||||||
|
const parsed = lostBirdReportSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid found bird report', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = await findBirdsByBandId(parsed.data.tagId);
|
||||||
|
let deliveredCount = 0;
|
||||||
|
|
||||||
|
for (const bird of matches) {
|
||||||
|
try {
|
||||||
|
const recipients = await listWorkspaceNotificationEmails(bird.workspace_id);
|
||||||
|
const delivery = await sendLostBirdReportNotification({
|
||||||
|
bird,
|
||||||
|
recipients,
|
||||||
|
report: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (delivery.delivered) {
|
||||||
|
deliveredCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lost bird notification failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
res.json({
|
||||||
|
status: 'not_found',
|
||||||
|
message: 'That band ID is not currently in the FlockPal system.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveredCount > 0) {
|
||||||
|
res.json({
|
||||||
|
status: 'contacted',
|
||||||
|
message: 'A matching bird was found and the flock contacts were notified.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(503).json({
|
||||||
|
status: 'not_contacted',
|
||||||
|
error: 'A matching bird was found, but FlockPal could not notify the flock right now. Please contact FlockPal support.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lost bird report handling failed', error);
|
||||||
|
res.status(500).json({ error: 'Unable to process this found bird report right now.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
providers: Object.values(oauthProviders).map((provider) => ({
|
providers: Object.values(oauthProviders).map((provider) => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import type { BirdGender, BirdRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
|
import type { BirdGender, BirdRow, LostBirdMatchRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js';
|
||||||
|
|
||||||
const birdSelectFields = `
|
const birdSelectFields = `
|
||||||
birds.id,
|
birds.id,
|
||||||
@@ -59,6 +59,30 @@ export const listBirds = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findBirdsByBandId = async (tagId: string) => {
|
||||||
|
const result = await db.query<LostBirdMatchRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
workspaces.billing_email AS workspace_billing_email
|
||||||
|
FROM birds
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE LOWER(birds.tag_id) = LOWER($1)
|
||||||
|
ORDER BY birds.created_at ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
[tagId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -250,6 +250,27 @@ export const listWorkspaceMembers = async (workspaceId: number) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listWorkspaceNotificationEmails = async (workspaceId: number) => {
|
||||||
|
const result = await db.query<{ email: string }>(
|
||||||
|
`SELECT DISTINCT LOWER(TRIM(email)) AS email
|
||||||
|
FROM (
|
||||||
|
SELECT COALESCE(invite_email, email) AS email
|
||||||
|
FROM workspace_members
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT billing_email AS email
|
||||||
|
FROM workspaces
|
||||||
|
WHERE id = $1
|
||||||
|
) contact_emails
|
||||||
|
WHERE email IS NOT NULL
|
||||||
|
AND TRIM(email) <> ''
|
||||||
|
ORDER BY email ASC`,
|
||||||
|
[workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => row.email);
|
||||||
|
};
|
||||||
|
|
||||||
export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => {
|
export const findAlternateWorkspaceForUser = async (userId: string, excludeWorkspaceId: number) => {
|
||||||
const result = await db.query<{ workspace_id: number }>(
|
const result = await db.query<{ workspace_id: number }>(
|
||||||
`SELECT workspace_id
|
`SELECT workspace_id
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ export type BirdRow = {
|
|||||||
latest_recorded_on: string | null;
|
latest_recorded_on: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LostBirdMatchRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
workspace_billing_email: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PendingBirdTransferRow = {
|
export type PendingBirdTransferRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ type AuthFormState = {
|
|||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LostBirdReportFormState = {
|
||||||
|
tagId: string;
|
||||||
|
finderEmail: string;
|
||||||
|
foundLocation: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AuthNotice = {
|
type AuthNotice = {
|
||||||
message: string;
|
message: string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
@@ -280,6 +287,13 @@ const emptyAuthForm: AuthFormState = {
|
|||||||
email: '',
|
email: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyLostBirdReportForm: LostBirdReportFormState = {
|
||||||
|
tagId: '',
|
||||||
|
finderEmail: '',
|
||||||
|
foundLocation: '',
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
||||||
name: '',
|
name: '',
|
||||||
scope: 'read_write',
|
scope: 'read_write',
|
||||||
@@ -867,6 +881,9 @@ function App() {
|
|||||||
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
|
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [authSubmitting, setAuthSubmitting] = useState(false);
|
const [authSubmitting, setAuthSubmitting] = useState(false);
|
||||||
|
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
|
||||||
|
const [lostBirdReportNotice, setLostBirdReportNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
|
||||||
|
const [lostBirdReportSubmitting, setLostBirdReportSubmitting] = useState(false);
|
||||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||||
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
const [activeMembership, setActiveMembership] = useState<WorkspaceMember | null>(null);
|
||||||
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
const [workspaceMembers, setWorkspaceMembers] = useState<WorkspaceMember[]>([]);
|
||||||
@@ -1533,6 +1550,43 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLostBirdReportSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLostBirdReportNotice(null);
|
||||||
|
setLostBirdReportSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/lost-bird/report', undefined, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tagId: lostBirdReportForm.tagId.trim(),
|
||||||
|
finderEmail: lostBirdReportForm.finderEmail.trim(),
|
||||||
|
foundLocation: lostBirdReportForm.foundLocation.trim(),
|
||||||
|
message: lostBirdReportForm.message.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to send this report right now.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ message?: string }>(response)) ?? {};
|
||||||
|
setLostBirdReportNotice({
|
||||||
|
message: data.message ?? 'Report received.',
|
||||||
|
kind: 'success',
|
||||||
|
});
|
||||||
|
setLostBirdReportForm(emptyLostBirdReportForm);
|
||||||
|
} catch (reportError) {
|
||||||
|
setLostBirdReportNotice({
|
||||||
|
message: reportError instanceof Error ? reportError.message : 'Unable to send this report right now.',
|
||||||
|
kind: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLostBirdReportSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
@@ -2610,6 +2664,57 @@ function App() {
|
|||||||
<p className="muted">
|
<p className="muted">
|
||||||
Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.
|
Keep every bird's care story in one place; your flock's health, history, and routines together and easier to visualize.
|
||||||
</p>
|
</p>
|
||||||
|
<details className="summary-card lost-bird-login-card">
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<span className="eyebrow">Report a missing bird</span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<p className="muted">Enter the band ID and FlockPal will notify the flock if that bird is in the system.</p>
|
||||||
|
<form className="form-panel" onSubmit={handleLostBirdReportSubmit}>
|
||||||
|
<label>
|
||||||
|
Bird band ID
|
||||||
|
<input
|
||||||
|
value={lostBirdReportForm.tagId}
|
||||||
|
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, tagId: event.target.value })}
|
||||||
|
placeholder="Example: ABC-123"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Your email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={lostBirdReportForm.finderEmail}
|
||||||
|
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, finderEmail: event.target.value })}
|
||||||
|
placeholder="Optional, but helpful"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Where was the bird found?
|
||||||
|
<input
|
||||||
|
value={lostBirdReportForm.foundLocation}
|
||||||
|
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, foundLocation: event.target.value })}
|
||||||
|
placeholder="City, neighborhood, or nearby landmark"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message for the flock
|
||||||
|
<textarea
|
||||||
|
value={lostBirdReportForm.message}
|
||||||
|
onChange={(event) => setLostBirdReportForm({ ...lostBirdReportForm, message: event.target.value })}
|
||||||
|
placeholder="Add any safe details that could help the flock contact you."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit" disabled={lostBirdReportSubmitting}>
|
||||||
|
{lostBirdReportSubmitting ? 'Sending report...' : 'Notify the flock'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{lostBirdReportNotice ? (
|
||||||
|
<p className={lostBirdReportNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{lostBirdReportNotice.message}</p>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
@@ -2676,6 +2781,7 @@ function App() {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+61
-2
@@ -152,7 +152,7 @@ textarea {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-hero-card {
|
.auth-hero-card {
|
||||||
@@ -167,7 +167,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-copy {
|
.auth-copy {
|
||||||
align-content: start;
|
position: relative;
|
||||||
|
align-content: stretch;
|
||||||
|
grid-template-rows: auto auto auto 1fr auto;
|
||||||
|
padding-bottom: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -177,6 +180,53 @@ textarea {
|
|||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card[open] {
|
||||||
|
box-shadow: 0 24px 48px rgba(86, 63, 34, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card summary {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card summary::after {
|
||||||
|
content: "Open";
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: rgba(255, 254, 250, 0.72);
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card[open] summary::after {
|
||||||
|
content: "Close";
|
||||||
|
}
|
||||||
|
|
||||||
|
.lost-bird-login-card .eyebrow {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-illustration-card {
|
.auth-illustration-card {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -1192,6 +1242,15 @@ label {
|
|||||||
color: #922728;
|
color: #922728;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success-banner {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(35, 138, 90, 0.1);
|
||||||
|
border: 1px solid rgba(35, 138, 90, 0.2);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
.picker-list {
|
.picker-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user