diff --git a/convex/quests.ts b/convex/quests.ts index 3d92551..145bd9a 100644 --- a/convex/quests.ts +++ b/convex/quests.ts @@ -63,6 +63,14 @@ export const updateQuest = userMutation({ title: v.string(), jurisdiction: v.optional(jurisdiction), category: v.optional(category), + costs: v.optional( + v.array( + v.object({ + cost: v.number(), + description: v.string(), + }), + ), + ), urls: v.optional(v.array(v.string())), content: v.optional(v.string()), }, @@ -72,6 +80,7 @@ export const updateQuest = userMutation({ title: args.title, jurisdiction: args.jurisdiction, category: args.category, + costs: args.costs, urls: args.urls, content: args.content, }); diff --git a/convex/schema.ts b/convex/schema.ts index 621fe13..14e8cce 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -17,6 +17,7 @@ import { * @param category - The category of the quest. (e.g. "Social") * @param creationUser - The user who created the quest. * @param jurisdiction - The US State the quest applies to. (e.g. "MA") + * @param costs - The costs of the quest in USD. * @param urls - Links to official documentation about changing names for this quest. * @param deletionTime - Time in ms since epoch when the quest was deleted. * @param content - Text written in markdown comprising the contents of the quest. @@ -26,6 +27,14 @@ const quests = defineTable({ category: v.optional(category), creationUser: v.id("users"), jurisdiction: v.optional(jurisdiction), + costs: v.optional( + v.array( + v.object({ + cost: v.number(), + description: v.string(), + }), + ), + ), urls: v.optional(v.array(v.string())), deletionTime: v.optional(v.number()), content: v.optional(v.string()), diff --git a/src/components/NumberField/NumberField.tsx b/src/components/NumberField/NumberField.tsx index 8b2eeaa..fdd217c 100644 --- a/src/components/NumberField/NumberField.tsx +++ b/src/components/NumberField/NumberField.tsx @@ -18,6 +18,7 @@ import { composeTailwindRenderProps } from "../utils"; export interface NumberFieldProps extends AriaNumberFieldProps { label?: string; + prefix?: React.ReactNode; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); } @@ -25,6 +26,7 @@ export interface NumberFieldProps extends AriaNumberFieldProps { export function NumberField({ label, description, + prefix, errorMessage, ...props }: NumberFieldProps) { @@ -40,6 +42,11 @@ export function NumberField({ {(renderProps) => ( <> + {prefix && ( + + {prefix} + + )}
({ )} > - + {({ state }) => state.values.map((_, i) => state.getThumbValueLabel(i)).join(" – ") } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dac53ba..d6125e0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -20,16 +20,16 @@ import { Route as AuthenticatedAdminRouteImport } from './routes/_authenticated/ import { Route as AuthenticatedSettingsIndexImport } from './routes/_authenticated/settings/index' import { Route as AuthenticatedBrowseIndexImport } from './routes/_authenticated/browse/index' import { Route as AuthenticatedAdminIndexImport } from './routes/_authenticated/admin/index' -import { Route as AuthenticatedHomeIndexImport } from './routes/_authenticated/_home.index' +import { Route as AuthenticatedHomeIndexImport } from './routes/_authenticated/_home/index' import { Route as AuthenticatedSettingsOverviewImport } from './routes/_authenticated/settings/overview' import { Route as AuthenticatedSettingsDataImport } from './routes/_authenticated/settings/data' import { Route as AuthenticatedAdminQuestsIndexImport } from './routes/_authenticated/admin/quests/index' import { Route as AuthenticatedAdminFormsIndexImport } from './routes/_authenticated/admin/forms/index' import { Route as AuthenticatedAdminFieldsIndexImport } from './routes/_authenticated/admin/fields/index' -import { Route as AuthenticatedHomeQuestsIndexImport } from './routes/_authenticated/_home.quests.index' +import { Route as AuthenticatedHomeQuestsIndexImport } from './routes/_authenticated/_home/quests.index' import { Route as AuthenticatedAdminQuestsQuestIdImport } from './routes/_authenticated/admin/quests/$questId' import { Route as AuthenticatedAdminFormsFormIdImport } from './routes/_authenticated/admin/forms/$formId' -import { Route as AuthenticatedHomeQuestsQuestIdImport } from './routes/_authenticated/_home.quests.$questId' +import { Route as AuthenticatedHomeQuestsQuestIdImport } from './routes/_authenticated/_home/quests.$questId' // Create/Update Routes @@ -589,7 +589,7 @@ export const routeTree = rootRoute "parent": "/_authenticated/settings" }, "/_authenticated/_home/": { - "filePath": "_authenticated/_home.index.tsx", + "filePath": "_authenticated/_home/index.tsx", "parent": "/_authenticated/_home" }, "/_authenticated/admin/": { @@ -605,7 +605,7 @@ export const routeTree = rootRoute "parent": "/_authenticated/settings" }, "/_authenticated/_home/quests/$questId": { - "filePath": "_authenticated/_home.quests.$questId.tsx", + "filePath": "_authenticated/_home/quests.$questId.tsx", "parent": "/_authenticated/_home" }, "/_authenticated/admin/forms/$formId": { @@ -617,7 +617,7 @@ export const routeTree = rootRoute "parent": "/_authenticated/admin" }, "/_authenticated/_home/quests/": { - "filePath": "_authenticated/_home.quests.index.tsx", + "filePath": "_authenticated/_home/quests.index.tsx", "parent": "/_authenticated/_home" }, "/_authenticated/admin/fields/": { diff --git a/src/routes/_authenticated/_home.index.tsx b/src/routes/_authenticated/_home.index.tsx deleted file mode 100644 index 1e64248..0000000 --- a/src/routes/_authenticated/_home.index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/_home/")({ - component: IndexRoute, -}); - -function IndexRoute() { - return; -} diff --git a/src/routes/_authenticated/_home.quests.index.tsx b/src/routes/_authenticated/_home.quests.index.tsx deleted file mode 100644 index 08028ab..0000000 --- a/src/routes/_authenticated/_home.quests.index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/_home/quests/")({ - beforeLoad: async () => { - throw redirect({ - to: "/", - }); - }, -}); diff --git a/src/routes/_authenticated/_home/index.tsx b/src/routes/_authenticated/_home/index.tsx new file mode 100644 index 0000000..815a8d3 --- /dev/null +++ b/src/routes/_authenticated/_home/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/_home/')({ + component: IndexRoute, +}) + +function IndexRoute() { + return +} diff --git a/src/routes/_authenticated/_home.quests.$questId.tsx b/src/routes/_authenticated/_home/quests.$questId.tsx similarity index 58% rename from src/routes/_authenticated/_home.quests.$questId.tsx rename to src/routes/_authenticated/_home/quests.$questId.tsx index 558331a..812d225 100644 --- a/src/routes/_authenticated/_home.quests.$questId.tsx +++ b/src/routes/_authenticated/_home/quests.$questId.tsx @@ -2,27 +2,99 @@ import { Badge, Button, Container, + DialogTrigger, Empty, Link, Menu, MenuItem, MenuTrigger, PageHeader, + Popover, StatusSelect, + Tooltip, + TooltipTrigger, } from "@/components"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; import type { Status } from "@convex/constants"; -import { RiLink, RiMoreFill, RiSignpostLine } from "@remixicon/react"; +import { + RiLink, + RiMoreFill, + RiQuestionLine, + RiSignpostLine, +} from "@remixicon/react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import Markdown from "react-markdown"; +import { Fragment } from "react/jsx-runtime"; import { toast } from "sonner"; export const Route = createFileRoute("/_authenticated/_home/quests/$questId")({ component: QuestDetailRoute, }); +const getTotalCosts = (costs?: { cost: number; description: string }[]) => { + if (!costs) return "Free"; + + const total = costs.reduce((acc, cost) => acc + cost.cost, 0); + return total > 0 + ? total.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }) + : "Free"; +}; + +const QuestCosts = ({ + costs, +}: { costs?: { cost: number; description: string }[] }) => { + const totalCosts = getTotalCosts(costs); + + return ( +
+
+
Cost
+
+ {totalCosts} + {costs?.length && ( + + + + See cost breakdown + + +
+ {costs.map(({ cost, description }) => ( + +
{description}
+
+ {cost.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + })} +
+
+ ))} +
+ Total +
+
+ {totalCosts} +
+
+
+
+ )} +
+
+
+ ); +}; + function QuestDetailRoute() { const { questId } = Route.useParams(); const navigate = useNavigate(); @@ -82,6 +154,7 @@ function QuestDetailRoute() { + {quest.urls && (
{quest.urls.map((url) => ( diff --git a/src/routes/_authenticated/_home/quests.index.tsx b/src/routes/_authenticated/_home/quests.index.tsx new file mode 100644 index 0000000..5a9fd07 --- /dev/null +++ b/src/routes/_authenticated/_home/quests.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/_home/quests/')({ + beforeLoad: async () => { + throw redirect({ + to: '/', + }) + }, +}) diff --git a/src/routes/_authenticated/admin/quests/$questId.tsx b/src/routes/_authenticated/admin/quests/$questId.tsx index 9b845b4..2a78ddc 100644 --- a/src/routes/_authenticated/admin/quests/$questId.tsx +++ b/src/routes/_authenticated/admin/quests/$questId.tsx @@ -1,6 +1,7 @@ import { Button, Form, + NumberField, RichTextEditor, Select, SelectItem, @@ -34,8 +35,13 @@ const URLInput = memo(function URLInput({ onRemove: () => void; }) { return ( -
- +
+ @@ -43,6 +49,39 @@ const URLInput = memo(function URLInput({ ); }); +const CostInput = memo(function CostInput({ + cost, + onChange, + onRemove, +}: { + cost: { cost: number; description: string }; + onChange: (cost: { cost: number; description: string }) => void; + onRemove: (cost: { cost: number; description: string }) => void; +}) { + return ( +
+ + onChange({ cost: value, description: cost.description }) + } + /> + onChange({ cost: cost.cost, description: value })} + /> + +
+ ); +}); + function AdminQuestDetailRoute() { const { questId } = Route.useParams(); const quest = useQuery(api.quests.getQuest, { @@ -53,6 +92,9 @@ function AdminQuestDetailRoute() { const [title, setTitle] = useState(""); const [category, setCategory] = useState(null); const [jurisdiction, setJurisdiction] = useState(null); + const [costs, setCosts] = useState<{ cost: number; description: string }[]>( + [], + ); const [urls, setUrls] = useState([]); const [content, setContent] = useState(""); @@ -61,6 +103,7 @@ function AdminQuestDetailRoute() { setTitle(quest.title ?? ""); setCategory(quest.category as Category); setJurisdiction(quest.jurisdiction as Jurisdiction); + setCosts(quest.costs ?? []); setUrls(quest.urls ?? []); setContent(quest.content ?? ""); } @@ -76,6 +119,7 @@ function AdminQuestDetailRoute() { title, category: category ?? undefined, jurisdiction: jurisdiction ?? undefined, + costs: costs ?? undefined, urls: urls ?? undefined, content, }).then(() => { @@ -122,6 +166,30 @@ function AdminQuestDetailRoute() { ))} +
+ {costs.map((cost, index) => ( + { + const newCosts = [...costs]; + newCosts[index] = value; + setCosts(newCosts); + }} + onRemove={() => { + setCosts(costs.filter((_, i) => i !== index)); + }} + /> + ))} + +
{urls.map((url, index) => (