Fixed delete workflow and added additional profile info
This commit is contained in:
+31
-1
@@ -224,6 +224,9 @@ const birdSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||
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(),
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||
@@ -474,6 +477,9 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
name: row.name,
|
||||
tagId: normalizeBandId(row.tag_id),
|
||||
species: row.species,
|
||||
motivators: row.motivators,
|
||||
demotivators: row.demotivators,
|
||||
favoriteSnack: row.favorite_snack,
|
||||
gender: row.gender,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
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) => {
|
||||
if (billingPlan === 'rescue_free') {
|
||||
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||
@@ -2347,13 +2368,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
||||
return;
|
||||
}
|
||||
|
||||
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
|
||||
|
||||
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
||||
|
||||
if (!nextWorkspaceId) {
|
||||
const fallbackWorkspaceId = await getNextWorkspaceId();
|
||||
const fallbackWorkspace = await createWorkspace({
|
||||
id: fallbackWorkspaceId,
|
||||
name: `${req.auth!.user.name}'s Flock`,
|
||||
name: 'New Flock',
|
||||
workspaceType: 'standard',
|
||||
billingEmail: req.auth!.user.email,
|
||||
billingPlan: 'household_basic',
|
||||
@@ -2382,6 +2405,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
||||
|
||||
res.json({
|
||||
deletedWorkspaceId: req.auth!.workspace.id,
|
||||
canceledStripeSubscriptionId,
|
||||
token: req.auth!.token,
|
||||
session: await buildSessionPayload(updatedAuth),
|
||||
});
|
||||
@@ -2492,6 +2516,9 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
name: parsed.data.name,
|
||||
tagId: normalizeBandId(parsed.data.tagId),
|
||||
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,
|
||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||
@@ -2613,6 +2640,9 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
name: parsed.data.name,
|
||||
tagId: normalizeBandId(parsed.data.tagId),
|
||||
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,
|
||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||
|
||||
@@ -58,10 +58,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
WHERE workspace_type = 'rescue'
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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,
|
||||
tag_id VARCHAR(80),
|
||||
species VARCHAR(120) NOT NULL,
|
||||
motivators VARCHAR(1000),
|
||||
demotivators VARCHAR(1000),
|
||||
favorite_snack VARCHAR(160),
|
||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||
date_of_birth DATE,
|
||||
gotcha_day DATE,
|
||||
@@ -217,6 +216,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
|
||||
ALTER TABLE birds
|
||||
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 date_of_birth DATE,
|
||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||
@@ -246,6 +248,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
END IF;
|
||||
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
|
||||
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
|
||||
name: 'Kiwi',
|
||||
tag_id: 'A-1',
|
||||
species: 'Cockatiel',
|
||||
motivators: 'Step-up practice',
|
||||
demotivators: 'Vacuum noise',
|
||||
favorite_snack: 'Millet',
|
||||
gender: 'female',
|
||||
date_of_birth: null,
|
||||
gotcha_day: null,
|
||||
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
|
||||
name: 'Kiwi',
|
||||
tagId: 'A-1',
|
||||
species: 'Cockatiel',
|
||||
motivators: 'Step-up practice',
|
||||
demotivators: 'Vacuum noise',
|
||||
favoriteSnack: 'Millet',
|
||||
gender: 'female',
|
||||
dateOfBirth: null,
|
||||
gotchaDay: null,
|
||||
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
|
||||
assert.equal(bird?.name, 'Kiwi');
|
||||
assert.equal(bird?.workspace_id, 10);
|
||||
assert.equal(bird?.gender, 'female');
|
||||
assert.equal(bird?.favorite_snack, 'Millet');
|
||||
});
|
||||
|
||||
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ const birdSelectFields = `
|
||||
birds.name,
|
||||
birds.tag_id,
|
||||
birds.species,
|
||||
birds.motivators,
|
||||
birds.demotivators,
|
||||
birds.favorite_snack,
|
||||
birds.gender,
|
||||
birds.date_of_birth::text,
|
||||
birds.gotcha_day::text,
|
||||
@@ -254,6 +257,9 @@ export const createBird = async ({
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
@@ -266,6 +272,9 @@ export const createBird = async ({
|
||||
name: string;
|
||||
tagId: string | null;
|
||||
species: string;
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
@@ -275,10 +284,25 @@ export const createBird = async ({
|
||||
notifyOnGotchaDay: boolean;
|
||||
}) => {
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
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`,
|
||||
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
||||
`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, $12, $13, $14)
|
||||
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,
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
@@ -290,6 +314,9 @@ export const updateBird = async ({
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
motivators,
|
||||
demotivators,
|
||||
favoriteSnack,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
@@ -303,6 +330,9 @@ export const updateBird = async ({
|
||||
name: string;
|
||||
tagId: string | null;
|
||||
species: string;
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
@@ -316,17 +346,20 @@ export const updateBird = async ({
|
||||
SET name = $2,
|
||||
tag_id = $3,
|
||||
species = $4,
|
||||
gender = $5,
|
||||
date_of_birth = $6,
|
||||
gotcha_day = $7,
|
||||
chart_color = $8,
|
||||
photo_data_url = $9,
|
||||
notify_on_dob = $10,
|
||||
notify_on_gotcha_day = $11
|
||||
motivators = $5,
|
||||
demotivators = $6,
|
||||
favorite_snack = $7,
|
||||
gender = $8,
|
||||
date_of_birth = $9,
|
||||
gotcha_day = $10,
|
||||
chart_color = $11,
|
||||
photo_data_url = $12,
|
||||
notify_on_dob = $13,
|
||||
notify_on_gotcha_day = $14
|
||||
WHERE id = $1
|
||||
AND workspace_id = $12
|
||||
AND workspace_id = $15
|
||||
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
|
||||
FROM weight_records
|
||||
@@ -341,7 +374,23 @@ export const updateBird = async ({
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) 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;
|
||||
|
||||
@@ -34,6 +34,36 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
|
||||
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 () => {
|
||||
const { calls } = mockDb(
|
||||
{ rowCount: 1, rows: [] },
|
||||
|
||||
@@ -91,40 +91,14 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
||||
return Number(existing.rows[0].workspace_id);
|
||||
}
|
||||
|
||||
const unclaimed = await db.query<{ workspace_id: number }>(
|
||||
`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 = await getNextWorkspaceId();
|
||||
|
||||
await db.query(
|
||||
`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')`,
|
||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||
);
|
||||
|
||||
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
|
||||
|
||||
if (!unclaimed.rowCount) {
|
||||
await db.query(
|
||||
`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')`,
|
||||
[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(
|
||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||
VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)
|
||||
|
||||
@@ -98,6 +98,9 @@ export type BirdRow = {
|
||||
name: string;
|
||||
tag_id: string | null;
|
||||
species: string;
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favorite_snack: string | null;
|
||||
gender: BirdGender;
|
||||
date_of_birth: string | null;
|
||||
gotcha_day: string | null;
|
||||
|
||||
+251
-193
@@ -19,6 +19,9 @@ type Bird = {
|
||||
name: string;
|
||||
tagId: string | null;
|
||||
species: string;
|
||||
motivators: string | null;
|
||||
demotivators: string | null;
|
||||
favoriteSnack: string | null;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string | null;
|
||||
gotchaDay: string | null;
|
||||
@@ -179,6 +182,9 @@ type BirdFormState = {
|
||||
name: string;
|
||||
tagId: string;
|
||||
species: string;
|
||||
motivators: string;
|
||||
demotivators: string;
|
||||
favoriteSnack: string;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string;
|
||||
gotchaDay: string;
|
||||
@@ -315,6 +321,9 @@ const emptyBirdForm: BirdFormState = {
|
||||
name: '',
|
||||
tagId: '',
|
||||
species: '',
|
||||
motivators: '',
|
||||
demotivators: '',
|
||||
favoriteSnack: '',
|
||||
gender: 'unknown',
|
||||
dateOfBirth: '',
|
||||
gotchaDay: '',
|
||||
@@ -437,6 +446,9 @@ const toBirdForm = (bird: Bird): BirdFormState => ({
|
||||
name: bird.name,
|
||||
tagId: bird.tagId ?? '',
|
||||
species: bird.species,
|
||||
motivators: bird.motivators ?? '',
|
||||
demotivators: bird.demotivators ?? '',
|
||||
favoriteSnack: bird.favoriteSnack ?? '',
|
||||
gender: bird.gender,
|
||||
dateOfBirth: bird.dateOfBirth ?? '',
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -2129,7 +2141,7 @@ function App() {
|
||||
setMedications([]);
|
||||
setMedicationAdministrations([]);
|
||||
setAllBirdVetVisits({});
|
||||
setActivePage('overview');
|
||||
setActivePage(nextActivePage);
|
||||
} catch (switchError) {
|
||||
setError(switchError instanceof Error ? switchError.message : 'Unable to switch flocks.');
|
||||
} finally {
|
||||
@@ -2279,6 +2291,7 @@ function App() {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to create flock.'));
|
||||
}
|
||||
|
||||
const createdData = (await readJsonSafely<{ workspace?: Workspace }>(response)) ?? {};
|
||||
const workspaceResponse = await apiFetch('/auth/session', authToken);
|
||||
if (!workspaceResponse.ok) {
|
||||
throw new Error(await readErrorMessage(workspaceResponse, 'Flock was created but the session could not be refreshed.'));
|
||||
@@ -2296,6 +2309,11 @@ function App() {
|
||||
...emptyWorkspaceCreateForm,
|
||||
billingEmail: data.session.user.email,
|
||||
});
|
||||
setExpandedSettingsSection(null);
|
||||
|
||||
if (createdData.workspace) {
|
||||
await handleWorkspaceSwitch(createdData.workspace.id, 'settings');
|
||||
}
|
||||
} catch (workspaceError) {
|
||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to create flock.');
|
||||
} finally {
|
||||
@@ -3205,7 +3223,7 @@ function App() {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -4247,10 +4265,6 @@ function App() {
|
||||
</section>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<span>Name</span>
|
||||
<strong>{selectedBird.name}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Band ID</span>
|
||||
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
|
||||
@@ -4267,6 +4281,18 @@ function App() {
|
||||
<span>Species</span>
|
||||
<strong>{selectedBird.species}</strong>
|
||||
</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">
|
||||
<span>Gender</span>
|
||||
<strong className="detail-gender">
|
||||
@@ -4559,11 +4585,35 @@ function App() {
|
||||
<p className="eyebrow">Flock</p>
|
||||
<h2>Flock profile</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' : 'New flock'}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
Flock name
|
||||
<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>
|
||||
) : null}
|
||||
</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 className="panel form-panel settings-card-billing">
|
||||
@@ -5021,191 +5240,6 @@ function App() {
|
||||
) : null}
|
||||
</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">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
@@ -5312,6 +5346,30 @@ function App() {
|
||||
</div>
|
||||
<small className="muted">Search or select a species so alerts and chart references stay consistent.</small>
|
||||
</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">
|
||||
<span>Gender</span>
|
||||
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
|
||||
|
||||
@@ -513,10 +513,6 @@ textarea {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.settings-card-separate-flock {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.settings-card-automation {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user