Merge dev into main
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
+6
-3
@@ -29,9 +29,12 @@ STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY=
|
|||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW=
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY=
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_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/?billing=portal
|
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /docker/FlockPal-dev
|
cd /docker/FlockPal-dev
|
||||||
git fetch --all --prune
|
git fetch --all --prune
|
||||||
git pull --ff-only
|
git reset --hard "origin/${{ github.ref_name }}"
|
||||||
|
git clean -fd
|
||||||
|
|
||||||
- name: Validate backend
|
- name: Validate backend
|
||||||
run: |
|
run: |
|
||||||
@@ -58,7 +59,8 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /docker/FlockPal
|
cd /docker/FlockPal
|
||||||
git fetch --all --prune
|
git fetch --all --prune
|
||||||
git pull --ff-only
|
git reset --hard "origin/${{ github.ref_name }}"
|
||||||
|
git clean -fd
|
||||||
|
|
||||||
- name: Validate backend
|
- name: Validate backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -203,8 +203,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
|
|||||||
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
|
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
|
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
|
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW`
|
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY`
|
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY`
|
||||||
|
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW`
|
||||||
|
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY`
|
||||||
- `STRIPE_CHECKOUT_SUCCESS_URL`
|
- `STRIPE_CHECKOUT_SUCCESS_URL`
|
||||||
- `STRIPE_CHECKOUT_CANCEL_URL`
|
- `STRIPE_CHECKOUT_CANCEL_URL`
|
||||||
- `STRIPE_PORTAL_RETURN_URL`
|
- `STRIPE_PORTAL_RETURN_URL`
|
||||||
@@ -221,7 +223,7 @@ Recommended defaults:
|
|||||||
- 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.
|
- 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`.
|
- 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`.
|
- 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`.
|
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_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.
|
- 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:
|
For local development with the Stripe CLI:
|
||||||
|
|||||||
+224
-7
@@ -61,6 +61,19 @@ import {
|
|||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
|
import {
|
||||||
|
deleteDailyEducation,
|
||||||
|
deleteEducationQuestion,
|
||||||
|
createEducationQuestion,
|
||||||
|
getDailyEducationForDate,
|
||||||
|
getEducationOptOut,
|
||||||
|
listDailyEducationForAdmin,
|
||||||
|
listDailyEducationQuestions,
|
||||||
|
listEducationQuestionsForAdmin,
|
||||||
|
updateEducationOptOut,
|
||||||
|
updateEducationQuestion,
|
||||||
|
upsertDailyEducation,
|
||||||
|
} from './repositories/educationRepository.js';
|
||||||
import {
|
import {
|
||||||
buildBirdPhotoObjectKey,
|
buildBirdPhotoObjectKey,
|
||||||
getImageExtensionFromContentType,
|
getImageExtensionFromContentType,
|
||||||
@@ -98,6 +111,8 @@ import type {
|
|||||||
AuthContext,
|
AuthContext,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
|
DailyEducationRow,
|
||||||
|
EducationQuestionRow,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
@@ -184,7 +199,7 @@ const switchWorkspaceSchema = z.object({
|
|||||||
|
|
||||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||||
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
||||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
|
||||||
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
||||||
@@ -323,6 +338,27 @@ const integrationTokenCreateSchema = z.object({
|
|||||||
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const educationQuestionSchema = z
|
||||||
|
.object({
|
||||||
|
prompt: z.string().trim().min(1).max(500),
|
||||||
|
options: z.array(z.string().trim().min(1).max(240)).min(2).max(4),
|
||||||
|
correctAnswerIndex: z.coerce.number().int().min(0).max(3),
|
||||||
|
explanation: z.string().trim().max(800).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
.refine((value) => value.correctAnswerIndex < value.options.length, {
|
||||||
|
message: 'Correct answer must match one of the quiz options.',
|
||||||
|
path: ['correctAnswerIndex'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyEducationSchema = z.object({
|
||||||
|
publishDate: dateStringSchema,
|
||||||
|
fact: z.string().trim().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const educationPreferenceSchema = z.object({
|
||||||
|
educationOptOut: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
const emptyToNull = (value?: string) => {
|
const emptyToNull = (value?: string) => {
|
||||||
const trimmed = value?.trim() ?? '';
|
const trimmed = value?.trim() ?? '';
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
@@ -347,12 +383,16 @@ const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').up
|
|||||||
|
|
||||||
const resolveBillingPlan = (
|
const resolveBillingPlan = (
|
||||||
workspaceType: WorkspaceType,
|
workspaceType: WorkspaceType,
|
||||||
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
|
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw',
|
||||||
) => {
|
) => {
|
||||||
if (workspaceType === 'rescue') {
|
if (workspaceType === 'rescue') {
|
||||||
return 'rescue_free' as const;
|
return 'rescue_free' as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestedPlan === 'household_hyacinth_macaw') {
|
||||||
|
return 'household_hyacinth_macaw';
|
||||||
|
}
|
||||||
|
|
||||||
if (requestedPlan === 'household_macaw') {
|
if (requestedPlan === 'household_macaw') {
|
||||||
return 'household_macaw';
|
return 'household_macaw';
|
||||||
}
|
}
|
||||||
@@ -404,8 +444,18 @@ const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, '
|
|||||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
||||||
},
|
},
|
||||||
household_macaw: {
|
household_macaw: {
|
||||||
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '',
|
monthly:
|
||||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '',
|
process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY?.trim() ||
|
||||||
|
process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY?.trim() ||
|
||||||
|
'',
|
||||||
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY?.trim() ?? '',
|
||||||
|
},
|
||||||
|
household_hyacinth_macaw: {
|
||||||
|
monthly:
|
||||||
|
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY?.trim() ||
|
||||||
|
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW?.trim() ||
|
||||||
|
'',
|
||||||
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY?.trim() ?? '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
|
const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
|
||||||
@@ -418,14 +468,19 @@ const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, '
|
|||||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'],
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'],
|
||||||
},
|
},
|
||||||
household_macaw: {
|
household_macaw: {
|
||||||
monthly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_MACAW'],
|
monthly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY'],
|
||||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY'],
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY'],
|
||||||
|
},
|
||||||
|
household_hyacinth_macaw: {
|
||||||
|
monthly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW'],
|
||||||
|
yearly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = {
|
const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = {
|
||||||
household_basic: 'Conure',
|
household_basic: 'Conure',
|
||||||
household_plus: 'Indian Ringneck',
|
household_plus: 'Indian Ringneck',
|
||||||
household_macaw: 'Macaw',
|
household_macaw: 'African Grey',
|
||||||
|
household_hyacinth_macaw: 'Hyacinth Macaw',
|
||||||
};
|
};
|
||||||
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
||||||
const adminEmails = new Set(
|
const adminEmails = new Set(
|
||||||
@@ -498,6 +553,25 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeEducationQuestion = (row: EducationQuestionRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
prompt: row.prompt,
|
||||||
|
options: row.options,
|
||||||
|
correctAnswerIndex: Number(row.correct_answer_index),
|
||||||
|
explanation: row.explanation ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({
|
||||||
|
id: row.id,
|
||||||
|
publishDate: row.publish_date,
|
||||||
|
fact: row.fact,
|
||||||
|
quizQuestions: questions.map(normalizeEducationQuestion),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
const signBirdPhotoAccessToken = (row: BirdRow) => {
|
const signBirdPhotoAccessToken = (row: BirdRow) => {
|
||||||
if (!row.photo_object_key) {
|
if (!row.photo_object_key) {
|
||||||
return '';
|
return '';
|
||||||
@@ -2342,6 +2416,149 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const education = await listDailyEducationForAdmin();
|
||||||
|
res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const questions = await listEducationQuestionsForAdmin();
|
||||||
|
res.json({ questions: questions.map(normalizeEducationQuestion) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = dailyEducationSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const education = await upsertDailyEducation({
|
||||||
|
publishDate: parsed.data.publishDate,
|
||||||
|
fact: parsed.data.fact,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
res.json({ education: normalizeDailyEducation(education) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationQuestionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await createEducationQuestion({
|
||||||
|
question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) },
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
res.status(201).json({ question: normalizeEducationQuestion(question) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationQuestionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await updateEducationQuestion(req.params.questionId, {
|
||||||
|
...parsed.data,
|
||||||
|
explanation: emptyToNull(parsed.data.explanation),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
res.status(404).json({ error: 'Education question not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ question: normalizeEducationQuestion(question) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const deleted = await deleteEducationQuestion(req.params.questionId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Education question not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const deleted = await deleteDailyEducation(req.params.educationId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Daily education item not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const educationOptOut = await getEducationOptOut(req.auth!.user.id);
|
||||||
|
const education = educationOptOut ? null : await getDailyEducationForDate();
|
||||||
|
const questions = education ? await listDailyEducationQuestions(education.publish_date) : [];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
educationOptOut,
|
||||||
|
education: education ? normalizeDailyEducation(education, questions) : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationPreferenceSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut);
|
||||||
|
res.json({ educationOptOut });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
@@ -139,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
ON auth_sessions (created_at DESC, user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_education (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
publish_date DATE NOT NULL UNIQUE,
|
||||||
|
fact TEXT NOT NULL,
|
||||||
|
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE daily_education
|
||||||
|
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
|
||||||
|
ON daily_education (publish_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS education_question_bank (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
prompt VARCHAR(500) NOT NULL,
|
||||||
|
options JSONB NOT NULL,
|
||||||
|
correct_answer_index INTEGER NOT NULL,
|
||||||
|
explanation VARCHAR(800),
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
|
||||||
|
ON education_question_bank (created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { db } from '../db/client.js';
|
||||||
|
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
|
||||||
|
|
||||||
|
export const getEducationOptOut = async (userId: string) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`SELECT education_opt_out
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`UPDATE users
|
||||||
|
SET education_opt_out = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING education_opt_out`,
|
||||||
|
[userId, educationOptOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? educationOptOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyEducationForDate = async (publishDate?: string) => {
|
||||||
|
const result = publishDate
|
||||||
|
? await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = $1`,
|
||||||
|
[publishDate],
|
||||||
|
)
|
||||||
|
: await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = CURRENT_DATE`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationForAdmin = async () => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
ORDER BY publish_date DESC
|
||||||
|
LIMIT 120`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDailyEducation = async ({
|
||||||
|
publishDate,
|
||||||
|
fact,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
publishDate: string;
|
||||||
|
fact: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (publish_date) DO UPDATE
|
||||||
|
SET fact = EXCLUDED.fact,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
|
||||||
|
[publishDate, fact, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listEducationQuestionsForAdmin = async () => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT 400`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationQuestions = async (seedDate?: string) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
|
||||||
|
LIMIT 4`,
|
||||||
|
[seedDate ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEducationQuestion = async ({
|
||||||
|
question,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
question: DailyEducationQuestion;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4, $5)
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`UPDATE education_question_bank
|
||||||
|
SET prompt = $2,
|
||||||
|
options = $3::jsonb,
|
||||||
|
correct_answer_index = $4,
|
||||||
|
explanation = $5,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEducationQuestion = async (questionId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM education_question_bank
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[questionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDailyEducation = async (educationId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM daily_education
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[educationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
+30
-1
@@ -1,6 +1,6 @@
|
|||||||
export type WorkspaceType = 'standard' | 'rescue';
|
export type WorkspaceType = 'standard' | 'rescue';
|
||||||
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
|
||||||
export type BillingInterval = 'monthly' | 'yearly';
|
export type BillingInterval = 'monthly' | 'yearly';
|
||||||
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
@@ -13,9 +13,38 @@ export type UserRow = {
|
|||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
education_opt_out?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DailyEducationQuestion = {
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correctAnswerIndex: number;
|
||||||
|
explanation: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyEducationRow = {
|
||||||
|
id: string;
|
||||||
|
publish_date: string;
|
||||||
|
fact: string;
|
||||||
|
quiz_questions: DailyEducationQuestion[];
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EducationQuestionRow = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correct_answer_index: number;
|
||||||
|
explanation: string | null;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceRow = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -73,9 +73,12 @@ services:
|
|||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_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}/?billing=portal}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
|
||||||
|
|||||||
+6
-3
@@ -71,9 +71,12 @@ services:
|
|||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_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/?billing=portal}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
|
||||||
|
|||||||
@@ -653,7 +653,7 @@ Request body:
|
|||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `workspaceType` must be `standard` or `rescue`
|
- `workspaceType` must be `standard` or `rescue`
|
||||||
- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw`
|
- `billingPlan` may be `household_basic`, `household_plus`, `household_macaw`, or `household_hyacinth_macaw`
|
||||||
- rescue workspaces are forced to `rescue_free`
|
- rescue workspaces are forced to `rescue_free`
|
||||||
|
|
||||||
Response `201`:
|
Response `201`:
|
||||||
|
|||||||
+602
-15
@@ -5,7 +5,7 @@ import defaultBirdPhoto from './assets/yoda-default.png';
|
|||||||
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
|
||||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||||
type BillingInterval = 'monthly' | 'yearly';
|
type BillingInterval = 'monthly' | 'yearly';
|
||||||
type WorkspaceType = 'standard' | 'rescue';
|
type WorkspaceType = 'standard' | 'rescue';
|
||||||
@@ -162,6 +162,23 @@ type AdminRescueWorkspace = {
|
|||||||
memberCount: number;
|
memberCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DailyEducationQuestion = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correctAnswerIndex: number;
|
||||||
|
explanation: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyEducation = {
|
||||||
|
id: string;
|
||||||
|
publishDate: string;
|
||||||
|
fact: string;
|
||||||
|
quizQuestions: DailyEducationQuestion[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type IntegrationTokenSummary = {
|
type IntegrationTokenSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -294,6 +311,18 @@ type BillingNotice = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DailyEducationQuestionFormState = {
|
||||||
|
prompt: string;
|
||||||
|
options: [string, string, string, string];
|
||||||
|
correctAnswerIndex: number;
|
||||||
|
explanation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DailyEducationFormState = {
|
||||||
|
publishDate: string;
|
||||||
|
fact: string;
|
||||||
|
};
|
||||||
|
|
||||||
type BulkWeightRowState = {
|
type BulkWeightRowState = {
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
};
|
};
|
||||||
@@ -661,6 +690,18 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = {
|
|||||||
expiresInDays: '',
|
expiresInDays: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({
|
||||||
|
prompt: '',
|
||||||
|
options: ['', '', '', ''],
|
||||||
|
correctAnswerIndex: 0,
|
||||||
|
explanation: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyDailyEducationForm = (): DailyEducationFormState => ({
|
||||||
|
publishDate: new Date().toISOString().slice(0, 10),
|
||||||
|
fact: '',
|
||||||
|
});
|
||||||
|
|
||||||
const defaultAuthProviders: AuthProvider[] = [
|
const defaultAuthProviders: AuthProvider[] = [
|
||||||
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
||||||
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
||||||
@@ -937,7 +978,7 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
|
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
|
||||||
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw';
|
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw' || billingPlan === 'household_hyacinth_macaw';
|
||||||
|
|
||||||
const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly');
|
const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly');
|
||||||
|
|
||||||
@@ -954,7 +995,11 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => {
|
|||||||
return 'Indian Ringneck';
|
return 'Indian Ringneck';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Macaw';
|
if (billingPlan === 'household_macaw') {
|
||||||
|
return 'African Grey';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Hyacinth Macaw';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
||||||
@@ -967,10 +1012,14 @@ const formatBillingPlanCapacity = (billingPlan: BillingPlan) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_plus') {
|
if (billingPlan === 'household_plus') {
|
||||||
return 'Permits 5 to 10 birds in the flock.';
|
return 'Permits 5 to 9 birds in the flock.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Permits 11 or more birds in the flock.';
|
if (billingPlan === 'household_macaw') {
|
||||||
|
return 'Permits 11 to 16 birds in the flock.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Permits 17 or more birds in the flock.';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => {
|
const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => {
|
||||||
@@ -979,10 +1028,14 @@ const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_plus') {
|
if (billingPlan === 'household_plus') {
|
||||||
return 'Indian Ringneck (10 birds)';
|
return 'Indian Ringneck (5-9 birds)';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Macaw (11+)';
|
if (billingPlan === 'household_macaw') {
|
||||||
|
return 'African Grey (11-16 birds)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Hyacinth Macaw (17+)';
|
||||||
};
|
};
|
||||||
|
|
||||||
const householdPlanPrices: Record<HouseholdBillingPlan, Record<BillingInterval, string>> = {
|
const householdPlanPrices: Record<HouseholdBillingPlan, Record<BillingInterval, string>> = {
|
||||||
@@ -998,6 +1051,10 @@ const householdPlanPrices: Record<HouseholdBillingPlan, Record<BillingInterval,
|
|||||||
monthly: '$15.99/month',
|
monthly: '$15.99/month',
|
||||||
yearly: '$160/year',
|
yearly: '$160/year',
|
||||||
},
|
},
|
||||||
|
household_hyacinth_macaw: {
|
||||||
|
monthly: '$49.99/month',
|
||||||
|
yearly: '$500/year',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) =>
|
const formatBillingIntervalDropdownLabel = (billingPlan: HouseholdBillingPlan, billingInterval: BillingInterval) =>
|
||||||
@@ -1009,11 +1066,15 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_plus') {
|
if (billingPlan === 'household_plus') {
|
||||||
return '10';
|
return '9';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (billingPlan === 'household_macaw') {
|
if (billingPlan === 'household_macaw') {
|
||||||
return '11+';
|
return '16';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingPlan === 'household_hyacinth_macaw') {
|
||||||
|
return '17+';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -1410,6 +1471,20 @@ function App() {
|
|||||||
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
|
||||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
||||||
|
const [adminDailyEducation, setAdminDailyEducation] = useState<DailyEducation[]>([]);
|
||||||
|
const [adminEducationQuestions, setAdminEducationQuestions] = useState<DailyEducationQuestion[]>([]);
|
||||||
|
const [dailyEducationForm, setDailyEducationForm] = useState<DailyEducationFormState>(emptyDailyEducationForm);
|
||||||
|
const [educationQuestionForm, setEducationQuestionForm] = useState<DailyEducationQuestionFormState>(emptyDailyEducationQuestion);
|
||||||
|
const [editingEducationQuestionId, setEditingEducationQuestionId] = useState('');
|
||||||
|
const [savingDailyEducation, setSavingDailyEducation] = useState(false);
|
||||||
|
const [savingEducationQuestion, setSavingEducationQuestion] = useState(false);
|
||||||
|
const [deletingDailyEducationId, setDeletingDailyEducationId] = useState('');
|
||||||
|
const [deletingEducationQuestionId, setDeletingEducationQuestionId] = useState('');
|
||||||
|
const [todayEducation, setTodayEducation] = useState<DailyEducation | null>(null);
|
||||||
|
const [educationOptOut, setEducationOptOut] = useState(false);
|
||||||
|
const [savingEducationPreference, setSavingEducationPreference] = useState(false);
|
||||||
|
const [educationAnswers, setEducationAnswers] = useState<Record<number, number>>({});
|
||||||
|
const [dailyEducationOpen, setDailyEducationOpen] = useState(false);
|
||||||
const [birds, setBirds] = useState<Bird[]>([]);
|
const [birds, setBirds] = useState<Bird[]>([]);
|
||||||
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||||
@@ -1952,6 +2027,15 @@ function App() {
|
|||||||
setIntegrationTokens([]);
|
setIntegrationTokens([]);
|
||||||
setAdminSummary(null);
|
setAdminSummary(null);
|
||||||
setAdminRescueWorkspaces([]);
|
setAdminRescueWorkspaces([]);
|
||||||
|
setAdminDailyEducation([]);
|
||||||
|
setAdminEducationQuestions([]);
|
||||||
|
setDailyEducationForm(emptyDailyEducationForm());
|
||||||
|
setEducationQuestionForm(emptyDailyEducationQuestion());
|
||||||
|
setEditingEducationQuestionId('');
|
||||||
|
setTodayEducation(null);
|
||||||
|
setEducationOptOut(false);
|
||||||
|
setEducationAnswers({});
|
||||||
|
setDailyEducationOpen(false);
|
||||||
setBirds([]);
|
setBirds([]);
|
||||||
setMemorializedBirds([]);
|
setMemorializedBirds([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
@@ -2190,20 +2274,27 @@ function App() {
|
|||||||
|
|
||||||
const loadAdminDashboard = async () => {
|
const loadAdminDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
const [summaryResponse, rescuesResponse] = await Promise.all([
|
const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([
|
||||||
apiFetch('/admin/summary', authToken),
|
apiFetch('/admin/summary', authToken),
|
||||||
apiFetch('/admin/rescue-workspaces', authToken),
|
apiFetch('/admin/rescue-workspaces', authToken),
|
||||||
|
apiFetch('/admin/daily-education', authToken),
|
||||||
|
apiFetch('/admin/education-questions', authToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!summaryResponse.ok || !rescuesResponse.ok) {
|
if (!summaryResponse.ok || !rescuesResponse.ok || !educationResponse.ok || !educationQuestionsResponse.ok) {
|
||||||
throw new Error('Unable to load admin dashboard.');
|
throw new Error('Unable to load admin dashboard.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
||||||
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
||||||
|
const educationData = (await readJsonSafely<{ education?: DailyEducation[] }>(educationResponse)) ?? {};
|
||||||
|
const educationQuestionsData =
|
||||||
|
(await readJsonSafely<{ questions?: DailyEducationQuestion[] }>(educationQuestionsResponse)) ?? {};
|
||||||
|
|
||||||
setAdminSummary(summaryData.summary ?? null);
|
setAdminSummary(summaryData.summary ?? null);
|
||||||
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
||||||
|
setAdminDailyEducation(educationData.education ?? []);
|
||||||
|
setAdminEducationQuestions(educationQuestionsData.questions ?? []);
|
||||||
} catch (adminError) {
|
} catch (adminError) {
|
||||||
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
||||||
}
|
}
|
||||||
@@ -2212,6 +2303,34 @@ function App() {
|
|||||||
void loadAdminDashboard();
|
void loadAdminDashboard();
|
||||||
}, [activePage, authSession?.isAdmin, authToken]);
|
}, [activePage, authSession?.isAdmin, authToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authToken || !authSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTodayEducation = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/education/today', authToken);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to load daily education.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
(await readJsonSafely<{ education?: DailyEducation | null; educationOptOut?: boolean }>(response)) ?? {};
|
||||||
|
|
||||||
|
setEducationOptOut(Boolean(data.educationOptOut));
|
||||||
|
setTodayEducation(data.education ?? null);
|
||||||
|
setEducationAnswers({});
|
||||||
|
setDailyEducationOpen(false);
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to load daily education.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadTodayEducation();
|
||||||
|
}, [authSession, authToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedBird?.id) {
|
if (!selectedBird?.id) {
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
@@ -2630,6 +2749,221 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDailyEducationIntoForm = (education: DailyEducation) => {
|
||||||
|
setDailyEducationForm({
|
||||||
|
publishDate: education.publishDate,
|
||||||
|
fact: education.fact,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEducationQuestionIntoForm = (question: DailyEducationQuestion) => {
|
||||||
|
setEditingEducationQuestionId(question.id);
|
||||||
|
setEducationQuestionForm({
|
||||||
|
prompt: question.prompt,
|
||||||
|
options: [
|
||||||
|
question.options[0] ?? '',
|
||||||
|
question.options[1] ?? '',
|
||||||
|
question.options[2] ?? '',
|
||||||
|
question.options[3] ?? '',
|
||||||
|
],
|
||||||
|
correctAnswerIndex: question.correctAnswerIndex,
|
||||||
|
explanation: question.explanation ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetEducationQuestionForm = () => {
|
||||||
|
setEditingEducationQuestionId('');
|
||||||
|
setEducationQuestionForm(emptyDailyEducationQuestion());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDailyEducationSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setSavingDailyEducation(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/admin/daily-education', authToken, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
publishDate: dailyEducationForm.publishDate,
|
||||||
|
fact: dailyEducationForm.fact.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to save daily education.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ education?: DailyEducation }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.education) {
|
||||||
|
throw new Error('Unable to save daily education.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminDailyEducation((current) =>
|
||||||
|
[data.education!, ...current.filter((education) => education.id !== data.education!.id && education.publishDate !== data.education!.publishDate)]
|
||||||
|
.sort((left, right) => right.publishDate.localeCompare(left.publishDate)),
|
||||||
|
);
|
||||||
|
if (data.education.publishDate === new Date().toISOString().slice(0, 10) && !educationOptOut) {
|
||||||
|
const todayResponse = await apiFetch('/education/today', authToken);
|
||||||
|
const todayData = todayResponse.ok
|
||||||
|
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
|
||||||
|
: null;
|
||||||
|
setTodayEducation(todayData?.education ?? null);
|
||||||
|
setEducationAnswers({});
|
||||||
|
}
|
||||||
|
setDailyEducationForm(emptyDailyEducationForm());
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to save daily education.');
|
||||||
|
} finally {
|
||||||
|
setSavingDailyEducation(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEducationQuestionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setSavingEducationQuestion(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
editingEducationQuestionId ? `/admin/education-questions/${editingEducationQuestionId}` : '/admin/education-questions',
|
||||||
|
authToken,
|
||||||
|
{
|
||||||
|
method: editingEducationQuestionId ? 'PUT' : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: educationQuestionForm.prompt.trim(),
|
||||||
|
options: educationQuestionForm.options.map((option) => option.trim()),
|
||||||
|
correctAnswerIndex: educationQuestionForm.correctAnswerIndex,
|
||||||
|
explanation: educationQuestionForm.explanation.trim(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to save education question.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await readJsonSafely<{ question?: DailyEducationQuestion }>(response)) ?? {};
|
||||||
|
|
||||||
|
if (!data.question) {
|
||||||
|
throw new Error('Unable to save education question.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminEducationQuestions((current) => [
|
||||||
|
data.question!,
|
||||||
|
...current.filter((question) => question.id !== data.question!.id),
|
||||||
|
]);
|
||||||
|
resetEducationQuestionForm();
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to save education question.');
|
||||||
|
} finally {
|
||||||
|
setSavingEducationQuestion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEducationQuestion = async (questionId: string) => {
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setDeletingEducationQuestionId(questionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/admin/education-questions/${questionId}`, authToken, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to delete education question.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminEducationQuestions((current) => current.filter((question) => question.id !== questionId));
|
||||||
|
if (editingEducationQuestionId === questionId) {
|
||||||
|
resetEducationQuestionForm();
|
||||||
|
}
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to delete education question.');
|
||||||
|
} finally {
|
||||||
|
setDeletingEducationQuestionId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDailyEducation = async (educationId: string) => {
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setDeletingDailyEducationId(educationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/admin/daily-education/${educationId}`, authToken, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to delete daily education.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminDailyEducation((current) => current.filter((education) => education.id !== educationId));
|
||||||
|
if (todayEducation?.id === educationId) {
|
||||||
|
setTodayEducation(null);
|
||||||
|
}
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to delete daily education.');
|
||||||
|
} finally {
|
||||||
|
setDeletingDailyEducationId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEducationPreferenceChange = async (nextEducationOptOut: boolean) => {
|
||||||
|
if (!authToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setSavingEducationPreference(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/education/preferences', authToken, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ educationOptOut: nextEducationOptOut }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response, 'Unable to save education preference.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEducationOptOut(nextEducationOptOut);
|
||||||
|
if (nextEducationOptOut) {
|
||||||
|
setTodayEducation(null);
|
||||||
|
setDailyEducationOpen(false);
|
||||||
|
} else {
|
||||||
|
const todayResponse = await apiFetch('/education/today', authToken);
|
||||||
|
const data = todayResponse.ok
|
||||||
|
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
|
||||||
|
: null;
|
||||||
|
setTodayEducation(data?.education ?? null);
|
||||||
|
}
|
||||||
|
setEducationAnswers({});
|
||||||
|
} catch (educationError) {
|
||||||
|
setError(educationError instanceof Error ? educationError.message : 'Unable to save education preference.');
|
||||||
|
} finally {
|
||||||
|
setSavingEducationPreference(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -4492,6 +4826,74 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{todayEducation ? (
|
||||||
|
<article className={dailyEducationOpen ? 'panel daily-education-panel open' : 'panel daily-education-panel condensed'}>
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Fid fact of the day</p>
|
||||||
|
<h2>{dailyEducationOpen ? formatDate(todayEducation.publishDate) : 'Daily learning'}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" onClick={() => setDailyEducationOpen((current) => !current)} type="button">
|
||||||
|
{dailyEducationOpen ? 'Close' : 'Open'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dailyEducationOpen ? (
|
||||||
|
<>
|
||||||
|
<p className="daily-fact">{todayEducation.fact}</p>
|
||||||
|
<section className="daily-quiz" aria-label="Daily education quiz">
|
||||||
|
{todayEducation.quizQuestions.map((question, questionIndex) => {
|
||||||
|
const selectedAnswer = educationAnswers[questionIndex];
|
||||||
|
const hasAnswer = selectedAnswer !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset key={`${todayEducation.id}-${question.id}`} className="quiz-question">
|
||||||
|
<legend>{question.prompt}</legend>
|
||||||
|
<div className="quiz-options">
|
||||||
|
{question.options.map((option, optionIndex) => (
|
||||||
|
<label
|
||||||
|
key={`${question.id}-${optionIndex}`}
|
||||||
|
className={
|
||||||
|
hasAnswer && optionIndex === question.correctAnswerIndex
|
||||||
|
? 'quiz-option correct'
|
||||||
|
: hasAnswer && optionIndex === selectedAnswer
|
||||||
|
? 'quiz-option incorrect'
|
||||||
|
: 'quiz-option'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`daily-question-${question.id}`}
|
||||||
|
checked={selectedAnswer === optionIndex}
|
||||||
|
onChange={() =>
|
||||||
|
setEducationAnswers((current) => ({
|
||||||
|
...current,
|
||||||
|
[questionIndex]: optionIndex,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{option}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasAnswer ? (
|
||||||
|
<p className={selectedAnswer === question.correctAnswerIndex ? 'quiz-feedback correct' : 'quiz-feedback'}>
|
||||||
|
{selectedAnswer === question.correctAnswerIndex ? 'Correct.' : 'Correct answer shown.'}{' '}
|
||||||
|
{question.explanation}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted daily-education-teaser">
|
||||||
|
Open today's fact and {todayEducation.quizQuestions.length || 'the'} quiz questions.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="forms-grid">
|
<section className="forms-grid">
|
||||||
<article className="panel form-panel pulse-panel">
|
<article className="panel form-panel pulse-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -4642,6 +5044,168 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article className="panel admin-education-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Education</p>
|
||||||
|
<h2>Fid facts</h2>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" onClick={() => setDailyEducationForm(emptyDailyEducationForm())} type="button">
|
||||||
|
New date
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form className="form-panel" onSubmit={handleDailyEducationSubmit}>
|
||||||
|
<div className="education-admin-basics">
|
||||||
|
<label>
|
||||||
|
Publish date
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dailyEducationForm.publishDate}
|
||||||
|
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, publishDate: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Fid fact
|
||||||
|
<textarea
|
||||||
|
value={dailyEducationForm.fact}
|
||||||
|
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, fact: event.target.value })}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="primary-button" type="submit" disabled={savingDailyEducation}>
|
||||||
|
{savingDailyEducation ? 'Saving...' : 'Save fact'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="recent-list education-admin-list">
|
||||||
|
{adminDailyEducation.length ? (
|
||||||
|
adminDailyEducation.map((education) => (
|
||||||
|
<article key={education.id} className="vet-visit-card">
|
||||||
|
<strong>{formatDate(education.publishDate)}</strong>
|
||||||
|
<span>{education.fact}</span>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => loadDailyEducationIntoForm(education)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteDailyEducation(education.id)}
|
||||||
|
disabled={deletingDailyEducationId === education.id}
|
||||||
|
>
|
||||||
|
{deletingDailyEducationId === education.id ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<article className="vet-visit-card empty-card">
|
||||||
|
<strong>No scheduled facts yet</strong>
|
||||||
|
<small>Add a dated Fid fact for the overview page.</small>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel admin-education-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Education</p>
|
||||||
|
<h2>Quiz question bank</h2>
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" onClick={resetEducationQuestionForm} type="button">
|
||||||
|
New question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Each day the quiz uses a stable random selection of four saved questions.</p>
|
||||||
|
<form className="form-panel" onSubmit={handleEducationQuestionSubmit}>
|
||||||
|
<fieldset className="settings-nested-card quiz-editor-question">
|
||||||
|
<legend>{editingEducationQuestionId ? 'Edit question' : 'Add question'}</legend>
|
||||||
|
<label>
|
||||||
|
Prompt
|
||||||
|
<input
|
||||||
|
value={educationQuestionForm.prompt}
|
||||||
|
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, prompt: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="quiz-editor-options">
|
||||||
|
{educationQuestionForm.options.map((option, optionIndex) => (
|
||||||
|
<label key={`bank-option-${optionIndex}`}>
|
||||||
|
Option {optionIndex + 1}
|
||||||
|
<input
|
||||||
|
value={option}
|
||||||
|
onChange={(event) => {
|
||||||
|
const options = [...educationQuestionForm.options] as DailyEducationQuestionFormState['options'];
|
||||||
|
options[optionIndex] = event.target.value;
|
||||||
|
setEducationQuestionForm({ ...educationQuestionForm, options });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Correct option
|
||||||
|
<select
|
||||||
|
value={educationQuestionForm.correctAnswerIndex}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEducationQuestionForm({ ...educationQuestionForm, correctAnswerIndex: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{educationQuestionForm.options.map((_, optionIndex) => (
|
||||||
|
<option key={`bank-correct-${optionIndex}`} value={optionIndex}>
|
||||||
|
Option {optionIndex + 1}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Explanation
|
||||||
|
<textarea
|
||||||
|
value={educationQuestionForm.explanation}
|
||||||
|
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, explanation: event.target.value })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button className="primary-button" type="submit" disabled={savingEducationQuestion}>
|
||||||
|
{savingEducationQuestion ? 'Saving...' : editingEducationQuestionId ? 'Save question changes' : 'Add question'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="recent-list education-admin-list">
|
||||||
|
{adminEducationQuestions.length ? (
|
||||||
|
adminEducationQuestions.map((question) => (
|
||||||
|
<article key={question.id} className="vet-visit-card">
|
||||||
|
<strong>{question.prompt}</strong>
|
||||||
|
<span>Answer: {question.options[question.correctAnswerIndex]}</span>
|
||||||
|
<small>{question.options.length} options</small>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => loadEducationQuestionIntoForm(question)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteEducationQuestion(question.id)}
|
||||||
|
disabled={deletingEducationQuestionId === question.id}
|
||||||
|
>
|
||||||
|
{deletingEducationQuestionId === question.id ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<article className="vet-visit-card empty-card">
|
||||||
|
<strong>No quiz questions yet</strong>
|
||||||
|
<small>Add at least four questions before the daily quiz can use a full set.</small>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -5888,8 +6452,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="household_basic">Conure (4 birds)</option>
|
<option value="household_basic">Conure (4 birds)</option>
|
||||||
<option value="household_plus">Indian Ringneck (10 birds)</option>
|
<option value="household_plus">Indian Ringneck (5-9 birds)</option>
|
||||||
<option value="household_macaw">Macaw (11+)</option>
|
<option value="household_macaw">African Grey (11-16 birds)</option>
|
||||||
|
<option value="household_hyacinth_macaw">Hyacinth Macaw (17+)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -6026,8 +6591,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="household_basic">Conure (4 birds)</option>
|
<option value="household_basic">Conure (4 birds)</option>
|
||||||
<option value="household_plus">Indian Ringneck (10 birds)</option>
|
<option value="household_plus">Indian Ringneck (5-9 birds)</option>
|
||||||
<option value="household_macaw">Macaw (11+)</option>
|
<option value="household_macaw">African Grey (11-16 birds)</option>
|
||||||
|
<option value="household_hyacinth_macaw">Hyacinth Macaw (17+)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -6135,6 +6701,27 @@ function App() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-column settings-column-right">
|
<div className="settings-column settings-column-right">
|
||||||
|
<article className="panel form-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Education</p>
|
||||||
|
<h2>Daily learning</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-card">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!educationOptOut}
|
||||||
|
onChange={(event) => handleEducationPreferenceChange(!event.target.checked)}
|
||||||
|
disabled={savingEducationPreference}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Show daily education</strong>
|
||||||
|
<small>Display the Fid fact of the day and four-question quiz on Overview.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article className="panel form-panel settings-card-collaborators">
|
<article className="panel form-panel settings-card-collaborators">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -745,6 +745,123 @@ textarea {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daily-education-panel,
|
||||||
|
.daily-quiz,
|
||||||
|
.quiz-options,
|
||||||
|
.education-question-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-panel.condensed {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-panel.condensed .panel-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-teaser {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-fact {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid var(--accent-gold);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
background: rgba(255, 254, 250, 0.7);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-quiz {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question,
|
||||||
|
.quiz-editor-question {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 254, 250, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question legend,
|
||||||
|
.quiz-editor-question legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option.correct {
|
||||||
|
border-color: rgba(35, 138, 90, 0.42);
|
||||||
|
background: rgba(223, 247, 229, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option.incorrect {
|
||||||
|
border-color: rgba(203, 58, 53, 0.36);
|
||||||
|
background: rgba(255, 236, 232, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-feedback {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-feedback.correct {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-education-panel,
|
||||||
|
.education-admin-basics,
|
||||||
|
.quiz-editor-question,
|
||||||
|
.education-admin-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-admin-basics {
|
||||||
|
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-editor-question {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-editor-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-admin-list span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -1849,6 +1966,11 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.education-admin-basics,
|
||||||
|
.quiz-editor-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.auth-panel,
|
.auth-panel,
|
||||||
.hero-card,
|
.hero-card,
|
||||||
|
|||||||
Reference in New Issue
Block a user