Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions app/components/sections/cms-voucher/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// app/components/sections/cms-voucher/select.tsx
import { twMerge } from "tailwind-merge";

export type SelectOption = {
value: string;
label: string;
disabled?: boolean;
};

export const Select = ({
label,
id,
name,
placeholder,
options,
defaultValue = null,
disabled = false,
readonly = false,
errorMessage,
}: {
label: string;
id: string;
name: string;
options: SelectOption[];
defaultValue?: string | null;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
errorMessage?: string;
}) => {
const isDisabled = disabled || readonly;
const normalizedDefaultValue = defaultValue ?? "";

return (
<div className="w-full">
<label htmlFor={id} className="block mb-2 text-sm font-medium text-black">
{label}
</label>

<select
id={id}
name={name}
defaultValue={normalizedDefaultValue}
disabled={isDisabled}
className={twMerge(
"w-full p-2 border rounded-lg",
isDisabled ? "bg-gray-100 cursor-not-allowed" : "bg-white",
errorMessage ? "border-red-500" : "border-gray-300",
)}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}

{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>

{errorMessage && (
<p className="mt-2 text-sm text-red-500">{errorMessage}</p>
)}
</div>
);
};
75 changes: 70 additions & 5 deletions app/routes/cms/voucher-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,69 @@ import { createVoucher } from "~/api/endpoint/.server/voucher";
import { clientErrorSchema } from "~/api/schema/shared";
import { Checkbox } from "~/components/sections/cms-voucher/checkbox";
import { Input } from "~/components/sections/cms-voucher/input";
import { Select } from "~/components/sections/cms-voucher/select";
import type { Route } from "./+types/voucher-create";

const PARTICIPANT_TYPES = [
"Keynote Speaker",
"Speaker",
"Organizer",
"Volunteer",
"Sponsor",
"Community",
"Patron",
] as const;

type ParticipantType = (typeof PARTICIPANT_TYPES)[number];

export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData();
const code = formData.get("code");
const value = formData.get("value");
const quota = formData.get("quota");
const type = formData.get("type");
const rawType = formData.get("type");
const rawEmails = formData.get("email_whitelist");
const is_active = !!formData.get("is_active");

let type: ParticipantType | null = null;

if (typeof rawType === "string" && rawType.trim() !== "") {
if ((PARTICIPANT_TYPES as readonly string[]).includes(rawType)) {
type = rawType as ParticipantType;
} else {
type = null;
}
}

let email_whitelist: { emails: string[] } | null = null;

if (typeof rawEmails === "string") {
const emails = rawEmails
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0);

if (emails.length > 0) {
email_whitelist = { emails };
} else {
email_whitelist = null;
}
} else {
email_whitelist = null;
}

const json = {
code: typeof code === "string" ? code : "",
value: value ? Number(value) : null,
quota: quota ? Number(quota) : 0,
type: typeof type === "string" ? type : null,
email_whitelist: null,
type,
email_whitelist,
is_active: is_active,
};

console.log(json);
const res = await createVoucher({ request, json });

