Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/app/portal/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import HeaderProfile from "@/components/profile/HeaderProfile"
import StatsCards from "@/components/profile/StatsCards"
import MergeEmails from "@/components/profile/MergeEmails"
import ReferralLinks from "@/components/profile/ReferralLinks"
import CalendarSubscriptions from "@/components/profile/CalendarSubscriptions"
import ProfileStats from "@/components/profile/ProfileStats"
import BannerEdgeWrapped from "@/components/profile/BannerEdgeWrapped"
import { Loader } from "@/components/ui/Loader"
Expand Down Expand Up @@ -146,6 +147,8 @@ export default function ProfileContent() {

<ReferralLinks referralCount={userData?.referral_count ?? 0} />

<CalendarSubscriptions />

<PopupsHistory popups={userData?.popups ?? []} />

<ProfileStats userData={userData} />
Expand Down
116 changes: 116 additions & 0 deletions src/components/profile/CalendarSubscriptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client"

import { useState } from "react"
import { Calendar, Check, Copy, ExternalLink, RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card } from "../ui/card"
import useCalendarSubscription from "@/hooks/useCalendarSubscription"

export default function CalendarSubscriptions() {
const { subscription, isLoading, error, regenerate, isRegenerating } =
useCalendarSubscription()
const [copied, setCopied] = useState(false)

const copyToClipboard = (url: string) => {
navigator.clipboard.writeText(url)
setCopied(true)
toast.success("Calendar link copied")
setTimeout(() => setCopied(false), 2000)
}

const handleRegenerate = async () => {
const confirmed = window.confirm(
"Regenerate your calendar link? Calendars using the old link will stop updating until you re-subscribe with the new one.",
)
if (!confirmed) return

const updated = await regenerate()
if (updated) {
toast.success("Calendar link regenerated")
} else {
toast.error("Could not regenerate the calendar link")
}
}

return (
<Card className="p-4 md:p-6">
<div className="flex flex-col gap-1 items-start justify-center mb-6">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#020817]" />
<h3 className="text-lg font-semibold text-[#020817]">Calendar Subscription</h3>
</div>
<p className="text-sm text-[#64748b]">
Subscribe in Apple, Google, or Outlook Calendar to keep the events you’ve
RSVP’d to in sync. Your calendar refreshes automatically.
</p>
</div>

<div className="bg-[#f8fafc] rounded-lg p-3 md:p-4 space-y-4">
{isLoading ? (
<p className="text-sm text-[#64748b] text-center p-2">
Loading your calendar link…
</p>
) : error && !subscription ? (
<p className="text-sm text-red-600 text-center p-2">{error}</p>
) : subscription ? (
<>
<div className="flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-4">
<div className="w-full md:w-40 flex-shrink-0">
<p className="text-sm text-[#020817] font-medium md:font-normal">
My RSVP’d events
</p>
</div>
<div className="flex-1 relative w-full">
<input
type="text"
value={subscription.rsvp_ics_url}
disabled
className="w-full px-3 py-2 pr-10 text-sm text-[#64748b] bg-[#ffffff] border border-[#e2e8f0] rounded-md cursor-default truncate"
/>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(subscription.rsvp_ics_url)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 hover:bg-[#f1f5f9]"
aria-label="Copy calendar link"
>
{copied ? (
<Check className="w-4 h-4 text-[#64748b]" />
) : (
<Copy className="w-4 h-4 text-[#64748b]" />
)}
</Button>
</div>
</div>

<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<Button asChild className="w-full sm:w-auto">
<a href={subscription.rsvp_webcal_url}>
<ExternalLink className="w-4 h-4" />
Subscribe
</a>
</Button>
<Button
variant="outline"
onClick={handleRegenerate}
disabled={isRegenerating}
className="w-full sm:w-auto"
>
<RefreshCw
className={`w-4 h-4 ${isRegenerating ? "animate-spin" : ""}`}
/>
{isRegenerating ? "Regenerating…" : "Regenerate link"}
</Button>
</div>

<p className="text-xs text-[#94a3b8]">
Treat this link like a password — anyone with it can see the events you’ve
RSVP’d to. Shared it by mistake? Regenerate to revoke the old one.
</p>
</>
) : null}
</div>
</Card>
)
}
88 changes: 88 additions & 0 deletions src/hooks/useCalendarSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client"

import { useEffect, useRef, useState } from "react"
import { api, instance } from "@/api"
import { CalendarSubscription } from "@/types/CalendarSubscription"

interface UseCalendarSubscriptionReturn {
subscription: CalendarSubscription | null
isLoading: boolean
error: string | null
/** Rotate the token; the previously shared URL stops working. */
regenerate: () => Promise<CalendarSubscription | null>
isRegenerating: boolean
}

const setAuthHeaderFromStorage = () => {
if (typeof window === "undefined") return
const token = window.localStorage.getItem("token")
if (token) {
instance.defaults.headers.common["Authorization"] = `Bearer ${token}`
}
}

const useCalendarSubscription = (): UseCalendarSubscriptionReturn => {
const [subscription, setSubscription] = useState<CalendarSubscription | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [isRegenerating, setIsRegenerating] = useState<boolean>(false)
// Single-flight guard: prevents a rapid double-fire from rotating the token
// twice and applying the earlier (already-revoked) response as current state.
const regeneratingRef = useRef(false)

// POST is idempotent (get-or-create), so visiting the page lazily provisions
// the token without creating a new one each time.
const fetchSubscription = async () => {
setIsLoading(true)
setError(null)
try {
setAuthHeaderFromStorage()
const response = await api.post("calendar/subscription")
if (response?.status === 200) {
setSubscription(response.data as CalendarSubscription)
} else {
setError("Failed to load calendar subscription")
}
} catch (e: unknown) {
const message =
e instanceof Error ? e.message : "Unexpected error loading calendar subscription"
setError(message)
} finally {
setIsLoading(false)
}
}

useEffect(() => {
fetchSubscription()
}, [])

const regenerate = async (): Promise<CalendarSubscription | null> => {
if (regeneratingRef.current) return null
regeneratingRef.current = true
setIsRegenerating(true)
setError(null)
try {
setAuthHeaderFromStorage()
const response = await api.post("calendar/subscription/regenerate")
if (response?.status === 200) {
const updated = response.data as CalendarSubscription
setSubscription(updated)
return updated
}
setError("Failed to regenerate calendar link")
return null
} catch (e: unknown) {
const message =
e instanceof Error ? e.message : "Unexpected error regenerating calendar link"
setError(message)
return null
} finally {
regeneratingRef.current = false
setIsRegenerating(false)
}
}

return { subscription, isLoading, error, regenerate, isRegenerating }
}

export default useCalendarSubscription
8 changes: 8 additions & 0 deletions src/types/CalendarSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface CalendarSubscription {
/** Bearer secret embedded in the feed URL; revocable/regenerable. */
token: string
/** Plain HTTPS iCal feed URL of the user's RSVP'd events. */
rsvp_ics_url: string
/** Same feed as a webcal:// link for one-tap "Subscribe" in calendar apps. */
rsvp_webcal_url: string
}