diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index c25f3a7..7e2c267 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -22,7 +22,7 @@ jobs: - exchange-oracle/server - exchange-oracle/client steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Enable corepack run: corepack enable - uses: actions/setup-node@v6 diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index 88e4732..1945145 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -21,7 +21,7 @@ jobs: exchange_oracle_server: ${{ steps.filter.outputs.exchange_oracle_server }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Enable corepack run: corepack enable @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Enable corepack run: corepack enable diff --git a/campaign-launcher/client/.env.example b/campaign-launcher/client/.env.example new file mode 100644 index 0000000..63e291a --- /dev/null +++ b/campaign-launcher/client/.env.example @@ -0,0 +1,14 @@ +# General +VITE_APP_WALLETCONNECT_PROJECT_ID=replace_me +VITE_APP_DOCS_URL=https://docs.humanprotocol.org +VITE_APP_STAKING_DASHBOARD_URL=https://staking.humanprotocol.org +VITE_FOOTER_LINK_GITHUB=https://github.com/Hu-Fi/hufi +VITE_FOOTER_LINK_TELEGRAM=https://t.me/Hu_Finance +VITE_FOOTER_LINK_X=https://x.com/Hu_Finance + +# Web3 +VITE_APP_ENVIRONMENT=localhost +VITE_APP_SUPPORTED_CHAINS=1338 +VITE_APP_EXCHANGE_ORACLE_ADDRESS=0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc +VITE_APP_RECORDING_ORACLE_ADDRESS=0x976EA74026E726554dB657fA54763abd0C3a0aa9 +VITE_APP_REPUTATION_ORACLE_ADDRESS=0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 diff --git a/campaign-launcher/client/.gitignore b/campaign-launcher/client/.gitignore new file mode 100644 index 0000000..b25fbaa --- /dev/null +++ b/campaign-launcher/client/.gitignore @@ -0,0 +1,6 @@ +dist +.env +.env.local +.env.*.local +.yarn/install-state.gz +node_modules/.yarn-state.yml diff --git a/campaign-launcher/client/.yarnrc.yml b/campaign-launcher/client/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/campaign-launcher/client/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/campaign-launcher/client/eslint.config.mjs b/campaign-launcher/client/eslint.config.mjs new file mode 100644 index 0000000..1f7556b --- /dev/null +++ b/campaign-launcher/client/eslint.config.mjs @@ -0,0 +1,53 @@ +import eslint from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import reactRefreshPlugin from 'eslint-plugin-react-refresh'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const config = tseslint.config( + { + ignores: ['dist', 'vite.config.ts'], + }, + eslint.configs.recommended, + tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx,js,jsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2022, + }, + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + plugins: { + 'react-hooks': reactHooksPlugin, + 'react-refresh': reactRefreshPlugin, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'off', + quotes: [ + 'error', + 'single', + { avoidEscape: true, allowTemplateLiterals: true }, + ], + }, + } +); + +export default config; diff --git a/campaign-launcher/client/index.html b/campaign-launcher/client/index.html new file mode 100644 index 0000000..21c82fc --- /dev/null +++ b/campaign-launcher/client/index.html @@ -0,0 +1,12 @@ + + + + + + Marketing Campaign Launcher + + +
+ + + diff --git a/campaign-launcher/client/package.json b/campaign-launcher/client/package.json new file mode 100644 index 0000000..b95e299 --- /dev/null +++ b/campaign-launcher/client/package.json @@ -0,0 +1,52 @@ +{ + "name": "marketing-campaign-launcher-client", + "private": true, + "version": "1.0.0", + "description": "Marketing Campaign Launcher Client", + "author": "Human Protocol", + "license": "MIT", + "packageManager": "yarn@4.10.3", + "scripts": { + "clean": "rm -rf dist", + "lint": "eslint \"**/*.{ts,tsx}\"", + "start": "vite", + "build": "vite build", + "preview": "vite preview", + "start:prod": "serve -s dist", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"" + }, + "dependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@human-protocol/core": "^7.0.0", + "@human-protocol/sdk": "^7.3.0", + "@mui/icons-material": "^5.18.0", + "@mui/material": "^5.16.7", + "@reown/appkit": "^1.8.19", + "@reown/appkit-adapter-wagmi": "^1.8.19", + "@tanstack/react-query": "^5.91.3", + "ethers": "^6.16.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.0", + "serve": "^14.2.4", + "viem": "2.x", + "wagmi": "^3.6.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^22.15.16", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^10.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.11", + "globals": "^16.3.0", + "prettier": "^3.8.1", + "typescript": "^5.6.3", + "typescript-eslint": "^8.57.0", + "vite": "^6.2.4", + "vite-plugin-node-polyfills": "^0.25.0" + } +} diff --git a/campaign-launcher/client/public/robots.txt b/campaign-launcher/client/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/campaign-launcher/client/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/campaign-launcher/client/src/App.tsx b/campaign-launcher/client/src/App.tsx new file mode 100644 index 0000000..45b4865 --- /dev/null +++ b/campaign-launcher/client/src/App.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { useAccount } from 'wagmi'; + +import CreateCampaignPage from '@/pages/CreateCampaignPage'; +import HomePage from '@/pages/HomePage'; + +function App() { + return ( + + } /> + + + + } + /> + } /> + + ); +} + +const ProtectedRoute = ({ children }: { children: ReactNode }) => { + const { isConnected } = useAccount(); + + if (!isConnected) { + return ; + } + + return children; +}; + +export default App; diff --git a/campaign-launcher/client/src/components/AccountDropdown.tsx b/campaign-launcher/client/src/components/AccountDropdown.tsx new file mode 100644 index 0000000..27d28ce --- /dev/null +++ b/campaign-launcher/client/src/components/AccountDropdown.tsx @@ -0,0 +1,99 @@ +import { useState, type FC } from 'react'; + +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import LogoutIcon from '@mui/icons-material/Logout'; +import { + Button, + List, + ListItemButton, + Popover, + Stack, + Typography, +} from '@mui/material'; +import { useAccount, useDisconnect } from 'wagmi'; + +const formatAddress = (address?: string) => { + if (!address) return ''; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const AccountDropdown: FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + const { address } = useAccount(); + const { disconnect } = useDisconnect(); + const open = Boolean(anchorEl); + + const closePopover = () => setAnchorEl(null); + + const handleDisconnect = () => { + closePopover(); + disconnect(); + }; + + return ( + <> + + + + + + Sign out + + + + + ); +}; + +export default AccountDropdown; diff --git a/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx b/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx new file mode 100644 index 0000000..25816f9 --- /dev/null +++ b/campaign-launcher/client/src/components/ConnectWallet/ConnectWalletContent.tsx @@ -0,0 +1,386 @@ +import { + createElement, + useCallback, + useEffect, + useRef, + useState, + type FC, +} from 'react'; + +import SearchIcon from '@mui/icons-material/Search'; +import { + Box, + Button, + CircularProgress, + Grid, + InputAdornment, + Stack, + TextField, + Typography, +} from '@mui/material'; +import '@reown/appkit-ui/wui-qr-code'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; +import { useReownWalletOptions } from '@/hooks/useReownWalletOptions'; + +type AppKitQrCodeElement = HTMLElement & { + alt?: string; + imageSrc?: string; + size?: number; + theme?: 'dark' | 'light'; + uri?: string; +}; + +type AppKitQrCodeProps = { + alt: string; + imageSrc?: string; + uri: string; +}; + +const AppKitQrCode: FC = ({ alt, imageSrc, uri }) => { + const qrCodeRef = useRef(null); + + useEffect(() => { + if (!qrCodeRef.current) return; + + qrCodeRef.current.alt = alt; + qrCodeRef.current.imageSrc = imageSrc; + qrCodeRef.current.size = 500; + qrCodeRef.current.theme = 'light'; + qrCodeRef.current.uri = uri; + }, [alt, imageSrc, uri]); + + return createElement('wui-qr-code', { + ref: qrCodeRef, + style: { display: 'block', height: '100%', width: '100%' }, + }); +}; + +const ConnectWalletContent: FC = () => { + const [search, setSearch] = useState(''); + const [showAllWallets, setShowAllWallets] = useState(false); + + const isMobile = useIsMobile(); + const { + canOpenMobileWallet, + connectingWallet, + connectWallet, + displayedWallets, + fetchMoreWallets, + hasMoreWallets, + isFetchingWallets, + isFetchingWcUri, + openMobileWallet, + resetSearch, + resetWalletConnect, + wcUri, + } = useReownWalletOptions({ search, showAllWallets }); + + const isFetching = isFetchingWallets || isFetchingWcUri; + + const resetState = useCallback(() => { + resetWalletConnect(); + setSearch(''); + resetSearch(); + setShowAllWallets(false); + }, [resetSearch, resetWalletConnect]); + + useEffect(() => { + return () => { + resetState(); + }; + }, [resetState]); + + if (isMobile && wcUri && connectingWallet && canOpenMobileWallet) { + return ( + + + Continue in {connectingWallet.name} + + + + Accept the connection request in your wallet. + + + + + + ); + } + + if (wcUri && connectingWallet) { + return ( + + + Scan with {connectingWallet.name} + + + + + + Open your wallet app and scan this QR code to continue. + + + + ); + } + + return ( + + + Connect wallet + + + Connect your wallet to create marketing campaigns and fund escrows + directly. + + {showAllWallets && ( + setSearch(event.target.value)} + placeholder="Search wallet" + size="small" + fullWidth + sx={{ mb: 2 }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: isFetching ? ( + + + + ) : null, + }} + /> + )} + + {!isFetching && showAllWallets && displayedWallets.length === 0 && ( + + + No wallets found + + + )} + {displayedWallets.length > 0 && ( + + {displayedWallets.map((wallet) => { + const isConnectingWallet = + connectingWallet?.id === wallet.id && isFetchingWcUri; + + return ( + + {isFetching && ( + + )} + + + ); + })} + + )} + + + {!showAllWallets ? ( + + ) : ( + <> + + + + )} + + + ); +}; + +export default ConnectWalletContent; diff --git a/campaign-launcher/client/src/components/ConnectWallet/index.tsx b/campaign-launcher/client/src/components/ConnectWallet/index.tsx new file mode 100644 index 0000000..9d07eda --- /dev/null +++ b/campaign-launcher/client/src/components/ConnectWallet/index.tsx @@ -0,0 +1,60 @@ +import { type FC } from 'react'; + +import WalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import { Button, type ButtonProps } from '@mui/material'; + +import ResponsiveOverlay from '@/components/ResponsiveOverlay'; +import { useConnectWalletModal } from '@/hooks/useConnectWalletModal'; + +import ConnectWalletContent from './ConnectWalletContent'; + +type ConnectWalletProps = { + size?: ButtonProps['size']; +}; + +const ConnectWallet: FC = ({ size = 'large' }) => { + const { + closeConnectWallet, + isConnecting, + isConnectWalletOpen, + openConnectWallet, + } = useConnectWalletModal(); + + return ( + <> + + + + + + ); +}; + +export default ConnectWallet; diff --git a/campaign-launcher/client/src/components/Field.tsx b/campaign-launcher/client/src/components/Field.tsx new file mode 100644 index 0000000..c6fff2e --- /dev/null +++ b/campaign-launcher/client/src/components/Field.tsx @@ -0,0 +1,5 @@ +import { TextField, type TextFieldProps } from '@mui/material'; + +export const Field = (props: TextFieldProps) => { + return ; +}; diff --git a/campaign-launcher/client/src/components/Footer/index.tsx b/campaign-launcher/client/src/components/Footer/index.tsx new file mode 100644 index 0000000..58b73e5 --- /dev/null +++ b/campaign-launcher/client/src/components/Footer/index.tsx @@ -0,0 +1,110 @@ +import type { FC } from 'react'; + +import GitHubIcon from '@mui/icons-material/GitHub'; +import TelegramIcon from '@mui/icons-material/Telegram'; +import XIcon from '@mui/icons-material/X'; +import { + Box, + Container, + IconButton, + Stack, + styled, + Typography, +} from '@mui/material'; + +import { MOBILE_BOTTOM_NAV_HEIGHT } from '@/constants'; + +const SocialMediaIconButton = styled(IconButton)(({ theme }) => ({ + padding: 0, + '& svg': { + fill: theme.palette.text.primary, + fillOpacity: 0.7, + }, + + '&:hover': { + background: 'none', + '& svg': { + fill: theme.palette.text.primary, + fillOpacity: 1, + }, + }, +})); + +const handleClickOnSocialButton = (url: string) => { + window.open(url, '_blank'); +}; + +const Footer: FC<{ reserveBottomOffset: boolean }> = ({ + reserveBottomOffset, +}) => { + return ( + + + + + © {new Date().getFullYear()} HuFi powered by HUMAN Protocol + + + + handleClickOnSocialButton( + import.meta.env.VITE_FOOTER_LINK_GITHUB + ) + } + > + + + + handleClickOnSocialButton(import.meta.env.VITE_FOOTER_LINK_X) + } + > + + + + handleClickOnSocialButton( + import.meta.env.VITE_FOOTER_LINK_TELEGRAM + ) + } + > + + + + + + + ); +}; + +export default Footer; diff --git a/campaign-launcher/client/src/components/KeyValue.tsx b/campaign-launcher/client/src/components/KeyValue.tsx new file mode 100644 index 0000000..b15297c --- /dev/null +++ b/campaign-launcher/client/src/components/KeyValue.tsx @@ -0,0 +1,22 @@ +import { Box, Typography } from '@mui/material'; + +type Props = { + label: string; + value: string; +}; + +export const KeyValue = ({ label, value }: Props) => { + return ( + + + {label} + + + {value || '-'} + + + ); +}; diff --git a/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx b/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx new file mode 100644 index 0000000..cc222bd --- /dev/null +++ b/campaign-launcher/client/src/components/ResponsiveOverlay/index.tsx @@ -0,0 +1,94 @@ +import { type FC, type PropsWithChildren } from 'react'; + +import CloseIcon from '@mui/icons-material/Close'; +import { + Dialog, + Drawer, + IconButton, + Paper, + type SxProps, + type Theme, +} from '@mui/material'; + +import { useIsMobile } from '@/hooks/useBreakpoints'; + +type Props = { + open: boolean; + onClose: () => void; + desktopSx?: SxProps; + mobileSx?: SxProps; + closeButtonSx?: SxProps; +}; + +const closeButtonBaseSx = { + position: 'absolute', + color: 'text.secondary', + zIndex: 2, +}; + +const ResponsiveOverlay: FC> = ({ + open, + onClose, + desktopSx, + mobileSx, + closeButtonSx, + children, +}) => { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( + + + + + {children} + + ); + } + + return ( + + + + + {children} + + ); +}; + +export default ResponsiveOverlay; diff --git a/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx b/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx new file mode 100644 index 0000000..698b9f1 --- /dev/null +++ b/campaign-launcher/client/src/components/campaign/CampaignSteps.tsx @@ -0,0 +1,771 @@ +import { + Alert, + Box, + Button, + Card, + CardActionArea, + Checkbox, + Divider, + FormControlLabel, + Grid, + MenuItem, + Paper, + Stack, + Typography, +} from '@mui/material'; + +import { Field } from '@/components/Field'; +import { KeyValue } from '@/components/KeyValue'; +import { ORACLE_ADDRESSES } from '@/constants'; +import { + getFundingTokenConfig, + getFundingTokenOptions, +} from '@/constants/fundingTokens'; +import { supportedChains } from '@/providers/wagmiConfig'; +import { + CampaignRequestType, + EscrowFundToken, + SocialPlatform, + type CampaignFormState, + type PreparedManifest, + type RecordingOracleKeyState, +} from '@/types'; +import { shortKeyPreview } from '@/utils/manifest'; + +type JobTypeStepProps = { + form: CampaignFormState; + onSelect: (requestType: CampaignRequestType) => void; +}; + +export const JobTypeStep = ({ form, onSelect }: JobTypeStepProps) => ( + + + Choose job type + + Start with the marketing workflow you want to validate. + + + + + onSelect(CampaignRequestType.SOCIAL_MEDIA_PROMOTION)} + /> + + + onSelect(CampaignRequestType.SOCIAL_MEDIA_ENGAGEMENT)} + /> + + + +); + +type PlatformStepProps = { + form: CampaignFormState; + onSelect: (platform: SocialPlatform) => void; +}; + +export const PlatformStep = ({ form, onSelect }: PlatformStepProps) => { + const isPromotion = + form.requestType === CampaignRequestType.SOCIAL_MEDIA_PROMOTION; + + return ( + + + Choose platform + + LinkedIn is currently available only for engagement campaigns. + + + + + onSelect(SocialPlatform.X)} + /> + + + onSelect(SocialPlatform.LINKEDIN)} + /> + + + {isPromotion && ( + + Social media promotion is limited to X / Twitter for this version. + + )} + + ); +}; + +type GeneralDataStepProps = { + form: CampaignFormState; + updateForm: ( + key: K, + value: CampaignFormState[K], + ) => void; + onChainChange: (chainId: CampaignFormState['chainId']) => void; +}; + +export const GeneralDataStep = ({ + form, + updateForm, + onChainChange, +}: GeneralDataStepProps) => { + const fundingTokenOptions = getFundingTokenOptions(form.chainId); + + return ( + + + General campaign data + + These fields are shared by every marketing manifest. + + + + + + onChainChange( + Number(event.target.value) as CampaignFormState['chainId'], + ) + } + > + {supportedChains.map((chain) => ( + + {chain.name} + + ))} + + + + + updateForm('fundToken', event.target.value as EscrowFundToken) + } + > + {fundingTokenOptions.map((token) => ( + + {token.symbol} + + ))} + + + + updateForm('fundAmount', event.target.value)} + /> + + + updateForm('campaignName', event.target.value)} + /> + + + + updateForm('submissionsRequired', event.target.value) + } + /> + + + + updateForm('campaignDescription', event.target.value) + } + /> + + + updateForm('endDate', event.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + updateForm('qualifications', event.target.value) + } + /> + + + + ); +}; + +type SelectionCardProps = { + selected: boolean; + title: string; + description: string; + disabled?: boolean; + onClick: () => void; +}; + +const SelectionCard = ({ + selected, + title, + description, + disabled, + onClick, +}: SelectionCardProps) => ( + + + + + {title} + + {description} + + + +); + +type PromotionFieldsProps = { + form: CampaignFormState; + updatePromotion: ( + key: K, + value: CampaignFormState['promotion'][K], + ) => void; +}; + +export const PromotionFields = ({ + form, + updatePromotion, +}: PromotionFieldsProps) => ( + + + Promotion details + + Define what a valid social media post must contain. + + + + + + updatePromotion('requiredHashtags', event.target.value) + } + /> + + + + updatePromotion('requiredKeywords', event.target.value) + } + /> + + + + updatePromotion('requiredLink', event.target.value) + } + /> + + + updatePromotion('minLength', event.target.value)} + /> + + + + updatePromotion('minLiveDurationHours', event.target.value) + } + /> + + + + updatePromotion('minFollowers', event.target.value) + } + /> + + + + updatePromotion('minAccountAgeDays', event.target.value) + } + /> + + + updatePromotion('minLikes', event.target.value)} + /> + + + + updatePromotion('minReposts', event.target.value) + } + /> + + + + updatePromotion( + 'allowedAbuseProbability', + event.target + .value as CampaignFormState['promotion']['allowedAbuseProbability'], + ) + } + > + Low + Medium + High + + + + + + updatePromotion('mustBePublic', event.target.checked) + } + /> + } + label="Must be public" + /> + + updatePromotion('requiresMedia', event.target.checked) + } + /> + } + label="Requires media" + /> + + + + +); + +type EngagementFieldsProps = { + form: CampaignFormState; + updateEngagement: ( + key: K, + value: CampaignFormState['engagement'][K], + ) => void; + updateXCredentials: < + K extends keyof CampaignFormState['engagement']['xApiCredentials'], + >( + key: K, + value: CampaignFormState['engagement']['xApiCredentials'][K], + ) => void; +}; + +export const EngagementFields = ({ + form, + updateEngagement, + updateXCredentials, +}: EngagementFieldsProps) => { + const isLinkedIn = form.platform === SocialPlatform.LINKEDIN; + const xLikeRequiresEncryption = + form.platform === SocialPlatform.X && form.engagement.checkLike; + + return ( + + + Engagement details + + Define what interactions workers must perform on the target post. + + + + updateEngagement('targetPostUrl', event.target.value) + } + /> + + + updateEngagement('checkLike', event.target.checked) + } + /> + } + label="Check like" + /> + + updateEngagement('checkComment', event.target.checked) + } + /> + } + label="Check comment" + /> + + updateEngagement('checkRepost', event.target.checked) + } + /> + } + label="Check repost" + /> + + updateEngagement('checkQuote', event.target.checked) + } + /> + } + label="Check quote" + /> + + + {isLinkedIn && ( + + LinkedIn supports like and comment checks only. + + )} + + {xLikeRequiresEncryption && ( + + + X API credentials + + + These fields are included only in the encrypted manifest. + + + + + updateXCredentials('consumerKey', event.target.value) + } + /> + + + + updateXCredentials('consumerSecret', event.target.value) + } + /> + + + + updateXCredentials('accessToken', event.target.value) + } + /> + + + + updateXCredentials('accessTokenSecret', event.target.value) + } + /> + + + + )} + + ); +}; + +type SummaryStepProps = { + form: CampaignFormState; + encryptionRequired: boolean; + recordingOracleKey: RecordingOracleKeyState; + preparedManifest: PreparedManifest | null; + isWrongNetwork: boolean; + isSwitchingChain: boolean; + selectedChainName: string; + manifestPreview: string; + onSwitchChain: () => void; +}; + +export const SummaryStep = ({ + form, + encryptionRequired, + recordingOracleKey, + preparedManifest, + isWrongNetwork, + isSwitchingChain, + selectedChainName, + manifestPreview, + onSwitchChain, +}: SummaryStepProps) => ( + + + Review and launch + + Confirm the exact manifest string and escrow settings before approving + the selected token. + + + + {isWrongNetwork && ( + + Switch + + } + > + Switch your wallet to {selectedChainName} before launching. + + )} + {encryptionRequired && recordingOracleKey.isLoading && ( + Fetching recording oracle public key... + )} + + + + + + + + Manifest preview + + + {manifestPreview || 'Manifest is not ready yet.'} + + + + + + + The launcher will approve {form.fundToken} if allowance is insufficient, + then create the escrow with the prepared manifest. + + +); + +type SummaryPanelProps = { + form: CampaignFormState; + encryptionRequired: boolean; + preparedManifest: PreparedManifest | null; + recordingOracleKey: RecordingOracleKeyState; +}; + +const SummaryPanel = ({ + form, + encryptionRequired, + preparedManifest, + recordingOracleKey, +}: SummaryPanelProps) => { + const selectedChain = supportedChains.find( + (chain) => chain.id === form.chainId, + ); + const selectedToken = getFundingTokenConfig(form.chainId, form.fundToken); + + return ( + + + + Launch summary + + + + + + + + + + + + + + + + + + + + + + + + + + + {encryptionRequired && ( + + + + )} + + + + + {encryptionRequired && ( + <> + + + + + + + + )} + + + ); +}; diff --git a/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx b/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx new file mode 100644 index 0000000..2dad464 --- /dev/null +++ b/campaign-launcher/client/src/components/campaign/LaunchProgressDialog.tsx @@ -0,0 +1,114 @@ +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Step, + StepContent, + StepLabel, + Stepper, + Typography, +} from '@mui/material'; + +type LaunchProgressDialogProps = { + open: boolean; + isApproving: boolean; + isCreating: boolean; + isNotifyingExchange: boolean; + error?: Error; + escrowAddress?: string; + onClose: () => void; +}; + +const getLaunchProgressStep = ({ + isApproving, + isCreating, + isNotifyingExchange, + escrowAddress, +}: Pick< + LaunchProgressDialogProps, + 'isApproving' | 'isCreating' | 'isNotifyingExchange' | 'escrowAddress' +>): number => { + if (escrowAddress) return 3; + if (isNotifyingExchange) return 2; + if (isCreating) return 1; + if (isApproving) return 0; + return 0; +}; + +const LaunchProgressDialog = ({ + open, + isApproving, + isCreating, + isNotifyingExchange, + error, + escrowAddress, + onClose, +}: LaunchProgressDialogProps) => { + const activeStep = getLaunchProgressStep({ + isApproving, + isCreating, + isNotifyingExchange, + escrowAddress, + }); + const canClose = Boolean(escrowAddress) || Boolean(error); + const launchSteps = [ + { + label: 'Approve funds', + description: isApproving + ? 'Waiting for token approval confirmation.' + : 'Token approval confirmed.', + }, + { + label: 'Create escrow', + description: isCreating + ? 'Waiting for escrow creation confirmation.' + : 'Escrow creation transaction confirmed.', + }, + { + label: 'Sign webhook', + description: isNotifyingExchange + ? 'Waiting for webhook signature and exchange oracle notification.' + : 'Exchange oracle webhook acknowledged.', + }, + { + label: 'Escrow completed', + description: escrowAddress + ? `Escrow created: ${escrowAddress}` + : 'Escrow creation is not completed yet.', + }, + ]; + + return ( + + Launch campaign + + + {error && {error.message}} + + {launchSteps.map((step) => ( + + {step.label} + + + {step.description} + + + + ))} + + + + + + + + ); +}; + +export default LaunchProgressDialog; diff --git a/campaign-launcher/client/src/components/layout/Shell.tsx b/campaign-launcher/client/src/components/layout/Shell.tsx new file mode 100644 index 0000000..635be9f --- /dev/null +++ b/campaign-launcher/client/src/components/layout/Shell.tsx @@ -0,0 +1,142 @@ +import { AppBar, Box, Button, Container, Stack, Toolbar } from '@mui/material'; +import type { ReactNode } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router-dom'; +import { useAccount } from 'wagmi'; + +import AccountDropdown from '@/components/AccountDropdown'; +import ConnectWallet from '@/components/ConnectWallet'; + +import Footer from '../Footer'; + +const docsUrl = + import.meta.env.VITE_APP_DOCS_URL || 'https://docs.humanprotocol.org'; +const stakeUrl = + import.meta.env.VITE_APP_STAKING_DASHBOARD_URL || + 'https://dashboard.humanprotocol.org'; + +type NavLinkProps = { + to: string; + active?: boolean; + external?: boolean; + disabled?: boolean; + children: ReactNode; +}; + +const NavLink = ({ + to, + active, + external, + disabled, + children, +}: NavLinkProps) => ( + +); + +type ShellProps = { + children: ReactNode; +}; + +const Shell = ({ children }: ShellProps) => { + const { isConnected } = useAccount(); + const location = useLocation(); + + return ( + + + + + + Marketing + + + + Dashboard + + + Support + + + Stake HMT + + + {isConnected ? ( + + + + ) : ( + + + + )} + + + + + {children} + +