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
2 changes: 2 additions & 0 deletions apps/page/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

VISITOR_JWT_SECRET=your-secure-secret-key-change-this-in-production

# Inngest
INNGEST_EVENT_KEY=

Expand Down
9 changes: 7 additions & 2 deletions apps/page/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IPageSettings } from "@changes-page/supabase/types/page";
import Image from "next/image";
import { useEffect } from "react";
import { IPageSettings } from "@changes-page/supabase/types/page";
import appStoreBadgeLight from "../public/badges/App_Store_Badge_US-UK_RGB_blk.svg";
import appStoreBadgeDark from "../public/badges/App_Store_Badge_US-UK_RGB_wht.svg";
import googlePlayBadge from "../public/badges/google-play-badge.png";
Expand All @@ -14,6 +14,7 @@ import {
TwitterIcon,
YouTubeIcon,
} from "./social-icons.component";
import VisitorStatus from "./visitor-status";

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

return (
<footer>
<div className="pt-4 py-2 flex justify-center space-x-6">
<VisitorStatus />
</div>

{(settings?.app_store_url || settings?.play_store_url) && (
<p className="pt-8 py-4 flex justify-center space-x-6">
<p className="pt-4 py-4 flex justify-center space-x-6">
{settings?.app_store_url ? (
<a
target="_blank"
Expand Down
29 changes: 27 additions & 2 deletions apps/page/components/reactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Transition } from "@headlessui/react";
import classNames from "classnames";
import { useCallback, useEffect, useState } from "react";
import { httpGet, httpPost } from "../utils/http";
import { useVisitorAuth } from "../hooks/useVisitorAuth";
import VisitorAuthModal from "./visitor-auth-modal";

const ReactionsCounter = ({
postId,
Expand All @@ -11,16 +13,23 @@ const ReactionsCounter = ({
floating,
optimisticUpdate,
setShowPicker,
onAuthRequired,
}: {
postId: string;
aggregate: IReactions;
user: IReactions;
floating: boolean;
optimisticUpdate?: (reaction: string, status: boolean) => void;
setShowPicker?: (v: boolean) => void;
onAuthRequired?: () => void;
}) => {
const doReact = useCallback(
(reaction: string) => {
if (onAuthRequired) {
onAuthRequired();
return;
}

if (setShowPicker) {
setShowPicker(false);
}
Expand All @@ -38,7 +47,7 @@ const ReactionsCounter = ({
},
});
},
[postId, setShowPicker, user, optimisticUpdate]
[postId, setShowPicker, user, optimisticUpdate, onAuthRequired]
);

return (
Expand Down Expand Up @@ -211,7 +220,9 @@ const ReactionsCounter = ({

export default function Reactions(props: any) {
const { post } = props;
const { visitor } = useVisitorAuth();
const [showPicker, setShowPicker] = useState(false);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [reactions, setReactions] = useState<IReactions>({});
const [userReaction, setUserReaction] = useState<IReactions>({});

Expand Down Expand Up @@ -258,12 +269,20 @@ export default function Reactions(props: any) {
updateReactions();
}, [updateReactions]);

const handleReactionClick = useCallback(() => {
if (!visitor) {
setIsAuthModalOpen(true);
return;
}
setShowPicker((v) => !v);
}, [visitor]);

return (
<div className="flex">
<div className="relative flex items-center">
<button
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"
onClick={() => setShowPicker((v) => !v)}
onClick={handleReactionClick}
>
<svg
className=" w-4 h-4"
Expand Down Expand Up @@ -313,9 +332,15 @@ export default function Reactions(props: any) {
user={userReaction}
optimisticUpdate={optimisticUpdate}
setShowPicker={setShowPicker}
onAuthRequired={!visitor ? () => setIsAuthModalOpen(true) : undefined}
floating={false}
/>
) : null}

<VisitorAuthModal
isOpen={isAuthModalOpen}
onClose={() => setIsAuthModalOpen(false)}
/>
</div>
);
}
141 changes: 141 additions & 0 deletions apps/page/components/visitor-auth-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Dialog } from "@headlessui/react";
import { XIcon } from "@heroicons/react/outline";
import { useState } from "react";
import { httpPost } from "../utils/http";

interface VisitorAuthModalProps {
isOpen: boolean;
onClose: () => void;
}

export default function VisitorAuthModal({
isOpen,
onClose,
}: VisitorAuthModalProps) {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isEmailSent, setIsEmailSent] = useState(false);
const [error, setError] = useState("");

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");

try {
await httpPost({
url: "/api/auth/request-magic-link",
data: { email },
});

setIsEmailSent(true);
if (typeof window !== "undefined") {
sessionStorage.setItem("auth_redirect", window.location.href);
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Something went wrong. Please try again."
);
} finally {
setIsLoading(false);
}
};

const handleClose = () => {
setEmail("");
setIsEmailSent(false);
setError("");
onClose();
};

return (
<Dialog open={isOpen} onClose={handleClose} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />

<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="mx-auto max-w-md rounded-lg bg-white p-6 shadow-lg dark:bg-gray-800">
<div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-gray-100">
{isEmailSent ? "Check your email" : "Sign in to continue"}
</Dialog.Title>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XIcon className="h-5 w-5" />
</button>
</div>

{isEmailSent ? (
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center">
<svg
className="h-6 w-6 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-4">
We have sent a magic link to <strong>{email}</strong>
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Click the link in your email to complete your sign-in. The link
will expire in 15 minutes.
</p>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email address
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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"
placeholder="your@email.com"
/>
</div>

{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
</div>
)}

<button
type="submit"
disabled={isLoading || !email}
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"
>
{isLoading ? "Sending..." : "Send magic link"}
</button>

<p className="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
We will send you a secure link to sign in without a password.
</p>
</form>
)}
</Dialog.Panel>
</div>
</Dialog>
);
}
71 changes: 71 additions & 0 deletions apps/page/components/visitor-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useState } from "react";
import { useVisitorAuth } from "../hooks/useVisitorAuth";
import VisitorAuthModal from "./visitor-auth-modal";

interface VisitorStatusProps {
onAuthRequired?: () => void;
showEmail?: boolean;
}

export default function VisitorStatus({
onAuthRequired,
showEmail = true,
}: VisitorStatusProps) {
const { visitor, isLoading, isAuthenticated, logout } = useVisitorAuth();
const [showAuthModal, setShowAuthModal] = useState(false);

const handleSignIn = () => {
if (onAuthRequired) {
onAuthRequired();
} else {
setShowAuthModal(true);
}
};

if (isLoading) {
return (
<div className="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<div className="w-4 h-4 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"></div>
<span>Loading...</span>
</div>
);
}

if (isAuthenticated && visitor) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
{showEmail && (
<span className="text-sm text-gray-700 dark:text-gray-300">
{visitor.email}
</span>
)}
</div>
<button
onClick={logout}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Sign out
</button>
</div>
);
}

return (
<>
<button
onClick={handleSignIn}
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"
>
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-500"></div>
<span>Sign in</span>
</button>

<VisitorAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
/>
</>
);
}
Loading