Skip to content

Commit 84572ed

Browse files
authored
Merge pull request #97 from techulus/develop
feat: Visitor authentication
2 parents f174e92 + 3d54b47 commit 84572ed

File tree

27 files changed

+1479
-195
lines changed

27 files changed

+1479
-195
lines changed

apps/page/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ NEXT_PUBLIC_SUPABASE_URL=
22
NEXT_PUBLIC_SUPABASE_ANON_KEY=
33
SUPABASE_SERVICE_ROLE_KEY=
44

5+
VISITOR_JWT_SECRET=your-secure-secret-key-change-this-in-production
6+
57
# Inngest
68
INNGEST_EVENT_KEY=
79

apps/page/components/footer.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { IPageSettings } from "@changes-page/supabase/types/page";
12
import Image from "next/image";
23
import { useEffect } from "react";
3-
import { IPageSettings } from "@changes-page/supabase/types/page";
44
import appStoreBadgeLight from "../public/badges/App_Store_Badge_US-UK_RGB_blk.svg";
55
import appStoreBadgeDark from "../public/badges/App_Store_Badge_US-UK_RGB_wht.svg";
66
import googlePlayBadge from "../public/badges/google-play-badge.png";
@@ -14,6 +14,7 @@ import {
1414
TwitterIcon,
1515
YouTubeIcon,
1616
} from "./social-icons.component";
17+
import VisitorStatus from "./visitor-status";
1718

