← back to CelebritySignatures

apps/mobile/screens/AccountScreen.tsx

617 lines

// AccountScreen — auth + account management.
// Apple App Store Review requirement 5.1.1(v): apps that support account creation
// MUST also offer in-app account deletion. The "Delete my account" button at the
// bottom of the signed-in view satisfies this requirement. The backend endpoint
// POST /api/auth/delete performs the irreversible deletion server-side.
//
// Usage: <AccountScreen />  (no props — self-contained)
// Rendered inside App's GlassScene; does NOT wrap itself in one.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
  ActivityIndicator,
  Alert,
  KeyboardAvoidingView,
  Platform,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import * as Haptics from 'expo-haptics';

import { api, User, appleSignIn } from '../api';
import { GLASS, GlassButton, GlassPanel } from '../ui/glass';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type AuthMode = 'signin' | 'signup';
type CheckState = 'checking' | 'done';

// ---------------------------------------------------------------------------
// Segmented toggle: "Sign in" | "Create account"
// ---------------------------------------------------------------------------

function ModeToggle({
  mode,
  onChange,
}: {
  mode: AuthMode;
  onChange: (m: AuthMode) => void;
}) {
  return (
    <View style={styles.toggleRow}>
      {(['signin', 'signup'] as const).map((m) => {
        const active = mode === m;
        const label = m === 'signin' ? 'Sign in' : 'Create account';
        return (
          <View key={m} style={[styles.toggleSegment, active && styles.toggleSegmentActive]}>
            <Text
              onPress={() => {
                if (!active) {
                  Haptics.selectionAsync();
                  onChange(m);
                }
              }}
              style={[styles.toggleLabel, active && styles.toggleLabelActive]}
            >
              {label}
            </Text>
          </View>
        );
      })}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Dark-styled TextInput wrapper matching the gallery search bar aesthetic
// ---------------------------------------------------------------------------

function Field({
  value,
  onChangeText,
  placeholder,
  secureTextEntry = false,
  autoCapitalize = 'none',
  returnKeyType = 'next',
  onSubmitEditing,
  inputRef,
}: {
  value: string;
  onChangeText: (v: string) => void;
  placeholder: string;
  secureTextEntry?: boolean;
  autoCapitalize?: 'none' | 'words';
  returnKeyType?: 'next' | 'done';
  onSubmitEditing?: () => void;
  inputRef?: React.RefObject<TextInput | null>;
}) {
  return (
    <View style={styles.fieldWrap}>
      <TextInput
        ref={inputRef}
        style={styles.fieldInput}
        value={value}
        onChangeText={onChangeText}
        placeholder={placeholder}
        placeholderTextColor={GLASS.textDim}
        secureTextEntry={secureTextEntry}
        autoCapitalize={autoCapitalize}
        autoCorrect={false}
        returnKeyType={returnKeyType}
        onSubmitEditing={onSubmitEditing}
        blurOnSubmit={returnKeyType === 'done'}
      />
    </View>
  );
}

// ---------------------------------------------------------------------------
// Auth view — sign-in / sign-up form
// ---------------------------------------------------------------------------

function AuthView({ onSuccess }: { onSuccess: (user: User) => void }) {
  const [mode, setMode] = useState<AuthMode>('signin');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [appleBusy, setAppleBusy] = useState(false);
  const [appleAvailable, setAppleAvailable] = useState(false);

  const passwordRef = useRef<TextInput>(null);
  const nameRef = useRef<TextInput>(null);

  // Show the Apple button only once its creds are configured server-side.
  useEffect(() => {
    api.authConfig().then((c) => setAppleAvailable(!!c.apple)).catch(() => setAppleAvailable(false));
  }, []);

  const handleApple = useCallback(async () => {
    setAppleBusy(true);
    setError(null);
    try {
      const r = await appleSignIn();
      if (r.ok) {
        const me = await api.me();
        if (me.ok && me.user) {
          Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
          onSuccess(me.user);
          return;
        }
        setError('Signed in, but could not load your account. Please try again.');
      } else if (r.error && r.error !== 'canceled') {
        setError(r.error);
      }
    } catch {
      setError('Network error. Please check your connection.');
    } finally {
      setAppleBusy(false);
    }
  }, [onSuccess]);

  // Reset error when switching mode
  const handleModeChange = useCallback((m: AuthMode) => {
    setMode(m);
    setError(null);
  }, []);

  const handleSubmit = useCallback(async () => {
    const trimmedEmail = email.trim();
    const trimmedName = name.trim();

    if (!trimmedEmail || !password) {
      setError('Email and password are required.');
      return;
    }

    setBusy(true);
    setError(null);

    try {
      const result =
        mode === 'signin'
          ? await api.login(trimmedEmail, password)
          : await api.signup(trimmedEmail, password, trimmedName || undefined);

      if (result.ok && result.user) {
        Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
        onSuccess(result.user);
      } else {
        setError(result.error ?? 'Something went wrong. Please try again.');
      }
    } catch {
      setError('Network error. Please check your connection.');
    } finally {
      setBusy(false);
    }
  }, [mode, email, password, name, onSuccess]);

  const submitLabel = mode === 'signin' ? 'Sign in' : 'Create account';

  return (
    <View style={styles.authRoot}>
      <Text style={styles.title}>Your Account</Text>
      <Text style={styles.subtitle}>
        One account for the app and celebsignatures.com.
      </Text>

      <ModeToggle mode={mode} onChange={handleModeChange} />

      <View style={styles.fields}>
        <Field
          value={email}
          onChangeText={setEmail}
          placeholder="Email"
          autoCapitalize="none"
          returnKeyType="next"
          onSubmitEditing={() => {
            if (mode === 'signup') {
              nameRef.current?.focus();
            } else {
              passwordRef.current?.focus();
            }
          }}
        />

        {mode === 'signup' && (
          <Field
            inputRef={nameRef}
            value={name}
            onChangeText={setName}
            placeholder="Name (optional)"
            autoCapitalize="words"
            returnKeyType="next"
            onSubmitEditing={() => passwordRef.current?.focus()}
          />
        )}

        <Field
          inputRef={passwordRef}
          value={password}
          onChangeText={setPassword}
          placeholder="Password"
          secureTextEntry
          returnKeyType="done"
          onSubmitEditing={handleSubmit}
        />
      </View>

      {error !== null && (
        <Text style={styles.errorText}>{error}</Text>
      )}

      <View style={styles.submitBtn}>
        <GlassButton
          label={submitLabel}
          onPress={handleSubmit}
          solid
          busy={busy}
          disabled={busy}
        />
      </View>

      {appleAvailable && (
        <View style={styles.appleWrap}>
          <Text style={styles.orText}>or</Text>
          <GlassButton
            label={'  Sign in with Apple'}
            onPress={handleApple}
            busy={appleBusy}
            disabled={appleBusy || busy}
          />
        </View>
      )}
    </View>
  );
}

// ---------------------------------------------------------------------------
// Signed-in view — greeting + sign out + delete account
// ---------------------------------------------------------------------------

function SignedInView({
  user,
  onSignOut,
}: {
  user: NonNullable<User>;
  onSignOut: () => void;
}) {
  const [signOutBusy, setSignOutBusy] = useState(false);
  const [deleteBusy, setDeleteBusy] = useState(false);
  const [deleteError, setDeleteError] = useState<string | null>(null);
  const [deleted, setDeleted] = useState(false);

  const handleSignOut = useCallback(async () => {
    setSignOutBusy(true);
    try {
      await api.logout();
    } finally {
      // Always return to auth view regardless of server response
      setSignOutBusy(false);
      onSignOut();
    }
  }, [onSignOut]);

  // Apple 5.1.1(v) — in-app account deletion with explicit confirmation.
  const handleDeletePress = useCallback(() => {
    Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

    Alert.alert(
      'Delete Account',
      'This permanently deletes your account, saved placements, and uploads. This cannot be undone.',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: async () => {
            setDeleteError(null);
            setDeleteBusy(true);

            await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);

            try {
              const result = await api.deleteAccount();
              if (result.ok) {
                setDeleted(true);
                // Brief "Account deleted" display then return to auth
                setTimeout(() => onSignOut(), 1800);
              } else {
                setDeleteError(result.error ?? 'Deletion failed. Please try again.');
              }
            } catch {
              setDeleteError('Network error. Please check your connection.');
            } finally {
              setDeleteBusy(false);
            }
          },
        },
      ],
    );
  }, [onSignOut]);

  if (deleted) {
    return (
      <View style={styles.center}>
        <Text style={styles.deletedText}>Account deleted.</Text>
      </View>
    );
  }

  return (
    <View style={styles.signedInRoot}>
      {/* Greeting panel */}
      <GlassPanel style={styles.greetingPanel}>
        <Text style={styles.greetingLine}>Signed in as</Text>
        <Text style={styles.greetingEmail} numberOfLines={1}>
          {user.email}
        </Text>
        {user.name ? (
          <Text style={styles.greetingName}>{user.name}</Text>
        ) : null}
      </GlassPanel>

      {/* Sign out */}
      <View style={styles.actionBtn}>
        <GlassButton
          label="Sign out"
          onPress={handleSignOut}
          busy={signOutBusy}
          disabled={signOutBusy || deleteBusy}
        />
      </View>

      {/* Spacer pushes delete to bottom */}
      <View style={styles.spacer} />

      {/* Delete account — Apple 5.1.1(v) required control */}
      <View style={styles.deleteSection}>
        {deleteError !== null && (
          <Text style={styles.errorText}>{deleteError}</Text>
        )}
        <GlassButton
          label={deleteBusy ? 'Deleting…' : 'Delete my account'}
          onPress={handleDeletePress}
          busy={deleteBusy}
          disabled={deleteBusy || signOutBusy}
        />
        <Text style={styles.deleteCaption}>
          Permanently removes your account, placements, and uploads.
        </Text>
      </View>
    </View>
  );
}

