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),
|
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),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user