Fixed delete workflow and added additional profile info

This commit is contained in:
Corey Blais
2026-05-20 17:12:15 -04:00
parent 5db30022eb
commit 0db90aab45
9 changed files with 403 additions and 248 deletions
+31 -1
View File
@@ -224,6 +224,9 @@ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')), tagId: z.string().trim().max(80).optional().or(z.literal('')),
species: z.string().trim().min(1).max(120), species: z.string().trim().min(1).max(120),
motivators: z.string().trim().max(1000).optional().or(z.literal('')),
demotivators: z.string().trim().max(1000).optional().or(z.literal('')),
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(), gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -474,6 +477,9 @@ const normalizeBird = (row: BirdRow) => ({
name: row.name, name: row.name,
tagId: normalizeBandId(row.tag_id), tagId: normalizeBandId(row.tag_id),
species: row.species, species: row.species,
motivators: row.motivators,
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
gender: row.gender, gender: row.gender,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day, gotchaDay: row.gotcha_day,
@@ -790,6 +796,21 @@ const syncWorkspaceStripeBilling = async (workspaceId: number) => {
); );
}; };
const cancelWorkspaceStripeSubscription = async (workspace: WorkspaceRow) => {
if (workspace.workspace_type === 'rescue' || (!workspace.stripe_subscription_id && !workspace.stripe_customer_id)) {
return null;
}
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
if (!subscription || subscription.status === 'canceled') {
return null;
}
await getStripeClient().subscriptions.cancel(subscription.id);
return subscription.id;
};
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => { const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
if (billingPlan === 'rescue_free') { if (billingPlan === 'rescue_free') {
throw new Error('Rescue flocks do not use Stripe billing.'); throw new Error('Rescue flocks do not use Stripe billing.');
@@ -2347,13 +2368,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
return; return;
} }
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id); let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
if (!nextWorkspaceId) { if (!nextWorkspaceId) {
const fallbackWorkspaceId = await getNextWorkspaceId(); const fallbackWorkspaceId = await getNextWorkspaceId();
const fallbackWorkspace = await createWorkspace({ const fallbackWorkspace = await createWorkspace({
id: fallbackWorkspaceId, id: fallbackWorkspaceId,
name: `${req.auth!.user.name}'s Flock`, name: 'New Flock',
workspaceType: 'standard', workspaceType: 'standard',
billingEmail: req.auth!.user.email, billingEmail: req.auth!.user.email,
billingPlan: 'household_basic', billingPlan: 'household_basic',
@@ -2382,6 +2405,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
res.json({ res.json({
deletedWorkspaceId: req.auth!.workspace.id, deletedWorkspaceId: req.auth!.workspace.id,
canceledStripeSubscriptionId,
token: req.auth!.token, token: req.auth!.token,
session: await buildSessionPayload(updatedAuth), session: await buildSessionPayload(updatedAuth),
}); });
@@ -2492,6 +2516,9 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
name: parsed.data.name, name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId), tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species, species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -2613,6 +2640,9 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
name: parsed.data.name, name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId), tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species, species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay), gotchaDay: emptyToNull(parsed.data.gotchaDay),
+12 -4
View File
@@ -58,10 +58,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE workspace_type = 'rescue' WHERE workspace_type = 'rescue'
AND rescue_verification_status = 'not_required'; AND rescue_verification_status = 'not_required';
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
VALUES (1, 'My Flock', 'standard', 'household_basic')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS workspace_members ( CREATE TABLE IF NOT EXISTS workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -201,6 +197,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80), tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL, species VARCHAR(120) NOT NULL,
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown', gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE, date_of_birth DATE,
gotcha_day DATE, gotcha_day DATE,
@@ -217,6 +216,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds ALTER TABLE birds
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS date_of_birth DATE,
ADD COLUMN IF NOT EXISTS gotcha_day DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE,
@@ -246,6 +248,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
END IF; END IF;
END $$; END $$;
DELETE FROM workspaces
WHERE id = 1
AND name = 'My Flock'
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id)
AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id);
ALTER TABLE birds ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key; DROP CONSTRAINT IF EXISTS birds_tag_id_key;
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tag_id: 'A-1', tag_id: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favorite_snack: 'Millet',
gender: 'female', gender: 'female',
date_of_birth: null, date_of_birth: null,
gotcha_day: null, gotcha_day: null,
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi', name: 'Kiwi',
tagId: 'A-1', tagId: 'A-1',
species: 'Cockatiel', species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favoriteSnack: 'Millet',
gender: 'female', gender: 'female',
dateOfBirth: null, dateOfBirth: null,
gotchaDay: null, gotchaDay: null,
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
assert.equal(bird?.name, 'Kiwi'); assert.equal(bird?.name, 'Kiwi');
assert.equal(bird?.workspace_id, 10); assert.equal(bird?.workspace_id, 10);
assert.equal(bird?.gender, 'female'); assert.equal(bird?.gender, 'female');
assert.equal(bird?.favorite_snack, 'Millet');
}); });
test('listWeightsForBird scopes by bird, workspace, and day window', async () => { test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
+63 -14
View File
@@ -20,6 +20,9 @@ const birdSelectFields = `
birds.name, birds.name,
birds.tag_id, birds.tag_id,
birds.species, birds.species,
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.gender, birds.gender,
birds.date_of_birth::text, birds.date_of_birth::text,
birds.gotcha_day::text, birds.gotcha_day::text,
@@ -254,6 +257,9 @@ export const createBird = async ({
name, name,
tagId, tagId,
species, species,
motivators,
demotivators,
favoriteSnack,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -266,6 +272,9 @@ export const createBird = async ({
name: string; name: string;
tagId: string | null; tagId: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
@@ -275,10 +284,25 @@ export const createBird = async ({
notifyOnGotchaDay: boolean; notifyOnGotchaDay: boolean;
}) => { }) => {
const result = await db.query<BirdRow>( const result = await db.query<BirdRow>(
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day) `INSERT INTO birds (workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay], [
workspaceId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -290,6 +314,9 @@ export const updateBird = async ({
name, name,
tagId, tagId,
species, species,
motivators,
demotivators,
favoriteSnack,
gender, gender,
dateOfBirth, dateOfBirth,
gotchaDay, gotchaDay,
@@ -303,6 +330,9 @@ export const updateBird = async ({
name: string; name: string;
tagId: string | null; tagId: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
@@ -316,17 +346,20 @@ export const updateBird = async ({
SET name = $2, SET name = $2,
tag_id = $3, tag_id = $3,
species = $4, species = $4,
gender = $5, motivators = $5,
date_of_birth = $6, demotivators = $6,
gotcha_day = $7, favorite_snack = $7,
chart_color = $8, gender = $8,
photo_data_url = $9, date_of_birth = $9,
notify_on_dob = $10, gotcha_day = $10,
notify_on_gotcha_day = $11 chart_color = $11,
photo_data_url = $12,
notify_on_dob = $13,
notify_on_gotcha_day = $14
WHERE id = $1 WHERE id = $1
AND workspace_id = $12 AND workspace_id = $15
AND memorialized_at IS NULL AND memorialized_at IS NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
( (
SELECT weight_grams::text SELECT weight_grams::text
FROM weight_records FROM weight_records
@@ -341,7 +374,23 @@ export const updateBird = async ({
ORDER BY recorded_on DESC ORDER BY recorded_on DESC
LIMIT 1 LIMIT 1
) AS latest_recorded_on`, ) AS latest_recorded_on`,
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId], [
birdId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
chartColor,
photoDataUrl,
notifyOnDob,
notifyOnGotchaDay,
workspaceId,
],
); );
return result.rows[0] ?? null; return result.rows[0] ?? null;
@@ -34,6 +34,36 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
assert.match(calls[0].text, /FROM workspace_members/); assert.match(calls[0].text, /FROM workspace_members/);
}); });
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensurePersonalWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 4);
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
assert.match(calls[2].text, /INSERT INTO workspaces/);
assert.match(calls[3].text, /INSERT INTO workspace_members/);
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
});
test('createWorkspace inserts owner membership and returns the created workspace', async () => { test('createWorkspace inserts owner membership and returns the created workspace', async () => {
const { calls } = mockDb( const { calls } = mockDb(
{ rowCount: 1, rows: [] }, { rowCount: 1, rows: [] },
@@ -91,39 +91,13 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return Number(existing.rows[0].workspace_id); return Number(existing.rows[0].workspace_id);
} }
const unclaimed = await db.query<{ workspace_id: number }>( const workspaceId = await getNextWorkspaceId();
`SELECT workspaces.id AS workspace_id
FROM workspaces
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.id = 1
GROUP BY workspaces.id
HAVING COUNT(workspace_members.id) = 0
LIMIT 1`,
);
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
if (!unclaimed.rowCount) {
await db.query( await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status) `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`, VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email], [workspaceId, `${user.name}'s Flock`, user.email],
); );
} else {
await db.query(
`UPDATE workspaces
SET name = $2,
workspace_type = 'standard',
billing_plan = 'household_basic',
billing_interval = 'monthly',
billing_email = $3,
subscription_status = 'none',
rescue_verification_status = 'not_required',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
}
await db.query( await db.query(
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at) `INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
+3
View File
@@ -98,6 +98,9 @@ export type BirdRow = {
name: string; name: string;
tag_id: string | null; tag_id: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
gender: BirdGender; gender: BirdGender;
date_of_birth: string | null; date_of_birth: string | null;
gotcha_day: string | null; gotcha_day: string | null;
+251 -193
View File
@@ -19,6 +19,9 @@ type Bird = {
name: string; name: string;
tagId: string | null; tagId: string | null;
species: string; species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
gotchaDay: string | null; gotchaDay: string | null;
@@ -179,6 +182,9 @@ type BirdFormState = {
name: string; name: string;
tagId: string; tagId: string;
species: string; species: string;
motivators: string;
demotivators: string;
favoriteSnack: string;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string; dateOfBirth: string;
gotchaDay: string; gotchaDay: string;
@@ -315,6 +321,9 @@ const emptyBirdForm: BirdFormState = {
name: '', name: '',
tagId: '', tagId: '',
species: '', species: '',
motivators: '',
demotivators: '',
favoriteSnack: '',
gender: 'unknown', gender: 'unknown',
dateOfBirth: '', dateOfBirth: '',
gotchaDay: '', gotchaDay: '',
@@ -437,6 +446,9 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name, name: bird.name,
tagId: bird.tagId ?? '', tagId: bird.tagId ?? '',
species: bird.species, species: bird.species,
motivators: bird.motivators ?? '',
demotivators: bird.demotivators ?? '',
favoriteSnack: bird.favoriteSnack ?? '',
gender: bird.gender, gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '', dateOfBirth: bird.dateOfBirth ?? '',
gotchaDay: bird.gotchaDay ?? '', gotchaDay: bird.gotchaDay ?? '',
@@ -2094,7 +2106,7 @@ function App() {
} }
}; };
const handleWorkspaceSwitch = async (workspaceId: number) => { const handleWorkspaceSwitch = async (workspaceId: number, nextActivePage: AppPage = 'overview') => {
if (!authToken || workspaceId === workspace?.id) { if (!authToken || workspaceId === workspace?.id) {
return; return;
} }
@@ -2129,7 +2141,7 @@ function App() {
setMedications([]); setMedications([]);
setMedicationAdministrations([]); setMedicationAdministrations([]);
setAllBirdVetVisits({}); setAllBirdVetVisits({});
setActivePage('overview'); setActivePage(nextActivePage);
} catch (switchError) { } catch (switchError) {
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.'); setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
} finally { } finally {
@@ -2279,6 +2291,7 @@ function App() {
throw new Error(await readErrorMessage(response, 'Unable to create flock.')); throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
} }
const createdData = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
const workspaceResponse = await apiFetch('/auth/session', authToken); const workspaceResponse = await apiFetch('/auth/session', authToken);
if (!workspaceResponse.ok) { if (!workspaceResponse.ok) {
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.')); throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
@@ -2296,6 +2309,11 @@ function App() {
...emptyWorkspaceCreateForm, ...emptyWorkspaceCreateForm,
billingEmail: data.session.user.email, billingEmail: data.session.user.email,
}); });
setExpandedSettingsSection(null);
if (createdData.workspace) {
await handleWorkspaceSwitch(createdData.workspace.id, 'settings');
}
} catch (workspaceError) { } catch (workspaceError) {
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.'); setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
} finally { } finally {
@@ -3205,7 +3223,7 @@ function App() {
} }
const confirmed = window.confirm( const confirmed = window.confirm(
`Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first.\n\nYou will be switched to another flock or a new personal flock automatically.`, `Delete ${workspace.name}?\n\nThis only works when the flock has no birds. Remove or transfer all birds first. If this flock has a Stripe subscription, FlockPal will cancel it before deleting the flock.\n\nYou will be switched to another flock or a new empty flock automatically.`,
); );
if (!confirmed) { if (!confirmed) {
@@ -4247,10 +4265,6 @@ function App() {
</section> </section>
<div className="detail-grid"> <div className="detail-grid">
<article className="detail-card">
<span>Name</span>
<strong>{selectedBird.name}</strong>
</article>
<article className="detail-card"> <article className="detail-card">
<span>Band ID</span> <span>Band ID</span>
<strong>{selectedBird.tagId || 'Not recorded'}</strong> <strong>{selectedBird.tagId || 'Not recorded'}</strong>
@@ -4267,6 +4281,18 @@ function App() {
<span>Species</span> <span>Species</span>
<strong>{selectedBird.species}</strong> <strong>{selectedBird.species}</strong>
</article> </article>
<article className="detail-card">
<span>Favorite snack</span>
<strong>{selectedBird.favoriteSnack || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Motivators</span>
<strong>{selectedBird.motivators || 'Not recorded'}</strong>
</article>
<article className="detail-card">
<span>Demotivates</span>
<strong>{selectedBird.demotivators || 'Not recorded'}</strong>
</article>
<article className="detail-card"> <article className="detail-card">
<span>Gender</span> <span>Gender</span>
<strong className="detail-gender"> <strong className="detail-gender">
@@ -4559,11 +4585,35 @@ function App() {
<p className="eyebrow">Flock</p> <p className="eyebrow">Flock</p>
<h2>Flock profile</h2> <h2>Flock profile</h2>
</div> </div>
<button
className="secondary-button"
onClick={() => setExpandedSettingsSection((current) => (current === 'new-workspace' ? null : 'new-workspace'))}
type="button"
aria-expanded={expandedSettingsSection === 'new-workspace'}
>
{expandedSettingsSection === 'new-workspace' ? 'Close' : 'New flock'}
</button>
</div> </div>
<p className="muted"> <p className="muted">
Manage this flock's name and type. Household billing details live in the Billing info card below. Choose the flock to manage here. Household billing details live in the Billing info card below.
</p> </p>
<form className="form-panel" onSubmit={handleWorkspaceSubmit}> <form className="form-panel" onSubmit={handleWorkspaceSubmit}>
{authSession.workspaces.length > 1 ? (
<label>
Flock
<select
value={workspace?.id ?? ''}
onChange={(event) => handleWorkspaceSwitch(Number(event.target.value), 'settings')}
disabled={switchingWorkspaceId !== null || savingWorkspace || deletingWorkspace}
>
{authSession.workspaces.map((entry) => (
<option key={entry.workspace.id} value={entry.workspace.id}>
{entry.workspace.name}
</option>
))}
</select>
</label>
) : null}
<label> <label>
Flock name Flock name
<input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required /> <input value={workspaceForm.name} onChange={(event) => setWorkspaceForm({ ...workspaceForm, name: event.target.value })} required />
@@ -4688,6 +4738,175 @@ function App() {
<small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small> <small className="muted">Delete is only available when this flock has no birds. Collaborators and tokens are removed with it.</small>
) : null} ) : null}
</form> </form>
{expandedSettingsSection === 'new-workspace' ? (
<section className="settings-subsection">
<div className="settings-nested-header">
<p className="eyebrow">New flock</p>
<h3>Add an additional flock</h3>
</div>
<p className="muted">
Use this only when you need a separate flock. To turn the current household flock into a rescue, update the current flock type above.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Flock name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
required
/>
</label>
<label>
Flock type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) => {
const workspaceType = event.target.value as WorkspaceCreateFormState['workspaceType'];
setWorkspaceCreateForm({
...workspaceCreateForm,
workspaceType,
rescueOnboarding:
workspaceType === 'rescue'
? {
...workspaceCreateForm.rescueOnboarding,
name: workspaceCreateForm.rescueOnboarding.name || workspaceCreateForm.name,
}
: workspaceCreateForm.rescueOnboarding,
});
}}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
value={workspaceCreateForm.billingPlan}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingPlan: event.target.value as WorkspaceCreateFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure (4 birds)</option>
<option value="household_plus">Indian Ringneck (10 birds)</option>
<option value="household_macaw">Macaw (11+)</option>
</select>
</label>
<label>
Billing frequency
<select
value={workspaceCreateForm.billingInterval}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingInterval: event.target.value as WorkspaceCreateFormState['billingInterval'],
})
}
>
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'monthly')}</option>
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'yearly')}</option>
</select>
</label>
<article className="summary-card">
<strong>
{formatBillingPlanName(workspaceCreateForm.billingPlan)} {' '}
{formatBillingIntervalName(workspaceCreateForm.billingInterval)}
</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : (
<>
<section className="settings-nested-card">
<h3>Rescue onboarding</h3>
<label>
Rescue Name
<input
value={workspaceCreateForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceCreateForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceCreateForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceCreateForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceCreateForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue flocks.</span>
</article>
</>
)}
<label>
Billing contact email
<input
type="email"
value={workspaceCreateForm.billingEmail}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, billingEmail: event.target.value })}
placeholder="Used for household billing and account notices"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button>
</form>
</section>
) : null}
</article> </article>
<article className="panel form-panel settings-card-billing"> <article className="panel form-panel settings-card-billing">
@@ -5021,191 +5240,6 @@ function App() {
) : null} ) : null}
</article> </article>
<article className="panel form-panel settings-card-separate-flock">
<div className="panel-header">
<div>
<p className="eyebrow">Separate flock</p>
<h2>Add an additional flock</h2>
</div>
<button
className="secondary-button"
onClick={() =>
setExpandedSettingsSection((current) => (current === 'new-workspace' ? null : 'new-workspace'))
}
type="button"
aria-expanded={expandedSettingsSection === 'new-workspace'}
>
{expandedSettingsSection === 'new-workspace' ? 'Close' : 'Open'}
</button>
</div>
{expandedSettingsSection === 'new-workspace' ? (
<>
<p className="muted">
Use this only when you need a separate flock. To turn the current household flock into a rescue, use Flock profile and
billing above instead.
</p>
<form className="form-panel" onSubmit={handleCreateWorkspace}>
<label>
Flock name
<input
value={workspaceCreateForm.name}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, name: event.target.value })}
required
/>
</label>
<label>
Flock type
<select
value={workspaceCreateForm.workspaceType}
onChange={(event) => {
const workspaceType = event.target.value as WorkspaceCreateFormState['workspaceType'];
setWorkspaceCreateForm({
...workspaceCreateForm,
workspaceType,
rescueOnboarding:
workspaceType === 'rescue'
? {
...workspaceCreateForm.rescueOnboarding,
name: workspaceCreateForm.rescueOnboarding.name || workspaceCreateForm.name,
}
: workspaceCreateForm.rescueOnboarding,
});
}}
>
<option value="standard">Standard household</option>
<option value="rescue">Rescue</option>
</select>
</label>
{workspaceCreateForm.workspaceType === 'standard' ? (
<>
<label>
Household plan
<select
value={workspaceCreateForm.billingPlan}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingPlan: event.target.value as WorkspaceCreateFormState['billingPlan'],
})
}
>
<option value="household_basic">Conure (4 birds)</option>
<option value="household_plus">Indian Ringneck (10 birds)</option>
<option value="household_macaw">Macaw (11+)</option>
</select>
</label>
<label>
Billing frequency
<select
value={workspaceCreateForm.billingInterval}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
billingInterval: event.target.value as WorkspaceCreateFormState['billingInterval'],
})
}
>
<option value="monthly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'monthly')}</option>
<option value="yearly">{formatBillingIntervalDropdownLabel(workspaceCreateForm.billingPlan, 'yearly')}</option>
</select>
</label>
<article className="summary-card">
<strong>
{formatBillingPlanName(workspaceCreateForm.billingPlan)} {' '}
{formatBillingIntervalName(workspaceCreateForm.billingInterval)}
</strong>
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
</article>
</>
) : (
<>
<section className="settings-nested-card">
<h3>Rescue onboarding</h3>
<label>
Rescue Name
<input
value={workspaceCreateForm.rescueOnboarding.name}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, name: event.target.value },
})
}
/>
</label>
<label>
City
<input
value={workspaceCreateForm.rescueOnboarding.city}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, city: event.target.value },
})
}
/>
</label>
<label>
State
<input
value={workspaceCreateForm.rescueOnboarding.state}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, state: event.target.value },
})
}
/>
</label>
<label>
EIN
<input
value={workspaceCreateForm.rescueOnboarding.ein}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, ein: event.target.value },
})
}
/>
</label>
<label>
Website
<input
type="url"
value={workspaceCreateForm.rescueOnboarding.website}
onChange={(event) =>
setWorkspaceCreateForm({
...workspaceCreateForm,
rescueOnboarding: { ...workspaceCreateForm.rescueOnboarding, website: event.target.value },
})
}
/>
</label>
</section>
<article className="summary-card">
<strong>{formatBillingPlanName('rescue_free')}</strong>
<span>No billing is applied to rescue flocks.</span>
</article>
</>
)}
<label>
Billing contact email
<input
type="email"
value={workspaceCreateForm.billingEmail}
onChange={(event) => setWorkspaceCreateForm({ ...workspaceCreateForm, billingEmail: event.target.value })}
placeholder="Used for household billing and account notices"
/>
</label>
<button className="primary-button" type="submit" disabled={creatingWorkspace}>
{creatingWorkspace ? 'Creating flock...' : 'Create flock'}
</button>
</form>
</>
) : null}
</article>
<article className="panel form-panel settings-card-bird-profiles"> <article className="panel form-panel settings-card-bird-profiles">
<div className="panel-header"> <div className="panel-header">
<div> <div>
@@ -5312,6 +5346,30 @@ function App() {
</div> </div>
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small> <small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
</label> </label>
<label>
Favorite snack
<input
value={birdForm.favoriteSnack}
onChange={(event) => setBirdForm({ ...birdForm, favoriteSnack: event.target.value })}
placeholder="Optional"
/>
</label>
<label className="wide-field">
Motivators
<textarea
value={birdForm.motivators}
onChange={(event) => setBirdForm({ ...birdForm, motivators: event.target.value })}
placeholder="Training rewards, sounds, people, toys, routines"
/>
</label>
<label className="wide-field">
Demotivates
<textarea
value={birdForm.demotivators}
onChange={(event) => setBirdForm({ ...birdForm, demotivators: event.target.value })}
placeholder="Stressors, disliked handling, noises, situations"
/>
</label>
<div className="segmented-field wide-field"> <div className="segmented-field wide-field">
<span>Gender</span> <span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender"> <div className="segmented-control" role="radiogroup" aria-label="Bird gender">
-4
View File
@@ -513,10 +513,6 @@ textarea {
order: 2; order: 2;
} }
.settings-card-separate-flock {
order: 3;
}
.settings-card-automation { .settings-card-automation {
order: 4; order: 4;
} }