A Web App Integration Sync Panel for a B2B SaaS platform that connects to multiple external services (Salesforce, HubSpot, Stripe, and more). The system supports bidirectional data synchronization with structured conflict detection and resolution.
Built as a take-home test for Portier's Product Engineer (Frontend) role.
| Tool | Purpose |
|---|---|
| React 19 + TypeScript | UI framework |
| Vite 8 | Build tool & dev server |
| Tailwind CSS v3 | Styling |
| React Router v7 | Client-side routing |
| Lucide React | Icon set |
| clsx + tailwind-merge | Conditional class names |
- Node.js 18+
- npm 9+
git clone <your-repo-url>
cd sync-panel-web
npm installCopy the example env file:
cp .env.example .envThen open .env and set the API base URL:
VITE_API_BASE_URL=https://portier-takehometest.onrender.com/api/v1npm run devnpm run build # Production build
npm run preview # Preview production build locally
npm run lint # Run ESLint
npx tsc --noEmit # Type check without building| Variable | Required | Description |
|---|---|---|
VITE_API_BASE_URL |
✅ Yes | Base URL for the sync API (no trailing slash) |
Why the
VITE_prefix? Vite only exposes environment variables prefixed withVITE_to the browser bundle. Variables without this prefix will beundefinedin your React code.
The variable is accessed in src/lib/api.ts via:
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;src/
├── lib/
│ ├── types.ts # All TypeScript interfaces & types
│ ├── utils.ts # Pure helper functions (formatting, colors)
│ ├── api.ts # Network calls — only file that uses fetch
│ └── mockData.ts # Mock integrations, history, and conflict data
│
├── hooks/
│ ├── useSync.ts # Sync flow state machine (5 phases)
│ └── useToast.ts # Toast notification state
│
├── components/
│ ├── layout/
│ │ └── Header.tsx
│ └── ui/
│ ├── Button.tsx
│ ├── StatusBadge.tsx
│ ├── Toast.tsx
│ └── SyncPreviewModal.tsx
│
├── pages/
│ ├── IntegrationsPage.tsx # /
│ ├── IntegrationDetailPage.tsx # /integrations/:id
│ ├── SyncHistoryPage.tsx # /integrations/:id/history
│ ├── VersionDetailPage.tsx # /integrations/:id/history/:version
│ └── ConflictResolutionPage.tsx # /integrations/:id/conflicts
│
├── App.tsx # Router setup
└── main.tsx # Entry point
- Overview of all connected integrations
- Status indicators: Synced, Syncing, Conflict, Error
- Clickable stat cards to filter by status
- Search by integration name
- Shows last synced time, record count, version
- Summary card with key metadata
- Connection health indicators
- Sync Now button calls the real API
- Preview modal shows incoming changes before applying
- Conflict and error banners with call-to-action
- List of past sync events with timestamp, source, version
- Expandable rows show event details
- Links to version diff for events with recorded changes
- Stats: added / updated / deleted / total
- Field-level diff showing previous and new values
- Color-coded by change type (ADD / UPDATE / DELETE)
- Side-by-side comparison of local vs. external values
- Per-field selection with "Use This" toggle
- Bulk "Accept All Local" / "Accept All External" actions
- Progress bar tracking resolved vs. total fields
- Merge button activates only when all fields are resolved
The Sync Now button calls:
GET {VITE_API_BASE_URL}/data/sync?application_id={integrationName}
All other data (integration list, history, conflicts) is mocked locally in src/lib/mockData.ts.
| Status | Message Shown to User |
|---|---|
| 400 | Bad request — check integration configuration |
| 404 | Integration not found or misconfigured |
| 500 | Internal server error |
| 502 | Gateway error — integration client service unavailable |
Environment-based config — the API base URL lives in .env so switching between development, staging, and production requires no code changes, only a different .env value.
Preview before apply — Sync Now shows a modal with all incoming changes before committing them. In a bidirectional sync system, showing users what will change prevents accidental data loss.
State machine for sync flow — instead of boolean flags (isLoading, isError), the sync uses a discriminated union with phases: idle → loading → preview → applying → success/error. TypeScript narrows the type at each phase, preventing impossible states.
Single ResolutionMap for conflicts — all conflict decisions are stored as Record<fieldId, "local" | "external">. Progress, button state, and per-field indicators all derive from this one object — no duplicated state.
Strict layer separation — lib/api.ts is the only file that calls fetch. Pages own state. Components are stateless. Hooks contain reusable logic.
application_idquery param uses the integration display name (e.g."Slack","HubSpot")- Conflict resolution is simulated locally in production this would POST the resolution map to an endpoint
- Sync history and conflict data are mocked in production these would be API-driven
- The
syncingstatus disables the Sync Now button (already in progress)
| Action | Where |
|---|---|
| See conflict resolution UI | Click HubSpot → Resolve Conflicts |
| Trigger real API call | Click any integration → Sync Now |
| Browse version diffs | Click Salesforce → View Sync History → View Changes |
| Test error state | Click Zendesk |
| Test syncing state | Click Stripe |