-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add admin panel to change member payment
- Loading branch information
Showing
12 changed files
with
290 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
'use client'; | ||
|
||
import Autocomplete from '@/components/Autocomplete'; | ||
import { fetcher } from '@/lib/fetcher'; | ||
import { useState } from 'react'; | ||
import useSWRMutation from 'swr/mutation'; | ||
import type { Member } from './page'; | ||
|
||
const getMemberStr = (member: Member) => `${member.email} - ${member.firstName} ${member.lastName}`; | ||
|
||
function MemberDetail({ member }: { member: Member }) { | ||
const { paid, ...details } = member; | ||
|
||
const [payment, setPayment] = useState(member.paid); | ||
const updatePayment = useSWRMutation('payment', fetcher.put.mutate, { | ||
onSuccess: () => { | ||
setPayment(!payment); | ||
}, | ||
}); | ||
const handlePaymentChange = () => { | ||
updatePayment.trigger({ id: member.id, paid: !member.paid }); | ||
}; | ||
|
||
return ( | ||
<table className="relative [&>tbody>*>*]:border [&>tbody>*>*]:border-grey [&>tbody>*>*]:px-4 [&>tbody>*>*]:py-2"> | ||
<tbody> | ||
{Object.entries(details).map(([key, value]) => ( | ||
<tr key={key}> | ||
<td className="font-bold capitalize">{key}</td> | ||
<td>{value}</td> | ||
</tr> | ||
))} | ||
<tr> | ||
<td className="font-bold">Payment</td> | ||
<td> | ||
<input | ||
type="checkbox" | ||
checked={payment} | ||
onChange={handlePaymentChange} | ||
disabled={updatePayment.isMutating} | ||
/> | ||
</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
); | ||
} | ||
|
||
export default function MemberForm({ members }: { members: Member[] }) { | ||
const [selectedMember, setSelectedMember] = useState<Member | null>(null); | ||
|
||
return ( | ||
<div className="w-full space-y-4"> | ||
<Autocomplete | ||
options={members} | ||
value={selectedMember} | ||
onChange={setSelectedMember} | ||
displayOptionStr={getMemberStr} | ||
placeholder="Search for a member by email or name..." | ||
notFoundMessage="No members found" | ||
className="grow" | ||
/> | ||
<div className="flex justify-center"> | ||
{selectedMember ? ( | ||
<MemberDetail member={selectedMember} /> | ||
) : ( | ||
<div className="py-24 text-center text-4xl"> | ||
Search a member to view details | ||
</div> | ||
)} | ||
</div> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import FancyRectangle from '@/components/FancyRectangle'; | ||
import Title from '@/components/Title'; | ||
import { db } from '@/db'; | ||
import { currentUser } from '@clerk/nextjs'; | ||
import { notFound } from 'next/navigation'; | ||
import MemberForm from './MemberForm'; | ||
|
||
const queryMembers = async () => { | ||
const dbMembers = await db.query.memberTable.findMany({ | ||
columns: { | ||
id: true, | ||
firstName: true, | ||
lastName: true, | ||
email: true, | ||
membershipExpiresAt: true, | ||
createdAt: true, | ||
}, | ||
}); | ||
const members = dbMembers.map(({ membershipExpiresAt, ...member }) => ({ | ||
...member, | ||
paid: membershipExpiresAt !== null && membershipExpiresAt > new Date(), | ||
})); | ||
return members; | ||
}; | ||
export type Member = Awaited<ReturnType<typeof queryMembers>>[number]; | ||
|
||
export default async function AdminPage() { | ||
const user = await currentUser(); | ||
if (!user?.publicMetadata.isAdmin) { | ||
return notFound(); | ||
} | ||
const members = await queryMembers(); | ||
|
||
return ( | ||
<div className="space-y-8"> | ||
<div className="flex justify-center"> | ||
<Title colour="purple">Admin Panel</Title> | ||
</div> | ||
<FancyRectangle colour="purple" offset="8" filled fullWidth> | ||
<div className="w-full border-4 border-black bg-white px-8 py-8 text-black md:px-12 md:py-12"> | ||
<MemberForm members={members} /> | ||
</div> | ||
</FancyRectangle> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { Combobox } from '@headlessui/react'; | ||
import { useState } from 'react'; | ||
import { IoCaretDown, IoCheckmark } from 'react-icons/io5'; | ||
|
||
interface AutocompleteProps<TOption> { | ||
value: TOption | null; | ||
onChange: (option: TOption) => void; | ||
options: TOption[]; | ||
displayOptionStr: (option: TOption) => string; | ||
placeholder?: string; | ||
notFoundMessage?: string; | ||
className?: string; | ||
} | ||
export default function Autocomplete<TOption>({ | ||
value, | ||
onChange, | ||
options, | ||
displayOptionStr, | ||
placeholder, | ||
notFoundMessage, | ||
className, | ||
}: AutocompleteProps<TOption>) { | ||
const [query, setQuery] = useState(''); | ||
const filteredOptions = | ||
query === '' | ||
? options | ||
: options.filter((option) => | ||
displayOptionStr(option).toLowerCase().includes(query.toLowerCase()) | ||
); | ||
|
||
return ( | ||
<Combobox value={value} onChange={onChange}> | ||
<div className={`${className} relative w-full`}> | ||
<div className="relative w-full"> | ||
<Combobox.Input | ||
onChange={(e) => setQuery(e.target.value)} | ||
className="w-full border border-gray-300 px-3 py-2 text-grey" | ||
displayValue={(option: TOption | null) => | ||
option ? displayOptionStr(option) : '' | ||
} | ||
placeholder={placeholder} | ||
/> | ||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2"> | ||
<IoCaretDown className="h-5 w-5 fill-grey" /> | ||
</Combobox.Button> | ||
</div> | ||
<Combobox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none"> | ||
{filteredOptions.length === 0 && query !== '' ? ( | ||
<div className="relative select-none px-4 py-2 text-grey"> | ||
{notFoundMessage ?? 'No results found'} | ||
</div> | ||
) : ( | ||
filteredOptions.map((option, i) => ( | ||
<Combobox.Option | ||
key={i} | ||
value={option} | ||
className={({ active }) => | ||
`relative cursor-default select-none py-2 pl-10 pr-4 ${ | ||
active ? 'bg-grey text-white' : 'text-black' | ||
}` | ||
} | ||
> | ||
{({ selected }) => ( | ||
<> | ||
{selected && ( | ||
<span className="absolute inset-y-0 left-0 flex items-center pl-3"> | ||
<IoCheckmark className="h-5 w-5" /> | ||
</span> | ||
)} | ||
<span>{displayOptionStr(option)}</span> | ||
</> | ||
)} | ||
</Combobox.Option> | ||
)) | ||
)} | ||
</Combobox.Options> | ||
</div> | ||
</Combobox> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,11 @@ | ||
import { env } from '@/env.mjs'; | ||
import { createClient } from '@libsql/client'; | ||
import { drizzle } from 'drizzle-orm/libsql'; | ||
import * as schema from './schema'; | ||
|
||
const client = createClient({ | ||
url: env.DATABASE_URL, | ||
authToken: env.DATABASE_AUTH_TOKEN, | ||
}); | ||
|
||
export const db = drizzle(client); | ||
export const db = drizzle(client, { schema }); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.