1819
export default function Footer({ settings }: { settings: IPageSettings }) {
1920
useEffect(() => {
@@ -25,8 +26,12 @@ export default function Footer({ settings }: { settings: IPageSettings }) {
2526

2627
return (
2728
<footer>
29+
<div className="pt-4 py-2 flex justify-center space-x-6">
30+
<VisitorStatus />
31+
</div>
32+
2833
{(settings?.app_store_url || settings?.play_store_url) && (
29-
<p className="pt-8 py-4 flex justify-center space-x-6">
34+
<p className="pt-4 py-4 flex justify-center space-x-6">
3035
{settings?.app_store_url ? (
3136
<a
3237
target="_blank"

apps/page/components/reactions.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Transition } from "@headlessui/react";
33
import classNames from "classnames";
44
import { useCallback, useEffect, useState } from "react";
55
import { httpGet, httpPost } from "../utils/http";
6+
import { useVisitorAuth } from "../hooks/useVisitorAuth";
7+
import VisitorAuthModal from "./visitor-auth-modal";
68

79
const ReactionsCounter = ({
810
postId,
@@ -11,16 +13,23 @@ const ReactionsCounter = ({
1113
floating,
1214
optimisticUpdate,
1315
setShowPicker,
16+
onAuthRequired,
1417
}: {
1518
postId: string;
1619
aggregate: IReactions;
1720
user: IReactions;
1821
floating: boolean;
1922
optimisticUpdate?: (reaction: string, status: boolean) => void;
2023
setShowPicker?: (v: boolean) => void;
24+
onAuthRequired?: () => void;
2125
}) => {
2226
const doReact = useCallback(
2327
(reaction: string) => {
28+
if (onAuthRequired) {
29+
onAuthRequired();
30+
return;
31+
}
32+
2433
if (setShowPicker) {
2534
setShowPicker(false);
2635
}
@@ -38,7 +47,7 @@ const ReactionsCounter = ({
3847
},
3948
});
4049
},
41-
[postId, setShowPicker, user, optimisticUpdate]
50+
[postId, setShowPicker, user, optimisticUpdate, onAuthRequired]
4251
);
4352

4453
return (
@@ -211,7 +220,9 @@ const ReactionsCounter = ({
211220

212221
export default function Reactions(props: any) {
213222
const { post } = props;
223+
const { visitor } = useVisitorAuth();
214224
const [showPicker, setShowPicker] = useState(false);
225+
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
215226
const [reactions, setReactions] = useState<IReactions>({});
216227
const [userReaction, setUserReaction] = useState<IReactions>({});
217228

@@ -258,12 +269,20 @@ export default function Reactions(props: any) {
258269
updateReactions();
259270
}, [updateReactions]);
260271

272+
const handleReactionClick = useCallback(() => {
273+
if (!visitor) {
274+
setIsAuthModalOpen(true);
275+
return;
276+
}
277+
setShowPicker((v) => !v);
278+
}, [visitor]);
279+
261280
return (
262281
<div className="flex">
263282
<div className="relative flex items-center">
264283
<button
265284
className="text-sm p-1.5 my-2 border border-gray-300 dark:border-gray-700 rounded-full bg-white dark:bg-gray-800 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200"
266-
onClick={() => setShowPicker((v) => !v)}
285+
onClick={handleReactionClick}
267286
>
268287
<svg
269288
className=" w-4 h-4"
@@ -313,9 +332,15 @@ export default function Reactions(props: any) {
313332
user={userReaction}
314333
optimisticUpdate={optimisticUpdate}
315334
setShowPicker={setShowPicker}
335+
onAuthRequired={!visitor ? () => setIsAuthModalOpen(true) : undefined}
316336
floating={false}
317337
/>
318338
) : null}
339+
340+
<VisitorAuthModal
341+
isOpen={isAuthModalOpen}
342+
onClose={() => setIsAuthModalOpen(false)}
343+
/>
319344
</div>
320345
);
321346
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Dialog } from "@headlessui/react";
2+
import { XIcon } from "@heroicons/react/outline";
3+
import { useState } from "react";
4+
import { httpPost } from "../utils/http";
5+
6+
interface VisitorAuthModalProps {
7+
isOpen: boolean;
8+
onClose: () => void;
9+
}
10+
11+
export default function VisitorAuthModal({
12+
isOpen,
13+
onClose,
14+
}: VisitorAuthModalProps) {
15+
const [email, setEmail] = useState("");
16+
const [isLoading, setIsLoading] = useState(false);
17+
const [isEmailSent, setIsEmailSent] = useState(false);
18+
const [error, setError] = useState("");
19+
20+
const handleSubmit = async (e: React.FormEvent) => {
21+
e.preventDefault();
22+
setIsLoading(true);
23+
setError("");
24+
25+
try {
26+
await httpPost({
27+
url: "/api/auth/request-magic-link",
28+
data: { email },
29+
});
30+
31+
setIsEmailSent(true);
32+
if (typeof window !== "undefined") {
33+
sessionStorage.setItem("auth_redirect", window.location.href);
34+
}
35+
} catch (err) {
36+
setError(
37+
err instanceof Error
38+
? err.message
39+
: "Something went wrong. Please try again."
40+
);
41+
} finally {
42+
setIsLoading(false);
43+
}
44+
};
45+
46+
const handleClose = () => {
47+
setEmail("");
48+
setIsEmailSent(false);
49+
setError("");
50+
onClose();
51+
};
52+
53+
return (
54+
<Dialog open={isOpen} onClose={handleClose} className="relative z-50">
55+
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
56+
57+
<div className="fixed inset-0 flex items-center justify-center p-4">
58+
<Dialog.Panel className="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg dark:bg-gray-800">
59+
<div className="flex items-center justify-between mb-4">
60+
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-gray-100">
61+
{isEmailSent ? "Check your email" : "Sign in to continue"}
62+
</Dialog.Title>
63+
<button
64+
onClick={handleClose}
65+
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
66+
>
67+
<XIcon className="h-5 w-5" />
68+
</button>
69+
</div>
70+
71+
{isEmailSent ? (
72+
<div className="text-center">
73+
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
74+
<svg
75+
className="h-6 w-6 text-green-600 dark:text-green-400"
76+
fill="none"
77+
viewBox="0 0 24 24"
78+
stroke="currentColor"
79+
>
80+
<path
81+
strokeLinecap="round"
82+
strokeLinejoin="round"
83+
strokeWidth={2}
84+
d="M5 13l4 4L19 7"
85+
/>
86+
</svg>
87+
</div>
88+
<p className="text-gray-600 dark:text-gray-300 mb-4">
89+
We have sent a magic link to <strong>{email}</strong>
90+
</p>
91+
<p className="text-sm text-gray-500 dark:text-gray-400">
92+
Click the link in your email to complete your sign-in. The link
93+
will expire in 15 minutes.
94+
</p>
95+
</div>
96+
) : (
97+
<form onSubmit={handleSubmit}>
98+
<div className="mb-4">
99+
<label
100+
htmlFor="email"
101+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
102+
>
103+
Email address
104+
</label>
105+
<input
106+
type="email"
107+
id="email"
108+
value={email}
109+
onChange={(e) => setEmail(e.target.value)}
110+
required
111+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-gray-100"
112+
placeholder="your@email.com"
113+
/>
114+
</div>
115+
116+
{error && (
117+
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
118+
<p className="text-sm text-red-600 dark:text-red-400">
119+
{error}
120+
</p>
121+
</div>
122+
)}
123+
124+
<button
125+
type="submit"
126+
disabled={isLoading || !email}
127+
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-indigo-700 dark:hover:bg-indigo-600"
128+
>
129+
{isLoading ? "Sending..." : "Send magic link"}
130+
</button>
131+
132+
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
133+
We will send you a secure link to sign in without a password.
134+
</p>
135+
</form>
136+
)}
137+
</Dialog.Panel>
138+
</div>
139+
</Dialog>
140+
);
141+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useState } from "react";
2+
import { useVisitorAuth } from "../hooks/useVisitorAuth";
3+
import VisitorAuthModal from "./visitor-auth-modal";
4+
5+
interface VisitorStatusProps {
6+
onAuthRequired?: () => void;
7+
showEmail?: boolean;
8+
}
9+
10+
export default function VisitorStatus({
11+
onAuthRequired,
12+
showEmail = true,
13+
}: VisitorStatusProps) {
14+
const { visitor, isLoading, isAuthenticated, logout } = useVisitorAuth();
15+
const [showAuthModal, setShowAuthModal] = useState(false);
16+
17+
const handleSignIn = () => {
18+
if (onAuthRequired) {
19+
onAuthRequired();
20+
} else {
21+
setShowAuthModal(true);
22+
}
23+
};
24+
25+
if (isLoading) {
26+
return (
27+
<div className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
28+
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"></div>
29+
<span>Loading...</span>
30+
</div>
31+
);
32+
}
33+
34+
if (isAuthenticated && visitor) {
35+
return (
36+
<div className="flex items-center space-x-3">
37+
<div className="flex items-center space-x-2">
38+
<div className="w-2 h-2 rounded-full bg-green-500"></div>
39+
{showEmail && (
40+
<span className="text-sm text-gray-700 dark:text-gray-300">
41+
{visitor.email}
42+
</span>
43+
)}
44+
</div>
45+
<button
46+
onClick={logout}
47+
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
48+
>
49+
Sign out
50+
</button>
51+
</div>
52+
);
53+
}
54+
55+
return (
56+
<>
57+
<button
58+
onClick={handleSignIn}
59+
className="inline-flex items-center space-x-2 text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300"
60+
>
61+
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-500"></div>
62+
<span>Sign in</span>
63+
</button>
64+
65+
<VisitorAuthModal
66+
isOpen={showAuthModal}
67+
onClose={() => setShowAuthModal(false)}
68+
/>
69+
</>
70+
);
71+
}

0 commit comments

Comments
 (0)