// ---------------------------------------------------------------------------
// Root screen — resolves auth state then delegates
// ---------------------------------------------------------------------------

export default function AccountScreen() {
  const [checkState, setCheckState] = useState<CheckState>('checking');
  const [user, setUser] = useState<User>(null);

  useEffect(() => {
    let cancelled = false;
    api.me().then((res) => {
      if (!cancelled) {
        setUser(res.ok ? res.user : null);
        setCheckState('done');
      }
    }).catch(() => {
      if (!cancelled) {
        setUser(null);
        setCheckState('done');
      }
    });
    return () => { cancelled = true; };
  }, []);

  const handleAuthSuccess = useCallback((u: User) => {
    setUser(u);
  }, []);

  const handleSignOut = useCallback(() => {
    setUser(null);
  }, []);

  // While checking session
  if (checkState === 'checking') {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" color={GLASS.accent} />
      </View>
    );
  }

  return (
    <KeyboardAvoidingView
      style={styles.flex}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <ScrollView
        style={styles.flex}
        contentContainerStyle={styles.scrollContent}
        keyboardShouldPersistTaps="handled"
        showsVerticalScrollIndicator={false}
      >
        {user !== null ? (
          <SignedInView user={user} onSignOut={handleSignOut} />
        ) : (
          <AuthView onSuccess={handleAuthSuccess} />
        )}
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------

const styles = StyleSheet.create({
  flex: {
    flex: 1,
  },
  scrollContent: {
    flexGrow: 1,
    paddingHorizontal: 20,
    paddingTop: 32,
    paddingBottom: 48,
  },
  center: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 32,
  },

  // Auth view
  authRoot: {
    flex: 1,
  },
  title: {
    fontSize: 28,
    fontWeight: '700',
    color: GLASS.accentSoft,
    letterSpacing: 0.2,
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 15,
    color: GLASS.textDim,
    marginBottom: 28,
    lineHeight: 21,
  },

  // Mode toggle
  toggleRow: {
    flexDirection: 'row',
    borderRadius: 12,
    overflow: 'hidden',
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: GLASS.edge,
    backgroundColor: '#ffffff',
    marginBottom: 24,
  },
  toggleSegment: {
    flex: 1,
    paddingVertical: 12,
    alignItems: 'center',
  },
  toggleSegmentActive: {
    backgroundColor: 'rgba(201,168,106,0.18)',
    borderBottomWidth: 2,
    borderBottomColor: GLASS.accent,
  },
  toggleLabel: {
    fontSize: 15,
    fontWeight: '600',
    color: GLASS.textDim,
  },
  toggleLabelActive: {
    color: GLASS.accent,
  },

  // Fields
  fields: {
    gap: 12,
    marginBottom: 16,
  },
  fieldWrap: {
    borderRadius: 12,
    overflow: 'hidden',
    backgroundColor: '#ffffff',
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: GLASS.edgeFaint,
  },
  fieldInput: {
    paddingHorizontal: 16,
    paddingVertical: 14,
    fontSize: 16,
    color: GLASS.accentSoft,
  },

  errorText: {
    color: '#e05c5c',
    fontSize: 14,
    marginBottom: 14,
    lineHeight: 19,
  },
  submitBtn: {
    marginTop: 8,
  },
  appleWrap: {
    marginTop: 14,
    gap: 8,
  },
  orText: {
    textAlign: 'center',
    color: GLASS.textDim,
    fontSize: 13,
    letterSpacing: 0.3,
  },

  // Signed-in view
  signedInRoot: {
    flex: 1,
    minHeight: 420,
  },
  greetingPanel: {
    marginBottom: 20,
  },
  greetingLine: {
    fontSize: 13,
    color: GLASS.textDim,
    letterSpacing: 0.3,
    marginBottom: 4,
    textTransform: 'uppercase',
  },
  greetingEmail: {
    fontSize: 18,
    fontWeight: '700',
    color: GLASS.accentSoft,
    marginBottom: 2,
  },
  greetingName: {
    fontSize: 15,
    color: GLASS.textDim,
    marginTop: 4,
  },
  actionBtn: {
    marginBottom: 12,
  },
  spacer: {
    flex: 1,
    minHeight: 32,
  },

  // Delete section
  deleteSection: {
    paddingTop: 20,
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: 'rgba(20,18,16,0.1)',
  },
  deleteCaption: {
    fontSize: 12,
    color: GLASS.textDim,
    textAlign: 'center',
    marginTop: 10,
    lineHeight: 17,
  },

  // Post-delete confirmation
  deletedText: {
    fontSize: 18,
    fontWeight: '700',
    color: GLASS.accentSoft,
    textAlign: 'center',
  },
});