diff --git a/backend/api/docs/utils.py b/backend/api/docs/utils.py index 20623732c..afdd920f2 100644 --- a/backend/api/docs/utils.py +++ b/backend/api/docs/utils.py @@ -46,6 +46,8 @@ def get_readable_field_name(field_name): return "string" case "RegexField": return "string" + case "UUIDField": + return "string" case _: return "object" diff --git a/backend/api/event/utils.py b/backend/api/event/utils.py index b8c1453c8..d8234fbfc 100644 --- a/backend/api/event/utils.py +++ b/backend/api/event/utils.py @@ -33,6 +33,7 @@ def check_custom_code(code): "api", "dashboard", "forgot-password", + "guest-import", "login", "new-event", "settings", diff --git a/backend/api/guest_import/serializers.py b/backend/api/guest_import/serializers.py new file mode 100644 index 000000000..6b6e14deb --- /dev/null +++ b/backend/api/guest_import/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + + +class GuestDataSummarySerializer(serializers.Serializer): + created_events = serializers.IntegerField() + participated_events = serializers.IntegerField() + + +class GuestEventSerializer(serializers.Serializer): + url_code = serializers.CharField(allow_null=True) + public_id = serializers.UUIDField() + title = serializers.CharField() + + +class GuestParticipationSerializer(serializers.Serializer): + url_code = serializers.CharField(allow_null=True) + public_id = serializers.UUIDField() + title = serializers.CharField() + guest_display_name = serializers.CharField() + account_display_name = serializers.CharField(allow_null=True) + + +class GuestDataSerializer(serializers.Serializer): + created_events = serializers.ListField( + child=GuestEventSerializer(), + allow_empty=True, + ) + participated_events = serializers.ListField( + child=GuestParticipationSerializer(), + allow_empty=True, + ) + + +class GuestImportDataSerializer(serializers.Serializer): + availability_choices = serializers.DictField( + required=True, + child=serializers.ChoiceField(required=True, choices=["guest", "account"]), + allow_empty=True, + ) diff --git a/backend/api/guest_import/urls.py b/backend/api/guest_import/urls.py new file mode 100644 index 000000000..a4b484349 --- /dev/null +++ b/backend/api/guest_import/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("get-summary/", views.get_summary), + path("get-data/", views.get_data), + path("import-data/", views.import_data), +] diff --git a/backend/api/guest_import/utils.py b/backend/api/guest_import/utils.py new file mode 100644 index 000000000..6ebe44448 --- /dev/null +++ b/backend/api/guest_import/utils.py @@ -0,0 +1,38 @@ +import logging + +from django.db import transaction + +from api.models import UserSession +from api.settings import GUEST_COOKIE_NAME +from api.utils import get_session, set_session_cookie, update_session_metadata + +logger = logging.getLogger("api") + + +def get_guest_account(request, response): + """ + Returns the guest account associated with the request, refreshing the session token + cookie and updating the metadata on the session. + + If there is no guest account, this will return `None`. + + This does NOT make the guest user available on the `request` object. + """ + guest_token = request.COOKIES.get(GUEST_COOKIE_NAME) + + if guest_token: + logger.debug("Guest session token: %s", guest_token) + try: + with transaction.atomic(): + session = get_session(guest_token) + if not session: + raise UserSession.DoesNotExist + + update_session_metadata(request, session) + except UserSession.DoesNotExist: + logger.info("Guest session expired.") + else: + set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True) + return session.user_account + + return None diff --git a/backend/api/guest_import/views.py b/backend/api/guest_import/views.py new file mode 100644 index 000000000..05806524b --- /dev/null +++ b/backend/api/guest_import/views.py @@ -0,0 +1,231 @@ +import logging + +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from rest_framework.response import Response + +from api.decorators import ( + api_endpoint, + require_account_auth, + validate_json_input, + validate_output, +) +from api.guest_import.serializers import ( + GuestDataSerializer, + GuestDataSummarySerializer, + GuestImportDataSerializer, +) +from api.guest_import.utils import get_guest_account +from api.models import ( + EventParticipant, + UserEvent, +) +from api.utils import MessageOutputSerializer + +logger = logging.getLogger("api") + + +@api_endpoint("GET") +@require_account_auth +@validate_output(GuestDataSummarySerializer) +def get_summary(request): + """ + Returns counts of events created and participated in by the guest user. + """ + response = Response() + + guest_user = get_guest_account(request, response) + + if guest_user is None: + response.data = { + "created_events": 0, + "participated_events": 0, + } + response.status_code = 200 + return response + + events = UserEvent.objects.filter(user_account=guest_user).count() + + participations = EventParticipant.objects.filter(user_account=guest_user).count() + + response.data = { + "created_events": events, + "participated_events": participations, + } + response.status_code = 200 + + return response + + +@api_endpoint("GET") +@require_account_auth +@validate_output(GuestDataSerializer) +def get_data(request): + """ + Returns brief information about events created and participated in by the guest user. + """ + response = Response() + + guest_user = get_guest_account(request, response) + account_user = request.user + + if guest_user is None: + response.data = { + "created_events": [], + "participated_events": [], + } + response.status_code = 200 + return response + + def get_url_code(event): + try: + return event.url_code.url_code + except ObjectDoesNotExist: + return None + + events = UserEvent.objects.filter(user_account=guest_user).select_related( + "url_code" + ) + created_events = [ + { + "url_code": get_url_code(event), + "public_id": event.public_id, + "title": event.title, + } + for event in events + ] + + participations = EventParticipant.objects.filter( + user_account=guest_user, + ).select_related("user_event__url_code") + participated_events = [ + { + "url_code": get_url_code(participation.user_event), + "public_id": participation.user_event.public_id, + "title": participation.user_event.title, + "guest_display_name": participation.display_name, + "account_display_name": None, + } + for participation in participations + ] + + conflicts = EventParticipant.objects.filter( + user_account=account_user, + user_event__in=participations.values_list("user_event", flat=True), + ).select_related("user_event") + participation_map = {p["public_id"]: p for p in participated_events} + for conflict in conflicts: + participation = participation_map.get(conflict.user_event.public_id) + if participation: + participation["account_display_name"] = conflict.display_name + logger.info( + "%s conflicted events found during guest import for user: %s", + len(conflicts), + account_user.email, + ) + + response.data = { + "created_events": created_events, + "participated_events": participated_events, + } + response.status_code = 200 + + return response + + +@api_endpoint("POST") +@require_account_auth +@validate_json_input(GuestImportDataSerializer) +@validate_output(MessageOutputSerializer) +def import_data(request): + """ + Transfers ownership of events and availabilities created by the guest to the account. + + The body of the request should contain a mapping of event's public ID to the user's + chosen submission to keep, for each event the guest had submitted availability to. If + there is no conflict, "guest" should be specified. + + Every conflicted submission that is not kept will be deleted when calling this + endpoint. + """ + availability_choices = request.validated_data.get("availability_choices", {}) + + response = Response() + + guest_user = get_guest_account(request, response) + account_user = request.user + + def no_data_found(): + response.data = { + "error": {"general": ["No guest data found."]}, + } + response.status_code = 400 + return response + + if guest_user is None: + return no_data_found() + + with transaction.atomic(): + # Check if the guest has any data to import + guest_events = UserEvent.objects.filter(user_account=guest_user) + guest_submissions = EventParticipant.objects.filter( + user_account=guest_user + ).select_related("user_event") + if not guest_events.exists() and not guest_submissions.exists(): + logger.warning( + "No guest data found for import attempt for user: %s", + account_user.email, + ) + return no_data_found() + + # Check if all the guest user's submissions are accounted for in the choices + if set(availability_choices.keys()) != set( + str(submission.user_event.public_id) for submission in guest_submissions + ): + logger.warning( + "Availability choices do not match guest submissions for user: %s", + account_user.email, + ) + response.data = { + "error": { + "availability_choices": [ + "Availability choices do not match guest submissions.", + ] + } + } + response.status_code = 400 + return response + + # Transfer event ownership + UserEvent.objects.filter(user_account=guest_user).update( + user_account=account_user + ) + + # === DELETE DISCARDED SUBMISSIONS === + account_chosen = [] + guest_chosen = [] + for public_id, choice in availability_choices.items(): + match choice: + case "account": + account_chosen.append(public_id) + case "guest": + guest_chosen.append(public_id) + + # Start by removing the guest's submissions from events that have "account" specified + EventParticipant.objects.filter( + user_account=guest_user, user_event__public_id__in=account_chosen + ).delete() + # Then remove the account's submissions from events that have "guest" specified + EventParticipant.objects.filter( + user_account=account_user, user_event__public_id__in=guest_chosen + ).delete() + + # Then of all the remaining guest submissions transfer ownership to the account + EventParticipant.objects.filter(user_account=guest_user).update( + user_account=account_user + ) + + response.data = {"message": ["Guest data imported successfully."]} + response.status_code = 200 + + return response diff --git a/backend/api/migrations/0034_userevent_public_id.py b/backend/api/migrations/0034_userevent_public_id.py new file mode 100644 index 000000000..828882545 --- /dev/null +++ b/backend/api/migrations/0034_userevent_public_id.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2026-07-09 17:46 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0033_urlcode_unique_case_insensitive_url_code"), + ] + + operations = [ + migrations.AddField( + model_name="userevent", + name="public_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + ] diff --git a/backend/api/migrations/0035_populate_public_id.py b/backend/api/migrations/0035_populate_public_id.py new file mode 100644 index 000000000..a94f77315 --- /dev/null +++ b/backend/api/migrations/0035_populate_public_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2026-07-09 17:47 + +import uuid + +from django.db import migrations + + +def gen_uuid(apps, _): + UserEvent = apps.get_model("api", "UserEvent") + for row in UserEvent.objects.iterator(): + row.public_id = uuid.uuid4() + row.save(update_fields=["public_id"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0034_userevent_public_id"), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/backend/api/migrations/0036_apply_unique_constraint.py b/backend/api/migrations/0036_apply_unique_constraint.py new file mode 100644 index 000000000..e44d54684 --- /dev/null +++ b/backend/api/migrations/0036_apply_unique_constraint.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2026-07-09 17:47 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0035_populate_public_id"), + ] + + operations = [ + migrations.AlterField( + model_name="userevent", + name="public_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 813fd6121..f21023fca 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -88,6 +88,7 @@ class UserLogin(models.Model): class UserEvent(models.Model): user_event_id = models.AutoField(primary_key=True) + public_id = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) user_account = models.ForeignKey( UserAccount, on_delete=models.CASCADE, related_name="events" ) diff --git a/backend/api/urls.py b/backend/api/urls.py index 4f12fec0f..6c4a104ef 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -7,4 +7,5 @@ path("availability/", include("api.availability.urls")), path("dashboard/", include("api.dashboard.urls")), path("account/", include("api.account.urls")), + path("guest-import/", include("api.guest_import.urls")), ] diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 0d90179a9..8ca06b1bc 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -10,6 +10,7 @@ import AuthPageLayout from "@/components/layout/auth-page"; import LinkText from "@/components/link-text"; import TextInputField from "@/components/text-input-field"; import ActionButton from "@/features/button/components/action"; +import { DONT_SHOW_AGAIN_KEY } from "@/features/guest-import/constants"; import { useFormErrors } from "@/lib/hooks/use-form-errors"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; @@ -24,7 +25,7 @@ export default function Page() { const router = useRouter(); const searchParams = useSearchParams(); - const callbackUrl = getSafeRedirectUrl(searchParams.get("callbackUrl")); + const callbackUrl = searchParams.get("callbackUrl"); // TOASTS AND ERROR STATES const { errors, handleError, clearAllErrors } = useFormErrors(); @@ -54,12 +55,22 @@ export default function Page() { } try { + let dontShowGuestImport = false; + try { + dontShowGuestImport = + localStorage.getItem(DONT_SHOW_AGAIN_KEY) === "true"; + } catch {} + const redirectUrl = getSafeRedirectUrl( + callbackUrl, + dontShowGuestImport ? undefined : "/guest-import/login", + ); + await clientPost(ROUTES.auth.login, { email, password, remember_me: rememberMe, }); - router.push(callbackUrl); + router.push(redirectUrl); router.refresh(); return true; } catch (e) { diff --git a/frontend/src/app/dashboard/page-client.tsx b/frontend/src/app/dashboard/page-client.tsx index aac3c7afd..aabf6106e 100644 --- a/frontend/src/app/dashboard/page-client.tsx +++ b/frontend/src/app/dashboard/page-client.tsx @@ -81,20 +81,17 @@ export default function ClientPage({

