cleaned up stripe config
This commit is contained in:
+1
-1
@@ -23,4 +23,4 @@ STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY=
|
|||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
||||||
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
||||||
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/
|
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
|
|||||||
|
|
||||||
- Medication and care reminders
|
- Medication and care reminders
|
||||||
- Invitation acceptance and onboarding polish for flock members
|
- Invitation acceptance and onboarding polish for flock members
|
||||||
- Stripe or equivalent billing integration for paid household tiers
|
|
||||||
- Scheduled reminder delivery for birthdays, gotcha days, and care events
|
- Scheduled reminder delivery for birthdays, gotcha days, and care events
|
||||||
- Audit logging for flock access changes and bird transfers
|
- Audit logging for flock access changes and bird transfers
|
||||||
|
|
||||||
@@ -111,9 +110,32 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
|
|||||||
- `STRIPE_CHECKOUT_CANCEL_URL`
|
- `STRIPE_CHECKOUT_CANCEL_URL`
|
||||||
- `STRIPE_PORTAL_RETURN_URL`
|
- `STRIPE_PORTAL_RETURN_URL`
|
||||||
|
|
||||||
|
Recommended defaults:
|
||||||
|
|
||||||
|
- `STRIPE_CHECKOUT_SUCCESS_URL=https://your-frontend-host/?billing=success`
|
||||||
|
- `STRIPE_CHECKOUT_CANCEL_URL=https://your-frontend-host/?billing=cancelled`
|
||||||
|
- `STRIPE_PORTAL_RETURN_URL=https://your-frontend-host/?billing=portal`
|
||||||
|
|
||||||
|
## Stripe operations
|
||||||
|
|
||||||
|
- Configure the Stripe Customer Portal to allow subscription plan changes for the household products.
|
||||||
|
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
|
||||||
|
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
|
||||||
|
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
|
||||||
|
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`.
|
||||||
|
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
|
||||||
|
|
||||||
|
For local development with the Stripe CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
stripe listen --forward-to http://localhost:5000/api/billing/stripe/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the signing secret printed by `stripe listen` into `STRIPE_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
## Notes for monetization and security
|
## Notes for monetization and security
|
||||||
|
|
||||||
This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, Stripe checkout/customer portal/webhook flows, audit logging, and background reminder delivery before launch.
|
This starter now includes the account and flock foundation for monetization, plus Stripe checkout, Customer Portal, and webhook synchronization. It still needs production-grade session hardening, invitation verification, audit logging, and background reminder delivery before launch.
|
||||||
|
|
||||||
Stripe billing should be attached to `workspaces`, not `users`. Each flock has its own billing plan, subscription status, Stripe customer ID, and Stripe subscription ID, which lets one person own multiple household flocks with separate subscriptions while rescue flocks can stay on the free rescue path.
|
Stripe billing should be attached to `workspaces`, not `users`. Each flock has its own billing plan, subscription status, Stripe customer ID, and Stripe subscription ID, which lets one person own multiple household flocks with separate subscriptions while rescue flocks can stay on the free rescue path.
|
||||||
|
|
||||||
|
|||||||
+140
-10
@@ -52,6 +52,7 @@ import {
|
|||||||
memorializeBird,
|
memorializeBird,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
|
updateMemorialReminderPreference,
|
||||||
updateMedicationForBird,
|
updateMedicationForBird,
|
||||||
upsertMedicationAdministrationForBird,
|
upsertMedicationAdministrationForBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
@@ -212,7 +213,7 @@ const lostBirdReportSchema = z.object({
|
|||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().min(1).max(80),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
species: z.string().trim().min(1).max(120),
|
species: z.string().trim().min(1).max(120),
|
||||||
gender: birdGenderSchema.optional(),
|
gender: birdGenderSchema.optional(),
|
||||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||||
@@ -229,6 +230,10 @@ const memorializeBirdSchema = z.object({
|
|||||||
notifyOnMemorialDay: z.boolean().optional(),
|
notifyOnMemorialDay: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const memorialReminderPreferenceSchema = z.object({
|
||||||
|
notifyOnMemorialDay: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
const weightSchema = z.object({
|
const weightSchema = z.object({
|
||||||
weightGrams: z.coerce.number().positive().max(10000),
|
weightGrams: z.coerce.number().positive().max(10000),
|
||||||
recordedOn: dateStringSchema,
|
recordedOn: dateStringSchema,
|
||||||
@@ -285,6 +290,13 @@ const emptyToNull = (value?: string) => {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unknownBandIdValues = new Set(['unknown', 'not recorded', 'n/a', 'na', 'none']);
|
||||||
|
|
||||||
|
const normalizeBandId = (value?: string | null) => {
|
||||||
|
const trimmed = value?.trim() ?? '';
|
||||||
|
return trimmed && !unknownBandIdValues.has(trimmed.toLowerCase()) ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeEmail = (value: string) => value.trim().toLowerCase();
|
const normalizeEmail = (value: string) => value.trim().toLowerCase();
|
||||||
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
||||||
@@ -318,9 +330,25 @@ const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
|||||||
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
|
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
|
||||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
|
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
|
||||||
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
|
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
|
||||||
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`;
|
const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => {
|
||||||
const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`;
|
const nextUrl = new URL(url);
|
||||||
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
|
|
||||||
|
if (!nextUrl.searchParams.has('billing')) {
|
||||||
|
nextUrl.searchParams.set('billing', billingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextUrl.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripeCheckoutSuccessUrl = withBillingRedirectState(
|
||||||
|
process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
const stripeCheckoutCancelUrl = withBillingRedirectState(
|
||||||
|
process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`,
|
||||||
|
'cancelled',
|
||||||
|
);
|
||||||
|
const stripePortalReturnUrl = withBillingRedirectState(process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl, 'portal');
|
||||||
const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
|
const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
|
||||||
household_basic: {
|
household_basic: {
|
||||||
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '',
|
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '',
|
||||||
@@ -434,7 +462,7 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
tagId: row.tag_id,
|
tagId: normalizeBandId(row.tag_id),
|
||||||
species: row.species,
|
species: row.species,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
@@ -697,6 +725,61 @@ const getStripeClient = () => {
|
|||||||
return stripe;
|
return stripe;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMostRelevantStripeSubscriptionForWorkspace = async (workspace: WorkspaceRow) => {
|
||||||
|
const stripeClient = getStripeClient();
|
||||||
|
|
||||||
|
if (workspace.stripe_subscription_id) {
|
||||||
|
return stripeClient.subscriptions.retrieve(workspace.stripe_subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspace.stripe_customer_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await stripeClient.subscriptions.list({
|
||||||
|
customer: workspace.stripe_customer_id,
|
||||||
|
status: 'all',
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
const matchingSubscription = [...subscriptions.data]
|
||||||
|
.filter((subscription) => String(subscription.metadata?.workspaceId ?? '') === String(workspace.id))
|
||||||
|
.sort((left, right) => right.created - left.created)[0];
|
||||||
|
|
||||||
|
return matchingSubscription ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncWorkspaceStripeBilling = async (workspaceId: number) => {
|
||||||
|
const workspace = await getWorkspaceById(workspaceId);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspace.workspace_type === 'rescue') {
|
||||||
|
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||||
|
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await setWorkspaceStripeSubscription({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
stripeSubscriptionId: subscription.id,
|
||||||
|
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
|
||||||
|
billingPlan: billingSelection.billingPlan,
|
||||||
|
billingInterval: billingSelection.billingInterval,
|
||||||
|
})) ?? workspace
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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.');
|
||||||
@@ -1073,7 +1156,7 @@ const sendLostBirdReportNotification = async ({
|
|||||||
const lines = [
|
const lines = [
|
||||||
`A possible found bird report was submitted for ${bird.name}.`,
|
`A possible found bird report was submitted for ${bird.name}.`,
|
||||||
'',
|
'',
|
||||||
`Band ID: ${bird.tag_id}`,
|
`Band ID: ${bird.tag_id ?? 'Not recorded'}`,
|
||||||
`Species: ${bird.species}`,
|
`Species: ${bird.species}`,
|
||||||
`Flock: ${bird.workspace_name}`,
|
`Flock: ${bird.workspace_name}`,
|
||||||
'',
|
'',
|
||||||
@@ -1100,7 +1183,7 @@ const sendLostBirdReportNotification = async ({
|
|||||||
html: `
|
html: `
|
||||||
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id)}</li>
|
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
|
||||||
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
|
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
|
||||||
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
|
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
|
||||||
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
|
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
|
||||||
@@ -1986,6 +2069,27 @@ app.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/billing/sync',
|
||||||
|
requireAuth,
|
||||||
|
requireSessionAuth,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const syncedWorkspace = await syncWorkspaceStripeBilling(req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!syncedWorkspace) {
|
||||||
|
res.status(404).json({ error: 'Workspace not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ workspace: normalizeWorkspace(syncedWorkspace) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
@@ -2292,7 +2396,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
const bird = await createBird({
|
const bird = await createBird({
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: parsed.data.tagId,
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
species: parsed.data.species,
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
@@ -2413,7 +2517,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
birdId: req.params.birdId,
|
birdId: req.params.birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: parsed.data.tagId,
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
species: parsed.data.species,
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
@@ -2491,7 +2595,7 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
|
|||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
memorializedOn: parsed.data.memorializedOn,
|
memorializedOn: parsed.data.memorializedOn,
|
||||||
memorialNote: emptyToNull(parsed.data.memorialNote),
|
memorialNote: emptyToNull(parsed.data.memorialNote),
|
||||||
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? true,
|
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bird) {
|
if (!bird) {
|
||||||
@@ -2505,6 +2609,32 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = memorialReminderPreferenceSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid memorial reminder payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await updateMemorialReminderPreference({
|
||||||
|
birdId: req.params.birdId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Memorialized bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
|
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
tag_id VARCHAR(80) NOT NULL,
|
tag_id VARCHAR(80),
|
||||||
species VARCHAR(120) NOT NULL,
|
species VARCHAR(120) NOT NULL,
|
||||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
@@ -229,6 +229,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE;
|
ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE birds
|
||||||
|
ALTER COLUMN tag_id DROP NOT NULL;
|
||||||
|
|
||||||
|
UPDATE birds
|
||||||
|
SET tag_id = NULL
|
||||||
|
WHERE tag_id IS NOT NULL
|
||||||
|
AND LOWER(BTRIM(tag_id)) IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN
|
||||||
@@ -241,8 +249,13 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE birds
|
ALTER TABLE birds
|
||||||
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
||||||
ON birds (workspace_id, tag_id);
|
ON birds (workspace_id, LOWER(tag_id))
|
||||||
|
WHERE tag_id IS NOT NULL
|
||||||
|
AND BTRIM(tag_id) <> ''
|
||||||
|
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ export const findBirdsByBandId = async (tagId: string) => {
|
|||||||
ORDER BY recorded_on DESC
|
ORDER BY recorded_on DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE
|
) latest ON TRUE
|
||||||
WHERE LOWER(birds.tag_id) = LOWER($1)
|
WHERE birds.tag_id IS NOT NULL
|
||||||
|
AND BTRIM(birds.tag_id) <> ''
|
||||||
|
AND LOWER(BTRIM(birds.tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
|
||||||
|
AND LOWER(birds.tag_id) = LOWER($1)
|
||||||
AND birds.memorialized_at IS NULL
|
AND birds.memorialized_at IS NULL
|
||||||
ORDER BY birds.created_at ASC
|
ORDER BY birds.created_at ASC
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
@@ -261,7 +264,7 @@ export const createBird = async ({
|
|||||||
}: {
|
}: {
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
@@ -298,7 +301,7 @@ export const updateBird = async ({
|
|||||||
birdId: string;
|
birdId: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
@@ -387,6 +390,42 @@ export const memorializeBird = async ({
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateMemorialReminderPreference = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
notifyOnMemorialDay,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
notifyOnMemorialDay: boolean;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<BirdRow>(
|
||||||
|
`UPDATE birds
|
||||||
|
SET notify_on_memorial_day = $3
|
||||||
|
WHERE id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND memorialized_at IS NOT 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,
|
||||||
|
(
|
||||||
|
SELECT weight_grams::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_weight_grams,
|
||||||
|
(
|
||||||
|
SELECT recorded_on::text
|
||||||
|
FROM weight_records
|
||||||
|
WHERE bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS latest_recorded_on`,
|
||||||
|
[birdId, workspaceId, notifyOnMemorialDay],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const deleteBird = async (birdId: string, workspaceId: number) => {
|
export const deleteBird = async (birdId: string, workspaceId: number) => {
|
||||||
const result = await db.query<{ id: string }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM birds
|
`DELETE FROM birds
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export type BirdRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
workspace_id: number;
|
workspace_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
tag_id: string;
|
tag_id: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ services:
|
|||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
|
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
|
||||||
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
|
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
|
||||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ services:
|
|||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
|
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
|
||||||
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
|
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
|
||||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
|
|||||||
+159
-52
@@ -17,7 +17,7 @@ type Bird = {
|
|||||||
id: string;
|
id: string;
|
||||||
workspaceId?: number;
|
workspaceId?: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
@@ -231,6 +231,11 @@ type AuthNotice = {
|
|||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BillingNotice = {
|
||||||
|
kind: 'success' | 'info' | 'error';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BulkWeightRowState = {
|
type BulkWeightRowState = {
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
};
|
};
|
||||||
@@ -310,7 +315,7 @@ const emptyBirdForm: BirdFormState = {
|
|||||||
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
|
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
|
||||||
memorializedOn: new Date().toISOString().slice(0, 10),
|
memorializedOn: new Date().toISOString().slice(0, 10),
|
||||||
memorialNote: '',
|
memorialNote: '',
|
||||||
notifyOnMemorialDay: true,
|
notifyOnMemorialDay: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyWorkspaceForm: WorkspaceFormState = {
|
const emptyWorkspaceForm: WorkspaceFormState = {
|
||||||
@@ -408,7 +413,7 @@ const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right)
|
|||||||
|
|
||||||
const toBirdForm = (bird: Bird): BirdFormState => ({
|
const toBirdForm = (bird: Bird): BirdFormState => ({
|
||||||
name: bird.name,
|
name: bird.name,
|
||||||
tagId: bird.tagId,
|
tagId: bird.tagId ?? '',
|
||||||
species: bird.species,
|
species: bird.species,
|
||||||
gender: bird.gender,
|
gender: bird.gender,
|
||||||
dateOfBirth: bird.dateOfBirth ?? '',
|
dateOfBirth: bird.dateOfBirth ?? '',
|
||||||
@@ -1072,6 +1077,7 @@ function App() {
|
|||||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||||
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
|
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
|
||||||
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
|
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
|
||||||
|
const [billingNotice, setBillingNotice] = useState<BillingNotice | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [authSubmitting, setAuthSubmitting] = useState(false);
|
const [authSubmitting, setAuthSubmitting] = useState(false);
|
||||||
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
|
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
|
||||||
@@ -1157,6 +1163,7 @@ function App() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deletingBird, setDeletingBird] = useState(false);
|
const [deletingBird, setDeletingBird] = useState(false);
|
||||||
const [memorializingBird, setMemorializingBird] = useState(false);
|
const [memorializingBird, setMemorializingBird] = useState(false);
|
||||||
|
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
|
||||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||||
@@ -1572,6 +1579,7 @@ function App() {
|
|||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
setAuthProviders(session.providers);
|
setAuthProviders(session.providers);
|
||||||
setAuthNotice(null);
|
setAuthNotice(null);
|
||||||
|
setBillingNotice(null);
|
||||||
setNewIntegrationTokenSecret('');
|
setNewIntegrationTokenSecret('');
|
||||||
setWorkspace(session.activeWorkspace);
|
setWorkspace(session.activeWorkspace);
|
||||||
setActiveMembership({
|
setActiveMembership({
|
||||||
@@ -1616,6 +1624,34 @@ function App() {
|
|||||||
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
||||||
setNewIntegrationTokenSecret('');
|
setNewIntegrationTokenSecret('');
|
||||||
setAuthNotice(null);
|
setAuthNotice(null);
|
||||||
|
setBillingNotice(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAuthSession = async (token: string) => {
|
||||||
|
const response = await apiFetch('/auth/session', token);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearAppSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.session) {
|
||||||
|
throw new Error('Unable to refresh your billing status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextToken = data.token || token;
|
||||||
|
persistSessionToken(nextToken);
|
||||||
|
applySession(data.session, nextToken);
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: data.session,
|
||||||
|
token: nextToken,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1646,30 +1682,61 @@ function App() {
|
|||||||
|
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const callbackToken = url.searchParams.get('auth_token') ?? '';
|
const callbackToken = url.searchParams.get('auth_token') ?? '';
|
||||||
|
const billingState = url.searchParams.get('billing');
|
||||||
const token = callbackToken || readStoredSessionToken();
|
const token = callbackToken || readStoredSessionToken();
|
||||||
|
|
||||||
if (callbackToken) {
|
if (callbackToken) {
|
||||||
persistSessionToken(callbackToken);
|
persistSessionToken(callbackToken);
|
||||||
url.searchParams.delete('auth_token');
|
url.searchParams.delete('auth_token');
|
||||||
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiFetch('/auth/session', token);
|
const { session, token: sessionToken } = await refreshAuthSession(token);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (billingState === 'success' || billingState === 'portal') {
|
||||||
clearAppSession();
|
try {
|
||||||
return;
|
const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' });
|
||||||
|
|
||||||
|
if (!syncResponse.ok) {
|
||||||
|
throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session: refreshedSession } = await refreshAuthSession(sessionToken);
|
||||||
|
const syncedWorkspace = refreshedSession.activeWorkspace;
|
||||||
|
const planName = formatBillingPlanName(syncedWorkspace.billingPlan);
|
||||||
|
const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval);
|
||||||
|
|
||||||
|
setBillingNotice({
|
||||||
|
kind: 'success',
|
||||||
|
message:
|
||||||
|
billingState === 'success'
|
||||||
|
? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.`
|
||||||
|
: `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`,
|
||||||
|
});
|
||||||
|
} catch (billingSyncError) {
|
||||||
|
setBillingNotice({
|
||||||
|
kind: 'info',
|
||||||
|
message:
|
||||||
|
billingSyncError instanceof Error
|
||||||
|
? billingSyncError.message
|
||||||
|
: 'Returned from Stripe. Billing changes may still be syncing.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (billingState === 'cancelled') {
|
||||||
|
setBillingNotice({
|
||||||
|
kind: 'info',
|
||||||
|
message: 'Stripe checkout was cancelled. No billing changes were applied.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBillingNotice(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
if (session && (callbackToken || billingState)) {
|
||||||
|
url.searchParams.delete('billing');
|
||||||
if (data.session && (data.token || token)) {
|
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
||||||
persistSessionToken(data.token || token);
|
|
||||||
applySession(data.session, data.token || token);
|
|
||||||
setError('');
|
setError('');
|
||||||
}
|
}
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
@@ -2931,6 +2998,38 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => {
|
||||||
|
if (savingMemorialReminderBirdId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingMemorialReminderBirdId(bird.id);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ notifyOnMemorialDay }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readJsonSafely<{ bird: Bird }>(response);
|
||||||
|
if (!data?.bird) {
|
||||||
|
throw new Error('Unable to update memorial reminder setting.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird)));
|
||||||
|
} catch (preferenceError) {
|
||||||
|
setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.');
|
||||||
|
} finally {
|
||||||
|
setSavingMemorialReminderBirdId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (transferringBird) {
|
if (transferringBird) {
|
||||||
@@ -3172,6 +3271,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setBillingNotice(null);
|
||||||
setBillingRedirecting(true);
|
setBillingRedirecting(true);
|
||||||
setSavingWorkspace(true);
|
setSavingWorkspace(true);
|
||||||
|
|
||||||
@@ -3210,6 +3310,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setBillingNotice(null);
|
||||||
setBillingRedirecting(true);
|
setBillingRedirecting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -4104,7 +4205,7 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{selectedBird.species} • Band {selectedBird.tagId}
|
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||||
</p>
|
</p>
|
||||||
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -4117,7 +4218,7 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="detail-card">
|
<article className="detail-card">
|
||||||
<span>Band ID</span>
|
<span>Band ID</span>
|
||||||
<strong>{selectedBird.tagId}</strong>
|
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
|
||||||
</article>
|
</article>
|
||||||
<article className="detail-card">
|
<article className="detail-card">
|
||||||
<span>Hatch Day</span>
|
<span>Hatch Day</span>
|
||||||
@@ -4489,6 +4590,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{workspace?.workspaceType !== 'rescue' ? (
|
{workspace?.workspaceType !== 'rescue' ? (
|
||||||
<div className="form-panel">
|
<div className="form-panel">
|
||||||
|
{billingNotice ? <p className={billingNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{billingNotice.message}</p> : null}
|
||||||
<label>
|
<label>
|
||||||
Household plan
|
Household plan
|
||||||
<select
|
<select
|
||||||
@@ -4964,32 +5066,6 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{memorializedBirds.length ? (
|
|
||||||
<section className="settings-subsection settings-nested-card">
|
|
||||||
<div className="panel-header">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Memorials</p>
|
|
||||||
<h3>Memorialized birds</h3>
|
|
||||||
<p className="muted">
|
|
||||||
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="recent-list">
|
|
||||||
{memorializedBirds.map((bird) => (
|
|
||||||
<article className="vet-visit-card" key={bird.id}>
|
|
||||||
<strong>{bird.name}</strong>
|
|
||||||
<small>
|
|
||||||
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
|
|
||||||
</small>
|
|
||||||
{bird.notifyOnMemorialDay ? <small>Memorial day reminders enabled.</small> : <small>Memorial day reminders off.</small>}
|
|
||||||
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
||||||
<section className="settings-nested-card">
|
<section className="settings-nested-card">
|
||||||
<div className="settings-nested-header">
|
<div className="settings-nested-header">
|
||||||
@@ -5003,7 +5079,11 @@ function App() {
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Band ID
|
Band ID
|
||||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
<input
|
||||||
|
value={birdForm.tagId}
|
||||||
|
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
|
||||||
|
placeholder="Optional if unknown"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="species-picker-field wide-field">
|
<label className="species-picker-field wide-field">
|
||||||
Species
|
Species
|
||||||
@@ -5398,15 +5478,6 @@ function App() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="toggle-card">
|
|
||||||
<span>Send memorial day reminders</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={memorializeBirdForm.notifyOnMemorialDay}
|
|
||||||
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, notifyOnMemorialDay: event.target.checked })}
|
|
||||||
/>
|
|
||||||
<small className="muted">Send an annual remembrance notification using the same delivery workflow as Hatch Day reminders.</small>
|
|
||||||
</label>
|
|
||||||
<label className="wide-field">
|
<label className="wide-field">
|
||||||
Memorial note
|
Memorial note
|
||||||
<textarea
|
<textarea
|
||||||
@@ -5433,6 +5504,42 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{memorializedBirds.length ? (
|
||||||
|
<section className="settings-subsection settings-nested-card">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Memorials</p>
|
||||||
|
<h3>Memorialized birds</h3>
|
||||||
|
<p className="muted">
|
||||||
|
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="recent-list">
|
||||||
|
{memorializedBirds.map((bird) => (
|
||||||
|
<article className="vet-visit-card" key={bird.id}>
|
||||||
|
<strong>{bird.name}</strong>
|
||||||
|
<small>
|
||||||
|
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
|
||||||
|
</small>
|
||||||
|
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
|
||||||
|
<label className="toggle-card">
|
||||||
|
<span>Send memorial reminders</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bird.notifyOnMemorialDay}
|
||||||
|
disabled={savingMemorialReminderBirdId === bird.id || activeMembership?.role !== 'owner'}
|
||||||
|
onChange={(event) => handleMemorialReminderPreferenceChange(bird, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<small className="muted">Send an annual rememberance notificaiton.</small>
|
||||||
|
</label>
|
||||||
|
{activeMembership?.role !== 'owner' ? <small>Only flock owners can change memorial reminder settings.</small> : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
@@ -5475,7 +5582,7 @@ function App() {
|
|||||||
<option value="">Select a bird from this flock</option>
|
<option value="">Select a bird from this flock</option>
|
||||||
{birds.map((bird) => (
|
{birds.map((bird) => (
|
||||||
<option key={bird.id} value={bird.id}>
|
<option key={bird.id} value={bird.id}>
|
||||||
{bird.name} • {bird.species} • Band {bird.tagId}
|
{bird.name} • {bird.species} • {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user