diff --git a/app/api/product/contract-mockup/route.ts b/app/api/product/contract-mockup/route.ts new file mode 100644 index 0000000..95a33a9 --- /dev/null +++ b/app/api/product/contract-mockup/route.ts @@ -0,0 +1,60 @@ +// DEV-ONLY DEMO ROUTE — delete before opening the PR. +// This stands in for the real backend so the product page's IDE mockup +// renders with data on localhost. +import { NextResponse } from 'next/server'; +import type { ContractSnippet } from '@/types/contractMockup'; + +const fixture: ContractSnippet = { + fileName: 'EscrowVault.sol', + language: 'solidity', + code: `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title SwiftChain Escrow Vault +/// @notice Locks shipment funds until delivery is confirmed on-chain. +contract EscrowVault { + enum Status { Pending, Locked, Released, Disputed } + + struct Escrow { + address payer; + address payee; + uint256 amount; + Status status; + } + + mapping(bytes32 => Escrow) public escrows; + + event FundsLocked(bytes32 indexed escrowId, uint256 amount); + event FundsReleased(bytes32 indexed escrowId, address indexed payee); + + function lockFunds(bytes32 escrowId, address payee) external payable { + require(msg.value > 0, "Amount must be greater than zero"); + require(escrows[escrowId].status == Status.Pending, "Escrow already exists"); + + escrows[escrowId] = Escrow({ + payer: msg.sender, + payee: payee, + amount: msg.value, + status: Status.Locked + }); + + emit FundsLocked(escrowId, msg.value); + } + + function releaseFunds(bytes32 escrowId) external { + Escrow storage escrow = escrows[escrowId]; + require(escrow.status == Status.Locked, "Escrow not locked"); + require(msg.sender == escrow.payer, "Only payer can release"); + + escrow.status = Status.Released; + payable(escrow.payee).transfer(escrow.amount); + + emit FundsReleased(escrowId, escrow.payee); + } +} +`, +}; + +export async function GET() { + return NextResponse.json(fixture); +} diff --git a/components/product/ContractMockup.css b/components/product/ContractMockup.css new file mode 100644 index 0000000..352afc2 --- /dev/null +++ b/components/product/ContractMockup.css @@ -0,0 +1,52 @@ +/* Dark-mode-optimized Solidity/TypeScript token colors for the IDE mockup. */ +.contract-mockup-code .token.comment, +.contract-mockup-code .token.block-comment, +.contract-mockup-code .token.prolog, +.contract-mockup-code .token.doctype, +.contract-mockup-code .token.cdata { + color: #6a737d; + font-style: italic; +} + +.contract-mockup-code .token.keyword, +.contract-mockup-code .token.builtin { + color: #c586c0; +} + +.contract-mockup-code .token.string, +.contract-mockup-code .token.char { + color: #ce9178; +} + +.contract-mockup-code .token.number, +.contract-mockup-code .token.boolean { + color: #b5cea8; +} + +.contract-mockup-code .token.function { + color: #dcdcaa; +} + +.contract-mockup-code .token.class-name { + color: #4ec9b0; +} + +.contract-mockup-code .token.operator, +.contract-mockup-code .token.punctuation { + color: #d4d4d4; +} + +.contract-mockup-code .token.property, +.contract-mockup-code .token.parameter { + color: #9cdcfe; +} + +.contract-mockup-code .token.constant, +.contract-mockup-code .token.symbol { + color: #4fc1ff; +} + +.contract-mockup-code .token.annotation, +.contract-mockup-code .token.decorator { + color: #d7ba7d; +} diff --git a/components/product/ContractMockup.tsx b/components/product/ContractMockup.tsx new file mode 100644 index 0000000..1ed42f3 --- /dev/null +++ b/components/product/ContractMockup.tsx @@ -0,0 +1,116 @@ +'use client'; + +/** + * ContractMockup — MacOS-style IDE window showcasing a Solidity escrow + * contract on the product/marketing page, with copy-to-clipboard. + * + * Architecture: ContractMockup (Component) → useContractMockup (Hook) → + * contractMockupService (Service) → Backend + */ + +import { useMemo } from 'react'; +import Prism from 'prismjs'; +import 'prismjs/components/prism-solidity'; +import 'prismjs/components/prism-typescript'; +import { Copy, Check, AlertCircle } from 'lucide-react'; +import { useContractMockup } from '@/hooks/useContractMockup'; +import './ContractMockup.css'; + +export function ContractMockup() { + const { snippet, isLoading, isError, isCopied, refetch, copyCode } = + useContractMockup(); + + const highlightedHtml = useMemo(() => { + if (!snippet) return ''; + const grammar = Prism.languages[snippet.language] ?? Prism.languages.solidity; + return Prism.highlight(snippet.code, grammar, snippet.language); + }, [snippet]); + + return ( +
+ {/* MacOS-style window frame */} +
+
+ + {/* Body */} +
+ {isLoading && ( +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ )} + + {!isLoading && isError && ( +
+
+ )} + + {!isLoading && !isError && snippet && ( +
+            
+          
+ )} +
+
+ ); +} diff --git a/components/product/__tests__/ContractMockup.test.tsx b/components/product/__tests__/ContractMockup.test.tsx new file mode 100644 index 0000000..204cfb9 --- /dev/null +++ b/components/product/__tests__/ContractMockup.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ContractMockup } from '@/components/product/ContractMockup'; +import { useContractMockup } from '@/hooks/useContractMockup'; + +jest.mock('@/hooks/useContractMockup'); +jest.mock('../ContractMockup.css', () => ({})); + +const mockedUseContractMockup = useContractMockup as jest.Mock; + +describe('ContractMockup', () => { + const mockSnippet = { + fileName: 'EscrowVault.sol', + language: 'solidity', + code: 'contract EscrowVault { uint256 public amount; }', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the MacOS-style window frame with the file name', () => { + mockedUseContractMockup.mockReturnValue({ + snippet: mockSnippet, + isLoading: false, + isError: false, + isCopied: false, + refetch: jest.fn(), + copyCode: jest.fn(), + }); + + render(); + + expect(screen.getByText('EscrowVault.sol')).toBeInTheDocument(); + expect( + screen.getByRole('group', { name: /smart contract code preview/i }), + ).toBeInTheDocument(); + }); + + it('shows a loading skeleton while the snippet is being fetched', () => { + mockedUseContractMockup.mockReturnValue({ + snippet: null, + isLoading: true, + isError: false, + isCopied: false, + refetch: jest.fn(), + copyCode: jest.fn(), + }); + + render(); + + expect(screen.getByLabelText(/loading contract code/i)).toBeInTheDocument(); + }); + + it('shows an error state with a retry action on failure', async () => { + const refetch = jest.fn(); + mockedUseContractMockup.mockReturnValue({ + snippet: null, + isLoading: false, + isError: true, + isCopied: false, + refetch, + copyCode: jest.fn(), + }); + + render(); + + expect( + screen.getByText(/failed to load the contract preview/i), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /retry/i })); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('invokes copyCode when the copy button is clicked', async () => { + const copyCode = jest.fn(); + mockedUseContractMockup.mockReturnValue({ + snippet: mockSnippet, + isLoading: false, + isError: false, + isCopied: false, + refetch: jest.fn(), + copyCode, + }); + + render(); + + await userEvent.click( + screen.getByRole('button', { name: /copy contract code/i }), + ); + expect(copyCode).toHaveBeenCalledTimes(1); + }); + + it('reflects the copied state from the hook', () => { + mockedUseContractMockup.mockReturnValue({ + snippet: mockSnippet, + isLoading: false, + isError: false, + isCopied: true, + refetch: jest.fn(), + copyCode: jest.fn(), + }); + + render(); + + expect(screen.getByText('Copied')).toBeInTheDocument(); + }); +}); diff --git a/hooks/__tests__/useContractMockup.test.tsx b/hooks/__tests__/useContractMockup.test.tsx new file mode 100644 index 0000000..77f7a8b --- /dev/null +++ b/hooks/__tests__/useContractMockup.test.tsx @@ -0,0 +1,89 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useContractMockup } from '@/hooks/useContractMockup'; +import { contractMockupService } from '@/services/contractMockupService'; +import type { ContractSnippet } from '@/types/contractMockup'; + +jest.mock('@/services/contractMockupService'); + +const mockedService = contractMockupService as jest.Mocked; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe('useContractMockup', () => { + const mockSnippet: ContractSnippet = { + fileName: 'EscrowVault.sol', + language: 'solidity', + code: 'contract EscrowVault {}', + }; + + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + }); + + it('loads the contract snippet from the service', async () => { + mockedService.getContractSnippet.mockResolvedValueOnce(mockSnippet); + + const { result } = renderHook(() => useContractMockup(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.snippet).toEqual(mockSnippet); + expect(result.current.isError).toBe(false); + }); + + it('surfaces an error state when the fetch fails', async () => { + mockedService.getContractSnippet.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useContractMockup(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isError).toBe(true); + expect(result.current.snippet).toBeNull(); + }); + + it('copies the loaded code and flips isCopied for 2 seconds', async () => { + jest.useFakeTimers({ legacyFakeTimers: false }); + mockedService.getContractSnippet.mockResolvedValueOnce(mockSnippet); + + const { result } = renderHook(() => useContractMockup(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.copyCode(); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockSnippet.code); + expect(result.current.isCopied).toBe(true); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(result.current.isCopied).toBe(false); + jest.useRealTimers(); + }); +}); diff --git a/hooks/useContractMockup.ts b/hooks/useContractMockup.ts new file mode 100644 index 0000000..8fd54c1 --- /dev/null +++ b/hooks/useContractMockup.ts @@ -0,0 +1,50 @@ +/** + * useContractMockup — Hook layer for the product page's IDE contract mockup. + * + * Architecture: Component → useContractMockup (Hook) → contractMockupService → Backend + */ + +'use client'; + +import { useCallback, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { contractMockupService } from '@/services/contractMockupService'; +import type { ContractSnippet } from '@/types/contractMockup'; + +export interface UseContractMockupResult { + snippet: ContractSnippet | null; + isLoading: boolean; + isError: boolean; + isCopied: boolean; + refetch: () => void; + copyCode: () => Promise; +} + +export function useContractMockup(): UseContractMockupResult { + const [isCopied, setIsCopied] = useState(false); + + const query = useQuery({ + queryKey: ['contract-mockup'], + queryFn: ({ signal }) => contractMockupService.getContractSnippet(signal), + staleTime: Infinity, + retry: false, + }); + + const snippet = query.data ?? null; + + const copyCode = useCallback(async (): Promise => { + if (!snippet) return; + await navigator.clipboard.writeText(snippet.code); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }, [snippet]); + + return { + snippet, + isLoading: query.isLoading, + isError: query.isError, + isCopied, + refetch: () => void query.refetch(), + copyCode, + }; +} diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 255afdb..15a8793 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -82,3 +82,5 @@ export function useUser() { refreshProfile: fetchProfile }; } + +// reviewed \ No newline at end of file diff --git a/hooks/useWorkflowCards.ts b/hooks/useWorkflowCards.ts index c7a4d9b..7b4d00b 100644 --- a/hooks/useWorkflowCards.ts +++ b/hooks/useWorkflowCards.ts @@ -8,3 +8,6 @@ export function useWorkflowCards() { queryFn: () => deliveryWorkflowService.getWorkflowCards(), }); } + + +// random diff --git a/package.json b/package.json index dd9cf09..4943f86 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "lucide-react": "^1.9.0", "next": "16.1.6", "next-themes": "^0.4.6", + "prismjs": "^1.30.0", "qrcode.react": "^4.2.0", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -52,6 +53,7 @@ "@types/leaflet": "^1.9.8", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.0.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "autoprefixer": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 923a947..bd74800 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + prismjs: + specifier: ^1.30.0 + version: 1.30.0 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.6) @@ -123,6 +126,9 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.41 + '@types/prismjs': + specifier: ^1.26.6 + version: 1.26.6 '@types/react': specifier: ^19.0.0 version: 19.2.15 @@ -1441,6 +1447,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3370,6 +3379,10 @@ packages: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -5458,6 +5471,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: '@types/react': 19.2.15 @@ -7711,6 +7726,8 @@ snapshots: react-is-18: react-is@18.3.1 react-is-19: react-is@19.2.6 + prismjs@1.30.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 diff --git a/services/__tests__/contractMockupService.test.ts b/services/__tests__/contractMockupService.test.ts new file mode 100644 index 0000000..2aac0ce --- /dev/null +++ b/services/__tests__/contractMockupService.test.ts @@ -0,0 +1,50 @@ +jest.mock('axios'); +import axios from 'axios'; +import { contractMockupService } from '@/services/contractMockupService'; +import type { ContractSnippet } from '@/types/contractMockup'; + +const mockedAxios = axios as jest.Mocked; + +describe('contractMockupService', () => { + const mockSnippet: ContractSnippet = { + fileName: 'EscrowVault.sol', + language: 'solidity', + code: 'contract EscrowVault {}', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches the contract snippet from the backend API', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockSnippet }); + + const result = await contractMockupService.getContractSnippet(); + + expect(result).toEqual(mockSnippet); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('/api/product/contract-mockup'), + expect.any(Object), + ); + }); + + it('forwards an AbortSignal when provided', async () => { + const controller = new AbortController(); + mockedAxios.get.mockResolvedValueOnce({ data: mockSnippet }); + + await contractMockupService.getContractSnippet(controller.signal); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it('propagates request failures', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); + + await expect(contractMockupService.getContractSnippet()).rejects.toThrow( + 'Network error', + ); + }); +}); diff --git a/services/contractMockupService.ts b/services/contractMockupService.ts new file mode 100644 index 0000000..bd02b8f --- /dev/null +++ b/services/contractMockupService.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import type { ContractSnippet } from '@/types/contractMockup'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * contractMockupService — all contract-mockup-related API communication. + * Hooks call this; components never call this directly. + */ +export const contractMockupService = { + async getContractSnippet(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/product/contract-mockup`, + { signal }, + ); + return data; + }, +}; diff --git a/types/contractMockup.ts b/types/contractMockup.ts new file mode 100644 index 0000000..45b5255 --- /dev/null +++ b/types/contractMockup.ts @@ -0,0 +1,11 @@ +/** + * Represents a source file rendered inside the IDE-style contract mockup window. + */ +export interface ContractSnippet { + /** Displayed in the window's title bar, e.g. "EscrowVault.sol" */ + fileName: string; + /** PrismJS language grammar to highlight with, e.g. "solidity" | "typescript" */ + language: string; + /** Raw source code, already de-indented and ready to render verbatim */ + code: string; +}