Skip to content

Commit

Permalink
feat: add admin panel to change member payment
Browse files Browse the repository at this point in the history
  • Loading branch information
jsun969 committed Feb 17, 2024
1 parent c88199b commit 28fca25
Show file tree
Hide file tree
Showing 12 changed files with 290 additions and 14 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@clerk/clerk-react": "^4.30.3",
"@clerk/nextjs": "^4.29.3",
"@headlessui/react": "^1.7.18",
"@hookform/resolvers": "^3.3.4",
"@libsql/client": "0.4.0-pre.7",
"@t3-oss/env-nextjs": "^0.7.3",
Expand Down
31 changes: 31 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions src/app/(account)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import FancyRectangle from '@/components/FancyRectangle';
import Title from '@/components/Title';
import { db } from '@/db';
import { checkUserExists } from '@/db/queries';
import { checkUserExists, updateMemberExpiryDate } from '@/db/queries';
import { memberTable } from '@/db/schema';
import { redisClient } from '@/lib/redis';
import { squareClient } from '@/lib/square';
Expand Down Expand Up @@ -39,12 +39,7 @@ const verifyMembershipPayment = async (clerkId: string) => {
}

// Set expiry date to be the January 1st of the following year
const now = new Date();
const expiryDate = new Date(`${now.getFullYear() + 1}-01-01`);
await db
.update(memberTable)
.set({ membershipExpiresAt: expiryDate })
.where(eq(memberTable.clerkId, clerkId));
const expiryDate = updateMemberExpiryDate(clerkId, 'clerkId');

// Delete key from Redis since it is no longer needed
await redisClient.del(`payment:membership:${clerkId}`);
Expand Down
74 changes: 74 additions & 0 deletions src/app/admin/MemberForm.tsx
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>
);
}
46 changes: 46 additions & 0 deletions src/app/admin/page.tsx
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>
);
}
32 changes: 32 additions & 0 deletions src/app/api/payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
* verify that the user is signed in (see `src/middleware.ts`)
*/
import { PRODUCTS } from '@/data/products';
import { db } from '@/db';
import { updateMemberExpiryDate } from '@/db/queries';
import { memberTable } from '@/db/schema';
import { env } from '@/env.mjs';
import { redisClient } from '@/lib/redis';
import { squareClient } from '@/lib/square';
import { currentUser } from '@clerk/nextjs';
import { eq } from 'drizzle-orm';
import type { CreatePaymentLinkRequest } from 'square';
import { ApiError } from 'square';
import { z } from 'zod';
Expand Down Expand Up @@ -84,3 +88,31 @@ export async function POST(request: Request) {
return new Response(null, { status: 500 });
}
}

export async function PUT(request: Request) {
const req = await request.json();
const schema = z.object({
id: z.string().min(1),
paid: z.boolean(),
});

const user = await currentUser();
if (!user?.publicMetadata.isAdmin) {
return new Response(null, { status: 401 });
}

const reqBody = schema.safeParse(req);
if (!reqBody.success) {
return new Response(JSON.stringify(reqBody.error.format()), { status: 400 });
}

if (reqBody.data.paid) {
await updateMemberExpiryDate(reqBody.data.id, 'id');
} else {
await db
.update(memberTable)
.set({ membershipExpiresAt: null })
.where(eq(memberTable.id, reqBody.data.id));
}
return Response.json({ success: true });
}
80 changes: 80 additions & 0 deletions src/components/Autocomplete.tsx
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>
);
}
12 changes: 8 additions & 4 deletions src/components/UserButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ export default function UserButton({ userExists }: { userExists: boolean }) {
await signOut();
};

if (!user) return <></>;

return (
<FancyRectangle colour="black" offset="4" filled={true}>
<div className="relative flex w-11 gap-y-2 border-2 border-black">
<button onClick={handleButtonClick}>
{/* Display user's profile icon */}
{user && user.imageUrl && (
<Image src={user.imageUrl} alt="Profile" width={100} height={100} />
)}
<Image src={user.imageUrl} alt="Profile" width={100} height={100} />
</button>

{/* Popup menu */}
Expand All @@ -46,6 +45,11 @@ export default function UserButton({ userExists }: { userExists: boolean }) {
<Link href="/settings" className="hover:underline">
Settings
</Link>
{user.publicMetadata.isAdmin && (
<Link href="/admin" className="hover:underline">
Admin Panel
</Link>
)}
</>
)}
{/* Sign Out */}
Expand Down
3 changes: 2 additions & 1 deletion src/db/index.ts
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 });
10 changes: 10 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@ export const checkUserExists = async (clerkUserId: string) => {
.where(eq(memberTable.clerkId, clerkUserId));
return existingUser.length > 0;
};

export const updateMemberExpiryDate = async (id: string, idType: 'clerkId' | 'id') => {
const now = new Date();
const expiryDate = new Date(`${now.getFullYear() + 1}-01-01`);
await db
.update(memberTable)
.set({ membershipExpiresAt: expiryDate })
.where(eq(memberTable[idType], id));
return expiryDate;
};
Loading

0 comments on commit 28fca25

Please sign in to comment.