Dashboard

{!logged_in && ( - +
- This data is only available from this browser.{" "} + This data is only available from this browser,{" "} - Create an account + create an account {" "} - to sync data across devices. -
-
- Currently, guest data cannot be transferred to an account. Keep an - eye out for updates! + to sync across devices. You can import this data into your account + at any time in account settings.
)} diff --git a/frontend/src/app/guest-import/import/loading.tsx b/frontend/src/app/guest-import/import/loading.tsx new file mode 100644 index 000000000..98c580f76 --- /dev/null +++ b/frontend/src/app/guest-import/import/loading.tsx @@ -0,0 +1,41 @@ +import HeaderSpacer from "@/features/header/components/header-spacer"; + +export default function Loading() { + return ( +
+ +

Guest Import Review

+ +
+ +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/guest-import/import/page-client.tsx b/frontend/src/app/guest-import/import/page-client.tsx new file mode 100644 index 000000000..a371ed2d4 --- /dev/null +++ b/frontend/src/app/guest-import/import/page-client.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { ExternalLinkIcon, TriangleAlertIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import MobileFooterIsland from "@/components/mobile-footer-island"; +import ActionButton from "@/features/button/components/action"; +import LinkButton from "@/features/button/components/link"; +import HeaderSpacer from "@/features/header/components/header-spacer"; +import Selector from "@/features/selector/components/selector"; +import { Banner, useToast } from "@/features/system-feedback"; +import Tooltip from "@/features/system-feedback/tooltip/base"; +import { clientPost } from "@/lib/utils/api/client-fetch"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; +import { GuestData } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; + +type AvailabilityImportChoice = "guest" | "account"; +type ImportPayload = { + [public_id: string]: AvailabilityImportChoice; +}; + +export default function ClientPage({ guestData }: { guestData: GuestData }) { + const { addToast } = useToast(); + const router = useRouter(); + + const [importPayload, setImportPayload] = useState<{ + [public_id: string]: AvailabilityImportChoice; + }>( + guestData.participated_events.reduce((acc, event) => { + if (event.account_display_name === null) { + acc[event.public_id] = "guest"; + } + return acc; + }, {} as ImportPayload), + ); + + const unresolvedConflicts = + guestData.participated_events.length - Object.keys(importPayload).length; + const conflictedEvents = useMemo(() => { + return new Set( + guestData.participated_events + .filter((event) => event.account_display_name !== null) + .map((event) => event.public_id), + ); + }, [guestData.participated_events]); + + const resolveConflict = ( + public_id: string, + choice: AvailabilityImportChoice, + ) => { + setImportPayload((prev) => ({ + ...prev, + [public_id]: choice, + })); + }; + + const submitImport = async () => { + try { + await clientPost(ROUTES.guestImport.importData, { + availability_choices: importPayload, + }); + addToast("success", "Guest data imported successfully."); + router.push("/dashboard"); + } catch (e) { + const error = e as ApiErrorResponse; + addToast( + "error", + error.formattedMessage || + "Something went wrong with your request. Please refresh the page and try again.", + ); + } + }; + + const cancelButton = ( + + ); + + const importButton = ( + 0} + tooltip={ + unresolvedConflicts > 0 + ? "Please resolve conflicts before importing." + : undefined + } + /> + ); + + const actionText = ( +
+ Are you sure you want to import this guest data? This action cannot be + undone. +
+ ); + + const noneText = ( +
None
+ ); + + return ( +
+ +
+

Guest Import

+
+ {cancelButton} + {importButton} +
+
+

{actionText}

+
+ + {guestData.created_events.length === 0 && noneText} + {guestData.created_events.map((event) => ( + + ))} + + + {unresolvedConflicts > 0 && ( + + Conflicts occur when you{"'"}ve added availability to the same + event both as a guest and from your account. You must{" "} + choose which submission to keep{" "} + when resolving each conflict. + + )} + + {guestData.participated_events.length === 0 && noneText} + + {guestData.participated_events.map((event) => { + const choice = importPayload[event.public_id] ?? null; + const hasConflict = conflictedEvents.has(event.public_id); + + return ( + + {hasConflict ? ( +
+
+ + +
+
+ + resolveConflict(event.public_id, choice) + } + options={[ + { label: "Keep Guest", value: "guest" }, + { + label: "Keep Account", + value: "account", + }, + ]} + value={choice} + placeholder="Resolve Conflict" + /> +
+ {choice !== null && ( +
+ The {choice === "guest" ? "account" : "guest"}{" "} + submission will be deleted. +
+ )} +
+ ) : ( +

Name: {event.guest_display_name}

+ )} +
+ ); + })} +
+
+ + {unresolvedConflicts > 0 ? ( +
+ + Please resolve conflicts. +
+ ) : ( + actionText + )} +
+
+ ); +} + +function DataSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function EventDisplay({ + title, + url_code, + conflict, + children, +}: { + title: string; + url_code: string | null; + conflict?: boolean; + children?: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function EventHeader({ + title, + url_code, + conflict, +}: { + title: string; + url_code: string | null; + conflict?: boolean; +}) { + return ( +
+
+ {conflict && ( + + )} +

{title}

+
+ {url_code && ( + + + View + + + + )} +
+ ); +} + +function ConflictOption({ + label, + name, + selected, +}: { + label: string; + name: string; + selected: boolean | null; +}) { + return ( +
+

{label}

+

{name}

+
+ ); +} diff --git a/frontend/src/app/guest-import/import/page.tsx b/frontend/src/app/guest-import/import/page.tsx new file mode 100644 index 000000000..eba951723 --- /dev/null +++ b/frontend/src/app/guest-import/import/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; + +import ClientPage from "@/app/guest-import/import/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestData = await serverGet(ROUTES.guestImport.getData); + + if ( + guestData.created_events.length === 0 && + guestData.participated_events.length === 0 + ) { + redirect("/settings/guest-import"); + } + + return ; +} diff --git a/frontend/src/app/guest-import/layout.tsx b/frontend/src/app/guest-import/layout.tsx new file mode 100644 index 000000000..f2b1f34de --- /dev/null +++ b/frontend/src/app/guest-import/layout.tsx @@ -0,0 +1,14 @@ +import { Metadata } from "next"; + +import { constructMetadata } from "@/lib/utils/construct-metadata"; + +export function generateMetadata(): Metadata { + return constructMetadata( + "Guest Import", + "Transfer guest data into your account for easy access across devices.", + ); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/frontend/src/app/guest-import/login/page-client.tsx b/frontend/src/app/guest-import/login/page-client.tsx new file mode 100644 index 000000000..c4a50d41b --- /dev/null +++ b/frontend/src/app/guest-import/login/page-client.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; + +import { useRouter } from "next/navigation"; + +import Checkbox from "@/components/checkbox"; +import MessagePage from "@/components/layout/message-page"; +import ActionButton from "@/features/button/components/action"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; +import { DONT_SHOW_AGAIN_KEY } from "@/features/guest-import/constants"; +import { GuestDataSummary } from "@/lib/utils/api/types"; + +export default function ClientPage({ + guestSummary, +}: { + guestSummary: GuestDataSummary; +}) { + const [dontShowAgain, setDontShowAgain] = useState(false); + const router = useRouter(); + + const navigate = (route: string) => { + try { + if (dontShowAgain) { + localStorage.setItem(DONT_SHOW_AGAIN_KEY, "true"); + } else { + localStorage.removeItem(DONT_SHOW_AGAIN_KEY); + } + } catch {} + router.push(route); + }; + + return ( +
+ { + navigate("/dashboard"); + }} + />, + { + navigate("/guest-import/import"); + }} + />, + ]} + > +
+
+ +
+
+ + {dontShowAgain && ( +
+ You can import guest data at any time in account settings. +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/guest-import/login/page.tsx b/frontend/src/app/guest-import/login/page.tsx new file mode 100644 index 000000000..28724fb53 --- /dev/null +++ b/frontend/src/app/guest-import/login/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; + +import ClientPage from "@/app/guest-import/login/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestSummary = await serverGet(ROUTES.guestImport.getSummary); + const events = guestSummary.created_events; + const availabilities = guestSummary.participated_events; + + if (events === 0 && availabilities === 0) { + redirect("/dashboard"); + } + + return ; +} diff --git a/frontend/src/app/settings/(submenus)/guest-import/page.tsx b/frontend/src/app/settings/(submenus)/guest-import/page.tsx new file mode 100644 index 000000000..4f0271874 --- /dev/null +++ b/frontend/src/app/settings/(submenus)/guest-import/page.tsx @@ -0,0 +1,39 @@ +import LinkButton from "@/features/button/components/link"; +import GuestSummary from "@/features/guest-import/components/guest-summary"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const guestSummary = await serverGet(ROUTES.guestImport.getSummary); + const events = guestSummary.created_events; + const availabilities = guestSummary.participated_events; + + return ( +
+
+
+

Guest Import

+

+ If you created events or submitted availability while not signed in, + you can transfer that data to your account here. +

+
+ + {events > 0 || availabilities > 0 ? ( + <> + +
+ +
+ + ) : ( +
No guest data found.
+ )} +
+
+ ); +} diff --git a/frontend/src/app/settings/loading.tsx b/frontend/src/app/settings/loading.tsx index 5df2a7350..b14ebb03b 100644 --- a/frontend/src/app/settings/loading.tsx +++ b/frontend/src/app/settings/loading.tsx @@ -13,7 +13,7 @@ export default function Loading() {
-
+
diff --git a/frontend/src/components/layout/message-page.tsx b/frontend/src/components/layout/message-page.tsx index 969ab9e83..04a35aee0 100644 --- a/frontend/src/components/layout/message-page.tsx +++ b/frontend/src/components/layout/message-page.tsx @@ -3,18 +3,21 @@ import { ButtonArray } from "@/features/button/button-array"; type MessagePageProps = { title: string; description?: string; + children?: React.ReactNode; buttons: ButtonArray; }; export default function MessagePage({ title, description, + children, buttons, }: MessagePageProps) { return ( -
-

{title}

- {description &&

{description}

} +
+

{title}

+ {description &&

{description}

} + {children}
{buttons.map((button, index) => (
{button}
diff --git a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx index 63826de10..6994d3b4f 100644 --- a/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx +++ b/frontend/src/features/account/setting-dialogs/change-password/main-dialog.tsx @@ -8,10 +8,8 @@ import ResetStep from "@/features/account/setting-dialogs/change-password/steps/ import { useChangePasswordFlow } from "@/features/account/setting-dialogs/change-password/use-change-password"; import EmptyButton from "@/features/button/components/empty"; import { FormDialog } from "@/features/system-feedback"; -import useCheckMobile from "@/lib/hooks/use-check-mobile"; export default function ChangePasswordDialog() { - const isMobile = useCheckMobile(); const flow = useChangePasswordFlow(); const [renderedStep, setRenderedStep] = useState(flow.step); @@ -56,7 +54,6 @@ export default function ChangePasswordDialog() { return ( - {SETTINGS_TABS.map((tab) => { - const isActive = pathname === tab.href; - return ( - - {tab.label} - - ); - })} - + <> + + + ); } diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx index 04d03d6ce..d01070b3a 100644 --- a/frontend/src/features/drawer/components/base.tsx +++ b/frontend/src/features/drawer/components/base.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import { XIcon } from "lucide-react"; diff --git a/frontend/src/features/guest-import/components/guest-summary.tsx b/frontend/src/features/guest-import/components/guest-summary.tsx new file mode 100644 index 000000000..3bc27fd48 --- /dev/null +++ b/frontend/src/features/guest-import/components/guest-summary.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/utils/classname"; + +export default function GuestSummary({ + events, + availabilities, +}: { + events: number; + availabilities: number; +}) { + return ( +
+
Guest data found on this browser:
+
+ • {events} event + {events !== 1 ? "s" : ""} that you created +
+
+ • {availabilities} availabilit + {availabilities !== 1 ? "ies" : "y"} that you added to{" "} + {availabilities === 1 ? "an event" : "events"} +
+
+ ); +} diff --git a/frontend/src/features/guest-import/constants.ts b/frontend/src/features/guest-import/constants.ts new file mode 100644 index 000000000..6e4c71a07 --- /dev/null +++ b/frontend/src/features/guest-import/constants.ts @@ -0,0 +1 @@ +export const DONT_SHOW_AGAIN_KEY = "login-guest-import-dont-show-again"; diff --git a/frontend/src/features/selector/components/drawer.tsx b/frontend/src/features/selector/components/drawer.tsx index 685f2b8eb..965e92754 100644 --- a/frontend/src/features/selector/components/drawer.tsx +++ b/frontend/src/features/selector/components/drawer.tsx @@ -20,6 +20,7 @@ export default function SelectorDrawer({ drawerNesting = false, trigger, disabled = false, + className, }: DrawerProps) { const [internalOpen, setInternalOpen] = useState(false); @@ -89,6 +90,7 @@ export default function SelectorDrawer({ // Visual styles for disabled state disabled && "bg-foreground/20 text-foreground hover:bg-foreground/20 active:bg-foreground/20 cursor-not-allowed opacity-50 hover:cursor-not-allowed", + className, )} > { @@ -86,7 +86,7 @@ export default function ConfirmationDialog({ trigger={trigger} open={open} onOpenChange={handleOpenChange} - asNestedDrawer={asNestedDrawer} + asNestedDrawer={effectiveAsNestedDrawer} triggerDisabled={triggerDisabled} showCloseButton={showCloseButton} overlayClassName={cn( diff --git a/frontend/src/features/system-feedback/dialog/components/form.tsx b/frontend/src/features/system-feedback/dialog/components/form.tsx index babb8bf53..33605ce4b 100644 --- a/frontend/src/features/system-feedback/dialog/components/form.tsx +++ b/frontend/src/features/system-feedback/dialog/components/form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useCallback, useState } from "react"; import ActionButton from "@/features/button/components/action"; @@ -5,6 +7,7 @@ import EmptyButton from "@/features/button/components/empty"; import BaseModal from "@/features/system-feedback/dialog/components/base"; import { DIALOG_CONFIG } from "@/features/system-feedback/dialog/config"; import { FormDialogProps } from "@/features/system-feedback/dialog/props"; +import useCheckMobile from "@/lib/hooks/use-check-mobile"; import { cn } from "@/lib/utils/classname"; export default function FormDialog({ @@ -13,6 +16,7 @@ export default function FormDialog({ description, onSubmit, submitLabel = "Save", + submitDisabled = false, cancelLabel = "Cancel", children, trigger, @@ -27,6 +31,9 @@ export default function FormDialog({ const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : uncontrolledOpen; + const isMobile = useCheckMobile(); + const effectiveAsNestedDrawer = asNestedDrawer || isMobile; + const handleOpenChange = useCallback( (newOpen: boolean) => { if (!isControlled) { @@ -55,7 +62,7 @@ export default function FormDialog({ trigger={trigger} open={open} onOpenChange={handleOpenChange} - asNestedDrawer={asNestedDrawer} + asNestedDrawer={effectiveAsNestedDrawer} showCloseButton={showCloseButton} overlayClassName={cn( type === "error" && @@ -79,6 +86,7 @@ export default function FormDialog({ type="submit" buttonStyle={config.buttonStyle} label={submitLabel} + disabled={submitDisabled} />
diff --git a/frontend/src/features/system-feedback/dialog/props.ts b/frontend/src/features/system-feedback/dialog/props.ts index 08349db8e..5a7fc6519 100644 --- a/frontend/src/features/system-feedback/dialog/props.ts +++ b/frontend/src/features/system-feedback/dialog/props.ts @@ -104,6 +104,9 @@ export type FormDialogProps = CommonDialogProps & { /** Text for the submit button (Defaults to "Save" or "Submit") */ submitLabel?: string; + /** Whether the submit button is disabled */ + submitDisabled?: boolean; + /** Text for the cancel button (Defaults to "Cancel") */ cancelLabel?: string; }; diff --git a/frontend/src/features/system-feedback/toast/context.ts b/frontend/src/features/system-feedback/toast/context.ts index 189b541d4..4f07c6b13 100644 --- a/frontend/src/features/system-feedback/toast/context.ts +++ b/frontend/src/features/system-feedback/toast/context.ts @@ -1,3 +1,5 @@ +"use client"; + import { createContext, useContext } from "react"; import { ToastOptions } from "@/features/system-feedback/toast/type"; diff --git a/frontend/src/lib/utils/api/endpoints.ts b/frontend/src/lib/utils/api/endpoints.ts index 72797104b..74d53ff0b 100644 --- a/frontend/src/lib/utils/api/endpoints.ts +++ b/frontend/src/lib/utils/api/endpoints.ts @@ -1,5 +1,6 @@ import { AccountData, + ActiveSessionList, AllAvailability, AuthedPasswordResetCode, AuthedPasswordResetData, @@ -12,6 +13,9 @@ import { EventDetails, EventDisplayNameData, EventEditData, + GuestData, + GuestDataSummary, + GuestImportData, LoginData, MessageResponse, NewEventData, @@ -20,10 +24,9 @@ import { PasswordResetData, RegisterData, SelfAvailability, - VerificationCode, - ActiveSessionList, SessionId, TrueCode, + VerificationCode, } from "@/lib/utils/api/types"; /** @@ -270,4 +273,24 @@ export const ROUTES = { */ deleteAccount: route("/account/delete-account/"), }, + guestImport: { + /** + * Gets a summary of the guest user's created and participated events. + * @method GET + */ + getSummary: route("/guest-import/get-summary/"), + /** + * Gets data about the guest user's created and participated events. + * @method GET + */ + getData: route("/guest-import/get-data/"), + /** + * Imports the guest user's data into the logged-in user's account, resolving any + * availability submission conflicts based on the provided choices. + * @method POST + * @throws 400 - If there is no guest data found. + * @throws 400 - If the availability choices do not match the guest submissions. + */ + importData: route("/guest-import/import-data/"), + } } as const; diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e87613620..43e12461f 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -162,3 +162,33 @@ export type AuthedPasswordResetData = { new_password: string; prune_sessions?: boolean; } + +export type GuestDataSummary = { + created_events: number; + participated_events: number; +} + +type GuestCreatedEvent = { + url_code: string | null; + public_id: string; + title: string; +} + +type GuestParticipatedEvent = { + url_code: string | null; + public_id: string; + title: string; + guest_display_name: string; + account_display_name: string | null; +} + +export type GuestData = { + created_events: GuestCreatedEvent[]; + participated_events: GuestParticipatedEvent[]; +} + +export type GuestImportData = { + availability_choices: { + [public_id: string]: "guest" | "account"; + }; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index c42febd30..c754239d3 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -const protectedRoutes = ["/settings"]; +const protectedRoutes = ["/settings", "/guest-import"]; export function middleware(request: NextRequest) { // Clone the request headers and inject the exact pathname into a custom header.