if (res.status === 422) {
const json = await res.json();
console.error("Validation error:", json);
Expand Down Expand Up @@ -111,18 +154,40 @@ export default function VoucherCreatePage(
.join(", ") || undefined
}
/>
<Input
<Select
id="type"
name="type"
label="participant type"
placeholder="type"
placeholder="Select participant type"
defaultValue={null}
options={[
{ value: "Keynote Speaker", label: "Keynote Speaker" },
{ value: "Speaker", label: "Speaker" },
{ value: "Organizer", label: "Organizer" },
{ value: "Volunteer", label: "Volunteer" },
{ value: "Sponsor", label: "Sponsor" },
{ value: "Community", label: "Community" },
{ value: "Patron", label: "Patron" },
]}
errorMessage={
actionData?.clientError?.errors
.filter((item) => item.field === "type")
.map((item) => item.message)
.join(", ") || undefined
}
/>
<Input
id="email_whitelist"
name="email_whitelist"
label="Allowed emails (comma separated)"
placeholder="[email protected], [email protected]"
errorMessage={
actionData?.clientError?.errors
.filter((item) => item.field === "email_whitelist")
.map((item) => item.message)
.join(", ") || undefined
}
/>
<Checkbox id="is_active" name="is_active" label="is active" />
<div className="flex justify-end gap-4">
<Link
Expand Down
78 changes: 72 additions & 6 deletions app/routes/cms/voucher-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@ import { clientErrorSchema } from "~/api/schema/shared";
import { VoucherResultSchema } from "~/api/schema/voucher";
import { Checkbox } from "~/components/sections/cms-voucher/checkbox";
import { Input } from "~/components/sections/cms-voucher/input";
import { Select } from "~/components/sections/cms-voucher/select";
import type { Route } from "./+types/voucher-edit";

const PARTICIPANT_TYPES = [
"Keynote Speaker",
"Speaker",
"Organizer",
"Volunteer",
"Sponsor",
"Community",
"Patron",
] as const;

type ParticipantType = (typeof PARTICIPANT_TYPES)[number];

export const loader = async ({ params, request }: Route.LoaderArgs) => {
const { id } = params;
if (!id) {
Expand All @@ -34,14 +47,45 @@ export const action = async ({ request }: Route.ActionArgs) => {
const code = formData.get("code");
const value = formData.get("value");
const quota = formData.get("quota");
const type = formData.get("type");
const rawType = formData.get("type");
const rawEmails = formData.get("email_whitelist");
const is_active = !!formData.get("is_active");

let type: ParticipantType | null = null;

if (typeof rawType === "string" && rawType.trim() !== "") {
if ((PARTICIPANT_TYPES as readonly string[]).includes(rawType)) {
type = rawType as ParticipantType;
} else {
// Optional: if someone tampers with the form we could:
// - keep it null, or
// - throw, or
// - map to an error
type = null;
}
}

let email_whitelist: { emails: string[] } | null = null;

if (typeof rawEmails === "string") {
const emails = rawEmails
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0);

if (emails.length > 0) {
email_whitelist = { emails };
} else {
email_whitelist = null;
}
}

const json = {
code: typeof code === "string" ? code : "",
value: value ? Number(value) : null,
quota: quota ? Number(quota) : 0,
type: typeof type === "string" ? type : null,
email_whitelist: null,
type,
email_whitelist,
is_active: is_active,
};
console.log(id);
Expand Down Expand Up @@ -138,18 +182,40 @@ export default function VoucherCreatePage(
}
defaultValue={voucher.quota?.toString()}
/>
<Input
<Select
id="type"
name="type"
label="participant type"
placeholder="type"
placeholder="Select participant type"
defaultValue={voucher.type ?? null}
options={[
{ value: "Keynote Speaker", label: "Keynote Speaker" },
{ value: "Speaker", label: "Speaker" },
{ value: "Organizer", label: "Organizer" },
{ value: "Volunteer", label: "Volunteer" },
{ value: "Sponsor", label: "Sponsor" },
{ value: "Community", label: "Community" },
{ value: "Patron", label: "Patron" },
]}
errorMessage={
actionData?.clientError?.errors
.filter((item) => item.field === "type")
.map((item) => item.message)
.join(", ") || undefined
}
defaultValue={voucher.type || ""}
/>
<Input
id="email_whitelist"
name="email_whitelist"
label="Allowed emails (comma separated)"
placeholder="[email protected], [email protected]"
defaultValue={voucher.email_whitelist?.emails?.join(", ") ?? ""}
errorMessage={
actionData?.clientError?.errors
.filter((item) => item.field === "email_whitelist")
.map((item) => item.message)
.join(", ") || undefined
}
/>
<Checkbox
id="is_active"
Expand Down