Skip to content

Commit 0fe9a86

Browse files
committed
Add QR Scan
1 parent 5469edf commit 0fe9a86

File tree

11 files changed

+181
-128
lines changed

11 files changed

+181
-128
lines changed

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
"autoprefixer": "10.4.15",
1717
"eslint": "8.47.0",
1818
"eslint-config-next": "^14.1.0",
19-
"jsonwebtoken": "^9.0.2",
2019
"lucide-react": "^0.469.0",
2120
"moment": "^2.30.1",
2221
"msw": "^2.6.5",
@@ -35,7 +34,6 @@
3534
"typescript": "5.1.6"
3635
},
3736
"devDependencies": {
38-
"@types/jsonwebtoken": "^9.0.7",
3937
"prettier": "^3.4.2"
4038
}
4139
}

src/app/(authenticated)/qr/page.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { UserService } from "@/services/UserService";
1010
import { CompanyService } from "@/services/CompanyService";
1111
import { convertToAppRole, isCompany } from "@/utils/utils";
1212
import Link from "next/link";
13+
import { ScanQrCode } from "lucide-react";
1314

1415
export default async function QR() {
1516
const session = await getServerSession(authOptions);
@@ -25,8 +26,8 @@ export default async function QR() {
2526
let company: Company | null = null;
2627
if (isCompany(user.role)) {
2728
// assumes that cannon api only provides the company associated with the current edition
28-
if (user.company) {
29-
company = await CompanyService.getCompany(user.company.id);
29+
if (user.company?.length) {
30+
company = await CompanyService.getCompany(user.company[0].company);
3031
} else {
3132
await demoteMe(session!.cannonToken);
3233
}
@@ -49,13 +50,15 @@ export default async function QR() {
4950

5051
return (
5152
<div className="container m-auto h-full">
52-
<div className="flex flex-col justify-center items-center text-center p-4 gap-y-4">
53+
<div className="h-full flex flex-col justify-center items-center text-center p-4 gap-y-4">
5354
<div className="flex flex-col justify-center items-center">
54-
<Image className="w-48" src={hackyPeeking} alt="Hacky Peaking" />
55+
{!isCompany(user.role) && (
56+
<Image className="w-48" src={hackyPeeking} alt="Hacky Peaking" />
57+
)}
5558
<QRCode
5659
className="w-72 h-auto p-4 border-[14px] bg-white rounded-lg"
5760
style={{ borderColor }}
58-
value={`sinfo://${btoa(JSON.stringify({ kind: "user", user: { id: user.id, name: user.name, img: user.img, role: user.role } }))}`}
61+
value={userQRCode}
5962
/>
6063
</div>
6164
<div>
@@ -64,17 +67,25 @@ export default async function QR() {
6467
{convertToAppRole(user.role)}
6568
</p>
6669
</div>
67-
{user.company && (
68-
<Link href={`/companies/${user.company.id}`}>
70+
{company && (
71+
<Link href={`/companies/${company.id}`}>
6972
<Image
7073
className="object-contain"
7174
width={100}
7275
height={100}
73-
src={user.company.img}
74-
alt={`${user.company.name} logo`}
76+
src={company.img}
77+
alt={`${company.name} logo`}
7578
/>
7679
</Link>
7780
)}
81+
<Link
82+
href="/qr/scan"
83+
className="button button-primary text-lg w-full"
84+
style={{ backgroundColor: borderColor }}
85+
>
86+
<ScanQrCode size={24} />
87+
Scan
88+
</Link>
7889
</div>
7990
</div>
8091
);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"use client";
2+
3+
import MessageCard from "@/components/MessageCard";
4+
import QRCodeScanner from "@/components/QRCodeScanner";
5+
import { UserTile } from "@/components/user/UserTile";
6+
import { CompanyService } from "@/services/CompanyService";
7+
import {
8+
getAchievementFromQRCode,
9+
getUserFromQRCode,
10+
isCompany,
11+
} from "@/utils/utils";
12+
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
13+
14+
interface QRScannerProps {
15+
user: User;
16+
cannonToken: string;
17+
}
18+
19+
export default function QRScanner({ user, cannonToken }: QRScannerProps) {
20+
const [bottomCard, setBottomCard] = useState<ReactNode | undefined>();
21+
const [statusCard, setStatusCard] = useState<ReactNode | undefined>();
22+
const cardsTimeout = useRef<NodeJS.Timeout>();
23+
24+
const handleQRCodeScanned = useCallback(
25+
async (data: string) => {
26+
cardsTimeout.current && clearTimeout(cardsTimeout.current);
27+
28+
const scannedUser = getUserFromQRCode(data);
29+
const scannedAchievement = getAchievementFromQRCode(data);
30+
31+
if (scannedUser) {
32+
setBottomCard(<UserTile user={scannedUser} />);
33+
if (isCompany(user.role) && user.company?.length) {
34+
const signedUser = await CompanyService.sign(
35+
cannonToken,
36+
user.company[0].company,
37+
scannedUser.id,
38+
);
39+
if (signedUser) {
40+
setStatusCard(
41+
<MessageCard type="info" content="User got its achievement." />,
42+
);
43+
} else {
44+
setStatusCard(
45+
<MessageCard
46+
type="warning"
47+
content="Failed to get user's achievement. Scan again!"
48+
/>,
49+
);
50+
}
51+
}
52+
} else if (scannedAchievement) {
53+
// FIXME: Redeem secret achievement
54+
setBottomCard(<h1>Achievement Redeemed</h1>);
55+
} else {
56+
setBottomCard(<MessageCard type="danger" content="Invalid QR-Code" />);
57+
}
58+
59+
cardsTimeout.current = setTimeout(() => {
60+
setBottomCard(null);
61+
setStatusCard(null);
62+
}, 10 * 1000); // 10 seconds
63+
},
64+
[cannonToken, user.role, user.company],
65+
);
66+
67+
useEffect(() => {
68+
setBottomCard((card) => (
69+
<div className="flex flex-col justify-start gap-y-1">
70+
{statusCard}
71+
{card}
72+
</div>
73+
));
74+
}, [statusCard]);
75+
76+
return (
77+
<div className="container m-auto h-full">
78+
<QRCodeScanner
79+
onQRCodeScanned={handleQRCodeScanned}
80+
bottomCard={bottomCard}
81+
/>
82+
</div>
83+
);
84+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getServerSession } from "next-auth";
2+
import QRScanner from "./QRScanner";
3+
import UserSignOut from "@/components/UserSignOut";
4+
import { UserService } from "@/services/UserService";
5+
import authOptions from "@/app/api/auth/[...nextauth]/authOptions";
6+
7+
export default async function ScanQRCode() {
8+
const session = await getServerSession(authOptions);
9+
10+
const user: User | null = await UserService.getMe(session?.cannonToken ?? "");
11+
if (!user) return <UserSignOut />;
12+
13+
return (
14+
<div className="container m-auto h-full">
15+
<QRScanner user={user} cannonToken={session!.cannonToken} />
16+
</div>
17+
);
18+
}

src/components/BottomNavbar/QRCodeButton.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default function QRCodeButton() {
1111
if (
1212
currPath === "/qr" ||
1313
currPath.endsWith("/check-in") ||
14-
currPath.endsWith("/promote")
14+
currPath.endsWith("/promote") ||
15+
currPath.endsWith("/scan")
1516
)
1617
return <></>;
1718

src/components/QRCodeScanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default function QRCodeScanner({
2626
scannerRef.current = new QrScanner(
2727
videoRef.current,
2828
(result) => {
29-
if (result.data && scanning) {
29+
if (result.data && scanning.current) {
3030
scanning.current = false;
3131
setTimeout(() => (scanning.current = true), TIMEOUT_SCAN);
3232
onQRCodeScanned(result.data);

src/services/CompanyService.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,24 @@ export const CompanyService = (() => {
3535
return null;
3636
};
3737

38-
return { getCompany, getCompanies, getConnections };
38+
const sign = async (
39+
cannonToken: string,
40+
id: string,
41+
userId: string,
42+
): Promise<User | null> => {
43+
try {
44+
const resp = await fetch(`${companiesEndpoint}/${id}/sign/${userId}`, {
45+
method: "POST",
46+
headers: {
47+
Authorization: `Bearer ${cannonToken}`,
48+
},
49+
});
50+
if (resp.ok) return (await resp.json()) as User;
51+
} catch (e) {
52+
console.error(e);
53+
}
54+
return null;
55+
};
56+
57+
return { getCompany, getCompanies, getConnections, sign };
3958
})();

src/services/UserService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const UserService = (() => {
4646
},
4747
});
4848

49-
if (resp.ok) return (await resp.json()).data;
49+
if (resp.ok) return (await resp.json()).token;
5050
} catch (error) {
5151
console.error(error);
5252
}
@@ -175,7 +175,7 @@ export const UserService = (() => {
175175
options: PromoteOptions,
176176
): Promise<boolean> => {
177177
try {
178-
const resp = await fetch(usersEndpoint + `/users/${id}`, {
178+
const resp = await fetch(usersEndpoint + `/${id}`, {
179179
method: "PUT",
180180
headers: {
181181
"Content-Type": "application/json",
@@ -193,7 +193,7 @@ export const UserService = (() => {
193193

194194
const demote = async (cannonToken: string, id: string): Promise<boolean> => {
195195
try {
196-
const resp = await fetch(usersEndpoint + `/users/${id}`, {
196+
const resp = await fetch(usersEndpoint + `/${id}`, {
197197
method: "PUT",
198198
headers: {
199199
"Content-Type": "application/json",

src/types/globals.d.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,18 @@ type User = {
1111
email?: string;
1212
github?: string;
1313
};
14+
signatures?: {
15+
edition: string;
16+
day: string;
17+
redeemed: boolean;
18+
signatures: SINFOSignature[];
19+
}[];
1420
achievements?: Achievement[];
1521
connections?: User[];
16-
company?: Company;
22+
company?: {
23+
edition: string;
24+
company: string;
25+
}[];
1726
mail?: string;
1827
bearer?:
1928
| []
@@ -61,6 +70,12 @@ type Achievement = {
6170
updated?: string;
6271
};
6372

73+
type SINFOSignature = {
74+
companyId: string;
75+
date: string;
76+
_id: string;
77+
};
78+
6479
type Company = {
6580
id: string;
6681
name: string;

src/utils/utils.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import jwt, { JwtPayload, VerifyOptions } from "jsonwebtoken";
2-
31
export function convertToAppRole(role: string): UserRole {
42
switch (role) {
53
case "company":
@@ -94,14 +92,27 @@ export function getEventFullDate(date: string): string {
9492
// TODO: Implement this correctly
9593
export function isValidQRCode(
9694
data: string,
97-
jwtOptions?: VerifyOptions,
95+
kind?: "user" | "achievement",
9896
): boolean {
99-
return data.startsWith("sinfo://");
97+
try {
98+
return (
99+
data.startsWith("sinfo://") &&
100+
(kind === undefined ||
101+
JSON.parse(atob(data.split("sinfo://")[1])).kind === kind)
102+
);
103+
} catch {
104+
return false;
105+
}
100106
}
101107

102108
// TODO: Implement this correctly
103109
export function getUserFromQRCode(data: string): User | null {
104-
if (!isValidQRCode(data, { subject: "user" })) return null;
105-
//return (jwt.decode(data.split("sinfo://")[1]) as JwtPayload).user as User;
110+
if (!isValidQRCode(data, "user")) return null;
106111
return JSON.parse(atob(data.split("sinfo://")[1])).user as User;
107112
}
113+
114+
// TODO: Implement this correctly
115+
export function getAchievementFromQRCode(data: string): string | null {
116+
if (!isValidQRCode(data, "achievement")) return null;
117+
return JSON.parse(atob(data.split("sinfo://")[1])).achievement as string;
118+
}

0 commit comments

Comments
 (0)