diff --git a/app/api/magic/provider/route.ts b/app/api/magic/provider/route.ts new file mode 100644 index 0000000..426f8fb --- /dev/null +++ b/app/api/magic/provider/route.ts @@ -0,0 +1,146 @@ +/** + * QualifyFirst - Magic Identity Provider Registration Route + * + * Supports registering one or many providers (QualifyFirst, + * JustTheTip, TiltCheck, etc.) against the same Magic project so + * every app can authenticate the exact same user accounts. + */ + +import { NextResponse } from 'next/server' + +import { + REQUIRED_FIELDS, + type MagicProviderPayload, + type MagicProviderBatchRequest, + registerMagicProvider, + registerMagicProvidersBatch, + sanitizeProviderPayload, + findMissingFields, +} from '../../../lib/magic/provider' + +const invalidBodyResponse = () => + NextResponse.json( + { + error: + 'Invalid payload. Provide issuer/audience/jwks_uri or a providers array with those fields.', + }, + { status: 400 }, + ) + +const isBatchRequest = (body: unknown): body is MagicProviderBatchRequest => { + if (!body || typeof body !== 'object') return false + const maybe = body as Partial + return Array.isArray(maybe.providers) +} + +export async function POST(request: Request) { + const secretKey = process.env.MAGIC_SECRET_KEY + + if (!secretKey) { + return NextResponse.json( + { + error: + 'Magic secret key is not configured. Set MAGIC_SECRET_KEY in your environment.', + }, + { status: 500 }, + ) + } + + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }) + } + + if (isBatchRequest(body)) { + if (body.providers.length === 0) { + return NextResponse.json( + { error: 'At least one provider payload is required.' }, + { status: 400 }, + ) + } + + const batchResult = await registerMagicProvidersBatch( + secretKey, + body.providers, + body.shared_audience, + ) + + if (!batchResult.success) { + const status = 'details' in batchResult ? 400 : 502 + return NextResponse.json(batchResult, { status }) + } + + return NextResponse.json(batchResult) + } + + if (!body || typeof body !== 'object') { + return invalidBodyResponse() + } + + const payload = sanitizeProviderPayload(body as MagicProviderPayload) + const missingFields = findMissingFields(payload) + + if (missingFields.length > 0) { + return NextResponse.json( + { + error: `Missing required field${missingFields.length > 1 ? 's' : ''}: ${missingFields.join(', ')}`, + }, + { status: 400 }, + ) + } + + const result = await registerMagicProvider(secretKey, payload) + + if (!result.success) { + return NextResponse.json( + { + error: result.error, + details: result.details, + }, + { status: result.status }, + ) + } + + return NextResponse.json({ success: true, provider: result.provider }) +} + +export async function GET() { + return NextResponse.json( + { + message: + 'POST issuer, audience, and jwks_uri or provide a providers array. Use shared_audience to keep QualifyFirst, JustTheTip, and TiltCheck on the same Magic user pool. Use the provider id from the response when calling /api/magic/wallet.', + required_fields: REQUIRED_FIELDS, + examples: { + single: { + issuer: 'https://your-auth-provider.com', + audience: 'qualifyfirst-users', + jwks_uri: 'https://your-auth-provider.com/.well-known/jwks.json', + }, + batch: { + shared_audience: 'qualifyfirst-users', + providers: [ + { + name: 'QualifyFirst Dashboard', + issuer: 'https://your-auth-provider.com', + jwks_uri: 'https://your-auth-provider.com/.well-known/jwks.json', + }, + { + name: 'JustTheTip Discord Bot', + issuer: 'https://your-justthetip-provider.com', + jwks_uri: 'https://your-justthetip-provider.com/.well-known/jwks.json', + }, + { + name: 'TiltCheck Tools', + issuer: 'https://your-tiltcheck-provider.com', + jwks_uri: 'https://your-tiltcheck-provider.com/.well-known/jwks.json', + }, + ], + }, + }, + }, + { status: 200 }, + ) +} diff --git a/app/api/magic/wallet/route.ts b/app/api/magic/wallet/route.ts new file mode 100644 index 0000000..d51ebe0 --- /dev/null +++ b/app/api/magic/wallet/route.ts @@ -0,0 +1,104 @@ +/** + * QualifyFirst - Magic Wallet Creation Route + * + * Creates (or fetches) a blockchain wallet for a user via the Magic Admin API. + * Requires the OIDC provider ID from the provider registration step and the + * JWT issued by your auth provider (e.g., Supabase) for the current user. + */ + +import { NextResponse } from 'next/server' + +import { + createMagicWallet, + type MagicWalletRequest, +} from '../../../lib/magic/provider' + +const invalidBodyResponse = () => + NextResponse.json( + { + error: + 'Invalid payload. Provide provider_id, user_jwt, and optionally chain.', + }, + { status: 400 }, + ) + +const REQUIRED_FIELDS: (keyof MagicWalletRequest)[] = ['providerId', 'userJwt'] + +export async function POST(request: Request) { + const secretKey = process.env.MAGIC_SECRET_KEY + + if (!secretKey) { + return NextResponse.json( + { + error: + 'Magic secret key is not configured. Set MAGIC_SECRET_KEY in your environment.', + }, + { status: 500 }, + ) + } + + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }) + } + + if (!body || typeof body !== 'object') { + return invalidBodyResponse() + } + + const { + provider_id: providerIdRaw, + user_jwt: userJwtRaw, + chain: chainRaw, + } = body as Record + + const payload: MagicWalletRequest = { + providerId: typeof providerIdRaw === 'string' ? providerIdRaw.trim() : '', + userJwt: typeof userJwtRaw === 'string' ? userJwtRaw.trim() : '', + chain: typeof chainRaw === 'string' ? chainRaw.trim() : undefined, + } + + const missingFields = REQUIRED_FIELDS.filter((field) => !payload[field]) + + if (missingFields.length > 0) { + return NextResponse.json( + { + error: `Missing required field${missingFields.length > 1 ? 's' : ''}: ${missingFields.join(', ')}`, + }, + { status: 400 }, + ) + } + + const result = await createMagicWallet(secretKey, payload) + + if (!result.success) { + return NextResponse.json( + { + error: result.error, + details: result.details, + }, + { status: result.status }, + ) + } + + return NextResponse.json({ success: true, public_address: result.publicAddress }) +} + +export async function GET() { + return NextResponse.json( + { + message: + 'POST provider_id and user_jwt to mint or fetch a Magic wallet. Optionally override chain (default: SOL).', + required_fields: ['provider_id', 'user_jwt'], + example: { + provider_id: 'magic_provider_id_from_registration', + user_jwt: 'jwt_from_your_auth_provider', + chain: 'SOL', + }, + }, + { status: 200 }, + ) +} diff --git a/app/auth/magic/page.tsx b/app/auth/magic/page.tsx new file mode 100644 index 0000000..17c4f3d --- /dev/null +++ b/app/auth/magic/page.tsx @@ -0,0 +1,140 @@ +/** + * QualifyFirst - Magic Auth Callback Page + * + * Handles the redirect from Magic email links, exchanges the Magic DID token + * for a Supabase session, and forwards the user to the appropriate page. + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; + +import { getMagicClient } from '../../lib/magic/client'; +import { supabase } from '../../lib/supabase'; + +export default function MagicCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const magicCredential = searchParams?.get('magic_credential'); + const returnTo = searchParams?.get('returnTo'); + + const [status, setStatus] = useState<'verifying' | 'error'>('verifying'); + const [message, setMessage] = useState('Verifying your magic link...'); + + useEffect(() => { + let cancelled = false; + + const verifyMagicLink = async () => { + if (!magicCredential) { + setStatus('error'); + setMessage('Missing Magic credential. Please request a new login link.'); + return; + } + + try { + setStatus('verifying'); + setMessage('Verifying your magic link...'); + + const magic = await getMagicClient(); + + if (!magic) { + throw new Error( + 'Magic publishable key is missing. Add NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY to your environment.', + ); + } + + const didToken = await magic.auth.loginWithCredential(magicCredential); + + const { error } = await supabase.auth.signInWithIdToken({ + provider: 'magic', + token: didToken, + }); + + if (error) { + throw error; + } + + if (cancelled) { + return; + } + + if (returnTo === 'profile-complete') { + router.replace('/profile/save-profile'); + return; + } + + if (returnTo) { + const normalized = returnTo.startsWith('/') ? returnTo : `/${returnTo}`; + router.replace(normalized); + return; + } + + router.replace('/dashboard'); + } catch (err) { + if (cancelled) { + return; + } + + console.error('Magic auth callback error:', err); + const fallback = + err instanceof Error + ? err.message + : 'Unable to verify the magic link. Please request a new one.'; + + setStatus('error'); + setMessage(fallback); + } + }; + + verifyMagicLink(); + + return () => { + cancelled = true; + }; + }, [magicCredential, returnTo, router]); + + if (status === 'verifying') { + return ( +
+
+
+
+
+

Verifying your magic link...

+

+ Hang tight while we confirm your login with Magic. You'll be redirected in a moment. +

+
+
+ ); + } + + return ( +
+
+
+ + + +
+

Magic link verification failed

+

{message}

+ + Return to login + +

+ Still stuck? Request another Magic link and double-check you're opening it on the same device. +

+
+
+ ); +} diff --git a/app/lib/magic/client.ts b/app/lib/magic/client.ts new file mode 100644 index 0000000..e9e9d6e --- /dev/null +++ b/app/lib/magic/client.ts @@ -0,0 +1,167 @@ +'use client'; + +/** + * Lightweight Magic SDK loader + * Loads the Magic Web SDK from the CDN so we avoid bundling the package and + * keep compatibility with environments that block `npm install` during CI. + */ + +type MagicLoginOptions = { + email: string + showUI?: boolean + redirectURI?: string +} + +type MagicAuthModule = { + loginWithMagicLink(options: MagicLoginOptions): Promise + loginWithCredential(credential: string): Promise +} + +type MagicUserModule = { + getIdToken(): Promise + isLoggedIn(): Promise + logout(): Promise +} + +type MagicClient = { + auth: MagicAuthModule + user: MagicUserModule +} + +type MagicConstructor = new ( + key: string, + config?: { + testMode?: boolean + }, +) => MagicClient + +declare global { + interface Window { + Magic?: MagicConstructor + } +} + +const MAGIC_CDN_URL = + 'https://cdn.jsdelivr.net/npm/magic-sdk@latest/dist/magic-sdk.umd.min.js' + +let magicInstance: MagicClient | null = null +let loaderPromise: Promise | null = null + +const loadMagicScript = () => + new Promise((resolve, reject) => { + if (typeof window === 'undefined') { + reject(new Error('Magic SDK can only be loaded in the browser.')) + return + } + + if (window.Magic) { + resolve() + return + } + + const existingScript = document.querySelector( + 'script[data-magic-sdk="true"]', + ) + + if (existingScript) { + if (existingScript.dataset.loaded === 'true') { + resolve() + return + } + + const onLoad = () => { + existingScript.dataset.loaded = 'true' + existingScript.removeEventListener('load', onLoad) + existingScript.removeEventListener('error', onError) + resolve() + } + + const onError = () => { + existingScript.removeEventListener('load', onLoad) + existingScript.removeEventListener('error', onError) + reject(new Error('Failed to load Magic SDK.')) + } + + existingScript.addEventListener('load', onLoad) + existingScript.addEventListener('error', onError) + return + } + + const script = document.createElement('script') + script.src = MAGIC_CDN_URL + script.async = true + script.dataset.magicSdk = 'true' + + script.onload = () => { + script.dataset.loaded = 'true' + resolve() + } + script.onerror = () => reject(new Error('Failed to load Magic SDK.')) + + document.head.appendChild(script) + }) + +const createMagicInstance = () => { + if (typeof window === 'undefined') { + return null + } + + const publishableKey = process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY + + if (!publishableKey) { + console.error( + 'NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY is not configured. Magic login cannot be initialised.', + ) + return null + } + + const MagicCtor = window.Magic + + if (!MagicCtor) { + console.error('Magic SDK constructor is unavailable in the browser.') + return null + } + + const config: { testMode?: boolean } = {} + + if (process.env.NEXT_PUBLIC_MAGIC_TEST_MODE === 'true') { + config.testMode = true + } + + magicInstance = new MagicCtor(publishableKey, config) + return magicInstance +} + +export const getMagicClient = async (): Promise => { + if (magicInstance) { + return magicInstance + } + + if (!loaderPromise) { + loaderPromise = (async () => { + try { + await loadMagicScript() + } catch (error) { + console.error(error) + return null + } + + return createMagicInstance() + })() + } + + const instance = await loaderPromise + + if (!instance) { + loaderPromise = null + } + + return instance +} + +export const resetMagicClient = () => { + magicInstance = null + loaderPromise = null +} + +export type { MagicClient } diff --git a/app/lib/magic/provider.ts b/app/lib/magic/provider.ts new file mode 100644 index 0000000..b8571ab --- /dev/null +++ b/app/lib/magic/provider.ts @@ -0,0 +1,257 @@ +/** + * Magic Provider Utilities + * ------------------------ + * Shared helper functions for working with the Magic Admin API + * when registering identity providers for QualifyFirst and the + * companion apps (JustTheTip & TiltCheck). + */ + +export const MAGIC_PROVIDER_ENDPOINT = + 'https://tee.express.magiclabs.com/v1/identity/provider' + +export const MAGIC_WALLET_ENDPOINT = 'https://tee.express.magiclabs.com/v1/wallet' + +export type MagicProviderPayload = { + issuer?: string + audience?: string + jwks_uri?: string +} + +export type NamedMagicProviderPayload = MagicProviderPayload & { + /** + * Friendly identifier so the dashboard can tell which app + * a provider registration response belongs to. + */ + name?: string +} + +export type MagicProviderBatchRequest = { + /** + * Optional shared audience value so multiple providers can + * authenticate the exact same user pool. + */ + shared_audience?: string + providers: NamedMagicProviderPayload[] +} + +export const REQUIRED_FIELDS: (keyof Required)[] = [ + 'issuer', + 'audience', + 'jwks_uri', +] + +/** + * Trim and coerce provider payload values into strings. + */ +export const sanitizeProviderPayload = ( + payload: MagicProviderPayload, + sharedAudience?: string, +): MagicProviderPayload => ({ + issuer: typeof payload.issuer === 'string' ? payload.issuer.trim() : '', + audience: + typeof payload.audience === 'string' + ? payload.audience.trim() + : typeof sharedAudience === 'string' + ? sharedAudience.trim() + : '', + jwks_uri: typeof payload.jwks_uri === 'string' ? payload.jwks_uri.trim() : '', +}) + +export const findMissingFields = (payload: MagicProviderPayload) => + REQUIRED_FIELDS.filter((field) => !payload[field]) + +export type MagicProviderSuccess = { + success: true + provider: unknown + name?: string +} + +export type MagicProviderFailure = { + success: false + status: number + error: string + details?: unknown + name?: string +} + +export type MagicProviderResult = MagicProviderSuccess | MagicProviderFailure + +export async function registerMagicProvider( + secretKey: string, + payload: MagicProviderPayload, + name?: string, +): Promise { + try { + const response = await fetch(MAGIC_PROVIDER_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Magic-Secret-Key': secretKey, + }, + body: JSON.stringify(payload), + }) + + const responseBody = await response + .json() + .catch(() => ({ message: 'Magic API did not return JSON content.' })) + + if (!response.ok) { + return { + success: false, + status: response.status, + error: 'Magic API request failed.', + details: responseBody, + name, + } + } + + return { + success: true, + provider: responseBody, + name, + } + } catch (error) { + console.error('Magic provider registration failed:', error) + return { + success: false, + status: 502, + error: + 'Unable to register Magic provider. Verify network connectivity and credentials.', + name, + } + } +} + +export async function registerMagicProvidersBatch( + secretKey: string, + providers: NamedMagicProviderPayload[], + sharedAudience?: string, +) { + const sanitizedProviders = providers.map((provider) => ({ + name: provider.name, + payload: sanitizeProviderPayload(provider, sharedAudience), + })) + + const validationErrors = sanitizedProviders + .map(({ name, payload }) => ({ name, missing: findMissingFields(payload) })) + .filter(({ missing }) => missing.length > 0) + + if (validationErrors.length > 0) { + return { + success: false, + error: 'Missing required fields for one or more providers.', + details: validationErrors.map(({ name, missing }) => ({ + name, + missing, + })), + } + } + + const results = await Promise.all( + sanitizedProviders.map(({ name, payload }) => + registerMagicProvider(secretKey, payload, name), + ), + ) + + const failed = results.filter((result) => !result.success) + + if (failed.length > 0) { + return { + success: false, + error: 'One or more providers failed to register with Magic.', + results, + } + } + + return { + success: true, + results, + } +} + +export type MagicWalletRequest = { + /** + * Provider identifier returned by the Magic Admin API when the OIDC + * provider was created (see registerMagicProvider). + */ + providerId: string + /** + * JWT minted by your auth provider (e.g., Supabase). Magic requires + * this token in the Authorization header when creating a wallet. + */ + userJwt: string + /** + * Which chain to mint a wallet for. Defaults to SOL to match the + * original implementation request. + */ + chain?: string +} + +export type MagicWalletSuccess = { + success: true + publicAddress: string +} + +export type MagicWalletFailure = { + success: false + status: number + error: string + details?: unknown +} + +export type MagicWalletResult = MagicWalletSuccess | MagicWalletFailure + +export async function createMagicWallet( + secretKey: string, + { providerId, userJwt, chain = 'SOL' }: MagicWalletRequest, +): Promise { + try { + const response = await fetch(MAGIC_WALLET_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${userJwt}`, + 'X-Magic-Secret-Key': secretKey, + 'X-OIDC-Provider-ID': providerId, + 'X-Magic-Chain': chain, + }, + }) + + const responseBody = await response + .json() + .catch(() => ({ message: 'Magic API did not return JSON content.' })) + + if (!response.ok) { + return { + success: false, + status: response.status, + error: 'Magic wallet request failed.', + details: responseBody, + } + } + + const publicAddress = (responseBody as { public_address?: string }).public_address + + if (!publicAddress) { + return { + success: false, + status: 502, + error: 'Magic wallet response missing public address.', + details: responseBody, + } + } + + return { + success: true, + publicAddress, + } + } catch (error) { + console.error('Magic wallet creation failed:', error) + return { + success: false, + status: 502, + error: + 'Unable to create Magic wallet. Verify network connectivity, provider ID, and credentials.', + } + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 35f850a..068b354 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -12,22 +12,25 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { supabase } from '../lib/supabase'; +import { useSearchParams } from 'next/navigation'; +import { getMagicClient } from '../lib/magic/client'; export default function LoginPage() { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); const [error, setError] = useState(''); + const searchParams = useSearchParams(); + + const returnToParam = searchParams?.get('returnTo') || undefined; useEffect(() => { // Check for error in URL parameters - const urlParams = new URLSearchParams(window.location.search); - const urlError = urlParams.get('error'); + const urlError = searchParams?.get('error'); if (urlError) { - setError(decodeURIComponent(urlError)); + setError(urlError); } - }, []); + }, [searchParams]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -35,17 +38,32 @@ export default function LoginPage() { setError(''); try { - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, - } - }); + const magic = await getMagicClient(); + + if (!magic) { + throw new Error( + 'Magic publishable key is missing. Add NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY to your environment.', + ); + } + + const redirectUrl = new URL('/auth/magic', window.location.origin); - if (error) throw error; - + if (returnToParam) { + redirectUrl.searchParams.set('returnTo', returnToParam); + } + + redirectUrl.searchParams.set('email', email); + + // Let the user know we sent the email while Magic processes the request. setSent(true); + + await magic.auth.loginWithMagicLink({ + email, + showUI: false, + redirectURI: redirectUrl.toString(), + }); } catch (err: unknown) { + setSent(false); setError(err instanceof Error ? err.message : 'An error occurred'); } finally { setLoading(false); @@ -64,10 +82,10 @@ export default function LoginPage() {

Check your email

- We sent a magic link to {email} + We sent a Magic login link to {email}

- Click the link in the email to access your dashboard + Click the link in the email to finish signing in. We'll redirect you automatically once Magic verifies it.

Back to home diff --git a/app/profile/page.tsx b/app/profile/page.tsx index e0a8113..6960c39 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -10,14 +10,14 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; -import { supabase } from '../lib/supabase'; +import { getMagicClient } from '../lib/magic/client'; import { profileQuestions, ProfileAnswer } from '../lib/lib/questions'; export default function ProfilePage() { - const router = useRouter(); + const searchParams = useSearchParams(); const [currentQuestion, setCurrentQuestion] = useState(-2); // Start at -2 for email, -1 for consent const [answers, setAnswers] = useState({}); const [email, setEmail] = useState(''); @@ -25,6 +25,34 @@ export default function ProfilePage() { const [error, setError] = useState(''); const [gdprConsent, setGdprConsent] = useState(false); const [marketingConsent, setMarketingConsent] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(null); + + useEffect(() => { + let isMounted = true; + + // Warm the Magic SDK so we avoid race conditions when the user submits. + getMagicClient().catch((sdkError) => { + console.error('Failed to preload Magic SDK:', sdkError); + if (isMounted) { + setError((prev) => + prev + ? prev + : 'We could not load the Magic login SDK. Refresh the page and try again.', + ); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + const urlError = searchParams?.get('error'); + if (urlError) { + setError(`Error: ${urlError}`); + } + }, [searchParams]); const question = currentQuestion >= 0 ? profileQuestions[currentQuestion] : null; const progress = currentQuestion >= 0 ? ((currentQuestion + 1) / profileQuestions.length) * 100 : 0; @@ -76,40 +104,69 @@ export default function ProfilePage() { const handleSubmit = async () => { setLoading(true); + setError(''); console.log('Starting profile submission...', { email, answers }); - + try { // Sign up the user // Store profile data temporarily in localStorage - const profileData = { + const profileData = { email, - ...answers + ...answers }; console.log('Storing profile data temporarily:', profileData); localStorage.setItem('pendingProfile', JSON.stringify(profileData)); - console.log('Sending magic link for authentication...'); - const { error: authError } = await supabase.auth.signInWithOtp({ - email, - options: { - emailRedirectTo: `${window.location.origin}/auth/callback?returnTo=profile-complete`, - } - }); + console.log('Preparing Magic link for authentication...'); - if (authError) { - console.error('Auth error:', authError); - throw authError; + const magic = await getMagicClient(); + + if (!magic) { + throw new Error( + 'Magic publishable key is missing. Add NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY to your environment.', + ); + } + + const redirectUrl = new URL('/auth/magic', window.location.origin); + redirectUrl.searchParams.set('returnTo', 'profile-complete'); + redirectUrl.searchParams.set('email', email); + let loginPromise: Promise | null = null; + + try { + loginPromise = magic.auth.loginWithMagicLink({ + email, + redirectURI: redirectUrl.toString(), + }); + } catch (loginError) { + throw loginError; + } + + const pendingLoginPromise = loginPromise; + + if (!pendingLoginPromise) { + throw new Error('Unable to start the Magic login request.'); } - console.log('Magic link sent successfully'); - router.push(`/profile/complete?email=${encodeURIComponent(email)}`); + console.log('Magic link requested successfully'); + + setSubmittedEmail(email); + + pendingLoginPromise.catch((asyncError) => { + console.error('Magic link request failed after submission:', asyncError); + setSubmittedEmail(null); + setError( + `Error: ${ + asyncError instanceof Error + ? asyncError.message + : 'Failed to send the magic link. Please try again.' + }`, + ); + }); } catch (err: unknown) { console.error('Profile submission error:', err); - if (err instanceof Error) { - setError(`Error: ${err.message}`); - } else { - setError('Failed to save profile. Please try again.'); - } + const message = + err instanceof Error ? err.message : 'Failed to save profile. Please try again.'; + setError(`Error: ${message}`); } finally { setLoading(false); } @@ -124,6 +181,73 @@ export default function ProfilePage() { handleAnswer(updated); }; + if (submittedEmail) { + return ( +
+
+
+
+ + + +
+

Profile complete!

+

+ We sent a magic login link to{' '} + {submittedEmail}. + Click it to finish activating your account. +

+
+ +
+

Next steps

+
    +
  • Open the email on the same device you used to start this profile.
  • +
  • Click the magic link so we can verify you with Magic and Supabase.
  • +
  • We'll automatically save your profile and send you to the dashboard.
  • +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Back to home + +

+ Didn't see an email? Double-check spam or request a new link from the{' '} + + login page + + . +

+
+
+
+ ); + } + return (
@@ -339,19 +463,4 @@ export default function ProfilePage() {
); } -
- - Start Building Your Profile - - -

- Already have an account? Login -

- -

- Built by Jamie Vargas • Currently in Development -

-
\ No newline at end of file + diff --git a/app/profile/save-profile/page.tsx b/app/profile/save-profile/page.tsx index 4e98d0f..6d1ea1e 100644 --- a/app/profile/save-profile/page.tsx +++ b/app/profile/save-profile/page.tsx @@ -37,13 +37,31 @@ export default function SaveProfilePage() { const pendingProfile = JSON.parse(pendingProfileStr); - // Generate referral code and add user_id to profile data - const userReferralCode = pendingProfile.email.substring(0, pendingProfile.email.indexOf('@')).toLowerCase().replace(/[^a-z0-9]/g, '') + Math.random().toString(36).substring(2, 6); - + const atIndex = pendingProfile.email.indexOf('@'); + const emailPrefix = (atIndex >= 0 ? pendingProfile.email.substring(0, atIndex) : pendingProfile.email) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + + const generateReferralCode = () => + `${emailPrefix}${Math.random().toString(36).substring(2, 6)}`; + + const { data: existingProfile, error: existingProfileError } = await supabase + .from('user_profiles') + .select('referral_code') + .eq('user_id', user.id) + .maybeSingle(); + + if (existingProfileError && existingProfileError.code !== 'PGRST116') { + throw existingProfileError; + } + + const referralCode = existingProfile?.referral_code || generateReferralCode(); + const isNewProfile = !existingProfile; + const profileData = { ...pendingProfile, user_id: user.id, - referral_code: userReferralCode + referral_code: referralCode }; console.log('Saving profile with user_id:', profileData); @@ -51,7 +69,7 @@ export default function SaveProfilePage() { // Save to database const { data, error: profileError } = await supabase .from('user_profiles') - .insert([profileData]) + .upsert(profileData, { onConflict: 'user_id' }) .select(); if (profileError) { @@ -66,9 +84,12 @@ export default function SaveProfilePage() { const referralCode = urlParams.get('ref') || localStorage.getItem('referralCode'); if (referralCode) { - // Track the referral - const { trackReferralSignup } = await import('../../lib/referrals'); - await trackReferralSignup(referralCode, user.id); + if (isNewProfile) { + // Track the referral for brand-new profiles only. + const { trackReferralSignup } = await import('../../lib/referrals'); + await trackReferralSignup(referralCode, user.id); + } + localStorage.removeItem('referralCode'); // Clean up }