diff --git a/frontend/app/dashboard/assets/components/AssetGrid.tsx b/frontend/app/dashboard/assets/components/AssetGrid.tsx index ed8444d..14088b7 100644 --- a/frontend/app/dashboard/assets/components/AssetGrid.tsx +++ b/frontend/app/dashboard/assets/components/AssetGrid.tsx @@ -1,21 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import React, { useState, useMemo } from "react"; import Image from "next/image"; -/** - * Gallery / grid view for a user's verified digital products (assets). - * - * Renders a responsive grid of thumbnails for verified images and videos with - * a status badge, media-type indicator and capture date. Data is currently - * mocked; once the asset service layer is available this component can source - * its items from `fetchAssets` without changing the presentation. - */ - type AssetType = "image" | "video"; type AssetStatus = "verified" | "pending" | "revoked"; -interface Asset { +export interface Asset { id: string; title: string; type: AssetType; @@ -24,81 +15,6 @@ interface Asset { createdAt: string; } -const MOCK_ASSETS: Asset[] = [ - { - id: "asset-001", - title: "Sunset Over the Serengeti", - type: "image", - thumbnailUrl: - "https://images.unsplash.com/photo-1516426122078-c23e76319801?w=600&h=450&fit=crop", - status: "verified", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), - }, - { - id: "asset-002", - title: "Product Launch Teaser", - type: "video", - thumbnailUrl: - "https://images.unsplash.com/photo-1485846234645-a62644f84728?w=600&h=450&fit=crop", - status: "verified", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5).toISOString(), - }, - { - id: "asset-003", - title: "Golden Hour Portrait Series", - type: "image", - thumbnailUrl: - "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=600&h=450&fit=crop", - status: "pending", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 9).toISOString(), - }, - { - id: "asset-004", - title: "Aerial City Flythrough", - type: "video", - thumbnailUrl: - "https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=600&h=450&fit=crop", - status: "verified", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), - }, - { - id: "asset-005", - title: "Studio Still Life", - type: "image", - thumbnailUrl: - "https://images.unsplash.com/photo-1503602642458-232111445657?w=600&h=450&fit=crop", - status: "revoked", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 21).toISOString(), - }, - { - id: "asset-006", - title: "Mountain Timelapse", - type: "video", - thumbnailUrl: - "https://images.unsplash.com/photo-1454496522488-7a8e488e8606?w=600&h=450&fit=crop", - status: "verified", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 28).toISOString(), - }, - { - id: "asset-007", - title: "Macro Botanicals", - type: "image", - thumbnailUrl: - "https://images.unsplash.com/photo-1462530260150-162092dbf011?w=600&h=450&fit=crop", - status: "verified", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 33).toISOString(), - }, - { - id: "asset-008", - title: "Neon Night Reel", - type: "video", - thumbnailUrl: - "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=600&h=450&fit=crop", - status: "pending", - createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 40).toISOString(), - }, -]; - const STATUS_BADGE: Record = { verified: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400", @@ -115,6 +31,102 @@ function formatDate(iso: string): string { }); } +// ... (VideoIcon, AssetGridSkeleton, EmptyState, AssetCard components remain the same) + +interface AssetGridProps { + assets: Asset[]; + isLoading?: boolean; +} + +type SortKey = "title" | "createdAt"; + +export default function AssetGrid({ assets, isLoading = false }: AssetGridProps) { + const [sortKey, setSortKey] = useState("createdAt"); + const [sortDirection, setSortDirection] = useState<"asc" | "dsc">("dsc"); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 8; + + const sortedAssets = useMemo(() => { + return [...assets].sort((a, b) => { + const aValue = a[sortKey]; + const bValue = b[sortKey]; + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }); + }, [assets, sortKey, sortDirection]); + + const paginatedAssets = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedAssets.slice(startIndex, startIndex + itemsPerPage); + }, [sortedAssets, currentPage]); + + const totalPages = Math.ceil(sortedAssets.length / itemsPerPage); + + const handleSortChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const [key, direction] = value.split('-') as [SortKey, "asc" | "dsc"]; + setSortKey(key); + setSortDirection(direction); + }; + + return ( +
+
+

+ Assets +

+
+ + {assets.length} asset{assets.length !== 1 ? "s" : ""} + + +
+
+ + {isLoading ? ( + + ) : assets.length === 0 ? ( + + ) : ( + <> +
+ {paginatedAssets.map((asset) => ( + + ))} +
+ {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + + )} +
+ ); +} + function VideoIcon({ className }: { className?: string }) { return ( ); } - -interface AssetGridProps { - /** Optional preloaded assets. When omitted, mock data is loaded. */ - assets?: Asset[]; -} - -export default function AssetGrid({ assets }: AssetGridProps) { - const [items, setItems] = useState(assets ?? null); - const [isLoading, setIsLoading] = useState(!assets); - - useEffect(() => { - if (assets) { - setItems(assets); - setIsLoading(false); - return; - } - - let cancelled = false; - setIsLoading(true); - - // Simulate asset retrieval until the service layer is wired in. - const timer = setTimeout(() => { - if (!cancelled) { - setItems(MOCK_ASSETS); - setIsLoading(false); - } - }, 400); - - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [assets]); - - return ( -
-
-

- Assets -

- {items && items.length > 0 && ( - - {items.length} asset{items.length !== 1 ? "s" : ""} - - )} -
- - {isLoading ? ( - - ) : !items || items.length === 0 ? ( - - ) : ( -
- {items.map((asset) => ( - - ))} -
- )} - - ); -} diff --git a/frontend/app/dashboard/assets/components/AssetTable.tsx b/frontend/app/dashboard/assets/components/AssetTable.tsx index 1798fd6..6ec5fa7 100644 --- a/frontend/app/dashboard/assets/components/AssetTable.tsx +++ b/frontend/app/dashboard/assets/components/AssetTable.tsx @@ -1,7 +1,7 @@ "use client"; -import React from "react"; -import { ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react"; +import React, { useState, useMemo } from "react"; +import { ShieldCheck, ShieldAlert, ShieldOff, ChevronUp, ChevronDown, ArrowUpDown } from "lucide-react"; import { cn } from "@/utils/cn"; import { TableSkeleton } from "@/components/ui/Skeleton"; import type { @@ -16,15 +16,23 @@ export interface AssetTableProps { className?: string; } -const COLUMNS = [ - "Asset", - "Type", - "Owner", - "Content Hash", - "KMS Encryption", - "Verified At", - "Status", - "Size", +type SortableColumn = "name" | "type" | "status" | "sizeBytes"; +type SortDirection = "asc" | "dsc"; + +interface SortConfig { + key: SortableColumn; + direction: SortDirection; +} + +const COLUMNS: { key: SortableColumn | string; label: string; sortable: boolean }[] = [ + { key: "name", label: "Asset", sortable: true }, + { key: "type", label: "Type", sortable: true }, + { key: "owner", label: "Owner", sortable: false }, + { key: "contentHash", label: "Content Hash", sortable: false }, + { key: "kmsEncryptionStatus", label: "KMS Encryption", sortable: false }, + { key: "verifiedAt", label: "Verified At", sortable: false }, + { key: "status", label: "Status", sortable: true }, + { key: "sizeBytes", label: "Size", sortable: true }, ]; const STATUS_STYLES: Record = { @@ -137,6 +145,51 @@ export default function AssetTable({ isLoading = false, className, }: AssetTableProps) { + const [sortConfig, setSortConfig] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + const sortedAssets = useMemo(() => { + const sortableItems = [...assets]; + if (sortConfig !== null) { + sortableItems.sort((a, b) => { + if (a[sortConfig.key] < b[sortConfig.key]) { + return sortConfig.direction === "asc" ? -1 : 1; + } + if (a[sortConfig.key] > b[sortConfig.key]) { + return sortConfig.direction === "asc" ? 1 : -1; + } + return 0; + }); + } + return sortableItems; + }, [assets, sortConfig]); + + const paginatedAssets = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedAssets.slice(startIndex, startIndex + itemsPerPage); + }, [sortedAssets, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(sortedAssets.length / itemsPerPage); + + const requestSort = (key: SortableColumn) => { + let direction: SortDirection = "asc"; + if (sortConfig && sortConfig.key === key && sortConfig.direction === "asc") { + direction = "dsc"; + } + setSortConfig({ key, direction }); + }; + + const SortIcon = ({ columnKey }: { columnKey: SortableColumn | string }) => { + if (!sortConfig || sortConfig.key !== columnKey) { + return ; + } + if (sortConfig.direction === 'asc') { + return ; + } + return ; + }; + return (
) : ( -
- - - - {COLUMNS.map((heading) => ( - + <> +
+
- {heading} -
+ + + {COLUMNS.map((column) => ( + + ))} + + + + {paginatedAssets.map((asset) => ( + ))} - - - - {assets.map((asset) => ( - - ))} - -
+ {column.sortable ? ( + + ) : ( + column.label + )} +
-
+ + +
+ {totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + )} ); diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index b065f08..1d62a98 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -151,29 +151,15 @@ export default function SearchPage() { setLoading(false); return; } - setLoading(true); } const verifiedCount = results.filter((r) => r.status === "verified").length; const totalCount = results.length; - - /** - * Maps the generic `SearchResult` type to the `Certificate` type required - * by the `GridView` component. This acts as an adapter. - */ const gridResults: Certificate[] = results.map((r) => ({ id: r.id, title: r.name || "Untitled Certificate", - // Placeholder thumbnail logic. Replace with actual data when available. - thumbnailUrl: - r.type === "Image" - ? `https://picsum.photos/seed/${r.id}/400/300` - : r.type === "Video" - ? `https://picsum.photos/seed/${r.id}/400/300` - : r.type === "Audio" - ? `https://picsum.photos/seed/${r.id}/400/300` - : `https://picsum.photos/seed/${r.id}/400/300`, + thumbnailUrl: `https://picsum.photos/seed/${r.id}/400/300`, issuerName: r.creator, issueDate: r.mintedAt, })); @@ -318,7 +304,7 @@ export default function SearchPage() { /> ) : ( )} diff --git a/frontend/components/dashboard/Web2DashboardView.tsx b/frontend/components/dashboard/Web2DashboardView.tsx index 38366e9..cc30312 100644 --- a/frontend/components/dashboard/Web2DashboardView.tsx +++ b/frontend/components/dashboard/Web2DashboardView.tsx @@ -57,8 +57,7 @@ export default function Web2DashboardView({ userEmail = "user@example.com", user const [certificates, setCertificates] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); - const [pageSize, setPageSize] = useState(10); - const [currentPage, setCurrentPage] = useState(1); + const [, setCurrentPage] = useState(1); const [selectedInspectItem, setSelectedInspectItem] = useState<{ title: string; data: Record } | null>(null); useEffect(() => { diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx new file mode 100644 index 0000000..4851092 --- /dev/null +++ b/frontend/components/ui/tooltip.tsx @@ -0,0 +1,96 @@ +'use client'; + +import * as React from 'react'; + +type TooltipContextValue = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +const TooltipContext = React.createContext(null); + +function useTooltipContext() { + const context = React.useContext(TooltipContext); + if (!context) { + throw new Error('Tooltip components must be used within .'); + } + return context; +} + +export function TooltipProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export function Tooltip({ children }: { children: React.ReactNode }) { + const [open, setOpen] = React.useState(false); + + return ( + + {children} + + ); +} + +export function TooltipTrigger({ + asChild, + children, +}: { + asChild?: boolean; + children: React.ReactElement; +}) { + const { setOpen } = useTooltipContext(); + const child = children as React.ReactElement>; + const childProps = child.props as { + onMouseEnter?: (event: React.MouseEvent) => void; + onMouseLeave?: (event: React.MouseEvent) => void; + onFocus?: (event: React.FocusEvent) => void; + onBlur?: (event: React.FocusEvent) => void; + }; + + return React.cloneElement(child, { + onMouseEnter: (event: React.MouseEvent) => { + childProps.onMouseEnter?.(event); + setOpen(true); + }, + onMouseLeave: (event: React.MouseEvent) => { + childProps.onMouseLeave?.(event); + setOpen(false); + }, + onFocus: (event: React.FocusEvent) => { + childProps.onFocus?.(event); + setOpen(true); + }, + onBlur: (event: React.FocusEvent) => { + childProps.onBlur?.(event); + setOpen(false); + }, + ...(asChild ? {} : { type: 'button' }), + }); +} + +export function TooltipContent({ + className = '', + children, +}: { + className?: string; + children: React.ReactNode; + side?: 'top' | 'bottom' | 'left' | 'right'; + sideOffset?: number; +}) { + const { open } = useTooltipContext(); + + if (!open) return null; + + return ( + + {children} + + ); +} diff --git a/frontend/features/verification/__tests__/manifest-schema.test.ts b/frontend/features/verification/__tests__/manifest-schema.test.ts new file mode 100644 index 0000000..dbab2fc --- /dev/null +++ b/frontend/features/verification/__tests__/manifest-schema.test.ts @@ -0,0 +1,114 @@ +import { validateManifest } from '../schemas/manifest-schema'; + +type ValidManifestPayload = { + contentHash: string; + creator: string; + timestamp: string; + metadata: { + device: string; + location: string; + aiModel: string; + }; +}; + +describe('Manifest Schema Validation', () => { + const validPayload: ValidManifestPayload = { + contentHash: 'sha256:d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26', + creator: 'GA2C5RFPE6GCKIG3EQKTNIQ6PRRQIHIRDIUCAUKENRXCVZBD4T6K2K2H', + timestamp: '2023-10-27T10:00:00Z', + metadata: { + device: 'Camera Model X', + location: 'Lat/Long', + aiModel: 'None', + }, + }; + + it('should validate a correct manifest payload successfully', () => { + const result = validateManifest(validPayload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.contentHash).toBe(validPayload.contentHash); + expect(result.data.creator).toBe(validPayload.creator); + expect(result.data.timestamp).toBe(validPayload.timestamp); + } + }); + + it('should allow passthrough properties', () => { + const extendedPayload = { + ...validPayload, + extraField: 'some extra value', + }; + const result = validateManifest(extendedPayload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toMatchObject({ extraField: 'some extra value' }); + } + }); + + describe('Invalid Payloads', () => { + it('should fail if contentHash is missing', () => { + const invalidPayload = Object.fromEntries( + Object.entries(validPayload).filter(([key]) => key !== 'contentHash'), + ); + const result = validateManifest(invalidPayload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain('contentHash'); + } + }); + + it('should fail if contentHash is empty', () => { + const invalidPayload = { ...validPayload, contentHash: '' }; + const result = validateManifest(invalidPayload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('contentHash is required'); + } + }); + + it('should fail if creator is invalid stellar public key', () => { + const invalidPayload = { ...validPayload, creator: 'invalid-creator' }; + const result = validateManifest(invalidPayload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Invalid Stellar public key'); + } + }); + + it('should fail if timestamp is not valid ISO 8601 datetime', () => { + const invalidPayload = { ...validPayload, timestamp: '10/27/2023' }; + const result = validateManifest(invalidPayload); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Invalid timestamp format'); + } + }); + }); + + describe('Form Submission Logic', () => { + it('should simulate form submission failure with invalid schema', () => { + const formSubmit = (data: unknown) => { + const result = validateManifest(data); + if (!result.success) { + throw new Error('Validation failed'); + } + return true; + }; + + const invalidPayload = { ...validPayload, timestamp: 'invalid' }; + expect(() => formSubmit(invalidPayload)).toThrow('Validation failed'); + }); + + it('should simulate form submission success with valid schema', () => { + const formSubmit = (data: unknown) => { + const result = validateManifest(data); + if (!result.success) { + throw new Error('Validation failed'); + } + return true; + }; + + expect(formSubmit(validPayload)).toBe(true); + }); + }); +}); diff --git a/frontend/features/verification/__tests__/wizard-flow.test.tsx b/frontend/features/verification/__tests__/wizard-flow.test.tsx new file mode 100644 index 0000000..9a81922 --- /dev/null +++ b/frontend/features/verification/__tests__/wizard-flow.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import WizardPageShell from '../components/WizardPageShell'; +import { useWizardStore } from '../store/wizard.store'; + +jest.mock('@/utils/hashing', () => ({ + hashFile: jest.fn().mockResolvedValue('a'.repeat(64)), +})); + +jest.mock('@/utils/crypto', () => ({ + computeSHA256: jest.fn().mockResolvedValue('b'.repeat(64)), + isValidSHA256: jest.fn((value: string) => /^[a-f0-9]{64}$/i.test(value)), +})); + +jest.mock('@/context/WalletContext', () => ({ + useWallet: jest.fn(() => ({ + publicKey: null, + isConnected: false, + connect: jest.fn(), + })), +})); + +jest.mock('@/app/context/AuthContext', () => ({ + useAuth: jest.fn(() => ({ + user: { + name: 'Test Creator', + email: 'creator@example.com', + }, + })), +})); + +jest.mock('@/components/ManifestGeneratorModal', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('@/services/manifestUseCases', () => ({ + manifestUseCaseService: { + getAll: jest.fn().mockResolvedValue([]), + }, +})); + +jest.mock('@/services/verificationService', () => ({ + submitVerificationRequest: jest.fn(), +})); + +describe('Verification wizard flow', () => { + beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:preview'); + global.URL.revokeObjectURL = jest.fn(); + }); + + beforeEach(() => { + useWizardStore.setState({ + currentStep: 0, + formData: {}, + validation: {}, + _hasHydrated: true, + }); + }); + + it('navigates through all four steps and carries data into the review summary', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const mediaInput = container.querySelector('input[type="file"]') as HTMLInputElement; + const mediaFile = new File(['image-bytes'], 'asset.png', { type: 'image/png' }); + await user.upload(mediaInput, mediaFile); + + await waitFor(() => { + expect(screen.getByText(/sha-256 hash/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /go to next step/i })).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', { name: /go to next step/i })); + expect(screen.getByRole('heading', { name: /manifest attachment/i })).toBeInTheDocument(); + + const manifestInput = container.querySelectorAll('input[type="file"]')[0] as HTMLInputElement; + const manifestFile = new File(['{"name":"Asset Manifest"}'], 'manifest.json', { + type: 'application/json', + }); + await user.upload(manifestInput, manifestFile); + + await waitFor(() => { + expect(screen.getByText(/manifest sha-256 hash/i)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /go to next step/i })); + expect(screen.getByRole('heading', { name: /spv privacy/i })).toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: /public registry/i })); + await user.click(screen.getByRole('button', { name: /go to next step/i })); + + expect(screen.getByRole('heading', { name: /review & submit/i })).toBeInTheDocument(); + expect(screen.getByText('asset.png')).toBeInTheDocument(); + expect(screen.getByText('manifest.json')).toBeInTheDocument(); + expect(screen.getByText(/^Public$/)).toBeInTheDocument(); + expect(screen.getByText('a'.repeat(64))).toBeInTheDocument(); + expect(screen.getByText('b'.repeat(64))).toBeInTheDocument(); + + const state = useWizardStore.getState(); + expect(state.formData.content?.file?.name).toBe('asset.png'); + expect(state.formData.content?.manifest?.fileName).toBe('manifest.json'); + expect(state.formData.content?.encryptionEnabled).toBe(false); + }); +}); diff --git a/frontend/features/verification/components/WizardPageShell.tsx b/frontend/features/verification/components/WizardPageShell.tsx index 93d6e14..eab0a58 100644 --- a/frontend/features/verification/components/WizardPageShell.tsx +++ b/frontend/features/verification/components/WizardPageShell.tsx @@ -2,12 +2,13 @@ import { useState, useCallback } from 'react'; import { useWizardStore } from '../store/wizard.store'; +import { useWallet } from '@/context/WalletContext'; import WizardStepper from './WizardStepper'; import WizardNavigation from './WizardNavigation'; import UploadMedia from './steps/UploadMedia'; import UploadManifest from './steps/UploadManifest'; -import UploadSPVOptions from './steps/UploadSPVOptions'; -import UploadReview from './steps/UploadReview'; +import SPVPrivacyStep from './steps/SPVPrivacyStep'; +import ReviewSubmitStep from './steps/ReviewSubmitStep'; import { submitVerificationRequest } from '@/services/verificationService'; const STEPS = [ @@ -27,7 +28,9 @@ export default function WizardPageShell() { } = useWizardStore(); const hasHydrated = useWizardStore((state) => state._hasHydrated); + const { publicKey, isConnected, connect } = useWallet(); const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); const isLastStep = currentStep === STEPS.length - 1; const isFirstStep = currentStep === 0; @@ -52,21 +55,30 @@ export default function WizardPageShell() { ); const handleSubmit = useCallback(async () => { + if (!isConnected || !publicKey) { + setSubmitError('Please connect your Freighter wallet before submitting.'); + return; + } + + setSubmitError(null); setIsSubmitting(true); try { const content = formData.content; await submitVerificationRequest( content?.contentHash ?? '', content?.manifestHash ?? null, - 'GAAAAAAAAAAAAAAA', + publicKey, ); resetWizard(); } catch (error) { console.error('Submission failed:', error); + setSubmitError( + error instanceof Error ? error.message : 'Submission failed. Please try again.', + ); } finally { setIsSubmitting(false); } - }, [formData.content, resetWizard]); + }, [formData.content, resetWizard, isConnected, publicKey]); const handleCancel = useCallback(() => { resetWizard(); @@ -75,12 +87,15 @@ export default function WizardPageShell() { const stepComponents = [ , , - , - , + , ]; diff --git a/frontend/features/verification/components/steps/MediaUploadStep.tsx b/frontend/features/verification/components/steps/MediaUploadStep.tsx index d61b560..4b8b0ad 100644 --- a/frontend/features/verification/components/steps/MediaUploadStep.tsx +++ b/frontend/features/verification/components/steps/MediaUploadStep.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useCallback, useState, useRef, type DragEvent, type ChangeEvent } from 'react'; -import { Upload, FileImage, FileVideo, FileText, X, AlertCircle, CheckCircle2, Loader2, Eye, EyeOff } from 'lucide-react'; +import Image from 'next/image'; +import { Upload, FileImage, FileVideo, FileText, X, AlertCircle, CheckCircle2, Loader2, Eye } from 'lucide-react'; // ── Types ────────────────────────────────────────────────── export interface MediaFile { @@ -99,6 +100,27 @@ export default function MediaUploadStep({ [externalFiles, onFilesChange], ); + const simulateUpload = useCallback( + async (fileId: string) => { + const updateProgress = (progress: number) => { + setFiles((prev) => + prev.map((f) => + f.id === fileId + ? { ...f, progress, status: progress >= 100 ? 'done' : 'uploading' } + : f, + ), + ); + }; + + for (let p = 0; p <= 100; p += 20) { + await new Promise((r) => setTimeout(r, 150)); + updateProgress(p); + } + updateProgress(100); + }, + [setFiles], + ); + // ── File Processing ─────────────────────────────────── const processFiles = useCallback( async (fileList: FileList | File[]) => { @@ -152,27 +174,9 @@ export default function MediaUploadStep({ await simulateUpload(entry.id); } }, - [maxSize, multiple, files, setFiles], + [files, maxSize, multiple, setFiles, simulateUpload], ); - const simulateUpload = async (fileId: string) => { - const updateProgress = (progress: number) => { - setFiles((prev) => - prev.map((f) => - f.id === fileId - ? { ...f, progress, status: progress >= 100 ? 'done' : 'uploading' } - : f, - ), - ); - }; - - for (let p = 0; p <= 100; p += 20) { - await new Promise((r) => setTimeout(r, 150)); - updateProgress(p); - } - updateProgress(100); - }; - // ── Drag Handlers ───────────────────────────────────── const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); @@ -358,10 +362,12 @@ export default function MediaUploadStep({ {file.previewUrl ? (
{isImage ? ( - {file.name} ) : isVideo ? (