feat: implement data migration for existing lists
- Created migration-actions.ts with server actions for claiming lists - Added ClaimListsModal component for UI during registration - Integrated claim modal into RegisterForm - Created anonymous-lists.ts utilities for tracking lists in localStorage - Added migrate-lists.ts script for deployment migration - Created comprehensive MIGRATION_GUIDE.md documentation - Added migration tests with 100% pass rate - Updated package.json with db:migrate script - Added tsx as dev dependency for running migration scripts Requirements: 10.2, 10.3, 10.4, 10.5 - Users can claim existing lists on first registration - All list data is preserved during migration - All lists have an owner after migration - Migrated lists appear in user's account
This commit is contained in:
204
components/ClaimListsModal.tsx
Normal file
204
components/ClaimListsModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { claimLists, getClaimableLists, type ClaimableList } from '@/lib/migration-actions'
|
||||
import Modal from './Modal'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
interface ClaimListsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for claiming existing anonymous lists after registration
|
||||
* Requirements: 10.2, 10.5 - Migration UI for users to claim existing lists
|
||||
*/
|
||||
export default function ClaimListsModal({ isOpen, onClose, onComplete }: ClaimListsModalProps) {
|
||||
const [claimableLists, setClaimableLists] = useState<ClaimableList[]>([])
|
||||
const [selectedLists, setSelectedLists] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [claiming, setClaiming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadClaimableLists()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const loadClaimableLists = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Get list IDs from localStorage
|
||||
const storedListIds = getStoredListIds()
|
||||
|
||||
if (storedListIds.length === 0) {
|
||||
setClaimableLists([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch lists from database
|
||||
const lists = await getClaimableLists(storedListIds)
|
||||
setClaimableLists(lists)
|
||||
|
||||
// Select all lists by default
|
||||
setSelectedLists(new Set(lists.map(l => l.id)))
|
||||
} catch (err) {
|
||||
console.error('Error loading claimable lists:', err)
|
||||
setError('Failed to load your lists. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStoredListIds = (): string[] => {
|
||||
try {
|
||||
// Check for list IDs in localStorage
|
||||
// This assumes the app previously stored list IDs locally
|
||||
const stored = localStorage.getItem('anonymousListIds')
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
return []
|
||||
} catch (err) {
|
||||
console.error('Error reading localStorage:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleListSelection = (listId: string) => {
|
||||
const newSelection = new Set(selectedLists)
|
||||
if (newSelection.has(listId)) {
|
||||
newSelection.delete(listId)
|
||||
} else {
|
||||
newSelection.add(listId)
|
||||
}
|
||||
setSelectedLists(newSelection)
|
||||
}
|
||||
|
||||
const handleClaimLists = async () => {
|
||||
try {
|
||||
setClaiming(true)
|
||||
setError(null)
|
||||
|
||||
const listIds = Array.from(selectedLists)
|
||||
const result = await claimLists(listIds)
|
||||
|
||||
if (result.success) {
|
||||
// Clear localStorage after successful claim
|
||||
localStorage.removeItem('anonymousListIds')
|
||||
onComplete()
|
||||
} else {
|
||||
setError(result.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error claiming lists:', err)
|
||||
setError('Failed to claim lists. Please try again.')
|
||||
} finally {
|
||||
setClaiming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
// User chose not to claim lists
|
||||
localStorage.removeItem('anonymousListIds')
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Claim Your Lists">
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : claimableLists.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">No lists found to claim.</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-700">
|
||||
We found {claimableLists.length} list{claimableLists.length !== 1 ? 's' : ''} from your previous session.
|
||||
Would you like to add {claimableLists.length !== 1 ? 'them' : 'it'} to your account?
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{claimableLists.map((list) => (
|
||||
<label
|
||||
key={list.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLists.has(list.id)}
|
||||
onChange={() => toggleListSelection(list.id)}
|
||||
className="mt-1 h-4 w-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: list.color }}
|
||||
/>
|
||||
<span className="font-medium text-gray-900 truncate">
|
||||
{list.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{list.itemCount} item{list.itemCount !== 1 ? 's' : ''} ·
|
||||
Created {new Date(list.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={claiming}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClaimLists}
|
||||
disabled={claiming || selectedLists.size === 0}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{claiming ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
<span>Claiming...</span>
|
||||
</>
|
||||
) : (
|
||||
`Claim ${selectedLists.size} List${selectedLists.size !== 1 ? 's' : ''}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { validateEmail, validatePassword, sanitizeString } from '@/lib/validation'
|
||||
import { registerUser } from '@/lib/auth-actions'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ClaimListsModal from './ClaimListsModal'
|
||||
|
||||
interface RegisterFormProps {
|
||||
callbackUrl?: string
|
||||
@@ -18,6 +19,7 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showClaimModal, setShowClaimModal] = useState(false)
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string
|
||||
email?: string
|
||||
@@ -154,8 +156,19 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) {
|
||||
}
|
||||
} else {
|
||||
showToast('Registration successful! Logging you in...', 'success')
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
|
||||
// Check if there are lists to claim from localStorage
|
||||
// Requirement 10.2 - Add UI for users to claim existing lists on first registration
|
||||
const hasAnonymousLists = checkForAnonymousLists()
|
||||
|
||||
if (hasAnonymousLists) {
|
||||
// Show claim modal
|
||||
setShowClaimModal(true)
|
||||
} else {
|
||||
// No lists to claim, proceed to app
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err)
|
||||
@@ -165,8 +178,42 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const checkForAnonymousLists = (): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem('anonymousListIds')
|
||||
if (stored) {
|
||||
const listIds = JSON.parse(stored)
|
||||
return Array.isArray(listIds) && listIds.length > 0
|
||||
}
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error('Error checking for anonymous lists:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClaimComplete = () => {
|
||||
setShowClaimModal(false)
|
||||
showToast('Lists claimed successfully!', 'success')
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleClaimClose = () => {
|
||||
setShowClaimModal(false)
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
|
||||
<>
|
||||
<ClaimListsModal
|
||||
isOpen={showClaimModal}
|
||||
onClose={handleClaimClose}
|
||||
onComplete={handleClaimComplete}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name *
|
||||
@@ -296,5 +343,6 @@ export default function RegisterForm({ callbackUrl = '/' }: RegisterFormProps) {
|
||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user