Skip to content

Commit

Permalink
feat: Display costs for each quest (#194)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Nov 17, 2024
1 parent c92d609 commit fa35737
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-snakes-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Display estimated costs for each quest
9 changes: 9 additions & 0 deletions convex/quests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
},
Expand All @@ -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,
});
Expand Down
9 changes: 9 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()),
Expand Down
7 changes: 7 additions & 0 deletions src/components/NumberField/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import { composeTailwindRenderProps } from "../utils";

export interface NumberFieldProps extends AriaNumberFieldProps {
label?: string;
prefix?: React.ReactNode;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
}

export function NumberField({
label,
description,
prefix,
errorMessage,
...props
}: NumberFieldProps) {
Expand All @@ -40,6 +42,11 @@ export function NumberField({
<FieldGroup>
{(renderProps) => (
<>
{prefix && (
<span className="text-gray-9 dark:text-graydark-9 ml-2 -mr-2">
{prefix}
</span>
)}
<Input />
<div
className={fieldBorderStyles({
Expand Down
2 changes: 1 addition & 1 deletion src/components/Slider/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function Slider<T extends number | number[]>({
)}
>
<Label>{label}</Label>
<SliderOutput className="text-sm text-gray-5 dark:text-gray-4 font-medium orientation-vertical:hidden">
<SliderOutput className="text-sm text-gray-4 dark:text-gray-4 font-medium orientation-vertical:hidden">
{({ state }) =>
state.values.map((_, i) => state.getThumbValueLabel(i)).join(" – ")
}
Expand Down
12 changes: 6 additions & 6 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/": {
Expand All @@ -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": {
Expand All @@ -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/": {
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex flex-col mb-4">
<div className="flex flex-col">
<div className="text-gray-dim">Cost</div>
<div className="text-2xl flex gap-0.5 items-center">
{totalCosts}
{costs?.length && (
<DialogTrigger>
<TooltipTrigger>
<Button variant="icon" size="small">
<RiQuestionLine />
</Button>
<Tooltip>See cost breakdown</Tooltip>
</TooltipTrigger>
<Popover className="p-4">
<dl className="grid grid-cols-[1fr_auto]">
{costs.map(({ cost, description }) => (
<Fragment key={description}>
<dt className="text-gray-dim pr-4">{description}</dt>
<dd className="text-right">
{cost.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})}
</dd>
</Fragment>
))}
<dt className="text-gray-dim pr-4 border-t border-gray-dim pt-2 mt-2">
Total
</dt>
<dd className="text-right border-t border-gray-dim pt-2 mt-2">
{totalCosts}
</dd>
</dl>
</Popover>
</DialogTrigger>
)}
</div>
</div>
</div>
);
};

function QuestDetailRoute() {
const { questId } = Route.useParams();
const navigate = useNavigate();
Expand Down Expand Up @@ -82,6 +154,7 @@ function QuestDetailRoute() {
</MenuTrigger>
</PageHeader>
<Container className="overflow-y-auto">
<QuestCosts costs={quest.costs} />
{quest.urls && (
<div className="flex flex-col items-start gap-1 mb-4">
{quest.urls.map((url) => (
Expand Down
77 changes: 75 additions & 2 deletions src/routes/_authenticated/admin/quests/$questId.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Button,
Form,
NumberField,
RichTextEditor,
Select,
SelectItem,
Expand Down Expand Up @@ -34,15 +35,57 @@ const URLInput = memo(function URLInput({
onRemove: () => void;
}) {
return (
<div className="flex gap-2">
<TextField value={value} onChange={onChange} className="flex-1" />
<div className="flex gap-2 items-end">
<TextField
value={value}
onChange={onChange}
label="URL"
className="flex-1 w-96"
/>
<Button type="button" variant="secondary" onPress={onRemove}>
Remove
</Button>
</div>
);
});

const CostInput = memo(function CostInput({
cost,
onChange,
onRemove,
hideLabel = false,
}: {
cost: { cost: number; description: string };
onChange: (cost: { cost: number; description: string }) => void;
onRemove: (cost: { cost: number; description: string }) => void;
hideLabel?: boolean;
}) {
return (
<div className="flex items-end gap-2">
<NumberField
label={hideLabel ? undefined : "Cost"}
aria-label={hideLabel ? "Cost" : undefined}
className="w-28"
prefix="$"
value={cost.cost}
onChange={(value) =>
onChange({ cost: value, description: cost.description })
}
/>
<TextField
label={hideLabel ? undefined : "For"}
aria-label={hideLabel ? "For" : undefined}
className="w-80"
value={cost.description}
onChange={(value) => onChange({ cost: cost.cost, description: value })}
/>
<Button type="button" variant="secondary" onPress={() => onRemove(cost)}>
Remove
</Button>
</div>
);
});

function AdminQuestDetailRoute() {
const { questId } = Route.useParams();
const quest = useQuery(api.quests.getQuest, {
Expand All @@ -53,6 +96,9 @@ function AdminQuestDetailRoute() {
const [title, setTitle] = useState("");
const [category, setCategory] = useState<Category | null>(null);
const [jurisdiction, setJurisdiction] = useState<Jurisdiction | null>(null);
const [costs, setCosts] = useState<{ cost: number; description: string }[]>(
[],
);
const [urls, setUrls] = useState<string[]>([]);
const [content, setContent] = useState("");

Expand All @@ -61,6 +107,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 ?? "");
}
Expand All @@ -76,6 +123,7 @@ function AdminQuestDetailRoute() {
title,
category: category ?? undefined,
jurisdiction: jurisdiction ?? undefined,
costs: costs ?? undefined,
urls: urls ?? undefined,
content,
}).then(() => {
Expand Down Expand Up @@ -122,6 +170,31 @@ function AdminQuestDetailRoute() {
</SelectItem>
))}
</Select>
<div className="flex flex-col gap-2">
{costs.map((cost, index) => (
<CostInput
// biome-ignore lint/suspicious/noArrayIndexKey:
key={index}
cost={cost}
onChange={(value) => {
const newCosts = [...costs];
newCosts[index] = value;
setCosts(newCosts);
}}
onRemove={() => {
setCosts(costs.filter((_, i) => i !== index));
}}
hideLabel={index > 0}
/>
))}
<Button
type="button"
variant="secondary"
onPress={() => setCosts([...costs, { cost: 0, description: "" }])}
>
Add cost
</Button>
</div>
<div className="flex flex-col gap-2">
{urls.map((url, index) => (
<URLInput
Expand Down

0 comments on commit fa35737

Please sign in to comment.