- 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
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
'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>
|
|
)
|
|
}
|