diff --git a/vynl/app.json b/vynl/app.json index 7a00a3d..a249006 100644 --- a/vynl/app.json +++ b/vynl/app.json @@ -4,7 +4,7 @@ "slug": "vynl", "version": "1.0.0", "orientation": "portrait", - "icon": "./assets/images/icon.png", + "icon": "./assets/images/logo.png", "scheme": "vynl", "userInterfaceStyle": "automatic", "newArchEnabled": true, diff --git a/vynl/src/app/(tabs)/SignupPage.tsx b/vynl/src/app/(tabs)/SignupPage.tsx index 93cf39e..b6f972a 100644 --- a/vynl/src/app/(tabs)/SignupPage.tsx +++ b/vynl/src/app/(tabs)/SignupPage.tsx @@ -1,12 +1,14 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { View, Text, StyleSheet, useColorScheme } from "react-native"; import { Colors } from '@/src/constants/theme'; -import { Link } from 'expo-router'; +import { Link, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; import AppButton from "@/src/components/AppButton"; // make sure path is correct import InputField from '@/src/components/InputField'; import { validatePassword } from "@/scripts/validatePassword"; import { passwordErrorMessages } from "@/scripts/validatePassword"; import { supabase } from '@/src/utils/supabase'; +import { useAuth } from '@/src/context/auth-context'; interface FormData { email: string; @@ -15,8 +17,11 @@ interface FormData { } const SignupPage: React.FC = () => { + const router = useRouter(); + const { login } = useAuth(); const [formData, setFormData] = useState({ email: "", password: "" , confirmPassword: ""}); const [errors, setErrors] = useState>>({}); + const [isLoading, setIsLoading] = useState(false); const handleChange = (name: keyof FormData, value: string) => { setFormData({ ...formData, [name]: value }); @@ -24,6 +29,19 @@ const SignupPage: React.FC = () => { setErrors((prev) => ({ ...prev, [name]: undefined })); }; + // Check individual password requirements + const passwordRequirements = useMemo(() => { + const password = formData.password; + return { + minLength: password.length >= 8, + maxLength: password.length <= 35, + hasNumber: /\d/.test(password), + hasSpecialChar: /[^A-Za-z0-9]/.test(password), + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + }; + }, [formData.password]); + const handleSubmit = async () => { // Validate email format const email = formData.email.trim(); @@ -48,9 +66,8 @@ const SignupPage: React.FC = () => { // Clear errors and proceed setErrors({}); - console.log("Form Data:", formData); - // Later: call API from services/api.ts - console.log('HERE'); + setIsLoading(true); + try { const { data, error: signupError } = await supabase.auth.signUp({ email: formData.email, @@ -59,15 +76,32 @@ const SignupPage: React.FC = () => { emailRedirectTo: 'https://example.com/welcome', }, }); + if (signupError) { console.error('Sign up error:', signupError); setErrors((prev) => ({ ...prev, email: 'Sign up failed. Please try again.' })); + setIsLoading(false); return; } + console.log('Sign up data:', data); + + // After successful signup, log the user in automatically + try { + await login(formData.email, formData.password); + console.log("Signed up and logged in successfully"); + // Navigate to home screen after successful signup and login + router.push('/(tabs)'); + } catch (loginError: any) { + console.error('Auto-login error after signup:', loginError); + // If auto-login fails, still show success but user may need to log in manually + setErrors((prev) => ({ ...prev, email: 'Account created, but auto-login failed. Please log in manually.' })); + } } catch (err) { console.error('Unexpected sign up error:', err); setErrors((prev) => ({ ...prev, email: 'Unexpected error. Please try again.' })); + } finally { + setIsLoading(false); } }; @@ -93,7 +127,26 @@ const SignupPage: React.FC = () => { }, inputcontainer: { marginVertical: 80, - marginBottom: 180, + marginBottom: 20, + }, + requirementsContainer: { + marginTop: 8, + marginBottom: 20, + paddingLeft: 4, + }, + requirementRow: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4, + }, + requirementText: { + fontSize: 13, + color: colors.text, + marginLeft: 8, + opacity: 0.7, + }, + requirementTextMet: { + opacity: 1, }, loginText: { textAlign: 'center', @@ -127,6 +180,70 @@ const SignupPage: React.FC = () => { height={55} error={errors.password} /> + {formData.password.length > 0 && ( + + + + + At least 8 characters + + + + + + Less than 35 characters + + + + + + At least one number + + + + + + At least one special character + + + + + + At least one uppercase letter + + + + + + At least one lowercase letter + + + + )} { error={errors.confirmPassword} /> - + Already have an account?{' '} diff --git a/vynl/src/app/(tabs)/playlists.tsx b/vynl/src/app/(tabs)/playlists.tsx index 04c3059..eec3317 100644 --- a/vynl/src/app/(tabs)/playlists.tsx +++ b/vynl/src/app/(tabs)/playlists.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; @@ -9,6 +9,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useUser } from '@/src/hooks/use-user'; import { useUserPlaylists } from '@/src/hooks/use-playlist-for-user'; import { usePartyPlaylists } from '@/src/hooks/use-party-playlists'; +import { useAuth } from '@/src/context/auth-context'; const PARTY_CODE_STORAGE_KEY = '@vynl:partyCode'; @@ -16,6 +17,7 @@ export default function PlaylistsScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { user, loading: authLoading } = useUser(); + const { authToken } = useAuth(); const uid = user?.id; const { playlists, loading: playlistLoading, error, refetch } = useUserPlaylists(uid ?? null); @@ -99,12 +101,53 @@ export default function PlaylistsScreen() { }, [refetch, uid]) ); - const handleDelete = async (playlistId: number) => { - try { - //TODO : implement - } catch (error) { - console.error('Error deleting playlist:', error); - } + const handleDelete = (playlistId: number, playlistName: string) => { + Alert.alert( + 'Delete Playlist', + `Are you sure you want to delete "${playlistName}"? This action cannot be undone.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + if (!authToken) { + console.error('No auth token available'); + Alert.alert('Error', 'Authentication failed. Please try again.'); + return; + } + + const res = await fetch(`/api/playlist/${playlistId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + }); + + if (!res.ok) { + const errorText = await res.text(); + console.error('Failed to delete playlist:', errorText); + Alert.alert('Error', 'Failed to delete playlist. Please try again.'); + return; + } + + // Refresh the playlists after successful deletion + if (refetch) { + refetch(); + } + } catch (error) { + console.error('Error deleting playlist:', error); + Alert.alert('Error', 'An error occurred while deleting the playlist. Please try again.'); + } + }, + }, + ] + ); }; const formatDate = (timestamp: number) => { @@ -184,7 +227,7 @@ export default function PlaylistsScreen() { { e.stopPropagation(); - handleDelete(p.id); + handleDelete(p.id, p.name); }} style={styles.deleteButton} > diff --git a/vynl/src/app/(tabs)/swipe.tsx b/vynl/src/app/(tabs)/swipe.tsx index c045072..d046317 100644 --- a/vynl/src/app/(tabs)/swipe.tsx +++ b/vynl/src/app/(tabs)/swipe.tsx @@ -15,6 +15,7 @@ import { useUpdatePlaylist } from '@/src/hooks/use-update-playlist'; import { useAuth } from '@/src/context/auth-context'; import { Audio } from 'expo-av'; import { useAudioPreview } from '@/src/hooks/use-audio-preview'; +import PartyEndedModal from '@/src/components/PartyEndedModal'; const { width, height } = Dimensions.get('window'); const DISC_SIZE = Math.min(width * 0.78, 320); @@ -123,10 +124,12 @@ export default function Swiping() { const [playlistName, setPlaylistName] = useState(isPartyMode && initialPlaylistName ? initialPlaylistName as string : ''); const [isSaving, setIsSaving] = useState(false); const [playlistSaved, setPlaylistSaved] = useState(false); + const [shouldSkipSessionSave, setShouldSkipSessionSave] = useState(false); const { updateLoading, updateError, updatePlaylist } = useUpdatePlaylist(); const [gettingSimilar, setGettingSimilar] = useState(false); const [recommendedSongs, setRecommendations] = useState([]); const { authToken } = useAuth(); + const [showPartyEndedModal, setShowPartyEndedModal] = useState(false); const { playing, setPlaying, @@ -320,7 +323,7 @@ export default function Swiping() { // Save session state whenever it changes (only if we have seed songs) useEffect(() => { - if (!isLoading && seedSongs.length > 0) { + if (!isLoading && seedSongs.length > 0 && !shouldSkipSessionSave) { const saveSession = async () => { try { const session = { @@ -338,7 +341,7 @@ export default function Swiping() { }; saveSession(); } - }, [index, liked, passed, swipeHistory, isLoading, seedSongs, addedSongs]); + }, [index, liked, passed, swipeHistory, isLoading, seedSongs, addedSongs, shouldSkipSessionSave]); const top = recommendedSongs[index]; const next = recommendedSongs[index + 1]; @@ -492,9 +495,35 @@ export default function Swiping() { ); const allSongs = [...seedSongs, ...filteredAddedSongs]; - if (isAddingMode) await updatePlaylist(newPlaylist.id, allSongs, newPlaylist.name); - else await updatePlaylist(newPlaylist.id, allSongs, playlistName); + const result = await (isAddingMode + ? updatePlaylist(newPlaylist.id, allSongs, newPlaylist.name) + : updatePlaylist(newPlaylist.id, allSongs, playlistName)); + // Check if the update failed due to party ended + // Give a small delay to ensure updateError is set by the hook + await new Promise(resolve => setTimeout(resolve, 50)); + + // If result is null, check if it's because party ended + if (result === null) { + // Check error state - if PARTY_ENDED, the useEffect will handle showing the modal + if (updateError === 'PARTY_ENDED') { + // Prevent session from being saved and clear session, but keep UI state + // so the confirmation page shows behind the modal + setShouldSkipSessionSave(true); + await clearSession(); + // Don't reset state - keep finished state and liked songs for confirmation page + // Re-enable session saving after a brief delay + setTimeout(() => setShouldSkipSessionSave(false), 100); + // Don't set playlistSaved - useEffect will ensure it stays false + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + return; + } + // Other error - don't show success + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + return; + } + + // Only set success if we got a valid result (not null) setPlaylistSaved(true); // Clear session after saving playlist @@ -508,6 +537,27 @@ export default function Swiping() { } }; + // Show modal when updateError is PARTY_ENDED and prevent success message + useEffect(() => { + if (updateError === 'PARTY_ENDED') { + setShowPartyEndedModal(true); + // Make sure success message doesn't show + setPlaylistSaved(false); + } + }, [updateError]); + + // Clear session when modal is shown (party ended) but keep UI state + useEffect(() => { + if (showPartyEndedModal) { + // Ensure session is cleared when modal appears, but don't reset UI state + // so the confirmation page shows behind the modal + setShouldSkipSessionSave(true); + clearSession().catch(console.error); + // Re-enable session saving after clearing + setTimeout(() => setShouldSkipSessionSave(false), 100); + } + }, [showPartyEndedModal, clearSession]); + // Don't render until session is loaded if (isLoading || gettingSimilar) { return ( @@ -744,6 +794,23 @@ export default function Swiping() { )} + setShowPartyEndedModal(false)} + onNavigate={async () => { + if (newPlaylist?.id) { + setShouldSkipSessionSave(true); + await clearSession(); + resetState(); + await stopAll(); + setActive(false); + router.push({ + pathname: '/(tabs)/playlist-detail', + params: { id: newPlaylist.id.toString() } + }); + } + }} + /> ); } diff --git a/vynl/src/app/api/playlist/[id]+api.ts b/vynl/src/app/api/playlist/[id]+api.ts index 8a1c9f7..bccfc6f 100644 --- a/vynl/src/app/api/playlist/[id]+api.ts +++ b/vynl/src/app/api/playlist/[id]+api.ts @@ -116,6 +116,16 @@ export async function PUT(req: Request, { id }: Record) { if (nps_err) { console.log("Failed to insert into database : ", nps_err); + // Check if error is due to row-level security policy (party mode ended) + if (nps_err.code === '42501' || (nps_err.message && nps_err.message.includes('row-level security policy'))) { + return new Response(JSON.stringify({ + error: 'PARTY_ENDED', + message: 'Host has ended the party session. No songs can be added at this time.' + }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } return new Response('Failed to insert into database', { status: 400 }); @@ -176,7 +186,7 @@ export async function DELETE(req: Request, { id }: Record) { const { data, error } = await supabase .from('playlists') .delete() - .eq('id', playlist_id); + .eq('playlist_id', playlist_id); return new Response("OK", { status: 200, diff --git a/vynl/src/app/api/playlist/add/[id]+api.ts b/vynl/src/app/api/playlist/add/[id]+api.ts index 863c4d2..97c9471 100644 --- a/vynl/src/app/api/playlist/add/[id]+api.ts +++ b/vynl/src/app/api/playlist/add/[id]+api.ts @@ -69,6 +69,16 @@ export async function PUT(req: Request, { id }: Record) { if (nps_err) { console.log("Failed to insert into database : ", nps_err); + // Check if error is due to row-level security policy (party mode ended) + if (nps_err.code === '42501' || (nps_err.message && nps_err.message.includes('row-level security policy'))) { + return new Response(JSON.stringify({ + error: 'PARTY_ENDED', + message: 'Host has ended the party session. No songs can be added at this time.' + }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } return new Response('Failed to insert into database', { status: 400 }); diff --git a/vynl/src/app/api/playlist/party/toggle/[id]+api.ts b/vynl/src/app/api/playlist/party/toggle/[id]+api.ts index 8b9a4cf..6c81d9e 100644 --- a/vynl/src/app/api/playlist/party/toggle/[id]+api.ts +++ b/vynl/src/app/api/playlist/party/toggle/[id]+api.ts @@ -17,6 +17,7 @@ export async function PUT(req: Request, { id }: Record) { return new Response("Missing playlist ID", { status: 400 }); } try { + console.log("PUT"); const playlist_id = parseInt(id); if (Number.isNaN(playlist_id)) { @@ -27,8 +28,10 @@ export async function PUT(req: Request, { id }: Record) { } const body = await req.json(); + // const uid = body.uid; const enable = body.enable; - + + // if (uid === undefined || uid === null) return new Response("Missing user ID", { status: 400 }); if (enable === undefined || enable === null) return new Response("Missing enable", { status: 400 }); const supabase = await createSupabaseClient(req); @@ -88,7 +91,7 @@ export async function PUT(req: Request, { id }: Record) { // Only insert playlist_id, just like the link endpoint does const { data: pu_data, error: pu_err } = await supabase .from('party_users') - .upsert({ playlist_id: playlist_id }); + .insert({ playlist_id: playlist_id }); if (pu_err) { console.log(pu_err) diff --git a/vynl/src/components/PartyEndedModal.tsx b/vynl/src/components/PartyEndedModal.tsx new file mode 100644 index 0000000..d66c4fe --- /dev/null +++ b/vynl/src/components/PartyEndedModal.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import AppButton from './AppButton'; + +interface PartyEndedModalProps { + visible: boolean; + onClose: () => void; + onNavigate?: () => void; +} + +export default function PartyEndedModal({ + visible, + onClose, + onNavigate, +}: PartyEndedModalProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + + + Party Session Ended + + Host has ended the party session. No songs can be added at this time. + + + + + { + onClose(); + onNavigate?.(); + }} + backgroundColor="#F28695" + textColor="#FFFFFF" + width="100%" + /> + + + + + + ); +} + +const styles = StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + modalContentWrapper: { + width: '100%', + maxWidth: 400, + }, + modalContent: { + borderRadius: 20, + padding: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + header: { + alignItems: 'center', + marginBottom: 24, + }, + iconContainer: { + marginBottom: 16, + }, + title: { + fontSize: 24, + fontWeight: '800', + color: '#001133', + fontFamily: 'AppleGaramond-Italic', + marginBottom: 12, + textAlign: 'center', + }, + message: { + fontSize: 16, + color: '#6F7A88', + textAlign: 'center', + lineHeight: 24, + }, + buttonContainer: { + marginTop: 8, + }, +}); + diff --git a/vynl/src/hooks/use-add-songs.ts b/vynl/src/hooks/use-add-songs.ts index 28f9a34..7c498ac 100644 --- a/vynl/src/hooks/use-add-songs.ts +++ b/vynl/src/hooks/use-add-songs.ts @@ -30,8 +30,23 @@ export function useAddToPlaylist(): UseAddPlaylistResult { }); if (!res.ok) { - const text = await res.text(); - setError(text || 'Failed to update playlist'); + const contentType = res.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const errorData = await res.json(); + if (errorData.error === 'PARTY_ENDED') { + setError('PARTY_ENDED'); + return null; + } + setError(errorData.message || 'Failed to update playlist'); + } catch { + const text = await res.text(); + setError(text || 'Failed to update playlist'); + } + } else { + const text = await res.text(); + setError(text || 'Failed to update playlist'); + } return null; } diff --git a/vynl/src/hooks/use-update-playlist.ts b/vynl/src/hooks/use-update-playlist.ts index e8b7a6f..995324c 100644 --- a/vynl/src/hooks/use-update-playlist.ts +++ b/vynl/src/hooks/use-update-playlist.ts @@ -30,8 +30,23 @@ export function useUpdatePlaylist(): UseCreatePlaylistResult { }); if (!res.ok) { - const text = await res.text(); - setError(text || 'Failed to update playlist'); + const contentType = res.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const errorData = await res.json(); + if (errorData.error === 'PARTY_ENDED') { + setError('PARTY_ENDED'); + return null; + } + setError(errorData.message || 'Failed to update playlist'); + } catch { + const text = await res.text(); + setError(text || 'Failed to update playlist'); + } + } else { + const text = await res.text(); + setError(text || 'Failed to update playlist'); + } return null; }